Compare commits

...

2 Commits

Author SHA1 Message Date
Rajarshee Chatterjee ae8d055689 perf: Limit Chapter Neighbor Queries To One Row
Lint / ESLint (push) Has been cancelled
Tests / Jest (push) Has been cancelled
Type Check / TypeScript (push) Has been cancelled
2026-09-26 20:43:28 +05:30
trietng 1607f80a0a feat: Add Volume Button Navigation and Slide Animation Settings for Paged Reading (#2058)
* feat: volume navigation and disable animation settings for paged reading

* fix: transition style removal

* fix: pageReader incorrect initialization

* fix: remove unneeded check

* chore: update Vietnamese translations for paged reading settings
2026-09-26 20:40:43 +05:30
14 changed files with 398 additions and 46 deletions
+6
View File
@@ -15,6 +15,12 @@ body.page-reader > #LNReader-chapter {
column-fill: auto;
transition: transform 200ms;
}
body.page-reader.no-animation > #LNReader-chapter {
transition: none !important;
}
body.page-reader.no-animation .transition-chapter {
transition: none !important;
}
.transition-chapter {
height: 100vh;
width: 100vw;
+42 -13
View File
@@ -524,7 +524,8 @@ van.derive(() => {
}
});
window.pageReader = new (function () {
var pageReader;
window.pageReader = pageReader = new (function () {
const config =
typeof initialPageReaderConfig === 'undefined'
? {}
@@ -544,18 +545,21 @@ window.pageReader = new (function () {
document.getElementsByClassName('transition-chapter')[0];
if (!this.chapterEnding) return;
}
const noAnim = Boolean(
reader.generalSettings?.val?.pageReaderDisableAnimation,
);
this.chapterEnding.style.transition = 'unset';
if (bool) {
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
requestAnimationFrame(() => {
if (!instant) {
if (!instant && !noAnim) {
this.chapterEnding.style.transition = 'transform 200ms';
}
this.chapterEnding.style.transform = 'translateX(-100vw)';
});
this.chapterEndingVisible.val = true;
} else {
if (!instant) {
if (!instant && !noAnim) {
this.chapterEnding.style.transition = 'transform 200ms';
}
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
@@ -579,26 +583,35 @@ window.pageReader = new (function () {
return;
}
destPage = parseInt(destPage, 10);
const noAnim = Boolean(
reader.generalSettings?.val?.pageReaderDisableAnimation,
);
if (destPage < 0) {
if (!reader.prevChapter) return;
document.getElementsByClassName('transition-chapter')[0].innerText =
reader.prevChapter.name;
this.showChapterEnding(true, false, true);
this.showChapterEnding(true, noAnim, true);
this.chapterNavigationPending = true;
setTimeout(() => {
reader.post({ type: 'prev' });
}, 200);
setTimeout(
() => {
reader.post({ type: 'prev' });
},
noAnim ? 0 : 200,
);
return;
}
if (destPage >= this.totalPages.val) {
if (!reader.nextChapter) return;
document.getElementsByClassName('transition-chapter')[0].innerText =
reader.nextChapter.name;
this.showChapterEnding(true);
this.showChapterEnding(true, noAnim);
this.chapterNavigationPending = true;
setTimeout(() => {
reader.post({ type: 'next' });
}, 200);
setTimeout(
() => {
reader.post({ type: 'next' });
},
noAnim ? 0 : 200,
);
return;
}
this.page.val = destPage;
@@ -606,7 +619,7 @@ window.pageReader = new (function () {
'translateX(-' + destPage * 100 + '%)';
const newProgress = parseInt(
((pageReader.page.val + 1) / pageReader.totalPages.val) * 100,
((this.page.val + 1) / this.totalPages.val) * 100,
10,
);
@@ -687,6 +700,18 @@ window.pageReader = new (function () {
});
}
});
van.derive(() => {
document.body.classList.toggle(
'no-animation',
Boolean(reader.generalSettings.val.pageReaderDisableAnimation),
);
reader.chapterElement?.style.removeProperty('transition');
const chapterEnding =
this.chapterEnding ||
document.getElementsByClassName('transition-chapter')[0];
chapterEnding?.style.removeProperty('transition');
});
})();
document.addEventListener('DOMContentLoaded', () => {
@@ -905,7 +930,11 @@ window.addEventListener('load', () => {
if (Math.abs(diffX) > 8 || Math.abs(diffY) > 8) {
pageReader.ignoreClickUntil = Date.now() + 400;
}
reader.chapterElement.style.transition = 'transform 200ms';
reader.chapterElement.style.transition = reader.generalSettings.val
.pageReaderDisableAnimation
? 'none'
: 'transform 200ms';
reader.chapterElement.style.removeProperty('transition');
const diffXPercentage = diffX / reader.layoutWidth;
if (diffXPercentage < -0.3) {
pageReader.movePage(pageReader.page.val + 1);
+4
View File
@@ -785,7 +785,9 @@ export const getPrevChapter = async (
.orderBy(
desc(castInt(chapterSchema.page)),
desc(castInt(chapterSchema.position)),
desc(chapterSchema.id),
)
.limit(1)
.get();
};
@@ -814,7 +816,9 @@ export const getNextChapter = async (
.orderBy(
asc(castInt(chapterSchema.page)),
asc(castInt(chapterSchema.position)),
asc(chapterSchema.id),
)
.limit(1)
.get();
};
@@ -1008,6 +1008,114 @@ describe('ChapterQueries', () => {
});
});
describe('chapter neighbors', () => {
it('limits rows transferred by both neighbor lookups to one', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb);
for (let position = 0; position < 5; position++) {
await insertTestChapter(testDb, novelId, { position });
}
const client = testDb.sqlite as typeof testDb.sqlite & {
executeRawAsync: typeof testDb.sqlite.executeRaw;
};
const executeRaw = jest.spyOn(client, 'executeRawAsync');
try {
expect((await getNextChapter(novelId, 0, '1'))?.position).toBe(1);
expect((await getPrevChapter(novelId, 4, '1'))?.position).toBe(3);
expect(executeRaw).toHaveBeenCalledTimes(2);
for (const call of executeRaw.mock.calls) {
expect(call[0]).toMatch(/limit \?/i);
expect(call[1]?.at(-1)).toBe(1);
}
for (const result of executeRaw.mock.results) {
expect(await result.value).toHaveLength(1);
}
} finally {
executeRaw.mockRestore();
}
});
it('returns no neighbor before the first or after the last chapter', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb);
await insertTestChapter(testDb, novelId, { page: '1', position: 0 });
await insertTestChapter(testDb, novelId, { page: '2', position: 1 });
expect(await getPrevChapter(novelId, 0, '1')).toBeUndefined();
expect(await getNextChapter(novelId, 1, '2')).toBeUndefined();
});
it('breaks duplicate position ties by id in the navigation direction', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb);
const firstId = await insertTestChapter(testDb, novelId, { position: 1 });
const secondId = await insertTestChapter(testDb, novelId, {
position: 1,
});
expect((await getNextChapter(novelId, 0, '1'))?.id).toBe(firstId);
expect((await getPrevChapter(novelId, 2, '1'))?.id).toBe(secondId);
expect(await getNextChapter(novelId, 1, '1')).toBeUndefined();
expect(await getPrevChapter(novelId, 1, '1')).toBeUndefined();
});
it('preserves numeric-looking page comparisons and integer page ordering', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb);
const page1 = await insertTestChapter(testDb, novelId, { page: '1' });
const page02 = await insertTestChapter(testDb, novelId, { page: '02' });
const page2 = await insertTestChapter(testDb, novelId, { page: '2' });
const page10 = await insertTestChapter(testDb, novelId, { page: '10' });
// The cast parameter gives comparisons numeric affinity: '02' and '2'
// compare equally, and page 10 sorts after page 2.
expect((await getPrevChapter(novelId, 0, '2'))?.id).toBe(page1);
expect((await getPrevChapter(novelId, 0, '10'))?.id).toBe(page2);
expect((await getNextChapter(novelId, 0, '02'))?.id).toBe(page10);
expect((await getPrevChapter(novelId, 0, '02'))?.id).toBe(page1);
expect((await getNextChapter(novelId, 0, '1'))?.id).toBe(page02);
});
it('preserves integer-cast behavior for source-defined page labels', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb);
await insertTestChapter(testDb, novelId, {
page: 'Volume A',
position: 2,
});
const chapterId = await insertTestChapter(testDb, novelId, {
page: 'Volume B',
position: 0,
});
// Labels cast to zero, so existing eligibility treats both as later
// pages and sorting resolves their equal page casts by position.
expect(await getPrevChapter(novelId, 1, 'Volume A')).toBeUndefined();
expect((await getNextChapter(novelId, 1, 'Volume A'))?.id).toBe(
chapterId,
);
});
it('skips excluded chapters at the previous page boundary', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb);
const chapterId = await insertTestChapter(testDb, novelId, {
page: '1',
position: 0,
scanlator: null,
});
await insertTestChapter(testDb, novelId, {
page: '1',
position: 1,
scanlator: 'Excluded',
});
expect((await getPrevChapter(novelId, 0, '2', ['Excluded']))?.id).toBe(
chapterId,
);
});
});
describe('getPrevChapter', () => {
it('should return previous chapter in same page', async () => {
const testDb = getTestDb();
@@ -66,6 +66,8 @@ export const initialChapterGeneralSettings = {
bionicReading: false,
tapToScroll: false,
TTSEnable: true,
pageReaderInvertVolumeButtons: false,
pageReaderDisableAnimation: false,
};
export const initialChapterReaderSettings = {
+4
View File
@@ -194,6 +194,8 @@ export interface ChapterGeneralSettings {
bionicReading: boolean;
tapToScroll: boolean;
TTSEnable: boolean;
pageReaderInvertVolumeButtons: boolean;
pageReaderDisableAnimation: boolean;
}
export interface ReaderTheme {
@@ -319,6 +321,8 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
bionicReading: false,
tapToScroll: false,
TTSEnable: true,
pageReaderInvertVolumeButtons: false,
pageReaderDisableAnimation: false,
};
export const initialChapterReaderSettings: ChapterReaderSettings = {
+4
View File
@@ -668,6 +668,10 @@
"padding": "Padding",
"pageReader": "Paged reading (Experimental)",
"pageReaderDescription": "Split the chapter into pages instead of scrolling. Experimental — may break formatting in some novels",
"invertVolumeButtons": "Invert volume buttons",
"invertVolumeButtonsDescription": "Swap volume up and down buttons for previous and next page",
"disablePageTransitions": "Disable page transition animation",
"disablePageTransitionsDescription": "Turn pages instantly without slide animation",
"removeExtraSpacing": "Remove extra spacing",
"removeExtraSpacingDescription": "Collapse extra blank lines between paragraphs",
"scrollAmount": "Scroll amount (screen height by default)",
+6 -2
View File
@@ -662,8 +662,12 @@
"lineHeight": "Line height",
"navigation": "Navigation",
"padding": "Khoảng đệm",
"pageReader": "Paged reading (Experimental)",
"pageReaderDescription": "Split the chapter into pages instead of scrolling. Experimental — may break formatting in some novels",
"pageReader": "Đọc phân trang (Thử nghiệm)",
"pageReaderDescription": "Phân chương ra thành trang thay vì scroll. Thử nghiệm - có thể làm hỏng format một số tiểu thuyết",
"invertVolumeButtons": "Đảo nút âm lượng",
"invertVolumeButtonsDescription": "Đảo nút tăng và giảm âm lượng cho trang trước và sau",
"disablePageTransitions": "Tắt hoạt hình chuyển trạng thái trang",
"disablePageTransitionsDescription": "Chuyển trang ngay lập tức mà không dùng hoạt hình trượt",
"removeExtraSpacing": "Remove extra spacing",
"removeExtraSpacingDescription": "Collapse extra blank lines between paragraphs",
"scrollAmount": "Scroll amount (screen height by default)",
+4
View File
@@ -566,6 +566,10 @@ export interface StringMap {
'readerScreen.bottomSheet.padding': 'string';
'readerScreen.bottomSheet.pageReader': 'string';
'readerScreen.bottomSheet.pageReaderDescription': 'string';
'readerScreen.bottomSheet.invertVolumeButtons': 'string';
'readerScreen.bottomSheet.invertVolumeButtonsDescription': 'string';
'readerScreen.bottomSheet.disablePageTransitions': 'string';
'readerScreen.bottomSheet.disablePageTransitionsDescription': 'string';
'readerScreen.bottomSheet.removeExtraSpacing': 'string';
'readerScreen.bottomSheet.removeExtraSpacingDescription': 'string';
'readerScreen.bottomSheet.scrollAmount': 'string';
@@ -138,6 +138,19 @@ const navigationPreferences: GeneralPreference[] = [
},
];
const pagedModePreferences: GeneralPreference[] = [
{
key: 'pageReaderInvertVolumeButtons',
label: 'invertVolumeButtons',
description: 'invertVolumeButtonsDescription',
},
{
key: 'pageReaderDisableAnimation',
label: 'disablePageTransitions',
description: 'disablePageTransitionsDescription',
},
];
const GeneralTab: React.FC = React.memo(() => {
const theme = useTheme();
const { setChapterGeneralSettings, ...settings } =
@@ -182,7 +195,18 @@ const GeneralTab: React.FC = React.memo(() => {
<List.SubHeader theme={theme}>
{getString('readerScreen.bottomSheet.navigation')}
</List.SubHeader>
{navigationPreferences.map(renderPreference)}
{navigationPreferences.map(item => {
if (item.key === 'pageReader') {
return (
<React.Fragment key={item.key}>
{renderPreference(item)}
{settings.pageReader &&
pagedModePreferences.map(renderPreference)}
</React.Fragment>
);
}
return renderPreference(item);
})}
</BottomSheetScrollView>
);
});
+30 -28
View File
@@ -374,7 +374,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
</head>
<body class="${
chapterGeneralSettings.pageReader ? 'page-reader' : ''
}">
} ${
chapterGeneralSettings.pageReaderDisableAnimation ? 'no-animation' : ''
}">
<div class="transition-chapter" style="transform: ${
isNextChapterScreenVisible
? 'translateX(-100%)'
@@ -437,8 +439,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
chapter,
chapterGeneralSettings,
processedHtml,
customJS,
customCSS,
customJS,
customCSS,
initialReaderSettings,
novel,
plugin,
@@ -449,31 +451,31 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
]);
return (
<>
<WebView
ref={webViewRef}
onTouchStart={onTouchStart}
style={{ backgroundColor: readerSettings.theme }}
allowFileAccess={true}
originWhitelist={['*']}
scalesPageToFit={true}
showsVerticalScrollIndicator={false}
javaScriptEnabled={true}
webviewDebuggingEnabled={__DEV__}
onShouldStartLoadWithRequest={({ url }) => {
if (isPluginIssueReportUrl(url)) {
void Linking.openURL(url);
return false;
}
if (isChapterRefreshUrl(url)) {
refetch();
return false;
}
return true;
}}
onLoadEnd={() => {
webViewRef.current?.injectJavaScript(
`if (window.reader && window.reader.batteryLevel) {
<>
<WebView
ref={webViewRef}
onTouchStart={onTouchStart}
style={{ backgroundColor: readerSettings.theme }}
allowFileAccess={true}
originWhitelist={['*']}
scalesPageToFit={true}
showsVerticalScrollIndicator={false}
javaScriptEnabled={true}
webviewDebuggingEnabled={__DEV__}
onShouldStartLoadWithRequest={({ url }) => {
if (isPluginIssueReportUrl(url)) {
void Linking.openURL(url);
return false;
}
if (isChapterRefreshUrl(url)) {
refetch();
return false;
}
return true;
}}
onLoadEnd={() => {
webViewRef.current?.injectJavaScript(
`if (window.reader && window.reader.batteryLevel) {
window.reader.batteryLevel.val = ${lastKnownBatteryLevel};
}`,
);
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import useChapter from '../useChapter';
import NativeFile from '@modules/native-file';
import NativeVolumeButtonListener from '@modules/native-volume-button-listener';
const mockUseNovelActions = jest.fn();
const mockUseChapterGeneralSettings = jest.fn();
@@ -172,6 +173,9 @@ describe('useChapter', () => {
autoScrollOffset: 100,
useVolumeButtons: false,
volumeButtonsOffset: 100,
pageReader: false,
pageReaderInvertVolumeButtons: false,
pageReaderDisableAnimation: false,
});
mockUseLibrarySettings.mockReturnValue({ incognitoMode: false });
mockUseAppSettings.mockReturnValue({
@@ -423,4 +427,108 @@ describe('useChapter', () => {
expect(result.current.chapter.id).toBe(nextChapter.id);
expect(result.current.chapterText).toBe('SANITIZED:next body');
});
it('injects scripts supporting paged and normal modes when volume buttons are pressed', async () => {
const store = createStore();
mockUseNovelActions.mockReturnValue(store.state);
const mockInjectJavaScript = jest.fn();
const { result } = renderHook(() =>
useChapter(
{ current: { injectJavaScript: mockInjectJavaScript } as any },
initialChapter,
novel,
),
);
await waitFor(() =>
expect(result.current.chapterContext.loading).toBe(false),
);
const volumeUpCall = (
NativeVolumeButtonListener.addListener as jest.Mock
).mock.calls.find(([event]) => event === 'VolumeUp');
const volumeDownCall = (
NativeVolumeButtonListener.addListener as jest.Mock
).mock.calls.find(([event]) => event === 'VolumeDown');
expect(volumeUpCall).toBeDefined();
expect(volumeDownCall).toBeDefined();
// Trigger VolumeUp callback
volumeUpCall[1]();
expect(mockInjectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) - 1)',
),
);
expect(mockInjectJavaScript).toHaveBeenCalledWith(
expect.stringContaining('window.scrollBy'),
);
// Trigger VolumeDown callback
volumeDownCall[1]();
expect(mockInjectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) + 1)',
),
);
expect(mockInjectJavaScript).toHaveBeenCalledWith(
expect.stringContaining('window.scrollBy'),
);
});
it('inverts volume button directions in paged mode when pageReaderInvertVolumeButtons is enabled', async () => {
const store = createStore();
mockUseNovelActions.mockReturnValue(store.state);
mockUseChapterGeneralSettings.mockReturnValue({
autoScroll: false,
autoScrollInterval: 1,
autoScrollOffset: 100,
useVolumeButtons: false,
volumeButtonsOffset: 100,
pageReader: true,
pageReaderInvertVolumeButtons: true,
pageReaderDisableAnimation: false,
});
const mockInjectJavaScript = jest.fn();
const { result } = renderHook(() =>
useChapter(
{ current: { injectJavaScript: mockInjectJavaScript } as any },
initialChapter,
novel,
),
);
await waitFor(() =>
expect(result.current.chapterContext.loading).toBe(false),
);
const volumeUpCall = (
NativeVolumeButtonListener.addListener as jest.Mock
).mock.calls.find(([event]) => event === 'VolumeUp');
const volumeDownCall = (
NativeVolumeButtonListener.addListener as jest.Mock
).mock.calls.find(([event]) => event === 'VolumeDown');
expect(volumeUpCall).toBeDefined();
expect(volumeDownCall).toBeDefined();
// VolumeUp callback should move +1 (forward) when inverted
volumeUpCall[1]();
expect(mockInjectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) + 1)',
),
);
// VolumeDown callback should move -1 (backward) when inverted
volumeDownCall[1]();
expect(mockInjectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) - 1)',
),
);
});
});
+21 -2
View File
@@ -74,6 +74,8 @@ export default function useChapter(
autoScrollOffset,
useVolumeButtons,
volumeButtonsOffset,
pageReader,
pageReaderInvertVolumeButtons,
} = useChapterGeneralSettings();
const { incognitoMode } = useLibrarySettings();
const { timeTrackingEnabled, inactivityTimeoutMs } = useAppSettings();
@@ -124,15 +126,32 @@ export default function useChapter(
Math.round(Dimensions.get('window').height * 0.75),
);
const volumeUpDelta = pageReaderInvertVolumeButtons ? 1 : -1;
const volumeDownDelta = pageReaderInvertVolumeButtons ? -1 : 1;
useEventListener(NativeVolumeButtonListener, 'VolumeUp', () => {
webViewRef.current?.injectJavaScript(`(()=>{
window.scrollBy({top: -${volumeButtonOffset}, behavior: 'smooth'})
const isPaged = ${Boolean(pageReader)};
if (isPaged && window.pageReader) {
window.pageReader.movePage((window.pageReader.page?.val ?? 0) ${
volumeUpDelta >= 0 ? '+' : '-'
} 1);
} else {
window.scrollBy({top: -${volumeButtonOffset}, behavior: 'smooth'});
}
})()`);
});
useEventListener(NativeVolumeButtonListener, 'VolumeDown', () => {
webViewRef.current?.injectJavaScript(`(()=>{
window.scrollBy({top: ${volumeButtonOffset}, behavior: 'smooth'})
const isPaged = ${Boolean(pageReader)};
if (isPaged && window.pageReader) {
window.pageReader.movePage((window.pageReader.page?.val ?? 0) ${
volumeDownDelta >= 0 ? '+' : '-'
} 1);
} else {
window.scrollBy({top: ${volumeButtonOffset}, behavior: 'smooth'});
}
})()`);
});
@@ -16,6 +16,8 @@ const NavigationTab: React.FC = () => {
verticalSeekbar = true,
swipeGestures = false,
pageReader = false,
pageReaderInvertVolumeButtons = false,
pageReaderDisableAnimation = false,
autoScroll = false,
autoScrollInterval = 10,
autoScrollOffset = null,
@@ -119,6 +121,38 @@ const NavigationTab: React.FC = () => {
onPress={() => setChapterGeneralSettings({ pageReader: !pageReader })}
theme={theme}
/>
{pageReader && (
<>
<SettingSwitch
label={getString('readerScreen.bottomSheet.invertVolumeButtons')}
description={getString(
'readerScreen.bottomSheet.invertVolumeButtonsDescription',
)}
value={pageReaderInvertVolumeButtons}
onPress={() =>
setChapterGeneralSettings({
pageReaderInvertVolumeButtons: !pageReaderInvertVolumeButtons,
})
}
theme={theme}
/>
<SettingSwitch
label={getString(
'readerScreen.bottomSheet.disablePageTransitions',
)}
description={getString(
'readerScreen.bottomSheet.disablePageTransitionsDescription',
)}
value={pageReaderDisableAnimation}
onPress={() =>
setChapterGeneralSettings({
pageReaderDisableAnimation: !pageReaderDisableAnimation,
})
}
theme={theme}
/>
</>
)}
</View>
<View style={styles.section}>