diff --git a/lib/models/settings.dart b/lib/models/settings.dart index ba671dbb..d5ad2861 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1213,6 +1213,7 @@ enum ReaderMode { verticalContinuous, webtoon, horizontalContinuous, + horizontalContinuousRTL, } enum NovelTextAlign { left, center, right, block } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 299c6e8a..b1d5df64 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -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 diff --git a/lib/modules/library/widgets/library_dialogs.dart b/lib/modules/library/widgets/library_dialogs.dart index 939c4e3c..0779fd4e 100644 --- a/lib/modules/library/widgets/library_dialogs.dart +++ b/lib/modules/library/widgets/library_dialogs.dart @@ -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); } diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index f3daf735..1cf97e5a 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -890,6 +890,7 @@ class _MangaDetailViewState extends ConsumerState return ChapterListTileWidget( chapter: chapters[indexx], chapterList: chapterList, + allChapters: chapters, sourceExist: widget.sourceExist, ); }, diff --git a/lib/modules/manga/detail/providers/state_providers.dart b/lib/modules/manga/detail/providers/state_providers.dart index 3af7e69d..4c22d407 100644 --- a/lib/modules/manga/detail/providers/state_providers.dart +++ b/lib/modules/manga/detail/providers/state_providers.dart @@ -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 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.from(state); + for (int i = start; i <= end; i++) { + if (!newList.contains(allChapters[i])) { + newList.add(allChapters[i]); + } + } + state = newList; + } + void clear() { state = []; } diff --git a/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart b/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart index 9779b935..f8062a3e 100644 --- a/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart +++ b/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart @@ -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 chapterList; + final List 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); diff --git a/lib/modules/manga/reader/image_view_webtoon.dart b/lib/modules/manga/reader/image_view_webtoon.dart index 00862206..094e583a 100644 --- a/lib/modules/manga/reader/image_view_webtoon.dart +++ b/lib/modules/manga/reader/image_view_webtoon.dart @@ -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, diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 74900e24..884dca63 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -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 _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; } } diff --git a/lib/modules/manga/reader/services/page_navigation_service.dart b/lib/modules/manga/reader/services/page_navigation_service.dart index a72b5bab..7f52e4f4 100644 --- a/lib/modules/manga/reader/services/page_navigation_service.dart +++ b/lib/modules/manga/reader/services/page_navigation_service.dart @@ -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; } } diff --git a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart index 2a3dcfb1..31af194e 100644 --- a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart @@ -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, diff --git a/lib/modules/manga/reader/widgets/reader_settings_modal.dart b/lib/modules/manga/reader/widgets/reader_settings_modal.dart index ec7deed9..b5c89a18 100644 --- a/lib/modules/manga/reader/widgets/reader_settings_modal.dart +++ b/lib/modules/manga/reader/widgets/reader_settings_modal.dart @@ -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( diff --git a/lib/modules/more/download_queue/download_queue_screen.dart b/lib/modules/more/download_queue/download_queue_screen.dart index ca026729..b206181f 100644 --- a/lib/modules/more/download_queue/download_queue_screen.dart +++ b/lib/modules/more/download_queue/download_queue_screen.dart @@ -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 = []; + final entries = []; + 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( diff --git a/lib/modules/more/settings/browse/browse_screen.dart b/lib/modules/more/settings/browse/browse_screen.dart index 7fe95c39..7aeb4c4e 100644 --- a/lib/modules/more/settings/browse/browse_screen.dart +++ b/lib/modules/more/settings/browse/browse_screen.dart @@ -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, diff --git a/lib/modules/more/settings/reader/reader_screen.dart b/lib/modules/more/settings/reader/reader_screen.dart index 31359c10..4520a1a9 100644 --- a/lib/modules/more/settings/reader/reader_screen.dart +++ b/lib/modules/more/settings/reader/reader_screen.dart @@ -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, }; }