Fix #480/#165 #479 #443 #541 #554 #452: reader and download fixes

- #480/#165: Fix double page mode chapter navigation index conversion
- #479: Fix page jump when switching single/double page mode
- #443: Clear zoom cache on page change for swipe-back
- #541: Add shift-click range selection for chapters (desktop)
- #554: Auto-clean orphaned download records, cascade delete
- #452: Add horizontal continuous RTL reader mode
This commit is contained in:
Moustapha Kodjo Amadou 2026-03-18 09:47:41 +01:00
parent 566da0ae06
commit f1dfbbaefc
14 changed files with 195 additions and 44 deletions

View file

@ -1213,6 +1213,7 @@ enum ReaderMode {
verticalContinuous,
webtoon,
horizontalContinuous,
horizontalContinuousRTL,
}
enum NovelTextAlign { left, center, right, block }

View file

@ -2549,6 +2549,7 @@ const _SettingsdefaultReaderModeEnumValueMap = {
'verticalContinuous': 3,
'webtoon': 4,
'horizontalContinuous': 5,
'horizontalContinuousRTL': 6,
};
const _SettingsdefaultReaderModeValueEnumMap = {
0: ReaderMode.vertical,
@ -2557,6 +2558,7 @@ const _SettingsdefaultReaderModeValueEnumMap = {
3: ReaderMode.verticalContinuous,
4: ReaderMode.webtoon,
5: ReaderMode.horizontalContinuous,
6: ReaderMode.horizontalContinuousRTL,
};
const _SettingsdisableSectionTypeEnumValueMap = {
'all': 0,
@ -21780,6 +21782,7 @@ const _PersonalReaderModereaderModeEnumValueMap = {
'verticalContinuous': 3,
'webtoon': 4,
'horizontalContinuous': 5,
'horizontalContinuousRTL': 6,
};
const _PersonalReaderModereaderModeValueEnumMap = {
0: ReaderMode.vertical,
@ -21788,6 +21791,7 @@ const _PersonalReaderModereaderModeValueEnumMap = {
3: ReaderMode.verticalContinuous,
4: ReaderMode.webtoon,
5: ReaderMode.horizontalContinuous,
6: ReaderMode.horizontalContinuousRTL,
};
extension PersonalReaderModeQueryFilter

View file

@ -179,6 +179,8 @@ void _removeImport(WidgetRef ref, Manga manga) {
isar.updates.deleteSync(update.id!);
provider.addChangedPart(ActionType.removeUpdate, update.id, "{}", false);
}
// Remove associated download record to prevent ghost entries
isar.downloads.deleteSync(chapter.id!);
isar.chapters.deleteSync(chapter.id!);
provider.addChangedPart(ActionType.removeChapter, chapter.id, "{}", false);
}

View file

@ -890,6 +890,7 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
return ChapterListTileWidget(
chapter: chapters[indexx],
chapterList: chapterList,
allChapters: chapters,
sourceExist: widget.sourceExist,
);
},

View file

@ -47,6 +47,30 @@ class ChaptersListState extends _$ChaptersListState {
state = newList;
}
/// Select all chapters between the last selected and [clicked] in [allChapters].
void selectRange(Chapter clicked, List<Chapter> allChapters) {
if (state.isEmpty) {
update(clicked);
return;
}
final lastSelected = state.last;
final lastIdx = allChapters.indexOf(lastSelected);
final clickedIdx = allChapters.indexOf(clicked);
if (lastIdx == -1 || clickedIdx == -1) {
update(clicked);
return;
}
final start = lastIdx < clickedIdx ? lastIdx : clickedIdx;
final end = lastIdx < clickedIdx ? clickedIdx : lastIdx;
var newList = List<Chapter>.from(state);
for (int i = start; i <= end; i++) {
if (!newList.contains(allChapters[i])) {
newList.add(allChapters[i]);
}
}
state = newList;
}
void clear() {
state = [];
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
@ -20,10 +21,12 @@ import 'package:photo_view/photo_view_gallery.dart';
class ChapterListTileWidget extends ConsumerWidget {
final Chapter chapter;
final List<Chapter> chapterList;
final List<Chapter> allChapters;
final bool sourceExist;
const ChapterListTileWidget({
required this.chapterList,
required this.chapter,
required this.allChapters,
required this.sourceExist,
super.key,
});
@ -243,7 +246,14 @@ class ChapterListTileWidget extends ConsumerWidget {
void _handleInteraction(WidgetRef ref, [BuildContext? context]) {
final isLongPressed = ref.read(isLongPressedStateProvider);
if (isLongPressed) {
ref.read(chaptersListStateProvider.notifier).update(chapter);
// Shift-click range selection on desktop
if (HardwareKeyboard.instance.isShiftPressed) {
ref
.read(chaptersListStateProvider.notifier)
.selectRange(chapter, allChapters);
} else {
ref.read(chaptersListStateProvider.notifier).update(chapter);
}
} else {
if (context != null) {
chapter.pushToReaderView(context, ignoreIsRead: true);

View file

@ -33,6 +33,7 @@ class ImageViewWebtoon extends StatelessWidget {
final VoidCallback onDoubleTap;
final int webtoonSidePadding;
final bool showPageGaps;
final bool reverse;
const ImageViewWebtoon({
super.key,
@ -58,6 +59,7 @@ class ImageViewWebtoon extends StatelessWidget {
required this.onDoubleTap,
this.webtoonSidePadding = 0,
this.showPageGaps = true,
this.reverse = false,
});
@override
@ -71,6 +73,7 @@ class ImageViewWebtoon extends StatelessWidget {
onScaleEnd: (context, details, controllerValue) => onScaleEnd(details),
child: ScrollablePositionedList.separated(
scrollDirection: scrollDirection,
reverse: reverse,
minCacheExtent: minCacheExtent,
initialScrollIndex: initialScrollIndex,
itemCount: pages.length,

View file

@ -185,9 +185,13 @@ class _MangaChapterPageGalleryState
);
}
discordRpc?.showIdleText();
final index = pages[_currentIndex!].index;
final actualIdx = _pageViewToActualIndex(_currentIndex!);
final index = pages[actualIdx].index;
if (index != null) {
_readerController.setPageIndex(_geCurrentIndex(index), true);
_readerController.setPageIndex(
_isDoublePageActive ? index : _geCurrentIndex(index),
true,
);
}
disposePreloadManager();
_readerController.keepAliveLink?.close();
@ -199,9 +203,13 @@ class _MangaChapterPageGalleryState
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
final index = pages[_currentIndex!].index;
final actualIdx = _pageViewToActualIndex(_currentIndex!);
final index = pages[actualIdx].index;
if (index != null) {
_readerController.setPageIndex(_geCurrentIndex(index), true);
_readerController.setPageIndex(
_isDoublePageActive ? index : _geCurrentIndex(index),
true,
);
}
}
}
@ -330,7 +338,8 @@ class _MangaChapterPageGalleryState
final cropBorders = ref.watch(cropBordersStateProvider);
final readerMode = ref.watch(_currentReaderMode);
final bool isHorizontalContinuaous =
readerMode == ReaderMode.horizontalContinuous;
readerMode == ReaderMode.horizontalContinuous ||
readerMode == ReaderMode.horizontalContinuousRTL;
if (cropBorders) {
_processCropBorders();
}
@ -443,6 +452,7 @@ class _MangaChapterPageGalleryState
webtoonSidePaddingStateProvider,
),
showPageGaps: ref.watch(showPageGapsStateProvider),
reverse: _isReverseHorizontal,
)
: Material(
color: getBackgroundColor(backgroundColor),
@ -459,13 +469,6 @@ class _MangaChapterPageGalleryState
return true;
},
itemBuilder: (context, index) {
if (index < pages.length &&
pages[index].isTransitionPage) {
return TransitionViewPaged(
data: pages[index],
);
}
int index1 = index * 2 - 1;
int index2 = index1 + 1;
final pageList = (index == 0
@ -838,15 +841,24 @@ class _MangaChapterPageGalleryState
},
onPageModeToggle: () async {
final readerMode = ref.read(_currentReaderMode);
if (!(readerMode == ReaderMode.horizontalContinuous)) {
if (!(readerMode == ReaderMode.horizontalContinuous ||
readerMode == ReaderMode.horizontalContinuousRTL)) {
// Get the actual page index being viewed
final actualIdx = _pageViewToActualIndex(
_currentIndex!,
);
final pageIdx = pages[actualIdx].index ?? 0;
// Compute target index for the new mode
final int targetIndex;
if (_pageMode == PageMode.onePage) {
// Switching to double page: convert actual index to page view index
targetIndex = pageIdx == 0 ? 0 : (pageIdx + 1) ~/ 2;
} else {
// Switching to single page: use the actual page index
targetIndex = pageIdx;
}
navigationService.jumpToPage(
index: _pageMode == PageMode.onePage
? (_geCurrentIndex(
pages[_currentIndex!].index!,
) /
2)
.ceil()
: _geCurrentIndex(pages[_currentIndex!].index!),
index: targetIndex,
readerMode: ref.read(_currentReaderMode)!,
);
PageMode newPageMode = _pageMode == PageMode.onePage
@ -941,7 +953,9 @@ class _MangaChapterPageGalleryState
int pagesLength =
(_pageMode == PageMode.doublePage &&
!(ref.watch(_currentReaderMode) ==
ReaderMode.horizontalContinuous))
ReaderMode.horizontalContinuous ||
ref.watch(_currentReaderMode) ==
ReaderMode.horizontalContinuousRTL))
? (pages.length / 2).ceil() + 1
: pages.length;
if (_currentIndex! >= 0 && _currentIndex! < pagesLength) {
@ -1083,7 +1097,16 @@ class _MangaChapterPageGalleryState
preloadPreviousChapter(chapterData, chap).then((prependCount) {
if (prependCount > 0 && mounted) {
_isAdjustingScroll = true;
_currentIndex = _currentIndex! + prependCount;
// In double page mode, _currentIndex stores the page view index,
// so convert the prepended page count to page view units.
if (_isDoublePageActive) {
// Recompute the page view index from the new actual index.
final oldActual = _pageViewToActualIndex(_currentIndex!);
final newActual = oldActual + prependCount;
_currentIndex = _actualToPageViewIndex(newActual);
} else {
_currentIndex = _currentIndex! + prependCount;
}
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@ -1153,22 +1176,32 @@ class _MangaChapterPageGalleryState
}
Future<void> _onPageChanged(int index) async {
// In non-continuous double page mode, convert page view index to actual
// pages array index for correct lookups.
final int actualIndex = _pageViewToActualIndex(index);
final int prevActualIndex = _pageViewToActualIndex(_currentIndex!);
final cropBorders = ref.watch(cropBordersStateProvider);
if (cropBorders) {
_processCropBordersByIndex(index);
}
final idx = pages[_currentIndex!].index;
final idx = pages[prevActualIndex].index;
if (idx != null) {
_readerController.setPageIndex(_geCurrentIndex(idx), false);
_readerController.setPageIndex(
_isDoublePageActive ? idx : _geCurrentIndex(idx),
false,
);
}
if (_readerController.chapter.id != pages[index].chapter!.id) {
if (_readerController.chapter.id != pages[actualIndex].chapter!.id) {
if (mounted) {
setState(() {
_readerController = ref.read(
readerControllerProvider(chapter: pages[index].chapter!).notifier,
readerControllerProvider(
chapter: pages[actualIndex].chapter!,
).notifier,
);
chapter = pages[index].chapter!;
final chapterUrlModel = pages[index].chapterUrlModel;
chapter = pages[actualIndex].chapter!;
final chapterUrlModel = pages[actualIndex].chapterUrlModel;
if (chapterUrlModel != null) {
_chapterUrlModel = chapterUrlModel;
}
@ -1176,21 +1209,23 @@ class _MangaChapterPageGalleryState
});
}
}
// Reset zoom of the previous page so user can swipe back freely (#443).
clearGestureDetailsCache();
_currentIndex = index;
if (pages[index].index != null) {
if (pages[actualIndex].index != null) {
ref
.read(currentIndexProvider(chapter).notifier)
.setCurrentIndex(pages[index].index!);
.setCurrentIndex(pages[actualIndex].index!);
}
// Next-chapter preloading: trigger when near the end
final distToEnd = pages.length - 1 - index;
final distToEnd = pages.length - 1 - actualIndex;
if (distToEnd <= pagePreloadAmount && !_isLastPageTransition) {
_triggerNextChapterPreload();
}
// Previous-chapter preloading: trigger when near the start
if (index <= pagePreloadAmount) {
if (actualIndex <= pagePreloadAmount) {
_triggerPrevChapterPreload();
}
}
@ -1258,7 +1293,10 @@ class _MangaChapterPageGalleryState
int index =
(_pageMode == PageMode.doublePage &&
!(ref.watch(_currentReaderMode) == ReaderMode.horizontalContinuous))
!(ref.watch(_currentReaderMode) ==
ReaderMode.horizontalContinuous ||
ref.watch(_currentReaderMode) ==
ReaderMode.horizontalContinuousRTL))
? (_currentIndex! / 2).ceil()
: _currentIndex!;
ref.read(_currentReaderMode.notifier).state = value;
@ -1292,7 +1330,7 @@ class _MangaChapterPageGalleryState
} else {
if (mounted) {
setState(() {
_isReverseHorizontal = false;
_isReverseHorizontal = value == ReaderMode.horizontalContinuousRTL;
});
// Wait for the next frame so the scroll view rebuilds
await WidgetsBinding.instance.endOfFrame;
@ -1384,10 +1422,38 @@ class _MangaChapterPageGalleryState
return !(index * 2 < pageLength) ? pageLength - 1 : index1 - 1;
}
/// Whether double page mode is active (continuous or paged).
/// Horizontal continuous mode does NOT use double page layout.
bool get _isDoublePageActive =>
_pageMode == PageMode.doublePage &&
ref.read(_currentReaderMode) != ReaderMode.horizontalContinuous &&
ref.read(_currentReaderMode) != ReaderMode.horizontalContinuousRTL;
/// Converts a page view index (from ExtendedPageController) to the actual
/// index in the [pages] array for double page mode.
///
/// In double page mode:
/// PV 0 pages[0] (first page shown solo)
/// PV n (n>0) pages[2n-1] (first page of the pair)
int _pageViewToActualIndex(int pageViewIndex) {
if (!_isDoublePageActive) return pageViewIndex;
if (pageViewIndex <= 0) return 0;
return (pageViewIndex * 2 - 1).clamp(0, pages.length - 1);
}
/// Converts an actual [pages] array index to a page view index
/// for double page mode.
int _actualToPageViewIndex(int actualIndex) {
if (!_isDoublePageActive) return actualIndex;
if (actualIndex <= 0) return 0;
return (actualIndex + 1) ~/ 2;
}
bool _isContinuousMode() {
final readerMode = ref.watch(_currentReaderMode);
return readerMode == ReaderMode.verticalContinuous ||
readerMode == ReaderMode.webtoon ||
readerMode == ReaderMode.horizontalContinuous;
readerMode == ReaderMode.horizontalContinuous ||
readerMode == ReaderMode.horizontalContinuousRTL;
}
}

View file

@ -108,7 +108,8 @@ class PageNavigationService {
bool _isContinuousMode(ReaderMode mode) {
return mode == ReaderMode.verticalContinuous ||
mode == ReaderMode.webtoon ||
mode == ReaderMode.horizontalContinuous;
mode == ReaderMode.horizontalContinuous ||
mode == ReaderMode.horizontalContinuousRTL;
}
}

View file

@ -109,7 +109,8 @@ class ReaderBottomBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final readerMode = ref.watch(currentReaderModeProvider);
final isHorizontalContinuous =
readerMode == ReaderMode.horizontalContinuous;
readerMode == ReaderMode.horizontalContinuous ||
readerMode == ReaderMode.horizontalContinuousRTL;
return Positioned(
bottom: 0,

View file

@ -134,7 +134,8 @@ class _ReadingModeTab extends ConsumerWidget {
final isContinuousMode =
readerMode == ReaderMode.verticalContinuous ||
readerMode == ReaderMode.webtoon ||
readerMode == ReaderMode.horizontalContinuous;
readerMode == ReaderMode.horizontalContinuous ||
readerMode == ReaderMode.horizontalContinuousRTL;
return SingleChildScrollView(
child: Padding(

View file

@ -27,8 +27,33 @@ class DownloadQueueScreen extends ConsumerWidget {
.watch(fireImmediately: true),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
final entries = snapshot.data!;
final allQueueLength = entries.toList().length;
// Filter out orphaned downloads (chapter or manga deleted)
final allEntries = snapshot.data!;
final orphanIds = <int>[];
final entries = <Download>[];
for (final d in allEntries) {
if (d.chapter.value == null ||
d.chapter.value?.manga.value == null) {
if (d.id != null) orphanIds.add(d.id!);
} else {
entries.add(d);
}
}
// Auto-clean orphaned download records
if (orphanIds.isNotEmpty) {
isar.writeTxnSync(() {
for (final id in orphanIds) {
isar.downloads.deleteSync(id);
}
});
}
if (entries.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(l10n!.download_queue)),
body: Center(child: Text(l10n.no_downloads)),
);
}
final allQueueLength = entries.length;
return Scaffold(
appBar: AppBar(
title: Row(
@ -122,9 +147,16 @@ class DownloadQueueScreen extends ConsumerWidget {
child: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value.toString() == 'Cancel') {
element.chapter.value?.cancelDownloads(
element.id!,
);
if (element.chapter.value != null) {
element.chapter.value!.cancelDownloads(
element.id!,
);
} else {
// Orphaned download just delete the record
isar.writeTxnSync(() {
isar.downloads.deleteSync(element.id!);
});
}
} else if (value.toString() == 'CancelAll') {
final a = entries
.where(

View file

@ -8,6 +8,7 @@ import 'package:mangayomi/eval/model/m_bridge.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/changed.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/download.dart';
import 'package:mangayomi/models/history.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
@ -434,6 +435,7 @@ void _showCleanNonLibraryDialog(BuildContext context, dynamic l10n) {
false,
);
}
isar.downloads.deleteSync(chapter.id!);
isar.chapters.deleteSync(chapter.id!);
provider.addChangedPart(
ActionType.removeChapter,
@ -568,6 +570,7 @@ void _showClearLibraryDialog(BuildContext context, WidgetRef ref) {
false,
);
}
isar.downloads.deleteSync(chapter.id!);
isar.chapters.deleteSync(chapter.id!);
provider.addChangedPart(
ActionType.removeChapter,

View file

@ -480,6 +480,8 @@ String getReaderModeName(ReaderMode readerMode, BuildContext context) {
ReaderMode.ltr => context.l10n.reading_mode_left_to_right,
ReaderMode.rtl => context.l10n.reading_mode_right_to_left,
ReaderMode.horizontalContinuous => context.l10n.horizontal_continious,
ReaderMode.horizontalContinuousRTL =>
"${context.l10n.horizontal_continious} (RTL)",
_ => context.l10n.reading_mode_webtoon,
};
}