Merge pull request #703 from NBA2K1/fix-reader

Fix reader and Code Cleanup
This commit is contained in:
Moustapha Kodjo Amadou 2026-04-21 16:54:08 +01:00 committed by GitHub
commit 8595d62c71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 994 additions and 1171 deletions

View file

@ -279,7 +279,7 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage>
final ValueNotifier<double> _playbackSpeed = ValueNotifier(1.0);
final ValueNotifier<bool> _isDoubleSpeed = ValueNotifier(false);
late final ValueNotifier<Duration> _currentPosition = ValueNotifier(
_streamController.geTCurrentPosition(),
_streamController.getCurrentPosition(),
);
final ValueNotifier<Duration?> _currentTotalDuration = ValueNotifier(null);
final ValueNotifier<bool> _showFitLabel = ValueNotifier(false);
@ -899,11 +899,11 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo
_completed;
_currentTotalDurationSub;
_loadAndroidFont().then((_) {
_openMedia(_video.value!, _streamController.geTCurrentPosition());
_openMedia(_video.value!, _streamController.getCurrentPosition());
if (widget.isTorrent) {
Future.delayed(const Duration(seconds: 10)).then((_) {
if (mounted) {
_openMedia(_video.value!, _streamController.geTCurrentPosition());
_openMedia(_video.value!, _streamController.getCurrentPosition());
}
});
}
@ -1020,8 +1020,8 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo
_currentTotalDuration.value,
save: save,
);
_streamController.setAnimeHistoryUpdate(
watchTimeSeconds: saveWatchTime ? _watchStopwatch.elapsed.inSeconds : 0,
_streamController.setHistoryUpdate(
elapsedSeconds: saveWatchTime ? _watchStopwatch.elapsed.inSeconds : 0,
);
}

View file

@ -2,11 +2,10 @@ import 'package:flutter_riverpod/misc.dart';
import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/history.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart';
import 'package:mangayomi/utils/extensions/chapter.dart';
import 'package:mangayomi/modules/more/settings/player/providers/player_state_provider.dart';
import 'package:mangayomi/services/aniskip.dart';
import 'package:mangayomi/utils/chapter_recognition.dart';
@ -17,7 +16,8 @@ part 'anime_player_controller_provider.g.dart';
final fullscreenProvider = StateProvider<bool>(() => false);
@riverpod
class AnimeStreamController extends _$AnimeStreamController {
class AnimeStreamController extends _$AnimeStreamController
with ChapterControllerMixin {
@override
KeepAliveLink build({required Chapter episode}) {
_keepAliveLink = ref.keepAlive();
@ -25,101 +25,35 @@ class AnimeStreamController extends _$AnimeStreamController {
}
KeepAliveLink? _keepAliveLink;
KeepAliveLink? get keepAliveLink => _keepAliveLink;
Manga getAnime() {
return episode.manga.value!;
}
final incognitoMode = isar.settings.getSync(227)!.incognitoMode!;
// Bridge the mixin's `chapter` contract to the `episode` build parameter.
@override
Chapter get chapter => episode;
Settings getIsarSetting() {
return isar.settings.getSync(227)!;
}
// Keep incognitoMode as a final field (read once, not on every access).
@override
final bool incognitoMode = isar.settings.getSync(227)!.incognitoMode!;
(int, bool) getEpisodeIndex() {
final episodes = getAnime().getFilteredChapterList();
int? index;
for (var i = 0; i < episodes.length; i++) {
if (episodes[i].id == episode.id) {
index = i;
}
}
if (index == null) {
final episodes = getAnime().chapters.toList().reversed.toList();
for (var i = 0; i < episodes.length; i++) {
if (episodes[i].id == episode.id) {
index = i;
}
}
return (index!, false);
}
return (index, true);
}
// ---------------------------------------------------------------------------
// Anime-flavoured aliases (preserve the existing public API)
// ---------------------------------------------------------------------------
(int, bool) getPrevEpisodeIndex() {
final episodes = getAnime().getFilteredChapterList();
int? index;
for (var i = 0; i < episodes.length; i++) {
if (episodes[i].id == episode.id) {
index = i + 1;
}
}
if (index == null) {
final episodes = getAnime().chapters.toList().reversed.toList();
for (var i = 0; i < episodes.length; i++) {
if (episodes[i].id == episode.id) {
index = i + 1;
}
}
return (index!, false);
}
return (index, true);
}
(int, bool) getEpisodeIndex() => getChapterIndex();
(int, bool) getNextEpisodeIndex() {
final episodes = getAnime().getFilteredChapterList();
int? index;
for (var i = 0; i < episodes.length; i++) {
if (episodes[i].id == episode.id) {
index = i - 1;
}
}
if (index == null) {
final episodes = getAnime().chapters.toList().reversed.toList();
for (var i = 0; i < episodes.length; i++) {
if (episodes[i].id == episode.id) {
index = i - 1;
}
}
return (index!, false);
}
return (index, true);
}
Chapter getPrevEpisode() => getPrevChapter();
Chapter getNextEpisode() => getNextChapter();
Chapter getPrevEpisode() {
final prevEpIdx = getPrevEpisodeIndex();
return prevEpIdx.$2
? getAnime().getFilteredChapterList()[prevEpIdx.$1]
: getAnime().chapters.toList().reversed.toList()[prevEpIdx.$1];
}
int getEpisodesLength(bool isInFilterList) =>
getChaptersLength(isInFilterList);
Chapter getNextEpisode() {
final nextEpIdx = getNextEpisodeIndex();
return nextEpIdx.$2
? getAnime().getFilteredChapterList()[nextEpIdx.$1]
: getAnime().chapters.toList().reversed.toList()[nextEpIdx.$1];
}
// ---------------------------------------------------------------------------
// Playback position
// ---------------------------------------------------------------------------
int getEpisodesLength(bool isInFilterList) {
return isInFilterList
? getAnime().getFilteredChapterList().length
: getAnime().chapters.length;
}
Duration geTCurrentPosition() {
Duration getCurrentPosition() {
if (incognitoMode) return Duration.zero;
String position = episode.lastPageRead ?? "0";
final position = episode.lastPageRead ?? '0';
return Duration(
milliseconds: episode.isRead!
? 0
@ -127,50 +61,6 @@ class AnimeStreamController extends _$AnimeStreamController {
);
}
void setAnimeHistoryUpdate({int watchTimeSeconds = 0}) {
if (incognitoMode) return;
isar.writeTxnSync(() {
Manga? anime = episode.manga.value;
anime!.lastRead = DateTime.now().millisecondsSinceEpoch;
anime.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.mangas.putSync(anime);
});
History? history;
final empty = isar.historys
.filter()
.mangaIdEqualTo(getAnime().id)
.isEmptySync();
if (empty) {
history = History(
mangaId: getAnime().id,
date: DateTime.now().millisecondsSinceEpoch.toString(),
itemType: getAnime().itemType,
chapterId: episode.id,
updatedAt: DateTime.now().millisecondsSinceEpoch,
)..chapter.value = episode;
} else {
history =
(isar.historys
.filter()
.mangaIdEqualTo(getAnime().id)
.findFirstSync())!
..chapterId = episode.id
..chapter.value = episode
..date = DateTime.now().millisecondsSinceEpoch.toString()
..updatedAt = DateTime.now().millisecondsSinceEpoch;
}
isar.writeTxnSync(() {
if (watchTimeSeconds > 0) {
history!.readingTimeSeconds =
(history.readingTimeSeconds ?? 0) + watchTimeSeconds;
}
isar.historys.putSync(history!);
history.chapter.saveSync();
});
}
void setCurrentPosition(
Duration duration,
Duration? totalDuration, {
@ -200,6 +90,10 @@ class AnimeStreamController extends _$AnimeStreamController {
}
}
// ---------------------------------------------------------------------------
// AniSkip
// ---------------------------------------------------------------------------
(int, int)? _getTrackId() {
final malId = isar.tracks
.filter()

View file

@ -11,7 +11,7 @@ import 'package:mangayomi/modules/manga/home/manga_home_screen.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/router/router.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/services/search_.dart';
import 'package:mangayomi/services/search.dart';
import 'package:mangayomi/utils/cached_network.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:mangayomi/utils/constant.dart';
@ -156,7 +156,7 @@ class _SourceSearchScreenState extends ConsumerState<SourceSearchScreen> {
source: widget.source,
page: 1,
query: widget.query,
filterList: [],
filterList: const [],
).future,
);
if (mounted) {

View file

@ -4,7 +4,7 @@ import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/utils/extensions/chapter.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'library_state_provider.g.dart';

View file

@ -22,7 +22,7 @@ import 'package:mangayomi/modules/library/providers/local_archive.dart';
import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart';
import 'package:mangayomi/modules/manga/detail/widgets/tracker_search_widget.dart';
import 'package:mangayomi/modules/manga/detail/widgets/tracker_widget.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/utils/extensions/chapter.dart';
import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart';
import 'package:mangayomi/modules/more/settings/appearance/providers/pure_black_dark_mode_state_provider.dart';
import 'package:mangayomi/modules/more/settings/track/widgets/track_listile.dart';

View file

@ -17,7 +17,7 @@ import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_pr
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/services/get_detail.dart';
import 'package:mangayomi/services/search_.dart';
import 'package:mangayomi/services/search.dart';
import 'package:mangayomi/utils/cached_network.dart';
import 'package:mangayomi/utils/date.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
@ -173,7 +173,7 @@ class _MigrationSourceSearchScreenState
source: widget.source,
page: 1,
query: widget.query,
filterList: [],
filterList: const [],
).future,
);
if (mounted) {

View file

@ -0,0 +1,135 @@
import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/history.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/utils/extensions/manga.dart';
/// Shared navigation and history logic used by [ReaderController],
/// [NovelReaderController], and [AnimeStreamController].
///
/// Concrete classes must satisfy the single abstract member [chapter].
/// The Riverpod-generated base class already exposes the build parameter as a
/// getter, so no extra boilerplate is needed in normal cases.
///
/// [incognitoMode] and [getIsarSetting] are concrete in the mixin but can be
/// overridden [ReaderController] overrides [getIsarSetting] to add caching.
mixin ChapterControllerMixin {
// ---------------------------------------------------------------------------
// Contract provided by the Riverpod-generated superclass
// ---------------------------------------------------------------------------
/// The current chapter or episode.
Chapter get chapter;
// ---------------------------------------------------------------------------
// Basic helpers
// ---------------------------------------------------------------------------
Manga getManga() => chapter.manga.value!;
// Declared as a getter so concrete classes can override with a `final` field
// (which is more efficient since incognito status never changes mid-session).
bool get incognitoMode => isar.settings.getSync(227)!.incognitoMode!;
Settings getIsarSetting() => isar.settings.getSync(227)!;
String getMangaName() => getManga().name!;
String getSourceName() => getManga().source!;
String getChapterTitle() => chapter.name!;
// ---------------------------------------------------------------------------
// Chapter / episode navigation
// ---------------------------------------------------------------------------
(int, bool) getChapterIndex() => _chapterIndexWithOffset(0);
Chapter getPrevChapter() => _chapterWithOffset(1);
Chapter getNextChapter() => _chapterWithOffset(-1);
/// Finds this [chapter] in either the filtered list or the raw list and
/// returns [index + offset]. The boolean indicates whether the filtered list
/// was used (true) or the full list (false).
(int, bool) _chapterIndexWithOffset(int offset) {
final manga = getManga();
int? findIn(List<Chapter> list) {
for (var i = 0; i < list.length; i++) {
if (list[i].id == chapter.id) return i + offset;
}
return null;
}
final index = findIn(manga.getChapterListForReading());
if (index != null) return (index, true);
// Fallback to raw list if chapter was filtered out.
final all = manga.chapters.toList();
return (findIn(all)!, false);
}
Chapter _chapterWithOffset(int offset) {
final idx = _chapterIndexWithOffset(offset);
final list = idx.$2
? getManga().getChapterListForReading()
: getManga().chapters.toList();
if (idx.$1 < 0 || idx.$1 >= list.length) {
throw RangeError('No chapter at offset $offset from ${chapter.id}');
}
return list[idx.$1];
}
int getChaptersLength(bool isInFilterList) => isInFilterList
? getManga().getChapterListForReading().length
: getManga().chapters.length;
// ---------------------------------------------------------------------------
// History
// ---------------------------------------------------------------------------
/// Writes a history entry for the current chapter/episode and bumps the
/// parent manga/anime's [lastRead] timestamp.
///
/// [elapsedSeconds] accumulates watch/reading time; pass 0 to skip that
/// field (the caller is responsible for tracking wall-clock deltas).
void setHistoryUpdate({int elapsedSeconds = 0}) {
if (incognitoMode) return;
final manga = getManga();
isar.writeTxnSync(() {
final m = chapter.manga.value!;
m.lastRead = DateTime.now().millisecondsSinceEpoch;
m.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.mangas.putSync(m);
});
final isEmpty = isar.historys
.filter()
.mangaIdEqualTo(manga.id)
.isEmptySync();
final History history;
if (isEmpty) {
history = History(
mangaId: manga.id,
date: DateTime.now().millisecondsSinceEpoch.toString(),
itemType: manga.itemType,
chapterId: chapter.id,
)..chapter.value = chapter;
} else {
history = isar.historys.filter().mangaIdEqualTo(manga.id).findFirstSync()!
..chapterId = chapter.id
..chapter.value = chapter
..date = DateTime.now().millisecondsSinceEpoch.toString();
}
isar.writeTxnSync(() {
history.updatedAt = DateTime.now().millisecondsSinceEpoch;
if (elapsedSeconds > 0) {
history.readingTimeSeconds =
(history.readingTimeSeconds ?? 0) + elapsedSeconds;
}
isar.historys.putSync(history);
history.chapter.saveSync();
});
}
}

View file

@ -0,0 +1,108 @@
import 'package:flutter/foundation.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart';
/// Shared reader-specific settings and actions for a [Chapter].
///
/// This mixin builds on top of [ChapterControllerMixin] and provides:
/// - bookmark toggling
/// - auto-scroll preferences
///
/// It is intended for reader-like controllers (manga/novel), not anime.
///
/// Classes using this mixin may override [onSettingsMutated] to react to
/// settings changes (e.g. invalidate caches).
mixin ChapterReaderSettingsMixin on ChapterControllerMixin {
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/// Called after any settings mutation (e.g. [setAutoScroll], [setReaderMode],
/// [setPageMode], [setShowPageNumber], [setPageIndex]).
///
/// Default is a no-op. Controllers can override this to invalidate caches
/// or trigger updates when settings change.
@protected
void onSettingsMutated() {}
// ---------------------------------------------------------------------------
// Bookmarks
// ---------------------------------------------------------------------------
/// Toggles the bookmark state of the current [chapter].
///
/// Updates the persisted chapter and bumps its [updatedAt] timestamp.
/// No-op in incognito mode.
void setChapterBookmarked() {
if (incognitoMode) return;
final isBookmarked = getChapterBookmarked();
final chap = chapter;
isar.writeTxnSync(() {
chap.isBookmarked = !isBookmarked;
chap.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.chapters.putSync(chap);
});
}
/// Returns whether the current [chapter] is bookmarked.
///
/// Reads directly from the database to ensure consistency.
bool getChapterBookmarked() {
return isar.chapters.getSync(chapter.id!)!.isBookmarked!;
}
// ---------------------------------------------------------------------------
// Auto-scroll
// ---------------------------------------------------------------------------
/// Returns the auto-scroll configuration for the current manga.
///
/// The tuple contains:
/// - whether auto-scroll is enabled
/// - the page offset (scroll speed / distance)
///
/// Falls back to `(false, 10)` if no custom setting exists.
(bool, double) autoScrollValues() {
final autoScrollPagesList = getIsarSetting().autoScrollPages ?? [];
final autoScrollPages = autoScrollPagesList.where(
(element) => element.mangaId == getManga().id,
);
if (autoScrollPages.isNotEmpty) {
return (
autoScrollPages.first.autoScroll ?? false,
autoScrollPages.first.pageOffset ?? 10,
);
}
return (false, 10);
}
/// Persists auto-scroll settings for the current manga.
///
/// Replaces any existing entry for this manga with the new values and updates
/// the global settings object. Calls [onSettingsMutated] afterwards so
/// controllers can react (e.g. invalidate cached settings).
void setAutoScroll(bool value, double offset) {
List<AutoScrollPages>? autoScrollPagesList = [];
for (var autoScrollPages in getIsarSetting().autoScrollPages ?? []) {
if (autoScrollPages.mangaId != getManga().id) {
autoScrollPagesList.add(autoScrollPages);
}
}
autoScrollPagesList.add(
AutoScrollPages()
..mangaId = getManga().id
..pageOffset = offset
..autoScroll = value,
);
isar.writeTxnSync(
() => isar.settings.putSync(
getIsarSetting()
..autoScrollPages = autoScrollPagesList
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
onSettingsMutated();
}
}

View file

@ -1,20 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.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/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart';
import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart';
import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart';
import 'package:mangayomi/modules/more/settings/downloads/providers/downloads_state_provider.dart';
import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart';
import 'package:mangayomi/utils/chapter_recognition.dart';
import 'package:mangayomi/utils/extensions/chapter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'reader_controller_provider.g.dart';
@ -47,7 +39,8 @@ BoxFit getBoxFit(ScaleType scaleType) {
}
@riverpod
class ReaderController extends _$ReaderController {
class ReaderController extends _$ReaderController
with ChapterControllerMixin, ChapterReaderSettingsMixin {
@override
KeepAliveLink build({required Chapter chapter}) {
_keepAliveLink = ref.keepAlive();
@ -55,20 +48,24 @@ class ReaderController extends _$ReaderController {
}
KeepAliveLink? _keepAliveLink;
KeepAliveLink? get keepAliveLink => _keepAliveLink;
Manga getManga() {
return chapter.manga.value!;
}
// Keep incognitoMode as a final field so it is read from Isar only once.
@override
final bool incognitoMode = isar.settings.getSync(227)!.incognitoMode!;
Chapter geChapter() {
return chapter;
}
final incognitoMode = isar.settings.getSync(227)!.incognitoMode!;
// Override getIsarSetting to add per-instance caching; callers that mutate
// settings must call _invalidateSettingsCache() afterwards.
Settings? _cachedSettings;
void _invalidateSettingsCache() => _cachedSettings = null;
@override
void onSettingsMutated() => _cachedSettings = null;
@override
Settings getIsarSetting() => _cachedSettings ??= isar.settings.getSync(227)!;
// ---------------------------------------------------------------------------
// Reader-specific settings
// ---------------------------------------------------------------------------
ReaderMode getReaderMode() {
final personalReaderModeList =
@ -79,44 +76,7 @@ class ReaderController extends _$ReaderController {
if (personalReaderMode.isNotEmpty) {
return personalReaderMode.first.readerMode;
}
return isar.settings.getSync(227)!.defaultReaderMode;
}
(bool, double) autoScrollValues() {
final autoScrollPagesList = getIsarSetting().autoScrollPages ?? [];
final autoScrollPages = autoScrollPagesList.where(
(element) => element.mangaId == getManga().id,
);
if (autoScrollPages.isNotEmpty) {
return (
autoScrollPages.first.autoScroll ?? false,
autoScrollPages.first.pageOffset ?? 10,
);
}
return (false, 10);
}
void setAutoScroll(bool value, double offset) {
List<AutoScrollPages>? autoScrollPagesList = [];
for (var autoScrollPages in getIsarSetting().autoScrollPages ?? []) {
if (autoScrollPages.mangaId != getManga().id) {
autoScrollPagesList.add(autoScrollPages);
}
}
autoScrollPagesList.add(
AutoScrollPages()
..mangaId = getManga().id
..pageOffset = offset
..autoScroll = value,
);
isar.writeTxnSync(
() => isar.settings.putSync(
getIsarSetting()
..autoScrollPages = autoScrollPagesList
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
_invalidateSettingsCache();
return getIsarSetting().defaultReaderMode;
}
PageMode getPageMode() {
@ -150,7 +110,7 @@ class ReaderController extends _$ReaderController {
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
_invalidateSettingsCache();
onSettingsMutated();
}
void setPageMode(PageMode newPageMode) {
@ -172,7 +132,7 @@ class ReaderController extends _$ReaderController {
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
_invalidateSettingsCache();
onSettingsMutated();
}
void setShowPageNumber(bool value) {
@ -184,154 +144,18 @@ class ReaderController extends _$ReaderController {
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
_invalidateSettingsCache();
onSettingsMutated();
}
}
Settings getIsarSetting() => _cachedSettings ??= isar.settings.getSync(227)!;
bool getShowPageNumber() {
if (!incognitoMode) return getIsarSetting().showPagesNumber!;
return true;
}
void setMangaHistoryUpdate({int readingTimeSeconds = 0}) {
if (incognitoMode) return;
isar.writeTxnSync(() {
Manga? manga = chapter.manga.value;
manga!.lastRead = DateTime.now().millisecondsSinceEpoch;
manga.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.mangas.putSync(manga);
});
History? history;
final empty = isar.historys
.filter()
.mangaIdEqualTo(getManga().id)
.isEmptySync();
if (empty) {
history = History(
mangaId: getManga().id,
date: DateTime.now().millisecondsSinceEpoch.toString(),
itemType: getManga().itemType,
chapterId: chapter.id,
)..chapter.value = chapter;
} else {
history =
(isar.historys
.filter()
.mangaIdEqualTo(getManga().id)
.findFirstSync())!
..chapterId = chapter.id
..chapter.value = chapter
..date = DateTime.now().millisecondsSinceEpoch.toString();
}
isar.writeTxnSync(() {
history!.updatedAt = DateTime.now().millisecondsSinceEpoch;
if (readingTimeSeconds > 0) {
history.readingTimeSeconds =
(history.readingTimeSeconds ?? 0) + readingTimeSeconds;
}
isar.historys.putSync(history);
history.chapter.saveSync();
});
}
void setChapterBookmarked() {
if (incognitoMode) return;
final isBookmarked = getChapterBookmarked();
final chap = chapter;
isar.writeTxnSync(() {
chap.isBookmarked = !isBookmarked;
chap.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.chapters.putSync(chap);
});
}
bool getChapterBookmarked() {
return isar.chapters.getSync(chapter.id!)!.isBookmarked!;
}
(int, bool) getPrevChapterIndex() {
final chapters = getManga().getFilteredChapterList();
int? index;
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i + 1;
}
}
if (index == null) {
final chapters = getManga().chapters.toList().reversed.toList();
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i + 1;
}
}
return (index!, false);
}
return (index, true);
}
(int, bool) getNextChapterIndex() {
final chapters = getManga().getFilteredChapterList();
int? index;
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i - 1;
}
}
if (index == null) {
final chapters = getManga().chapters.toList().reversed.toList();
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i - 1;
}
}
return (index!, false);
}
return (index, true);
}
(int, bool) getChapterIndex() {
final chapters = getManga().getFilteredChapterList();
int? index;
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i;
}
}
if (index == null) {
final chapters = getManga().chapters.toList().reversed.toList();
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i;
}
}
return (index!, false);
}
return (index, true);
}
Chapter getPrevChapter() {
final prevChapIdx = getPrevChapterIndex();
return prevChapIdx.$2
? getManga().getFilteredChapterList()[prevChapIdx.$1]
: getManga().chapters.toList().reversed.toList()[prevChapIdx.$1];
}
Chapter getNextChapter() {
final nextChapIdx = getNextChapterIndex();
return nextChapIdx.$2
? getManga().getFilteredChapterList()[nextChapIdx.$1]
: getManga().chapters.toList().reversed.toList()[nextChapIdx.$1];
}
int getChaptersLength(bool isInFilterList) {
return isInFilterList
? getManga().getFilteredChapterList().length
: getManga().chapters.length;
}
// ---------------------------------------------------------------------------
// Page tracking
// ---------------------------------------------------------------------------
int getPageIndex() {
if (incognitoMode) return 0;
@ -392,7 +216,7 @@ class ReaderController extends _$ReaderController {
chap.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.chapters.putSync(chap);
});
_invalidateSettingsCache();
onSettingsMutated();
if (isRead) {
chapter.updateTrackChapterRead(ref);
if (ref.read(deleteDownloadAfterReadingStateProvider)) {
@ -401,179 +225,4 @@ class ReaderController extends _$ReaderController {
}
}
}
String getMangaName() {
return getManga().name!;
}
String getSourceName() {
return getManga().source!;
}
String getChapterTitle() {
return chapter.name!;
}
}
extension ChapterExtensions on Chapter {
void updateTrackChapterRead(dynamic ref) {
if (!(ref is WidgetRef || ref is Ref)) return;
final updateProgressAfterReading = ref.watch(
updateProgressAfterReadingStateProvider,
);
if (!updateProgressAfterReading) return;
final manga = this.manga.value!;
final chapterNumber = ChapterRecognition().parseChapterNumber(
manga.name!,
name!,
);
final tracks = isar.tracks
.filter()
.idIsNotNull()
.itemTypeEqualTo(manga.itemType)
.mangaIdEqualTo(manga.id!)
.findAllSync();
if (tracks.isEmpty) return;
for (var track in tracks) {
final service = isar.trackPreferences
.filter()
.syncIdIsNotNull()
.syncIdEqualTo(track.syncId)
.findFirstSync();
if (!(service == null || chapterNumber <= (track.lastChapterRead ?? 0))) {
if (track.status != TrackStatus.completed) {
track.lastChapterRead = chapterNumber;
if (track.lastChapterRead == track.totalChapter &&
(track.totalChapter ?? 0) > 0) {
track.status = TrackStatus.completed;
track.finishedReadingDate = DateTime.now().millisecondsSinceEpoch;
} else {
track.status = manga.itemType == ItemType.manga
? TrackStatus.reading
: TrackStatus.watching;
if (track.lastChapterRead == 1) {
track.startedReadingDate = DateTime.now().millisecondsSinceEpoch;
}
}
}
ref
.read(
trackStateProvider(
track: track,
itemType: manga.itemType,
widgetRef: ref,
).notifier,
)
.updateManga();
}
}
}
}
extension MangaExtensions on Manga {
List<Chapter> getFilteredChapterList() {
final data = this.chapters.toList().reversed.toList();
final settings = isar.settings.getSync(227)!;
final filterUnread =
(settings.chapterFilterUnreadList!
.where((element) => element.mangaId == id)
.toList()
.firstOrNull ??
ChapterFilterUnread(mangaId: id, type: 0))
.type!;
final filterBookmarked =
(settings.chapterFilterBookmarkedList!
.where((element) => element.mangaId == id)
.toList()
.firstOrNull ??
ChapterFilterBookmarked(mangaId: id, type: 0))
.type!;
final filterDownloaded =
(settings.chapterFilterDownloadedList!
.where((element) => element.mangaId == id)
.toList()
.firstOrNull ??
ChapterFilterDownloaded(mangaId: id, type: 0))
.type!;
final sortChapter =
(settings.sortChapterList!
.where((element) => element.mangaId == id)
.toList()
.firstOrNull ??
SortChapter(mangaId: id, index: 1, reverse: false))
.index;
final filterScanlator = _getFilterScanlator(this) ?? [];
final chapterIds = data.map((c) => c.id).whereType<int>().toList();
final downloadedIds = (filterDownloaded == 0 || chapterIds.isEmpty)
? const <int>{}
: isar.downloads
.filter()
.anyOf(chapterIds, (q, id) => q.idEqualTo(id))
.isDownloadEqualTo(true)
.findAllSync()
.map((d) => d.id!)
.toSet();
List<Chapter>? chapterList;
chapterList = data
.where(
(element) => filterUnread == 1
? element.isRead == false
: filterUnread == 2
? element.isRead == true
: true,
)
.where(
(element) => filterBookmarked == 1
? element.isBookmarked == true
: filterBookmarked == 2
? element.isBookmarked == false
: true,
)
.where((element) {
if (filterDownloaded == 0) return true;
final isDownloaded = downloadedIds.contains(element.id);
return filterDownloaded == 1 ? isDownloaded : !isDownloaded;
})
.where((element) => !filterScanlator.contains(element.scanlator))
.toList();
List<Chapter> chapters = sortChapter == 0
? chapterList.reversed.toList()
: chapterList;
if (sortChapter == 0) {
chapters.sort((a, b) {
return (a.scanlator == null ||
b.scanlator == null ||
a.dateUpload == null ||
b.dateUpload == null)
? 0
: a.scanlator!.compareTo(b.scanlator!) |
a.dateUpload!.compareTo(b.dateUpload!);
});
} else if (sortChapter == 2) {
chapters.sort((a, b) {
return (a.dateUpload == null || b.dateUpload == null)
? 0
: int.parse(a.dateUpload!).compareTo(int.parse(b.dateUpload!));
});
} else if (sortChapter == 3) {
chapters.sort((a, b) {
return (a.name == null || b.name == null)
? 0
: a.name!.compareTo(b.name!);
});
}
return chapters;
}
}
List<String>? _getFilterScanlator(Manga manga) {
final scanlators = isar.settings.getSync(227)!.filterScanlatorList ?? [];
final filter = scanlators
.where((element) => element.mangaId == manga.id)
.toList();
return filter.firstOrNull?.scanlators;
}

View file

@ -24,6 +24,7 @@ import 'package:mangayomi/modules/manga/reader/widgets/page_indicator.dart';
import 'package:mangayomi/modules/manga/reader/widgets/image_actions_dialog.dart';
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/extensions/others.dart';
import 'package:mangayomi/utils/riverpod.dart';
import 'package:mangayomi/modules/manga/reader/providers/push_router.dart';
import 'package:mangayomi/services/get_chapter_pages.dart';
@ -154,7 +155,6 @@ class _MangaChapterPageGalleryState
bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows;
final ValueNotifier<bool> _isScrolling = ValueNotifier(false);
Timer? _scrollIdleTimer;
bool _firstLaunch = true;
final Stopwatch _readingStopwatch = Stopwatch();
/// Flag to prevent fullscreen from being disabled when navigating between
@ -166,8 +166,8 @@ class _MangaChapterPageGalleryState
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_readingStopwatch.stop();
_readerController.setMangaHistoryUpdate(
readingTimeSeconds: _readingStopwatch.elapsed.inSeconds,
_readerController.setHistoryUpdate(
elapsedSeconds: _readingStopwatch.elapsed.inSeconds,
);
_rebuildDetail.close();
_doubleClickAnimationController.dispose();
@ -176,6 +176,7 @@ class _MangaChapterPageGalleryState
_autoScroll.value = false;
_autoScroll.dispose();
_autoScrollPage.dispose();
_currentPageDisplayIndex.dispose();
_scrollIdleTimer?.cancel();
_isScrolling.dispose();
_itemPositionsListener.itemPositions.removeListener(_readProgressListener);
@ -194,11 +195,11 @@ class _MangaChapterPageGalleryState
);
}
discordRpc?.showIdleText();
final actualIdx = _pageViewToActualIndex(_currentIndex!);
final actualIdx = _pageViewToActualIndexSync(_currentIndex!);
final index = pages[actualIdx].index;
if (index != null) {
_readerController.setPageIndex(
_isDoublePageActive ? index : _geCurrentIndex(index),
_isDoublePageActiveSync ? index : _geCurrentIndex(index),
true,
);
}
@ -237,6 +238,9 @@ class _MangaChapterPageGalleryState
final _failedToLoadImage = ValueNotifier<bool>(false);
late int? _currentIndex = _readerController.getPageIndex();
late final ValueNotifier<int> _currentPageDisplayIndex = ValueNotifier(
_readerController.getPageIndex(),
);
late final ItemScrollController _itemScrollController =
ItemScrollController();
@ -300,6 +304,9 @@ class _MangaChapterPageGalleryState
final _currentReaderMode = StateProvider<ReaderMode?>(() => null);
PageMode? _pageMode;
bool _isView = false;
/// Cached reader mode to safely access in dispose without ref.read()
ReaderMode? _cachedReaderMode;
Alignment _scalePosition = Alignment.center;
final PhotoViewController _photoViewController = PhotoViewController();
final PhotoViewScaleStateController _photoViewScaleStateController =
@ -346,6 +353,9 @@ class _MangaChapterPageGalleryState
@override
Widget build(BuildContext context) {
final animatePageTransitions = ref.watch(
animatePageTransitionsStateProvider,
);
final backgroundColor = ref.watch(backgroundColorStateProvider);
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
final readerMode = ref.watch(_currentReaderMode);
@ -358,13 +368,13 @@ class _MangaChapterPageGalleryState
onPreviousPage: () => navigationService.previousPage(
readerMode: readerMode!,
currentIndex: _currentIndex!,
animate: true,
animate: animatePageTransitions,
),
onNextPage: () => navigationService.nextPage(
readerMode: readerMode!,
currentIndex: _currentIndex!,
maxPages: _pageViewPageCount,
animate: true,
animate: animatePageTransitions,
),
onEscape: () => _goBack(context),
onFullScreen: () => _setFullScreen(),
@ -422,8 +432,9 @@ class _MangaChapterPageGalleryState
scrollDirection: isHorizontalContinuous
? Axis.horizontal
: Axis.vertical,
minCacheExtent:
pagePreloadAmount * context.height(1),
minCacheExtent: isHorizontalContinuous
? pagePreloadAmount * context.width(1)
: pagePreloadAmount * context.height(1),
initialScrollIndex: _readerController
.getPageIndex(),
physics: const ClampingScrollPhysics(),
@ -758,13 +769,13 @@ class _MangaChapterPageGalleryState
onPreviousPage: () => navigationService.previousPage(
readerMode: readerMode!,
currentIndex: _currentIndex!,
animate: true,
animate: animatePageTransitions,
),
onNextPage: () => navigationService.nextPage(
readerMode: readerMode!,
currentIndex: _currentIndex!,
maxPages: _pageViewPageCount,
animate: true,
animate: animatePageTransitions,
),
onDoubleTapDown: (position) => _toggleScale(position),
onDoubleTap: () {},
@ -824,6 +835,7 @@ class _MangaChapterPageGalleryState
);
},
onSliderChanged: (value, ref) {
_currentPageDisplayIndex.value = value;
ref
.read(currentIndexProvider(chapter).notifier)
.setCurrentIndex(value);
@ -907,7 +919,7 @@ class _MangaChapterPageGalleryState
},
),
currentReaderModeProvider: _currentReaderMode,
currentIndexProvider: currentIndexProvider,
currentPageListenable: _currentPageDisplayIndex,
currentPageMode: _pageMode,
isReverseHorizontal: _isReverseHorizontal,
totalPages: _readerController.getPageLength(
@ -917,8 +929,8 @@ class _MangaChapterPageGalleryState
backgroundColor: _backgroundColor,
),
PageIndicator(
chapter: chapter,
isUiVisible: _isView,
currentPageListenable: _currentPageDisplayIndex,
totalPages: _readerController.getPageLength(
_chapterUrlModel.pageUrls,
),
@ -1008,6 +1020,7 @@ class _MangaChapterPageGalleryState
final idx = pages[_currentIndex!].index;
if (idx != null) {
_currentPageDisplayIndex.value = idx;
_readerController.setPageIndex(
_isDoublePageActive ? idx : _geCurrentIndex(idx),
false,
@ -1061,7 +1074,10 @@ class _MangaChapterPageGalleryState
if (_isNextChapterPreloading || _isLastPageTransition) return;
_isNextChapterPreloading = true;
try {
if (!mounted) return;
if (!mounted) {
_isNextChapterPreloading = false;
return;
}
final nextChapter = _readerController.getNextChapter();
if (isChapterLoaded(nextChapter)) {
_isNextChapterPreloading = false;
@ -1155,6 +1171,7 @@ class _MangaChapterPageGalleryState
void _initCurrentIndex() async {
if (ref.read(cropBordersStateProvider)) _processCropBorders();
final readerMode = _readerController.getReaderMode();
_currentPageDisplayIndex.value = _readerController.getPageIndex();
// Initialize the preload manager with bounded memory (from ReaderMemoryManagement mixin)
initializePreloadManager(
@ -1167,10 +1184,14 @@ class _MangaChapterPageGalleryState
},
);
// Kick off ordered prefetch before the first frame so lower-indexed pages
// win the HTTP race against the simultaneous widget-driven loads.
_prefetchPagesInOrder(); // intentionally not awaited
// proactively start loading adjacent chapters in background
_proactivePreload();
_readerController.setMangaHistoryUpdate();
_readerController.setHistoryUpdate();
// Use post-frame callback instead of Future.delayed(1ms) timing hack
await Future(() {});
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
@ -1202,22 +1223,55 @@ class _MangaChapterPageGalleryState
}
}
/// Warms Flutter's [ImageCache] in page order before the widget tree renders.
///
/// [ScrollablePositionedList] builds all items within [minCacheExtent] in a
/// single frame, firing every network request simultaneously, which means
/// pages complete in arbitrary (server-response) order. By resolving each
/// provider sequentially here starting before that first frame we seed
/// the cache so that earlier pages win the HTTP race: lower-indexed pages
/// start their requests first and are therefore ready sooner.
///
/// For pages already within the cache extent the widget will attach to the
/// already-pending Future (Flutter deduplicates by provider key), so no
/// extra requests are made. Pages beyond the cache extent are fetched
/// strictly one at a time in reading order, so the reader never sees a
/// later page appear before an earlier one.
///
/// This is fully async [await] inside a fire-and-forget call so the
/// UI stays interactive throughout.
Future<void> _prefetchPagesInOrder() async {
final startIdx = (_currentIndex ?? 0).clamp(0, pages.length - 1);
// Visit pages from the opening position forward, then backward.
final indices = [
for (var i = startIdx; i < pages.length; i++) i,
for (var i = startIdx - 1; i >= 0; i--) i,
];
for (final i in indices) {
if (!mounted) return;
final page = pages[i];
if (page.isTransitionPage) continue;
try {
// Awaiting ensures page[i] finishes (or fails) before page[i+1]
// starts downloading, giving strict reading-order priority.
await precacheImage(page.getImageProvider(ref, true), context);
} catch (_) {
// Swallow errors: network failures, widget disposal, etc.
}
}
}
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);
}
if (_firstLaunch) {
Future.delayed(const Duration(milliseconds: 100)).then((_) {
_firstLaunch = false;
});
return;
}
final idx = pages[prevActualIndex].index;
if (idx != null) {
_readerController.setPageIndex(
@ -1246,6 +1300,7 @@ class _MangaChapterPageGalleryState
clearGestureDetailsCache();
_currentIndex = index;
if (pages[actualIndex].index != null) {
_currentPageDisplayIndex.value = pages[actualIndex].index!;
ref
.read(currentIndexProvider(chapter).notifier)
.setCurrentIndex(pages[actualIndex].index!);
@ -1324,6 +1379,9 @@ class _MangaChapterPageGalleryState
_failedToLoadImage.value = false;
_readerController.setReaderMode(value);
// Cache the reader mode for safe access in dispose
_cachedReaderMode = value;
int index = _pageViewToActualIndex(_currentIndex!);
ref.read(_currentReaderMode.notifier).state = value;
if (value == ReaderMode.vertical) {
@ -1451,11 +1509,19 @@ class _MangaChapterPageGalleryState
/// Whether double page mode is active (continuous or paged).
/// Horizontal continuous mode does NOT use double page layout.
/// Uses ref.read() so cannot be called during dispose.
bool get _isDoublePageActive =>
_pageMode == PageMode.doublePage &&
ref.read(_currentReaderMode) != ReaderMode.horizontalContinuous &&
ref.read(_currentReaderMode) != ReaderMode.horizontalContinuousRTL;
/// Safe version of _isDoublePageActive that uses cached reader mode.
/// Safe to call during dispose without Riverpod assertion errors.
bool get _isDoublePageActiveSync =>
_pageMode == PageMode.doublePage &&
_cachedReaderMode != ReaderMode.horizontalContinuous &&
_cachedReaderMode != ReaderMode.horizontalContinuousRTL;
/// Converts a page view index (from ExtendedPageController) to the actual
/// index in the [pages] array for double page mode.
///
@ -1467,6 +1533,12 @@ class _MangaChapterPageGalleryState
return (pageViewIndex * 2).clamp(0, pages.length - 1);
}
/// Safe version that uses cached reader mode for use in dispose.
int _pageViewToActualIndexSync(int pageViewIndex) {
if (!_isDoublePageActiveSync) return pageViewIndex;
return (pageViewIndex * 2).clamp(0, pages.length - 1);
}
/// Converts an actual [pages] array index to a page view index
/// for double page mode.
int _actualToPageViewIndex(int actualIndex) {

View file

@ -4,7 +4,7 @@ import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/modules/manga/reader/providers/push_router.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/utils/extensions/manga.dart';
import 'package:mangayomi/modules/manga/reader/reader_view.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/date.dart';

View file

@ -1,216 +1,323 @@
import 'package:flutter/material.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
class ChapterTransitionPage extends StatelessWidget {
final Chapter currentChapter;
final Chapter? nextChapter;
final String mangaName;
final ReaderMode readerMode;
const ChapterTransitionPage({
super.key,
required this.currentChapter,
required this.nextChapter,
required this.mangaName,
required this.readerMode,
});
bool get _isVertical =>
readerMode == ReaderMode.vertical ||
readerMode == ReaderMode.verticalContinuous ||
readerMode == ReaderMode.webtoon;
bool get _isRTL =>
readerMode == ReaderMode.rtl ||
readerMode == ReaderMode.horizontalContinuousRTL;
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final screenWidth = MediaQuery.of(context).size.width;
final l10n = context.l10n;
return Container(
constraints: BoxConstraints(minHeight: screenHeight * 0.5),
color: Theme.of(context).scaffoldBackgroundColor,
child: Center(
child: Padding(
padding: EdgeInsets.all(screenWidth * 0.08),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icône de transition
Icon(
Icons.auto_stories_outlined,
size: screenWidth * 0.16,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(height: screenHeight * 0.04),
// Titre
Text(
l10n.end_of_chapter,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
SizedBox(height: screenHeight * 0.03),
Container(
padding: EdgeInsets.all(screenWidth * 0.04),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
child: LayoutBuilder(
builder: (context, constraints) {
return FittedBox(
fit: BoxFit.scaleDown,
child: ConstrainedBox(
// Give the content a natural maximum size to fit within.
// FittedBox will scale it down if the available space is smaller.
constraints: BoxConstraints(
maxWidth: _isVertical
? constraints.maxWidth.clamp(100.0, 480.0)
: constraints.maxWidth.clamp(100.0, double.infinity),
maxHeight: constraints.maxHeight.clamp(
100.0,
double.infinity,
),
),
child: Column(
children: [
Text(
l10n.chapter_completed,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
SizedBox(height: screenHeight * 0.01),
Text(
currentChapter.name ?? 'Chapitre ${currentChapter.id}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
child: Padding(
padding: const EdgeInsets.all(24.0),
child: _isVertical
? _buildVerticalLayout(context)
: _buildHorizontalLayout(context),
),
),
);
},
),
),
);
}
SizedBox(height: screenHeight * 0.03),
// Vertical layout (top arrow bottom)
Icon(
nextChapter != null
? Icons.keyboard_arrow_down
: Icons.check_circle_outline,
size: screenWidth * 0.08,
color: nextChapter != null
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
SizedBox(height: screenHeight * 0.03),
if (nextChapter != null) ...[
Container(
padding: EdgeInsets.all(screenWidth * 0.04),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Column(
children: [
Text(
l10n.next_chapter,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.8),
),
),
SizedBox(height: screenHeight * 0.01),
Text(
nextChapter!.name ?? 'Chapitre ${nextChapter!.id}',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(height: screenHeight * 0.04),
Text(
l10n.continue_to_next_chapter,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
] else ...[
Container(
padding: EdgeInsets.all(screenWidth * 0.04),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Column(
children: [
Icon(
Icons.last_page,
size: screenWidth * 0.06,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
SizedBox(height: screenHeight * 0.01),
Text(
l10n.no_next_chapter,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
SizedBox(height: screenHeight * 0.005),
Text(
l10n.you_have_finished_reading,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
),
),
SizedBox(height: screenHeight * 0.04),
Text(
l10n.return_to_the_list_of_chapters,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
],
Widget _buildVerticalLayout(BuildContext context) {
final l10n = context.l10n;
return IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.auto_stories_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 20),
Text(
l10n.end_of_chapter,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
_buildChapterCard(
context,
label: l10n.chapter_completed,
name: currentChapter.name ?? 'Chapter ${currentChapter.id}',
isPrimary: false,
),
const SizedBox(height: 16),
Icon(
nextChapter != null
? Icons.keyboard_arrow_down
: Icons.check_circle_outline,
size: 32,
color: nextChapter != null
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(height: 16),
if (nextChapter != null) ...[
_buildChapterCard(
context,
label: l10n.next_chapter,
name: nextChapter!.name ?? 'Chapter ${nextChapter!.id}',
isPrimary: true,
),
const SizedBox(height: 20),
Text(
l10n.continue_to_next_chapter,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
] else ...[
_buildEndOfMangaCard(context),
const SizedBox(height: 20),
Text(
l10n.return_to_the_list_of_chapters,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
],
),
);
}
// Horizontal layout (left arrow right)
Widget _buildHorizontalLayout(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
// For LTR: [current] [next]
// For RTL: [next] [current]
final Widget currentCard = _buildChapterCard(
context,
label: l10n.chapter_completed,
name: currentChapter.name ?? 'Chapter ${currentChapter.id}',
isPrimary: false,
);
final Widget arrowIcon = Icon(
nextChapter != null
? (_isRTL ? Icons.keyboard_arrow_left : Icons.keyboard_arrow_right)
: Icons.check_circle_outline,
size: 36,
color: nextChapter != null
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.6),
);
final Widget nextCard = nextChapter != null
? _buildChapterCard(
context,
label: l10n.next_chapter,
name: nextChapter!.name ?? 'Chapter ${nextChapter!.id}',
isPrimary: true,
)
: _buildEndOfMangaCard(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.auto_stories_outlined,
size: 40,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
l10n.end_of_chapter,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _isRTL
? [
Expanded(child: nextCard),
const SizedBox(width: 12),
Center(child: arrowIcon),
const SizedBox(width: 12),
Expanded(child: currentCard),
]
: [
Expanded(child: currentCard),
const SizedBox(width: 12),
Center(child: arrowIcon),
const SizedBox(width: 12),
Expanded(child: nextCard),
],
),
),
const SizedBox(height: 16),
Text(
nextChapter != null
? l10n.continue_to_next_chapter
: l10n.return_to_the_list_of_chapters,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
);
}
// Shared card widgets
Widget _buildChapterCard(
BuildContext context, {
required String label,
required String name,
required bool isPrimary,
}) {
final theme = Theme.of(context);
final bgColor = isPrimary
? theme.colorScheme.primaryContainer
: theme.colorScheme.surface;
final borderColor = isPrimary
? theme.colorScheme.primary.withValues(alpha: 0.3)
: theme.colorScheme.outline.withValues(alpha: 0.2);
final labelColor = isPrimary
? theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8)
: theme.colorScheme.onSurface.withValues(alpha: 0.7);
final nameColor = isPrimary ? theme.colorScheme.onPrimaryContainer : null;
return SizedBox(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium?.copyWith(color: labelColor),
maxLines: 2,
),
const SizedBox(height: 6),
Text(
name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: nameColor,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
Widget _buildEndOfMangaCard(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return SizedBox(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.last_page,
size: 24,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(height: 6),
Text(
l10n.no_next_chapter,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
l10n.you_have_finished_reading,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
),
),
);

View file

@ -1,7 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
/// Page indicator widget showing current page / total pages.
@ -9,12 +8,12 @@ import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_pr
/// Displayed at the bottom center when the UI is hidden and
/// "show page numbers" setting is enabled.
class PageIndicator extends ConsumerWidget {
/// The current chapter being read.
final Chapter chapter;
/// Whether the UI overlay is currently visible.
final bool isUiVisible;
/// Session-local current page index for the visible reader state.
final ValueListenable<int> currentPageListenable;
/// Total number of pages.
final int totalPages;
@ -23,15 +22,14 @@ class PageIndicator extends ConsumerWidget {
const PageIndicator({
super.key,
required this.chapter,
required this.isUiVisible,
required this.currentPageListenable,
required this.totalPages,
required this.formatCurrentIndex,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentIndex = ref.watch(currentIndexProvider(chapter));
final showPagesNumber = ref.watch(showPagesNumberStateProvider);
// Don't show when UI is visible or setting is disabled
@ -41,19 +39,24 @@ class PageIndicator extends ConsumerWidget {
return Align(
alignment: Alignment.bottomCenter,
child: Text(
'${formatCurrentIndex(currentIndex)} / $totalPages',
style: const TextStyle(
color: Colors.white,
fontSize: 20.0,
shadows: [
Shadow(offset: Offset(-1, -1), blurRadius: 1),
Shadow(offset: Offset(1, -1), blurRadius: 1),
Shadow(offset: Offset(1, 1), blurRadius: 1),
Shadow(offset: Offset(-1, 1), blurRadius: 1),
],
),
textAlign: TextAlign.center,
child: ValueListenableBuilder<int>(
valueListenable: currentPageListenable,
builder: (context, currentIndex, child) {
return Text(
'${formatCurrentIndex(currentIndex)} / $totalPages',
style: const TextStyle(
color: Colors.white,
fontSize: 20.0,
shadows: [
Shadow(offset: Offset(-1, -1), blurRadius: 1),
Shadow(offset: Offset(1, -1), blurRadius: 1),
Shadow(offset: Offset(1, 1), blurRadius: 1),
Shadow(offset: Offset(-1, 1), blurRadius: 1),
],
),
textAlign: TextAlign.center,
);
},
),
);
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'dart:math';
import 'package:flutter/cupertino.dart';
@ -6,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/widgets/custom_value_indicator_shape.dart';
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
@ -62,9 +62,8 @@ class ReaderBottomBar extends ConsumerWidget {
/// (StateProvider, NotifierProvider, etc.)
final ProviderListenable<ReaderMode?> currentReaderModeProvider;
/// Provider family for watching current page index
/// Type: CurrentIndexFamily (from reader_controller_provider.g.dart)
final CurrentIndexFamily currentIndexProvider;
/// Session-local current page index for the visible reader state.
final ValueListenable<int> currentPageListenable;
/// Current page mode (nullable for safety)
final PageMode? currentPageMode;
@ -95,7 +94,7 @@ class ReaderBottomBar extends ConsumerWidget {
this.onPageModeToggle,
required this.onSettingsPressed,
required this.currentReaderModeProvider,
required this.currentIndexProvider,
required this.currentPageListenable,
required this.currentPageMode,
required this.isReverseHorizontal,
required this.totalPages,
@ -194,15 +193,17 @@ class ReaderBottomBar extends ConsumerWidget {
child: Center(
child: Consumer(
builder: (context, ref, child) {
final currentIndex = ref.watch(
currentIndexProvider(chapter),
);
return Text(
currentIndexLabel(currentIndex),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
return ValueListenableBuilder<int>(
valueListenable: currentPageListenable,
builder: (context, currentIndex, child) {
return Text(
currentIndexLabel(currentIndex),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
);
},
);
},
),
@ -277,34 +278,37 @@ class ReaderBottomBar extends ConsumerWidget {
) {
return Consumer(
builder: (context, ref, child) {
final currentIndex = ref.watch(currentIndexProvider(chapter));
return ValueListenableBuilder<int>(
valueListenable: currentPageListenable,
builder: (context, currentIndex, child) {
final maxValue = (totalPages - 1).toDouble();
final maxValue = (totalPages - 1).toDouble();
final divisions = totalPages <= 1 ? null : totalPages - 1;
final divisions = totalPages <= 1 ? null : totalPages - 1;
final currentValue = min(currentIndex.toDouble(), maxValue);
final currentValue = min(currentIndex.toDouble(), maxValue);
return SliderTheme(
data: SliderTheme.of(context).copyWith(
valueIndicatorShape: CustomValueIndicatorShape(
tranform: isReverseHorizontal,
),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0),
),
child: Slider(
onChanged: (value) {
onSliderChanged(value.toInt(), ref);
},
onChangeEnd: (newValue) {
onSliderChangeEnd(newValue.toInt());
},
divisions: divisions,
value: currentValue,
label: currentIndexLabel(currentIndex),
min: 0,
max: maxValue,
),
return SliderTheme(
data: SliderTheme.of(context).copyWith(
valueIndicatorShape: CustomValueIndicatorShape(
tranform: isReverseHorizontal,
),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0),
),
child: Slider(
onChanged: (value) {
onSliderChanged(value.toInt(), ref);
},
onChangeEnd: (newValue) {
onSliderChangeEnd(newValue.toInt());
},
divisions: divisions,
value: currentValue,
label: currentIndexLabel(currentIndex),
min: 0,
max: maxValue,
),
);
},
);
},
);

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
import 'package:mangayomi/modules/manga/reader/widgets/chapter_transition_page.dart';
@ -14,10 +15,15 @@ class TransitionViewPaged extends ConsumerWidget {
return const SizedBox.shrink();
}
final readerMode = ref
.read(readerControllerProvider(chapter: data.chapter!).notifier)
.getReaderMode();
return ChapterTransitionPage(
currentChapter: data.chapter!,
nextChapter: data.nextChapter,
mangaName: data.mangaName ?? '',
readerMode: readerMode,
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
import 'package:mangayomi/modules/manga/reader/widgets/chapter_transition_page.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
@ -15,12 +16,17 @@ class TransitionViewVertical extends ConsumerWidget {
return const SizedBox.shrink();
}
final readerMode = ref
.read(readerControllerProvider(chapter: data.chapter!).notifier)
.getReaderMode();
return SizedBox(
height: context.height(1),
child: ChapterTransitionPage(
currentChapter: data.chapter!,
nextChapter: data.nextChapter,
mangaName: data.mangaName ?? '',
readerMode: readerMode,
),
);
}

View file

@ -1,5 +1,3 @@
import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar_community/isar.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
@ -15,7 +13,7 @@ import 'package:mangayomi/modules/mass_migration/models/mass_migration_models.da
import 'package:mangayomi/modules/manga/detail/providers/isar_providers.dart';
import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart';
import 'package:mangayomi/services/get_detail.dart';
import 'package:mangayomi/services/search_.dart';
import 'package:mangayomi/services/search.dart';
import 'package:mangayomi/utils/extensions/string_extensions.dart';
Future<void> migrateLibraryItem({

View file

@ -21,7 +21,7 @@ Future<void> checkForUpdate(
bool? manualUpdate,
}) async {
manualUpdate = manualUpdate ?? false;
final checkForUpdates = ref.watch(checkForAppUpdatesProvider);
final checkForUpdates = ref.read(checkForAppUpdatesProvider);
if (!checkForUpdates && !manualUpdate) return;
final l10n = l10nLocalizations(context!)!;

View file

@ -1,110 +1,33 @@
import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/history.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/modules/manga/reader/mixins/chapter_reader_settings_mixin.dart';
import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'novel_reader_controller_provider.g.dart';
@riverpod
class NovelReaderController extends _$NovelReaderController {
class NovelReaderController extends _$NovelReaderController
with ChapterControllerMixin, ChapterReaderSettingsMixin {
@override
void build({required Chapter chapter}) {}
Manga getManga() {
return chapter.manga.value!;
}
// Keep incognitoMode as a final field (read once, not on every access).
@override
final bool incognitoMode = isar.settings.getSync(227)!.incognitoMode!;
Chapter geChapter() {
return chapter;
}
// Override getIsarSetting to add per-instance caching; callers that mutate
// settings must call _invalidateSettingsCache() afterwards.
Settings? _cachedSettings;
@override
void onSettingsMutated() => _cachedSettings = null;
final incognitoMode = isar.settings.getSync(227)!.incognitoMode!;
@override
Settings getIsarSetting() => _cachedSettings ??= isar.settings.getSync(227)!;
Settings getIsarSetting() {
return isar.settings.getSync(227)!;
}
(bool, double) autoScrollValues() {
final autoScrollPagesList = getIsarSetting().autoScrollPages ?? [];
final autoScrollPages = autoScrollPagesList.where(
(element) => element.mangaId == getManga().id,
);
if (autoScrollPages.isNotEmpty) {
return (
autoScrollPages.first.autoScroll ?? false,
autoScrollPages.first.pageOffset ?? 10,
);
}
return (false, 10);
}
void setAutoScroll(bool value, double offset) {
List<AutoScrollPages>? autoScrollPagesList = [];
for (var autoScrollPages in getIsarSetting().autoScrollPages ?? []) {
if (autoScrollPages.mangaId != getManga().id) {
autoScrollPagesList.add(autoScrollPages);
}
}
autoScrollPagesList.add(
AutoScrollPages()
..mangaId = getManga().id
..pageOffset = offset
..autoScroll = value,
);
isar.writeTxnSync(
() => isar.settings.putSync(
getIsarSetting()
..autoScrollPages = autoScrollPagesList
..updatedAt = DateTime.now().millisecondsSinceEpoch,
),
);
}
void setMangaHistoryUpdate({int readingTimeSeconds = 0}) {
if (incognitoMode) return;
isar.writeTxnSync(() {
Manga? manga = chapter.manga.value;
manga!.lastRead = DateTime.now().millisecondsSinceEpoch;
manga.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.mangas.putSync(manga);
});
History? history;
final empty = isar.historys
.filter()
.mangaIdEqualTo(getManga().id)
.isEmptySync();
if (empty) {
history = History(
mangaId: getManga().id,
date: DateTime.now().millisecondsSinceEpoch.toString(),
itemType: getManga().itemType,
chapterId: chapter.id,
)..chapter.value = chapter;
} else {
history =
(isar.historys
.filter()
.mangaIdEqualTo(getManga().id)
.findFirstSync())!
..chapterId = chapter.id
..chapter.value = chapter
..date = DateTime.now().millisecondsSinceEpoch.toString();
}
isar.writeTxnSync(() {
history!.updatedAt = DateTime.now().millisecondsSinceEpoch;
if (readingTimeSeconds > 0) {
history.readingTimeSeconds =
(history.readingTimeSeconds ?? 0) + readingTimeSeconds;
}
isar.historys.putSync(history);
history.chapter.saveSync();
});
}
// ---------------------------------------------------------------------------
// Scroll-position tracking
// ---------------------------------------------------------------------------
void setChapterOffset(double newOffset, double maxOffset, bool save) {
if (incognitoMode) return;
@ -120,111 +43,4 @@ class NovelReaderController extends _$NovelReaderController {
});
}
}
void setChapterBookmarked() {
if (incognitoMode) return;
final isBookmarked = getChapterBookmarked();
final chap = chapter;
isar.writeTxnSync(() {
chap.isBookmarked = !isBookmarked;
chap.updatedAt = DateTime.now().millisecondsSinceEpoch;
isar.chapters.putSync(chap);
});
}
bool getChapterBookmarked() {
return isar.chapters.getSync(chapter.id!)!.isBookmarked!;
}
(int, bool) getPrevChapterIndex() {
final chapters = getManga().getFilteredChapterList();
int? index;
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i + 1;
}
}
if (index == null) {
final chapters = getManga().chapters.toList().toList();
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i + 1;
}
}
return (index!, false);
}
return (index, true);
}
(int, bool) getNextChapterIndex() {
final chapters = getManga().getFilteredChapterList();
int? index;
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i - 1;
}
}
if (index == null) {
final chapters = getManga().chapters.toList().toList();
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i - 1;
}
}
return (index!, false);
}
return (index, true);
}
(int, bool) getChapterIndex() {
final chapters = getManga().getFilteredChapterList();
int? index;
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i;
}
}
if (index == null) {
final chapters = getManga().chapters.toList().toList();
for (var i = 0; i < chapters.length; i++) {
if (chapters[i].id == chapter.id) {
index = i;
}
}
return (index!, false);
}
return (index, true);
}
Chapter getPrevChapter() {
final prevChapIdx = getPrevChapterIndex();
return prevChapIdx.$2
? getManga().getFilteredChapterList()[prevChapIdx.$1]
: getManga().chapters.toList().toList()[prevChapIdx.$1];
}
Chapter getNextChapter() {
final nextChapIdx = getNextChapterIndex();
return nextChapIdx.$2
? getManga().getFilteredChapterList()[nextChapIdx.$1]
: getManga().chapters.toList().toList()[nextChapIdx.$1];
}
int getChaptersLength(bool isInFilterList) {
return isInFilterList
? getManga().getFilteredChapterList().length
: getManga().chapters.length;
}
String getMangaName() {
return getManga().name!;
}
String getSourceName() {
return getManga().source!;
}
String getChapterTitle() {
return chapter.name!;
}
}

View file

@ -90,8 +90,8 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
_readingStopwatch.stop();
WidgetsBinding.instance.removeObserver(this);
_readerController.setChapterOffset(offset, maxOffset, true);
_readerController.setMangaHistoryUpdate(
readingTimeSeconds: _readingStopwatch.elapsed.inSeconds,
_readerController.setHistoryUpdate(
elapsedSeconds: _readingStopwatch.elapsed.inSeconds,
);
_scrollController.removeListener(onScroll);
_scrollController.dispose();

View file

@ -40,7 +40,6 @@ Future<GetChapterPagesModel> getChapterPages(
}) async {
final keepAlive = ref.keepAlive();
try {
List<UChapDataPreload> uChapDataPreloadp = [];
Directory? path;
List<PageUrl> pageUrls = [];
List<bool> isLocaleList = [];
@ -86,6 +85,14 @@ Future<GetChapterPagesModel> getChapterPages(
}
}
final chapterModel = GetChapterPagesModel(
path: path,
pageUrls: pageUrls,
isLocaleList: isLocaleList,
archiveImages: archiveImages,
uChapDataPreload: [],
);
if (pageUrls.isNotEmpty || isLocalArchive) {
if (await File(
p.join(mangaDirectory!.path, "${chapter.name}.cbz"),
@ -144,7 +151,7 @@ Future<GetChapterPagesModel> getChapterPages(
});
}
for (var i = 0; i < pageUrls.length; i++) {
uChapDataPreloadp.add(
chapterModel.uChapDataPreload.add(
UChapDataPreload(
chapter,
path,
@ -152,26 +159,14 @@ Future<GetChapterPagesModel> getChapterPages(
isLocaleList[i],
archiveImages[i],
i,
GetChapterPagesModel(
path: path,
pageUrls: pageUrls,
isLocaleList: isLocaleList,
archiveImages: archiveImages,
uChapDataPreload: uChapDataPreloadp,
),
chapterModel,
i,
),
);
}
}
keepAlive.close();
return GetChapterPagesModel(
path: path,
pageUrls: pageUrls,
isLocaleList: isLocaleList,
archiveImages: archiveImages,
uChapDataPreload: uChapDataPreloadp,
);
return chapterModel;
} catch (e) {
keepAlive.close();
rethrow;

View file

@ -1,48 +0,0 @@
import 'package:isar_community/isar.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart';
import 'package:mangayomi/services/isolate_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_.g.dart';
@riverpod
Future<MPages?> search(
Ref ref, {
required Source source,
required String query,
required int page,
required List<dynamic> filterList,
}) async {
if (source.name == "local" && source.lang == "") {
final result =
(await isar.mangas
.filter()
.group(
(q) => q
.sourceEqualTo("local")
.or()
.linkContains("Mangayomi/local")
.or()
.linkContains("Mangayomi\\local"),
)
.nameContains(query, caseSensitive: false)
.offset(page * 50)
.limit(50)
.findAll())
.map((e) => MManga(name: e.name))
.toList();
return MPages(list: result, hasNextPage: true);
}
return getIsolateService.get<MPages?>(
query: query,
filterList: filterList,
source: source,
page: page,
serviceType: 'search',
proxyServer: ref.read(androidProxyServerStateProvider),
);
}

View file

@ -1,109 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(search)
final searchProvider = SearchFamily._();
final class SearchProvider
extends $FunctionalProvider<AsyncValue<MPages?>, MPages?, FutureOr<MPages?>>
with $FutureModifier<MPages?>, $FutureProvider<MPages?> {
SearchProvider._({
required SearchFamily super.from,
required ({Source source, String query, int page, List<dynamic> filterList})
super.argument,
}) : super(
retry: null,
name: r'searchProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchHash();
@override
String toString() {
return r'searchProvider'
''
'$argument';
}
@$internal
@override
$FutureProviderElement<MPages?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<MPages?> create(Ref ref) {
final argument =
this.argument
as ({
Source source,
String query,
int page,
List<dynamic> filterList,
});
return search(
ref,
source: argument.source,
query: argument.query,
page: argument.page,
filterList: argument.filterList,
);
}
@override
bool operator ==(Object other) {
return other is SearchProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$searchHash() => r'0fa9d882436b1b58b3420dae5a757e7622273eb5';
final class SearchFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<MPages?>,
({Source source, String query, int page, List<dynamic> filterList})
> {
SearchFamily._()
: super(
retry: null,
name: r'searchProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SearchProvider call({
required Source source,
required String query,
required int page,
required List<dynamic> filterList,
}) => SearchProvider._(
argument: (
source: source,
query: query,
page: page,
filterList: filterList,
),
from: this,
);
@override
String toString() => r'searchProvider';
}

View file

@ -1,13 +1,21 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/download.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart';
import 'package:mangayomi/modules/manga/reader/providers/push_router.dart';
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
import 'package:mangayomi/utils/extensions/manga.dart';
import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart';
import 'package:mangayomi/providers/storage_provider.dart';
import 'package:mangayomi/services/download_manager/download_isolate_pool.dart';
import 'package:mangayomi/services/download_manager/m_downloader.dart';
import 'package:mangayomi/utils/chapter_recognition.dart';
import 'package:mangayomi/utils/extensions/string_extensions.dart';
import 'package:path/path.dart' as p;
@ -19,9 +27,9 @@ extension ChapterExtension on Chapter {
if (ignoreIsRead || !isRead!) {
await pushMangaReaderView(context: context, chapter: this);
} else {
final filteredChaps = manga.value!.getFilteredChapterList();
final filteredChaps = manga.value!.getChapterListForReading();
bool exist = false;
for (var filteredChap in filteredChaps.reversed) {
for (var filteredChap in filteredChaps) {
if (filteredChap.toJson().toString() == toJson().toString()) {
exist = true;
}
@ -80,4 +88,59 @@ extension ChapterExtension on Chapter {
cancelDownloads(download.id);
}
void updateTrackChapterRead(dynamic ref) {
if (!(ref is WidgetRef || ref is Ref)) return;
final updateProgressAfterReading = ref.read(
updateProgressAfterReadingStateProvider,
);
if (!updateProgressAfterReading) return;
final manga = this.manga.value!;
final chapterNumber = ChapterRecognition().parseChapterNumber(
manga.name!,
name!,
);
final tracks = isar.tracks
.filter()
.idIsNotNull()
.itemTypeEqualTo(manga.itemType)
.mangaIdEqualTo(manga.id!)
.findAllSync();
if (tracks.isEmpty) return;
for (var track in tracks) {
final service = isar.trackPreferences
.filter()
.syncIdIsNotNull()
.syncIdEqualTo(track.syncId)
.findFirstSync();
if (!(service == null || chapterNumber <= (track.lastChapterRead ?? 0))) {
if (track.status != TrackStatus.completed) {
track.lastChapterRead = chapterNumber;
if (track.lastChapterRead == track.totalChapter &&
(track.totalChapter ?? 0) > 0) {
track.status = TrackStatus.completed;
track.finishedReadingDate = DateTime.now().millisecondsSinceEpoch;
} else {
track.status = manga.itemType == ItemType.manga
? TrackStatus.reading
: TrackStatus.watching;
if (track.lastChapterRead == 1) {
track.startedReadingDate = DateTime.now().millisecondsSinceEpoch;
}
}
}
ref
.read(
trackStateProvider(
track: track,
itemType: manga.itemType,
widgetRef: ref,
).notifier,
)
.updateManga();
}
}
}
}

View file

@ -0,0 +1,124 @@
import 'package:isar_community/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/download.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
extension MangaExtensions on Manga {
// For the READER: always ascending story order, filters applied
List<Chapter> getChapterListForReading() {
final settings = isar.settings.getSync(227)!;
final filterUnread =
(settings.chapterFilterUnreadList!
.where((e) => e.mangaId == id)
.firstOrNull ??
ChapterFilterUnread(mangaId: id, type: 0))
.type!;
final filterBookmarked =
(settings.chapterFilterBookmarkedList!
.where((e) => e.mangaId == id)
.firstOrNull ??
ChapterFilterBookmarked(mangaId: id, type: 0))
.type!;
final filterDownloaded =
(settings.chapterFilterDownloadedList!
.where((e) => e.mangaId == id)
.firstOrNull ??
ChapterFilterDownloaded(mangaId: id, type: 0))
.type!;
final scanlators = settings.filterScanlatorList ?? [];
final filter = scanlators.where((e) => e.mangaId == id).toList();
final filterScanlator = filter.firstOrNull?.scanlators ?? [];
// Canonical ascending order (ch1 ... chN) reader always moves forward.
final data = chapters
.toList(); // keep DB/insertion order, assumed ascending
final chapterIds = data.map((c) => c.id).whereType<int>().toList();
final downloadedIds = (filterDownloaded == 0 || chapterIds.isEmpty)
? const <int>{}
: isar.downloads
.filter()
.anyOf(chapterIds, (q, id) => q.idEqualTo(id))
.isDownloadEqualTo(true)
.findAllSync()
.map((d) => d.id!)
.toSet();
return data
.where(
(e) => filterUnread == 1
? e.isRead == false
: filterUnread == 2
? e.isRead == true
: true,
)
.where(
(e) => filterBookmarked == 1
? e.isBookmarked == true
: filterBookmarked == 2
? e.isBookmarked == false
: true,
)
.where((e) {
if (filterDownloaded == 0) return true;
final dl = downloadedIds.contains(e.id);
return filterDownloaded == 1 ? dl : !dl;
})
.where((e) => !filterScanlator.contains(e.scanlator))
.toList()
.reversed
.toList();
}
// For the UI LIST: filters + user-chosen sort + reverse
List<Chapter> getFilteredChapterList() {
final settings = isar.settings.getSync(227)!;
final sortChapterEntry =
settings.sortChapterList!.where((e) => e.mangaId == id).firstOrNull ??
SortChapter(mangaId: id, index: 1, reverse: false);
final sortIndex = sortChapterEntry.index!;
final reverse = sortChapterEntry.reverse!;
// Start from the reading list so filter logic lives in one place.
List<Chapter> list = getChapterListForReading();
switch (sortIndex) {
case 0: // by scanlator, then date
list.sort((a, b) {
if (a.scanlator == null || b.scanlator == null) return 0;
final s = a.scanlator!.compareTo(b.scanlator!);
if (s != 0) return s;
if (a.dateUpload == null || b.dateUpload == null) return 0;
return (int.tryParse(a.dateUpload!) ?? 0).compareTo(
int.tryParse(b.dateUpload!) ?? 0,
);
});
break;
case 1: // by chapter number - reading list is already ascending
break;
case 2: // by upload date
list.sort((a, b) {
if (a.dateUpload == null || b.dateUpload == null) return 0;
return (int.tryParse(a.dateUpload!) ?? 0).compareTo(
int.tryParse(b.dateUpload!) ?? 0,
);
});
break;
case 3: // by name
list.sort((a, b) {
if (a.name == null || b.name == null) return 0;
return a.name!.compareTo(b.name!);
});
break;
}
return reverse ? list.reversed.toList() : list;
}
}