diff --git a/lib/main.dart b/lib/main.dart index d2387a2f..eed7cade 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,6 +39,7 @@ import 'package:mangayomi/services/download_manager/m_downloader.dart'; import 'package:mangayomi/src/rust/frb_generated.dart'; import 'package:mangayomi/utils/discord_rpc.dart'; import 'package:mangayomi/utils/log/logger.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:mangayomi/utils/url_protocol/api.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/theme_provider.dart'; import 'package:mangayomi/modules/library/providers/file_scanner.dart'; @@ -84,7 +85,7 @@ void main(List args) async { await RustLib.init(); await imgCropIsolate.start(); await getIsolateService.start(); - if (!(Platform.isAndroid || Platform.isIOS)) { + if (!isMobile) { await windowManager.ensureInitialized(); await WindowGeometry.restore(); } @@ -120,13 +121,10 @@ void main(List args) async { Future _postLaunchInit(StorageProvider storage) async { await AppLogger.init(); unawaited(MDownloader.initializeIsolatePool(poolSize: 6)); - final hivePath = (Platform.isIOS || Platform.isMacOS) - ? "databases" - : p.join("Mangayomi", "databases"); + final hivePath = isApple ? "databases" : p.join("Mangayomi", "databases"); await Hive.initFlutter(Platform.isAndroid ? "" : hivePath); Hive.registerAdapter(TrackSearchAdapter()); - if ((Platform.isMacOS || Platform.isLinux || Platform.isWindows) && - !kDebugMode) { + if (isDesktop && !kDebugMode) { discordRpc = DiscordRPC(applicationId: "1395040506677039157"); await discordRpc?.initialize(); } @@ -151,9 +149,7 @@ class _MyAppState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - if (!(Platform.isAndroid || Platform.isIOS)) { - windowManager.addListener(this); - } + if (!isMobile) windowManager.addListener(this); initializeDateFormatting(); customDns = ref.read(customDnsStateProvider); _checkTrackerRefresh(); @@ -210,7 +206,7 @@ class _MyAppState extends ConsumerState builder: (context, child) { child = BotToastInit()(context, child); final appChild = child; - if (!(Platform.isAndroid || Platform.isIOS)) { + if (!isMobile) { child = _MouseBackButtonHandler(router: router, child: appChild); } else { child = appChild; @@ -240,7 +236,7 @@ class _MyAppState extends ConsumerState @override void dispose() { WidgetsBinding.instance.removeObserver(this); - if (!(Platform.isAndroid || Platform.isIOS)) { + if (!isMobile) { windowManager.removeListener(this); WindowGeometry.save(); } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index bc2dd107..92e2231a 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1261,6 +1261,18 @@ enum ReaderMode { horizontalContinuousRTL, } +extension ReaderModeExtension on ReaderMode { + bool get isContinuous => isVerticalContinuous || isHorizontalContinuous; + bool get isVertical => this == ReaderMode.vertical || isVerticalContinuous; + bool get isVerticalContinuous => + this == ReaderMode.verticalContinuous || this == ReaderMode.webtoon; + bool get isHorizontalContinuous => + this == ReaderMode.horizontalContinuous || + this == ReaderMode.horizontalContinuousRTL; + bool get isRTL => + this == ReaderMode.rtl || this == ReaderMode.horizontalContinuousRTL; +} + enum NovelTextAlign { left, center, right, block } enum PageMode { onePage, doublePage } diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index acb74d19..b6f08c1f 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -43,6 +43,8 @@ import 'package:mangayomi/services/get_video_list.dart'; import 'package:mangayomi/services/torrent_server.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/language.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; +import 'package:mangayomi/utils/system_ui.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit/generated/libmpv/bindings.dart' as generated; import 'package:media_kit_video/media_kit_video.dart'; @@ -56,8 +58,6 @@ import 'package:window_manager/window_manager.dart' show windowManager; import 'widgets/search_subtitles.dart'; -bool _isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; - class AnimePlayerView extends riv.ConsumerStatefulWidget { final int episodeId; const AnimePlayerView({super.key, required this.episodeId}); @@ -72,16 +72,13 @@ class _AnimePlayerViewState extends riv.ConsumerState { bool desktopFullScreenPlayer = false; @override void dispose() { - if (_isDesktop) { + if (isDesktop) { setFullScreen(value: desktopFullScreenPlayer); } for (var infoHash in _infoHashList) { MTorrentServer().removeTorrent(infoHash); } - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); super.dispose(); } @@ -129,10 +126,7 @@ class _AnimePlayerViewState extends riv.ConsumerState { title: const Text(''), leading: BackButton( onPressed: () { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); }, ), @@ -148,10 +142,7 @@ class _AnimePlayerViewState extends riv.ConsumerState { leading: BackButton( color: Colors.white, onPressed: () { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); }, ), @@ -316,7 +307,7 @@ class _AnimeStreamPageState extends riv.ConsumerState discordRpc?.updateChapterTimestamp(_currentPosition.value, duration); }); - bool get hasNextEpisode => _streamController.getEpisodeIndex().$1 != 0; + bool get hasNextEpisode => _streamController.hasNextEpisode; late final StreamSubscription _completed = _player.stream.completed .listen((val) { @@ -327,7 +318,7 @@ class _AnimeStreamPageState extends riv.ConsumerState } // If the last episode of an Anime has ended, exit fullscreen mode final isFullScreen = ref.read(fullscreenProvider); - if (!hasNextEpisode && val && _isDesktop && isFullScreen) { + if (!hasNextEpisode && val && isDesktop && isFullScreen) { setFullScreen(value: false); ref.read(fullscreenProvider.notifier).state = false; widget.desktopFullScreenPlayer.call(false); @@ -362,6 +353,23 @@ class _AnimeStreamPageState extends riv.ConsumerState } } + String? _readMpvString(Pointer value) { + if (value.ref.format != generated.mpv_format.MPV_FORMAT_STRING) return null; + final text = value.ref.u.string.cast().toDartString(); + return text.isEmpty ? null : text; + } + + Future _seekTo(int absoluteSeconds) async { + _tempPosition.value = Duration(seconds: absoluteSeconds); + await _player.seek(Duration(seconds: absoluteSeconds)); + _tempPosition.value = null; + } + + Future _seekBy(int deltaSeconds) async { + final pos = _currentPosition.value.inSeconds + deltaSeconds; + await _seekTo(pos); + } + Future _handleMpvNodeEvents( String propName, Pointer value, @@ -369,272 +377,230 @@ class _AnimeStreamPageState extends riv.ConsumerState final nativePlayer = _player.platform as NativePlayer; switch (propName.substring(10)) { case "aniyomi/show_text": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - botToast( - text, - alignY: -0.99, - second: 2, - dismissDirections: const [ - DismissDirection.vertical, - DismissDirection.horizontal, - ], - showIcon: false, - ); - nativePlayer.setProperty("user-data/aniyomi/show_text", ""); - } + final text = _readMpvString(value); + if (text == null) break; + botToast( + text, + alignY: -0.99, + second: 2, + dismissDirections: const [ + DismissDirection.vertical, + DismissDirection.horizontal, + ], + showIcon: false, + ); + nativePlayer.setProperty("user-data/aniyomi/show_text", ""); break; case "aniyomi/toggle_ui": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - // WIP - case "show": - break; - case "hide": - break; - case "toggle": - break; - } - nativePlayer.setProperty("user-data/aniyomi/toggle_ui", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + // WIP + case "show": + break; + case "hide": + break; + case "toggle": + break; } + nativePlayer.setProperty("user-data/aniyomi/toggle_ui", ""); break; case "aniyomi/show_panel": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - // WIP - case "subtitle_settings": - break; - case "subtitle_delay": - break; - case "audio_delay": - break; - case "video_filters": - break; - } - nativePlayer.setProperty("user-data/aniyomi/show_panel", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + // WIP + case "subtitle_settings": + break; + case "subtitle_delay": + break; + case "audio_delay": + break; + case "video_filters": + break; } + nativePlayer.setProperty("user-data/aniyomi/show_panel", ""); break; case "aniyomi/software_keyboard": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - // WIP - case "show": - break; - case "hide": - break; - case "toggle": - break; - } - nativePlayer.setProperty("user-data/aniyomi/software_keyboard", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + // WIP + case "show": + break; + case "hide": + break; + case "toggle": + break; } + nativePlayer.setProperty("user-data/aniyomi/software_keyboard", ""); break; case "aniyomi/set_button_title": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final temp = _customButton.value; - if (temp == null) break; - _customButton.value = temp..currentTitle = text; - nativePlayer.setProperty("user-data/aniyomi/set_button_title", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final temp = _customButton.value; + if (temp == null) break; + _customButton.value = temp..currentTitle = text; + nativePlayer.setProperty("user-data/aniyomi/set_button_title", ""); break; case "aniyomi/reset_button_title": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final temp = _customButton.value; - if (temp == null) break; - _customButton.value = temp..currentTitle = temp.button.title ?? ""; - nativePlayer.setProperty("user-data/aniyomi/reset_button_title", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final temp = _customButton.value; + if (temp == null) break; + _customButton.value = temp..currentTitle = temp.button.title ?? ""; + nativePlayer.setProperty("user-data/aniyomi/reset_button_title", ""); break; case "aniyomi/toggle_button": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final temp = _customButton.value; - if (temp == null) break; - switch (text) { - case "show": - _customButton.value = temp..visible = true; - break; - case "hide": - _customButton.value = temp..visible = false; - break; - case "toggle": - _customButton.value = temp..visible = !temp.visible; - break; - } - nativePlayer.setProperty("user-data/aniyomi/toggle_button", ""); + final text = _readMpvString(value); + if (text == null) break; + final temp = _customButton.value; + if (temp == null) break; + switch (text) { + case "show": + _customButton.value = temp..visible = true; + break; + case "hide": + _customButton.value = temp..visible = false; + break; + case "toggle": + _customButton.value = temp..visible = !temp.visible; + break; } + nativePlayer.setProperty("user-data/aniyomi/toggle_button", ""); break; case "aniyomi/switch_episode": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - case "n": - pushToNewEpisode(context, _streamController.getNextEpisode()); - break; - case "p": - pushToNewEpisode(context, _streamController.getPrevEpisode()); - break; - } - nativePlayer.setProperty("user-data/aniyomi/switch_episode", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + case "n": + pushToNewEpisode(context, _streamController.getNextEpisode()); + break; + case "p": + pushToNewEpisode(context, _streamController.getPrevEpisode()); + break; } + nativePlayer.setProperty("user-data/aniyomi/switch_episode", ""); break; case "aniyomi/pause": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - switch (text) { - case "pause": - await _player.pause(); - break; - case "unpause": - await _player.play(); - break; - case "pauseunpause": - await _player.playOrPause(); - break; - } - nativePlayer.setProperty("user-data/aniyomi/pause", ""); + final text = _readMpvString(value); + if (text == null) break; + switch (text) { + case "pause": + await _player.pause(); + break; + case "unpause": + await _player.play(); + break; + case "pauseunpause": + await _player.playOrPause(); + break; } + nativePlayer.setProperty("user-data/aniyomi/pause", ""); break; case "aniyomi/seek_by": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = int.parse(text.replaceAll("\"", "")); - final pos = _currentPosition.value.inSeconds + data; - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; - nativePlayer.setProperty("user-data/aniyomi/seek_by", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = int.parse(text.replaceAll("\"", "")); + await _seekBy(data); + nativePlayer.setProperty("user-data/aniyomi/seek_by", ""); break; case "aniyomi/seek_to": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = int.parse(text.replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: data); - await _player.seek(Duration(seconds: data)); - _tempPosition.value = null; - nativePlayer.setProperty("user-data/aniyomi/seek_to", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = int.parse(text.replaceAll("\"", "")); + await _seekTo(data); + nativePlayer.setProperty("user-data/aniyomi/seek_to", ""); break; case "aniyomi/seek_by_with_text": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = text.split("|"); - final pos = - _currentPosition.value.inSeconds + - int.parse(data[0].replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; - (_player.platform as NativePlayer).command(["show-text", data[1]]); - nativePlayer.setProperty("user-data/aniyomi/seek_by_with_text", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = text.split("|"); + await _seekBy(int.parse(data[0].replaceAll("\"", ""))); + (_player.platform as NativePlayer).command(["show-text", data[1]]); + nativePlayer.setProperty("user-data/aniyomi/seek_by_with_text", ""); break; case "aniyomi/seek_to_with_text": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = text.split("|"); - final pos = int.parse(data[0].replaceAll("\"", "")); - _tempPosition.value = Duration(seconds: pos); - await _player.seek(Duration(seconds: pos)); - _tempPosition.value = null; - (_player.platform as NativePlayer).command(["show-text", data[1]]); - nativePlayer.setProperty("user-data/aniyomi/seek_to_with_text", ""); - } + final text = _readMpvString(value); + if (text == null) break; + final data = text.split("|"); + await _seekTo(int.parse(data[0].replaceAll("\"", ""))); + (_player.platform as NativePlayer).command(["show-text", data[1]]); + nativePlayer.setProperty("user-data/aniyomi/seek_to_with_text", ""); break; case "aniyomi/launch_int_picker": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - if (text.isEmpty) break; - final data = text.split("|"); - final start = int.parse(data[2]); - final stop = int.parse(data[3]); - final step = int.parse(data[4]); - int currentValue = start; - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(data[0]), - content: StatefulBuilder( - builder: (context, setState) => SizedBox( - height: 200, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - NumberPicker( - value: currentValue, - minValue: start, - maxValue: stop, - step: step, - haptics: true, - textMapper: (numberText) => - data[1].replaceAll("%d", numberText), - onChanged: (value) => - setState(() => currentValue = value), - ), - ], - ), - ), - ), - actions: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, + final text = _readMpvString(value); + if (text == null) break; + final data = text.split("|"); + final start = int.parse(data[2]); + final stop = int.parse(data[3]); + final step = int.parse(data[4]); + int currentValue = start; + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(data[0]), + content: StatefulBuilder( + builder: (context, setState) => SizedBox( + height: 200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - TextButton( - onPressed: () async { - Navigator.pop(context); - }, - child: Text( - context.l10n.cancel, - style: TextStyle(color: context.primaryColor), - ), - ), - TextButton( - onPressed: () async { - final namePtr = data[5].toNativeUtf8(); - final valuePtr = calloc(1) - ..value = currentValue; - nativePlayer.mpv.mpv_set_property( - nativePlayer.ctx, - namePtr.cast(), - generated.mpv_format.MPV_FORMAT_INT64, - valuePtr.cast(), - ); - malloc.free(namePtr); - malloc.free(valuePtr); - Navigator.pop(context); - }, - child: Text( - context.l10n.ok, - style: TextStyle(color: context.primaryColor), - ), + NumberPicker( + value: currentValue, + minValue: start, + maxValue: stop, + step: step, + haptics: true, + textMapper: (numberText) => + data[1].replaceAll("%d", numberText), + onChanged: (value) => + setState(() => currentValue = value), ), ], ), - ], - ); - }, - ); - nativePlayer.setProperty("user-data/aniyomi/launch_int_picker", ""); - } + ), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () async { + Navigator.pop(context); + }, + child: Text( + context.l10n.cancel, + style: TextStyle(color: context.primaryColor), + ), + ), + TextButton( + onPressed: () async { + final namePtr = data[5].toNativeUtf8(); + final valuePtr = calloc(1)..value = currentValue; + nativePlayer.mpv.mpv_set_property( + nativePlayer.ctx, + namePtr.cast(), + generated.mpv_format.MPV_FORMAT_INT64, + valuePtr.cast(), + ); + malloc.free(namePtr); + malloc.free(valuePtr); + Navigator.pop(context); + }, + child: Text( + context.l10n.ok, + style: TextStyle(color: context.primaryColor), + ), + ), + ], + ), + ], + ); + }, + ); + nativePlayer.setProperty("user-data/aniyomi/launch_int_picker", ""); break; case "mangayomi/chapter_titles": if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { @@ -653,10 +619,8 @@ class _AnimeStreamPageState extends riv.ConsumerState } break; case "mangayomi/selected_shader": - if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) { - final text = value.ref.u.string.cast().toDartString(); - _selectedShader.value = text; - } + final text = _readMpvString(value); + _selectedShader.value = text ?? ''; break; } } @@ -875,7 +839,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo "$defaultSkipIntroLength", ); } catch (_) {} - if (_isDesktop && _firstTime) { + if (isDesktop && _firstTime) { final globalFullscreen = ref.read(fullScreenPlayerStateProvider); // Delay fullscreen until after the first frame so the window is ready. // On Windows, calling setFullScreen before the widget tree is built @@ -887,7 +851,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo }); _firstTime = false; } - if (!_isDesktop) { + if (!isDesktop) { final forceLandscape = ref.read(forceLandscapePlayerStateProvider); if (forceLandscape) { _setLandscapeMode(true); @@ -1006,7 +970,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo _skipPhase.dispose(); _subDelayController.dispose(); _subSpeedController.dispose(); - if (!_isDesktop) _setLandscapeMode(false); + if (!isDesktop) _setLandscapeMode(false); discordRpc?.showIdleText(); discordRpc?.showOriginalTimestamp(); _streamController.keepAliveLink?.close(); @@ -1524,21 +1488,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ? ElevatedButton( onPressed: value?.onPress ?? - () async { - _tempPosition.value = Duration( - seconds: - defaultSkipIntroLength + - _currentPosition.value.inSeconds, - ); - await _player.seek( - Duration( - seconds: - _currentPosition.value.inSeconds + - defaultSkipIntroLength, - ), - ); - _tempPosition.value = null; - }, + () async => await _seekBy(defaultSkipIntroLength), onLongPress: value?.onLongPress, child: Padding( padding: const EdgeInsets.all(8.0), @@ -1619,11 +1569,6 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo } Widget _desktopBottomButtonBar(BuildContext context) { - bool hasPrevEpisode = - _streamController.getEpisodeIndex().$1 + 1 != - _streamController.getEpisodesLength( - _streamController.getEpisodeIndex().$2, - ); final skipDuration = ref.watch(defaultDoubleTapToSkipLengthStateProvider); return Column( mainAxisAlignment: MainAxisAlignment.end, @@ -1633,7 +1578,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo children: [ Row( children: [ - if (hasPrevEpisode) + if (_streamController.hasPreviousEpisode) IconButton( onPressed: () { pushToNewEpisode( @@ -1643,10 +1588,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo }, icon: const Icon(Icons.skip_previous, color: Colors.white), ), - CustomPlayOrPauseButton( - controller: _controller, - isDesktop: _isDesktop, - ), + CustomPlayOrPauseButton(controller: _controller), if (hasNextEpisode) IconButton( onPressed: () async { @@ -1661,19 +1603,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo height: 50, width: 50, child: IconButton( - onPressed: () async { - _tempPosition.value = Duration( - seconds: - skipDuration - _currentPosition.value.inSeconds, - ); - await _player.seek( - Duration( - seconds: - _currentPosition.value.inSeconds - skipDuration, - ), - ); - _tempPosition.value = null; - }, + onPressed: () async => await _seekBy(-skipDuration), icon: Stack( children: [ const Positioned.fill( @@ -1705,19 +1635,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo height: 50, width: 50, child: IconButton( - onPressed: () async { - _tempPosition.value = Duration( - seconds: - skipDuration + _currentPosition.value.inSeconds, - ); - await _player.seek( - Duration( - seconds: - _currentPosition.value.inSeconds + skipDuration, - ), - ); - _tempPosition.value = null; - }, + onPressed: () async => await _seekBy(skipDuration), icon: Stack( children: [ const Positioned.fill( @@ -1875,7 +1793,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo return Row( children: [ IconButton( - padding: _isDesktop ? EdgeInsets.zero : const EdgeInsets.all(5), + padding: isDesktop ? EdgeInsets.zero : const EdgeInsets.all(5), onPressed: () => _videoSettingDraggableMenu(context), icon: const Icon(Icons.video_settings, color: Colors.white), ), @@ -1902,7 +1820,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo _changeFitLabel(ref); }, ), - if (_isDesktop) + if (isDesktop) CustomMaterialDesktopFullscreenButton( controller: _controller, desktopFullScreenPlayer: widget.desktopFullScreenPlayer, @@ -1926,24 +1844,19 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo final fullScreen = ref.watch(fullscreenProvider); return Padding( padding: EdgeInsets.only( - top: !_isDesktop && !fullScreen - ? MediaQuery.of(context).padding.top - : 0, + top: !isDesktop && !fullScreen ? MediaQuery.of(context).padding.top : 0, ), child: Row( children: [ BackButton( color: Colors.white, onPressed: () { - if (_isDesktop && fullScreen) { + if (isDesktop && fullScreen) { setFullScreen(value: !fullScreen); ref.read(fullscreenProvider.notifier).state = !fullScreen; widget.desktopFullScreenPlayer.call(!fullScreen); } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } if (mounted) { // Set variable to true, so the player uses the global @@ -2024,7 +1937,10 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ); } + BoxFit? _lastFit; void _resize(BoxFit fit) async { + if (fit == _lastFit) return; + _lastFit = fit; // Wait for the widget tree to settle before updating fit await WidgetsBinding.instance.endOfFrame; if (mounted) { @@ -2052,7 +1968,7 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo ), fit: fit, key: _key, - controls: (state) => _isDesktop + controls: (state) => isDesktop ? DesktopControllerWidget( videoController: _controller, topButtonBarWidget: _topButtonBar(context), @@ -2446,6 +2362,5 @@ mixin _AlwaysOnTopStateMixin on State { } // Whether the platform support AlwaysOnTop feature. - bool _supportAlwaysOnTop() => - !kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows); + bool _supportAlwaysOnTop() => !kIsWeb && isDesktop; } diff --git a/lib/modules/anime/providers/anime_player_controller_provider.dart b/lib/modules/anime/providers/anime_player_controller_provider.dart index 23bdc76f..515ce1b0 100644 --- a/lib/modules/anime/providers/anime_player_controller_provider.dart +++ b/lib/modules/anime/providers/anime_player_controller_provider.dart @@ -5,7 +5,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/track.dart'; import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.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'; @@ -44,6 +44,9 @@ class AnimeStreamController extends _$AnimeStreamController Chapter getPrevEpisode() => getPrevChapter(); Chapter getNextEpisode() => getNextChapter(); + bool get hasPreviousEpisode => hasPreviousChapter; + bool get hasNextEpisode => hasNextChapter; + int getEpisodesLength(bool isInFilterList) => getChaptersLength(isInFilterList); @@ -127,7 +130,7 @@ class AnimeStreamController extends _$AnimeStreamController .read(aniSkipProvider.notifier) .getResult( id, - ChapterRecognition().parseChapterNumber( + ChapterRecognition().parseEpisodeNumber( episode.manga.value!.name!, episode.name!, ), diff --git a/lib/modules/anime/widgets/custom_seekbar.dart b/lib/modules/anime/widgets/custom_seekbar.dart index e4305c83..bcb40c2c 100644 --- a/lib/modules/anime/widgets/custom_seekbar.dart +++ b/lib/modules/anime/widgets/custom_seekbar.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:mangayomi/modules/anime/widgets/custom_track_shape.dart'; @@ -61,7 +61,6 @@ class CustomSeekBarState extends State { buffer = player.state.buffer; } - final isDesktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; @override Widget build(BuildContext context) { final maxValue = max(duration.inMilliseconds.toDouble(), 0).toDouble(); diff --git a/lib/modules/anime/widgets/mobile.dart b/lib/modules/anime/widgets/mobile.dart index c797973e..5db55b57 100644 --- a/lib/modules/anime/widgets/mobile.dart +++ b/lib/modules/anime/widgets/mobile.dart @@ -918,7 +918,7 @@ List mobilePrimaryButtonBar( ), ), const Spacer(), - CustomPlayOrPauseButton(controller: controller, isDesktop: false), + CustomPlayOrPauseButton(controller: controller), const Spacer(), IconButton( onPressed: hasNextEpisode diff --git a/lib/modules/anime/widgets/play_or_pause_button.dart b/lib/modules/anime/widgets/play_or_pause_button.dart index e6443edc..73ac357b 100644 --- a/lib/modules/anime/widgets/play_or_pause_button.dart +++ b/lib/modules/anime/widgets/play_or_pause_button.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:media_kit_video/media_kit_video.dart'; // BUTTON: PLAY/PAUSE @@ -7,13 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart'; /// A material design play/pause button. class CustomPlayOrPauseButton extends StatefulWidget { final VideoController controller; - final bool isDesktop; - const CustomPlayOrPauseButton({ - super.key, - required this.controller, - required this.isDesktop, - }); + const CustomPlayOrPauseButton({super.key, required this.controller}); @override CustomPlayOrPauseButtonState createState() => CustomPlayOrPauseButtonState(); @@ -29,7 +25,7 @@ class CustomPlayOrPauseButtonState extends State StreamSubscription? subscription; - double get iconSize => widget.isDesktop ? 25 : 65; + double get iconSize => isDesktop ? 25 : 65; @override void setState(VoidCallback fn) { diff --git a/lib/modules/anime/widgets/search_subtitles.dart b/lib/modules/anime/widgets/search_subtitles.dart index 112a6db7..dd1c2342 100644 --- a/lib/modules/anime/widgets/search_subtitles.dart +++ b/lib/modules/anime/widgets/search_subtitles.dart @@ -14,6 +14,7 @@ import 'package:mangayomi/services/http/m_client.dart'; import 'package:mangayomi/services/http/rhttp/src/model/settings.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/log/logger.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:path/path.dart' as path; import 'package:super_sliver_list/super_sliver_list.dart'; @@ -118,7 +119,7 @@ class _SubtitlesWidgetSearchState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 10), child: TextFormField( onTap: () { - if (Platform.isAndroid || Platform.isIOS) { + if (isMobile) { setState(() { hide = true; }); diff --git a/lib/modules/history/history_screen.dart b/lib/modules/history/history_screen.dart index a6ea4535..483c0e4e 100644 --- a/lib/modules/history/history_screen.dart +++ b/lib/modules/history/history_screen.dart @@ -18,7 +18,7 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/utils/date.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; import 'package:mangayomi/modules/widgets/error_text.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; diff --git a/lib/modules/library/providers/library_state_provider.dart b/lib/modules/library/providers/library_state_provider.dart index 7c5c64dd..b7208189 100644 --- a/lib/modules/library/providers/library_state_provider.dart +++ b/lib/modules/library/providers/library_state_provider.dart @@ -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/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'library_state_provider.g.dart'; diff --git a/lib/modules/library/widgets/continue_reader_button.dart b/lib/modules/library/widgets/continue_reader_button.dart index df1945e2..9dfaa65d 100644 --- a/lib/modules/library/widgets/continue_reader_button.dart +++ b/lib/modules/library/widgets/continue_reader_button.dart @@ -6,7 +6,7 @@ import 'package:mangayomi/models/history.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; class ContinueReaderButton extends ConsumerWidget { final Manga entry; diff --git a/lib/modules/library/widgets/library_app_bar.dart b/lib/modules/library/widgets/library_app_bar.dart index bc38b898..152610ed 100644 --- a/lib/modules/library/widgets/library_app_bar.dart +++ b/lib/modules/library/widgets/library_app_bar.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mangayomi/models/manga.dart'; @@ -76,7 +76,6 @@ class LibraryAppBar extends ConsumerWidget implements PreferredSizeWidget { ), ); final l10n = l10nLocalizations(context)!; - final isMobile = Platform.isIOS || Platform.isAndroid; if (isLongPressed) { return manga.when( diff --git a/lib/modules/main_view/main_screen.dart b/lib/modules/main_view/main_screen.dart index d51a1bf5..e84a64da 100644 --- a/lib/modules/main_view/main_screen.dart +++ b/lib/modules/main_view/main_screen.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -544,7 +544,7 @@ class _DownloadedOnlyBar extends StatelessWidget { return Material( child: AnimatedContainer( height: downloadedOnly - ? Platform.isAndroid || Platform.isIOS + ? isMobile ? MediaQuery.of(context).padding.top * 2 : 50 : 0, @@ -583,7 +583,7 @@ class _IncognitoModeBar extends StatelessWidget { return Material( child: AnimatedContainer( height: incognitoMode - ? Platform.isAndroid || Platform.isIOS + ? isMobile ? MediaQuery.of(context).padding.top * 2 : 50 : 0, diff --git a/lib/modules/manga/detail/manga_detail_view.dart b/lib/modules/manga/detail/manga_detail_view.dart index 5bc42e45..6e0e637f 100644 --- a/lib/modules/manga/detail/manga_detail_view.dart +++ b/lib/modules/manga/detail/manga_detail_view.dart @@ -22,7 +22,8 @@ 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/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/manga_extensions.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.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'; @@ -107,22 +108,13 @@ class _MangaDetailViewState extends ConsumerState late final isLocalArchive = widget.manga!.isLocalArchive ?? false; @override Widget build(BuildContext context) { - final scanlators = ref.watch(scanlatorsFilterStateProvider(widget.manga!)); - final reverse = ref - .watch(sortChapterStateProvider(mangaId: widget.manga!.id!)) - .reverse!; - final filterUnread = ref.watch( - chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!), - ); - final filterBookmarked = ref.watch( - chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!), - ); - final filterDownloaded = ref.watch( - chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!), - ); - final sortChapter = - ref.watch(sortChapterStateProvider(mangaId: widget.manga!.id!)).index - as int; + // Watch all sort/filter providers so the list rebuilds whenever + // the user changes settings in _showDraggableMenu(). + ref.watch(scanlatorsFilterStateProvider(widget.manga!)); + ref.watch(sortChapterStateProvider(mangaId: widget.manga!.id!)); + ref.watch(chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!)); + ref.watch(chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!)); + ref.watch(chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!)); final chapters = ref.watch( getChaptersStreamProvider(mangaId: widget.manga!.id!), ); @@ -137,134 +129,22 @@ class _MangaDetailViewState extends ConsumerState return true; }, child: chapters.when( - data: (data) { - List chapters = _filterAndSortChapter( - data: data.reversed.toList(), - filterUnread: filterUnread, - filterBookmarked: filterBookmarked, - filterDownloaded: filterDownloaded, - sortChapter: sortChapter, - filterScanlator: scanlators.$2, - ); + data: (_) { + List chapters = widget.manga!.getFilteredChapterList(); ref.read(chaptersListttStateProvider.notifier).set(chapters); - return _buildWidget(chapters: chapters, reverse: reverse); + return _buildWidget(chapters: chapters); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { - return _buildWidget( - chapters: widget.manga!.chapters.toList().reversed.toList(), - reverse: reverse, - ); + return _buildWidget(chapters: widget.manga!.chapters.toList()); }, ), ); } - List _getFilteredAndSortedChapters() { - final filterScanlator = ref.read( - scanlatorsFilterStateProvider(widget.manga!), - ); - final filterUnread = ref.read( - chapterFilterUnreadStateProvider(mangaId: widget.manga!.id!), - ); - final filterBookmarked = ref.read( - chapterFilterBookmarkedStateProvider(mangaId: widget.manga!.id!), - ); - final filterDownloaded = ref.read( - chapterFilterDownloadedStateProvider(mangaId: widget.manga!.id!), - ); - final sortChapter = - ref.read(sortChapterStateProvider(mangaId: widget.manga!.id!)).index - as int; - final chapters = isar.chapters - .filter() - .idIsNotNull() - .mangaIdEqualTo(widget.manga!.id!) - .findAllSync(); - return _filterAndSortChapter( - data: chapters, - filterUnread: filterUnread, - filterBookmarked: filterBookmarked, - filterDownloaded: filterDownloaded, - sortChapter: sortChapter, - filterScanlator: filterScanlator.$2, - ); - } - - List _filterAndSortChapter({ - required List data, - required int filterUnread, - required int filterBookmarked, - required int filterDownloaded, - required int sortChapter, - required List filterScanlator, - }) { - List? 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) { - final modelChapDownload = isar.downloads - .filter() - .idEqualTo(element.id) - .findAllSync(); - return filterDownloaded == 1 - ? modelChapDownload.isNotEmpty && - modelChapDownload.first.isDownload == true - : filterDownloaded == 2 - ? !(modelChapDownload.isNotEmpty && - modelChapDownload.first.isDownload == true) - : true; - }) - .where((element) => !filterScanlator.contains(element.scanlator)) - .toList(); - List chapters = sortChapter == 1 - ? 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 chapterList; - } - - Widget _buildWidget({ - required List chapters, - required bool reverse, - }) { + Widget _buildWidget({required List chapters}) { final chapterList = ref.watch(chaptersListStateProvider); final isLongPressed = ref.watch(isLongPressedStateProvider); final checkCategoryList = isar.categorys @@ -492,8 +372,8 @@ class _MangaDetailViewState extends ConsumerState ]; }, onSelected: (value) { - final chapters = - _getFilteredAndSortedChapters(); + final chapters = widget.manga! + .getFilteredChapterList(); if (value == 0 || value == 1 || value == 2 || @@ -549,13 +429,13 @@ class _MangaDetailViewState extends ConsumerState ref.watch(processDownloadsProvider()); } } else if (value == 4) { - final List unreadChapters = - _getFilteredAndSortedChapters() - .where( - (element) => - !(element.isRead ?? false), - ) - .toList(); + final List unreadChapters = widget + .manga! + .getFilteredChapterList() + .where( + (element) => !(element.isRead ?? false), + ) + .toList(); isar.chapters .filter() .idIsNotNull() @@ -577,8 +457,9 @@ class _MangaDetailViewState extends ConsumerState } ref.watch(processDownloadsProvider()); } else if (value == 5) { - final List allChapters = - _getFilteredAndSortedChapters(); + final List allChapters = widget + .manga! + .getFilteredChapterList(); for (var chapter in allChapters) { final entry = isar.downloads .filter() @@ -900,17 +781,8 @@ class _MangaDetailViewState extends ConsumerState chapterLength: chapters.length, ); } - int reverseIndex = - chapters.length - - chapters.reversed.toList().indexOf( - chapters.reversed.toList()[finalIndex], - ) - - 1; - final indexx = reverse - ? reverseIndex - : finalIndex; return ChapterListTileWidget( - chapter: chapters[indexx], + chapter: chapters[finalIndex], chapterList: chapterList, allChapters: chapters, sourceExist: widget.sourceExist, diff --git a/lib/modules/manga/detail/manga_details_view.dart b/lib/modules/manga/detail/manga_details_view.dart index 0b18e47b..21ecf219 100644 --- a/lib/modules/manga/detail/manga_details_view.dart +++ b/lib/modules/manga/detail/manga_details_view.dart @@ -14,7 +14,7 @@ import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/modules/manga/detail/manga_detail_view.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; import 'package:mangayomi/modules/more/providers/incognito_mode_state_provider.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; class MangaDetailsView extends ConsumerStatefulWidget { final Manga manga; diff --git a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart index ad06fe2b..a86b01a9 100644 --- a/lib/modules/manga/detail/providers/update_manga_detail_providers.dart +++ b/lib/modules/manga/detail/providers/update_manga_detail_providers.dart @@ -1,5 +1,5 @@ import 'package:mangayomi/eval/model/m_bridge.dart'; -import 'package:mangayomi/eval/model/m_manga.dart'; +import 'package:mangayomi/utils/chapter_recognition.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/update.dart'; @@ -20,7 +20,12 @@ Future updateMangaDetail( }) async { try { final manga = isar.mangas.getSync(mangaId!); - if ((manga!.isLocalArchive ?? false) || + if (manga == null) return; + + // loadSync() so .isNotEmpty is reliable (IsarLinks are lazy by default). + manga.chapters.loadSync(); + + if ((manga.isLocalArchive ?? false) || (manga.chapters.isNotEmpty && isInit)) { return; } @@ -30,10 +35,10 @@ Future updateMangaDetail( manga.sourceId, installedOnly: true, ); - MManga getManga; + if (source == null) return; - getManga = await ref.read( - getDetailProvider(url: manga.link!, source: source!).future, + final getManga = await ref.read( + getDetailProvider(url: manga.link!, source: source).future, ); final genre = @@ -45,6 +50,8 @@ Future updateMangaDetail( []; final imgUrl = getManga.imageUrl.trimmedOrDefault(manga.imageUrl); + final now = DateTime.now().millisecondsSinceEpoch; + manga ..imageUrl = imgUrl == null ? null @@ -64,90 +71,124 @@ Future updateMangaDetail( ..source = manga.source ..lang = manga.lang ..itemType = source.itemType - ..lastUpdate = DateTime.now().millisecondsSinceEpoch - ..updatedAt = DateTime.now().millisecondsSinceEpoch; - final checkManga = isar.mangas.getSync(mangaId); - if (checkManga!.chapters.isNotEmpty && isInit) { - return; - } - isar.writeTxnSync(() { - final mangaId = isar.mangas.putSync(manga); - manga.lastUpdate = DateTime.now().millisecondsSinceEpoch; + ..lastUpdate = now + ..updatedAt = now; - List chapters = []; + final chaps = getManga.chapters; - final chaps = getManga.chapters; - if (chaps!.isNotEmpty && chaps.length > manga.chapters.length) { - int newChapsIndex = chaps.length - manga.chapters.length; - manga.lastUpdate = DateTime.now().millisecondsSinceEpoch; - for (var i = 0; i < newChapsIndex; i++) { - final chapter = Chapter( - name: chaps[i].name!, - url: chaps[i].url!.trim(), - dateUpload: chaps[i].dateUpload == null - ? DateTime.now().millisecondsSinceEpoch.toString() - : chaps[i].dateUpload.toString(), - scanlator: chaps[i].scanlator ?? '', - mangaId: mangaId, - updatedAt: DateTime.now().millisecondsSinceEpoch, - isFiller: chaps[i].isFiller, - thumbnailUrl: chaps[i].thumbnailUrl, - description: chaps[i].description, - downloadSize: chaps[i].downloadSize, - duration: chaps[i].duration, + await isar.writeTxn(() async { + // Persist updated manga metadata. + final savedMangaId = await isar.mangas.put(manga); + + if (chaps == null || chaps.isEmpty) return; + + // loadSync() was called before the transaction; the set is still valid + // here because we haven't written to chapters yet. + final existingChapters = manga.chapters.toList(); + final existingByUrl = { + for (final c in existingChapters) + if (c.url?.isNotEmpty == true) c.url!.trim(): c, + }; + + // Build a chapterNumber -> isRead map so that when a new scanlator covers + // a chapter the user has already read, the new entry is pre-marked read. + // The value is true if ANY existing chapter at that number is read. + final recognition = ChapterRecognition(); + final readByNumber = {}; + for (final c in existingChapters) { + if (c.name == null) continue; + final num = recognition.parseChapterNumber(manga.name ?? '', c.name!); + if (num > 0) { + readByNumber[num] = + (readByNumber[num] ?? false) || (c.isRead ?? false); + } + } + + final newChapters = []; + + for (final chap in chaps) { + final url = chap.url?.trim(); + if (url == null || url.isEmpty) continue; + final existing = existingByUrl[url]; + + if (existing == null) { + // Determine whether this chapter number has already been read under + // a different scanlator, so we don't show it as unread to the user. + final chapNum = chap.name != null + ? recognition.parseChapterNumber(manga.name!, chap.name!) + : 0; + final alreadyRead = chapNum > 0 && (readByNumber[chapNum] ?? false); + + final newChapter = Chapter( + name: chap.name!, + url: url, + dateUpload: chap.dateUpload == null + ? now.toString() + : chap.dateUpload.toString(), + scanlator: chap.scanlator ?? '', + mangaId: savedMangaId, + updatedAt: now, + isFiller: chap.isFiller, + thumbnailUrl: chap.thumbnailUrl, + description: chap.description, + downloadSize: chap.downloadSize, + duration: chap.duration, )..manga.value = manga; - chapters.add(chapter); - } - } - if (chapters.isNotEmpty) { - for (var chap in chapters.reversed.toList()) { - isar.chapters.putSync(chap); - chap.manga.saveSync(); - if (manga.chapters.isNotEmpty) { - final update = Update( - mangaId: mangaId, - chapterName: chap.name, - date: DateTime.now().millisecondsSinceEpoch.toString(), - updatedAt: DateTime.now().millisecondsSinceEpoch, - )..chapter.value = chap; - isar.updates.putSync(update); - update.chapter.saveSync(); + + // Carry over read state if another scanlator's version was read. + if (alreadyRead) { + newChapter.isRead = alreadyRead; + newChapter.lastPageRead = "1"; } + + newChapters.add(newChapter); + } else { + // Existing chapter - refresh metadata only. + existing + ..name = chap.name + ..scanlator = chap.scanlator + ..updatedAt = now + ..isFiller = chap.isFiller + ..thumbnailUrl = chap.thumbnailUrl + ..description = chap.description + ..downloadSize = chap.downloadSize + ..duration = chap.duration; + await isar.chapters.put(existing); } } - final oldChapers = isar.mangas - .getSync(mangaId)! - .chapters - .toList() - .reversed - .toList(); - if (oldChapers.length == chaps.length) { - for (var i = 0; i < oldChapers.length; i++) { - final oldChap = oldChapers[i]; - final newChap = chaps[i]; - oldChap.name = newChap.name; - oldChap.url = newChap.url; - oldChap.scanlator = newChap.scanlator; - oldChap.updatedAt = DateTime.now().millisecondsSinceEpoch; - oldChap.isFiller = newChap.isFiller; - oldChap.thumbnailUrl = newChap.thumbnailUrl; - oldChap.description = newChap.description; - oldChap.downloadSize = newChap.downloadSize; - oldChap.duration = newChap.duration; - isar.chapters.putSync(oldChap); - oldChap.manga.saveSync(); + + // Insert new chapters oldest-first (API typically returns newest-first). + if (newChapters.isNotEmpty) { + final hasExisting = existingChapters.isNotEmpty; + for (final chap in newChapters.reversed) { + await isar.chapters.put(chap); + await chap.manga.save(); + + // Only create an Update entry for genuinely new (unread) chapters, + // so that pre-read cross-scanlator chapters don't spam the updates feed. + if (hasExisting && !(chap.isRead ?? false)) { + final update = Update( + mangaId: savedMangaId, + chapterName: chap.name, + date: now.toString(), + updatedAt: now, + )..chapter.value = chap; + await isar.updates.put(update); + await update.chapter.save(); + } } } // Calculate fetch interval: // median of gaps between recent distinct chapter dates, clamped [1, 28]. - final allChapters = isar.mangas.getSync(mangaId)!.chapters.toList(); + final allChapters = newChapters.isEmpty + ? existingChapters + : [...existingChapters, ...newChapters]; if (allChapters.isNotEmpty) { final interval = FetchInterval.calculateInterval(allChapters); - isar.mangas.putSync( - manga - ..id = mangaId - ..smartUpdateDays = interval, - ); + manga + ..id = savedMangaId + ..smartUpdateDays = interval; + await isar.mangas.put(manga); } }); } catch (e, s) { @@ -156,6 +197,5 @@ Future updateMangaDetail( } else { rethrow; } - return; } } 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 f8062a3e..211cc806 100644 --- a/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart +++ b/lib/modules/manga/detail/widgets/chapter_list_tile_widget.dart @@ -11,7 +11,7 @@ import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/date.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; import 'package:mangayomi/modules/manga/download/download_page_widget.dart'; diff --git a/lib/modules/manga/detail/widgets/tracker_search_widget.dart b/lib/modules/manga/detail/widgets/tracker_search_widget.dart index 6d3013ca..d2ec5215 100644 --- a/lib/modules/manga/detail/widgets/tracker_search_widget.dart +++ b/lib/modules/manga/detail/widgets/tracker_search_widget.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mangayomi/models/manga.dart'; @@ -210,7 +209,7 @@ class _TrackerWidgetSearchState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 10), child: TextFormField( onTap: () { - if (Platform.isAndroid || Platform.isIOS) { + if (isMobile) { setState(() { hide = true; }); diff --git a/lib/modules/manga/download/download_page_widget.dart b/lib/modules/manga/download/download_page_widget.dart index 63191441..38dbbcb7 100644 --- a/lib/modules/manga/download/download_page_widget.dart +++ b/lib/modules/manga/download/download_page_widget.dart @@ -9,7 +9,7 @@ import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/modules/manga/download/providers/download_provider.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/global_style.dart'; import 'package:share_plus/share_plus.dart'; diff --git a/lib/modules/manga/download/providers/download_provider.dart b/lib/modules/manga/download/providers/download_provider.dart index 806ed29d..9da1be81 100644 --- a/lib/modules/manga/download/providers/download_provider.dart +++ b/lib/modules/manga/download/providers/download_provider.dart @@ -26,7 +26,7 @@ import 'package:mangayomi/services/http/m_client.dart'; import 'package:mangayomi/services/download_manager/m3u8/m3u8_downloader.dart'; import 'package:mangayomi/services/download_manager/m3u8/models/download.dart'; import 'package:mangayomi/utils/chapter_recognition.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; import 'package:mangayomi/utils/reg_exp_matcher.dart'; diff --git a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart index af1100a9..2e35c6d0 100644 --- a/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart +++ b/lib/modules/manga/reader/mixins/chapter_controller_mixin.dart @@ -4,7 +4,7 @@ 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'; +import 'package:mangayomi/utils/extensions/manga_extensions.dart'; /// Shared navigation and history logic used by [ReaderController], /// [NovelReaderController], and [AnimeStreamController]. @@ -33,6 +33,13 @@ mixin ChapterControllerMixin { // (which is more efficient since incognito status never changes mid-session). bool get incognitoMode => isar.settings.getSync(227)!.incognitoMode!; + bool get hasNextChapter { + final idx = getChapterIndex(); + return idx.$1 < getChaptersLength(idx.$2) - 1; + } + + bool get hasPreviousChapter => getChapterIndex().$1 > 0; + Settings getIsarSetting() => isar.settings.getSync(227)!; String getMangaName() => getManga().name!; @@ -44,8 +51,8 @@ mixin ChapterControllerMixin { // --------------------------------------------------------------------------- (int, bool) getChapterIndex() => _chapterIndexWithOffset(0); - Chapter getPrevChapter() => _chapterWithOffset(1); - Chapter getNextChapter() => _chapterWithOffset(-1); + 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 diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.dart b/lib/modules/manga/reader/providers/reader_controller_provider.dart index 1c53ccee..6d00a771 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.dart @@ -9,7 +9,7 @@ import 'package:mangayomi/modules/manga/reader/mixins/chapter_controller_mixin.d import 'package:mangayomi/models/settings.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/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'reader_controller_provider.g.dart'; @@ -187,9 +187,7 @@ class ReaderController extends _$ReaderController if (chapter.isRead! || incognitoMode) return; if (!save && newIndex == _lastSavedIndex) return; _lastSavedIndex = newIndex; - final isContinuousLike = - getReaderMode() == ReaderMode.verticalContinuous || - getReaderMode() == ReaderMode.webtoon; + final isContinuousLike = getReaderMode().isVerticalContinuous; final isRead = isContinuousLike ? (newIndex + 2) >= getPageLength([]) - 1 : (newIndex + 2) >= getPageLength([]); diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 725af615..c8da2853 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -37,6 +37,7 @@ import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart'; import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart'; import 'package:mangayomi/modules/manga/reader/image_view_webtoon.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; +import 'package:mangayomi/utils/system_ui.dart'; import 'package:photo_view/photo_view.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -102,10 +103,7 @@ class _MangaReaderViewState extends ConsumerState { leading: BackButton( onPressed: () { if (restoreUi) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } Navigator.of(context).pop(); }, @@ -152,7 +150,6 @@ class _MangaChapterPageGalleryState readerControllerProvider(chapter: chapter).notifier, ); - bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; final ValueNotifier _isScrolling = ValueNotifier(false); Timer? _scrollIdleTimer; final Stopwatch _readingStopwatch = Stopwatch(); @@ -179,6 +176,7 @@ class _MangaChapterPageGalleryState _currentPageDisplayIndex.dispose(); _scrollIdleTimer?.cancel(); _isScrolling.dispose(); + _keyboardFocusNode.dispose(); _itemPositionsListener.itemPositions.removeListener(_readProgressListener); _photoViewController.dispose(); _photoViewScaleStateController.dispose(); @@ -189,10 +187,7 @@ class _MangaChapterPageGalleryState } else if (isDesktop) { setFullScreen(value: false); } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } discordRpc?.showIdleText(); final actualIdx = _pageViewToActualIndexSync(_currentIndex!); @@ -304,6 +299,7 @@ class _MangaChapterPageGalleryState final _currentReaderMode = StateProvider(() => null); PageMode? _pageMode; bool _isView = false; + final _keyboardFocusNode = FocusNode(); /// Cached reader mode to safely access in dispose without ref.read() ReaderMode? _cachedReaderMode; @@ -351,14 +347,33 @@ class _MangaChapterPageGalleryState ref.read(fullScreenReaderStateProvider.notifier).set(!value!); } + /// Goes to either next or previous chapter + /// + /// The [next] parameter determines the navigation direction: + /// - `true` -> navigate to next chapter + /// - `false` -> navigate to previous chapter + /// + /// If the reader is already at the first or last chapter (depending on + /// the direction), the method returns without navigating. + void _goToChapter(bool next) { + if (next && !_readerController.hasNextChapter) return; + if (!next && !_readerController.hasPreviousChapter) return; + _isNavigatingToChapter = true; + pushReplacementMangaReaderView( + context: context, + chapter: next + ? _readerController.getNextChapter() + : _readerController.getPrevChapter(), + ); + } + @override Widget build(BuildContext context) { final backgroundColor = ref.watch(backgroundColorStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); final readerMode = ref.watch(_currentReaderMode); - final bool isHorizontalContinuous = - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; + if (readerMode == null) return const SizedBox.shrink(); + final bool isHorizontalContinuous = readerMode.isHorizontalContinuous; final l10n = l10nLocalizations(context)!; return ReaderKeyboardHandler( @@ -366,32 +381,11 @@ class _MangaChapterPageGalleryState onNextPage: () => _handlePageNavigation(forward: true), onEscape: () => _goBack(context), onFullScreen: () => _setFullScreen(), - onNextChapter: () { - bool hasNextChapter = _readerController.getChapterIndex().$1 != 0; - if (hasNextChapter) { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ); - } - }, - onPreviousChapter: () { - bool hasPrevChapter = - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ); - if (hasPrevChapter) { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ); - } - }, + onNextChapter: () => _goToChapter(true), + onPreviousChapter: () => _goToChapter(false), ).wrapWithKeyboardListener( isReverseHorizontal: _isReverseHorizontal, + focusNode: _keyboardFocusNode, child: NotificationListener( onNotification: (notification) { if (notification.direction == ScrollDirection.idle) { @@ -411,7 +405,7 @@ class _MangaChapterPageGalleryState builder: (context, failedToLoadImage, child) { return Stack( children: [ - _isContinuousMode() + readerMode.isContinuous ? ImageViewWebtoon( pages: pages, itemScrollController: _itemScrollController, @@ -752,7 +746,7 @@ class _MangaChapterPageGalleryState navigationLayout: navigationLayout, isRTL: _isReverseHorizontal, hasImageError: failedToLoadImage, - isContinuousMode: _isContinuousMode(), + isContinuousMode: readerMode.isContinuous, onToggleUI: _isViewFunction, onPreviousPage: () => _handlePageNavigation(forward: false), @@ -794,27 +788,10 @@ class _MangaChapterPageGalleryState ReaderBottomBar( chapter: chapter, isVisible: _isView, - hasPreviousChapter: - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ), - hasNextChapter: - _readerController.getChapterIndex().$1 != 0, - onPreviousChapter: () { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ); - }, - onNextChapter: () { - _isNavigatingToChapter = true; - pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ); - }, + hasPreviousChapter: _readerController.hasPreviousChapter, + hasNextChapter: _readerController.hasNextChapter, + onPreviousChapter: () => _goToChapter(false), + onNextChapter: () => _goToChapter(true), onSliderChanged: (value, ref) { _currentPageDisplayIndex.value = value; ref @@ -918,7 +895,7 @@ class _MangaChapterPageGalleryState formatCurrentIndex: _currentIndexLabel, ), ReaderAutoScrollButton( - isContinuousMode: _isContinuousMode(), + isContinuousMode: readerMode.isContinuous, isUiVisible: _isView, autoScrollPage: _autoScrollPage, autoScroll: _autoScroll, @@ -1485,10 +1462,7 @@ class _MangaChapterPageGalleryState } void _goBack(BuildContext context) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); } @@ -1501,10 +1475,7 @@ class _MangaChapterPageGalleryState } if (fullScreenReader) { if (_isView) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } @@ -1569,11 +1540,8 @@ class _MangaChapterPageGalleryState int get _pageViewPageCount => _isDoublePageActive ? (pages.length / 2).ceil() : pages.length; - bool _isContinuousMode() { - final readerMode = ref.read(_currentReaderMode); - return readerMode == ReaderMode.verticalContinuous || - readerMode == ReaderMode.webtoon || - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; + bool _isContinuousMode([ReaderMode? mode]) { + final readerMode = mode ?? ref.read(_currentReaderMode); + return readerMode!.isContinuous; } } diff --git a/lib/modules/manga/reader/services/page_navigation_service.dart b/lib/modules/manga/reader/services/page_navigation_service.dart index 7f52e4f4..e754b4fe 100644 --- a/lib/modules/manga/reader/services/page_navigation_service.dart +++ b/lib/modules/manga/reader/services/page_navigation_service.dart @@ -30,7 +30,7 @@ class PageNavigationService { }) { if (index < 0) return; - if (_isContinuousMode(readerMode)) { + if (readerMode.isContinuous) { _navigateContinuous(index, animate); } else { _navigatePaged(index, animate); @@ -70,7 +70,7 @@ class PageNavigationService { void jumpToPage({required int index, required ReaderMode readerMode}) { if (index < 0) return; - if (_isContinuousMode(readerMode)) { + if (readerMode.isContinuous) { itemScrollController.jumpTo(index: index); } else { if (extendedController.hasClients) { @@ -104,13 +104,6 @@ class PageNavigationService { extendedController.jumpToPage(index); } } - - bool _isContinuousMode(ReaderMode mode) { - return mode == ReaderMode.verticalContinuous || - mode == ReaderMode.webtoon || - mode == ReaderMode.horizontalContinuous || - mode == ReaderMode.horizontalContinuousRTL; - } } /// Mixin to add page navigation capabilities to reader state. diff --git a/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart b/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart index 07236d9b..8e3cb5da 100644 --- a/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart +++ b/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart @@ -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/utils/extensions/manga.dart'; +import 'package:mangayomi/utils/extensions/manga_extensions.dart'; import 'package:mangayomi/modules/manga/reader/reader_view.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/date.dart'; diff --git a/lib/modules/manga/reader/widgets/chapter_transition_page.dart b/lib/modules/manga/reader/widgets/chapter_transition_page.dart index f6167e64..1397f693 100644 --- a/lib/modules/manga/reader/widgets/chapter_transition_page.dart +++ b/lib/modules/manga/reader/widgets/chapter_transition_page.dart @@ -17,20 +17,11 @@ class ChapterTransitionPage extends StatelessWidget { 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) { return Container( color: Theme.of(context).scaffoldBackgroundColor, - child: _isVertical + child: readerMode.isVertical ? _buildVerticalScaffold(context) : _buildHorizontalScaffold(context), ); @@ -174,7 +165,9 @@ class ChapterTransitionPage extends StatelessWidget { final Widget arrowIcon = Icon( nextChapter != null - ? (_isRTL ? Icons.keyboard_arrow_left : Icons.keyboard_arrow_right) + ? (readerMode.isRTL + ? Icons.keyboard_arrow_left + : Icons.keyboard_arrow_right) : Icons.check_circle_outline, size: 36, color: nextChapter != null @@ -214,7 +207,7 @@ class ChapterTransitionPage extends StatelessWidget { child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, - children: _isRTL + children: readerMode.isRTL ? [ Expanded(child: nextCard), const SizedBox(width: 12), @@ -315,10 +308,17 @@ class ChapterTransitionPage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.last_page, - size: 24, - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + RotatedBox( + quarterTurns: readerMode.isVertical + ? 1 // turn 90° clockwise, so Icon is pointing down + : readerMode.isRTL + ? 2 // turn 180°, so Icon is pointing left + : 0, // no rotation, Icon points to the right. + child: Icon( + Icons.last_page, + size: 24, + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), ), const SizedBox(height: 6), Text( diff --git a/lib/modules/manga/reader/widgets/color_filter_widget.dart b/lib/modules/manga/reader/widgets/color_filter_widget.dart index 81ef7dfb..23e80727 100644 --- a/lib/modules/manga/reader/widgets/color_filter_widget.dart +++ b/lib/modules/manga/reader/widgets/color_filter_widget.dart @@ -6,6 +6,7 @@ import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.d import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; // ── Color matrix utilities (5×4 row-major, 20 elements) ── @@ -197,7 +198,7 @@ Widget customColorFilterListTile( Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( - trackHeight: context.isDesktop ? null : 3, + trackHeight: isDesktop ? null : 3, overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0), ), child: Slider( diff --git a/lib/modules/manga/reader/widgets/reader_app_bar.dart b/lib/modules/manga/reader/widgets/reader_app_bar.dart index 063a33ec..e2c862f5 100644 --- a/lib/modules/manga/reader/widgets/reader_app_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_app_bar.dart @@ -8,6 +8,7 @@ import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_pr import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:mangayomi/utils/utils.dart'; /// The app bar for the manga reader. @@ -65,8 +66,6 @@ class ReaderAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - final isDesktop = - Platform.isMacOS || Platform.isLinux || Platform.isWindows; final isLocalArchive = chapter.manga.value?.isLocalArchive ?? false; double height = isVisible diff --git a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart index 8c93596b..e75e7b6f 100644 --- a/lib/modules/manga/reader/widgets/reader_bottom_bar.dart +++ b/lib/modules/manga/reader/widgets/reader_bottom_bar.dart @@ -107,9 +107,8 @@ class ReaderBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final readerMode = ref.watch(currentReaderModeProvider); - final isHorizontalContinuous = - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; + if (readerMode == null) return const SizedBox.shrink(); + final isHorizontalContinuous = readerMode.isHorizontalContinuous; 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 b5c89a18..a446b444 100644 --- a/lib/modules/manga/reader/widgets/reader_settings_modal.dart +++ b/lib/modules/manga/reader/widgets/reader_settings_modal.dart @@ -131,12 +131,6 @@ class _ReadingModeTab extends ConsumerWidget { final showPageGaps = ref.watch(showPageGapsStateProvider); final webtoonSidePadding = ref.watch(webtoonSidePaddingStateProvider); - final isContinuousMode = - readerMode == ReaderMode.verticalContinuous || - readerMode == ReaderMode.webtoon || - readerMode == ReaderMode.horizontalContinuous || - readerMode == ReaderMode.horizontalContinuousRTL; - return SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(vertical: 20), @@ -206,7 +200,7 @@ class _ReadingModeTab extends ConsumerWidget { ), // Show Page Gaps (only for continuous modes) - if (isContinuousMode) + if (readerMode.isContinuous) SwitchListTile( value: showPageGaps, title: Text( @@ -224,7 +218,7 @@ class _ReadingModeTab extends ConsumerWidget { ), // Webtoon Side Padding (only for continuous modes) - if (isContinuousMode) + if (readerMode.isContinuous) ListTile( title: Text( '${l10n.webtoon_side_padding}: $webtoonSidePadding%', @@ -249,7 +243,7 @@ class _ReadingModeTab extends ConsumerWidget { ), // Auto-scroll (only for continuous modes) - if (isContinuousMode) + if (readerMode.isContinuous) ValueListenableBuilder( valueListenable: autoScrollPage, builder: (context, valueT, child) { diff --git a/lib/modules/more/categories/categories_screen.dart b/lib/modules/more/categories/categories_screen.dart index a89a0980..25c6af09 100644 --- a/lib/modules/more/categories/categories_screen.dart +++ b/lib/modules/more/categories/categories_screen.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar_community/isar.dart'; @@ -114,16 +114,13 @@ class _CategoriesTabState extends ConsumerState super.dispose(); } - final bool _isDesktop = - Platform.isMacOS || Platform.isLinux || Platform.isWindows; - /// Moves a category from `index` to `newIndex` in the list, /// swaps their positions in memory, and persists the change in Isar. Future _moveCategory(int index, int newIndex) async { // Prevent invalid moves (out of bounds) if (newIndex < 0 || newIndex >= _entries.length) return; - if (_isDesktop && mounted) { + if (isDesktop && mounted) { setState(() { _animatingFromIndex = index; _animatingToIndex = newIndex; @@ -185,7 +182,7 @@ class _CategoriesTabState extends ConsumerState Widget itemWidget = _buildCategoryCard(context, category, index); - if (_isDesktop && + if (isDesktop && _animatingFromIndex != null && _animatingToIndex != null) { if (index == _animatingFromIndex || diff --git a/lib/modules/more/download_queue/download_queue_screen.dart b/lib/modules/more/download_queue/download_queue_screen.dart index b206181f..29ac083e 100644 --- a/lib/modules/more/download_queue/download_queue_screen.dart +++ b/lib/modules/more/download_queue/download_queue_screen.dart @@ -8,7 +8,7 @@ import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/modules/manga/detail/widgets/custom_floating_action_btn.dart'; import 'package:mangayomi/modules/manga/download/providers/download_provider.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/global_style.dart'; class DownloadQueueScreen extends ConsumerWidget { diff --git a/lib/modules/more/settings/browse/browse_screen.dart b/lib/modules/more/settings/browse/browse_screen.dart index 9d76e6d5..57728b71 100644 --- a/lib/modules/more/settings/browse/browse_screen.dart +++ b/lib/modules/more/settings/browse/browse_screen.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -59,12 +58,12 @@ class BrowseSScreen extends ConsumerWidget { ListTile( onTap: () => context.push('/extensionServer'), title: Text( - Platform.isAndroid || Platform.isIOS + isMobile ? l10n.android_proxy_server : l10n.android_proxy_server_mihon, ), subtitle: Text( - Platform.isAndroid || Platform.isIOS + isMobile ? l10n.apkbridge_description : l10n.android_proxy_server_mihon_description, style: TextStyle( diff --git a/lib/modules/more/settings/browse/extension_server_screen.dart b/lib/modules/more/settings/browse/extension_server_screen.dart index ab871d92..da721797 100644 --- a/lib/modules/more/settings/browse/extension_server_screen.dart +++ b/lib/modules/more/settings/browse/extension_server_screen.dart @@ -18,6 +18,7 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/fetch_sources_list.dart'; import 'package:mangayomi/services/m_extension_server.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:path/path.dart' as path; import 'package:url_launcher/url_launcher.dart'; @@ -56,14 +57,11 @@ class _ExtensionServerScreenState extends ConsumerState { bool get _requiresJre => !Platform.isIOS; - bool get _showExtensionServerSection => - !Platform.isAndroid && !Platform.isIOS; + bool get _showExtensionServerSection => !isMobile; - bool get _showAndroidProxyServerSection => - Platform.isAndroid || Platform.isIOS; + bool get _showAndroidProxyServerSection => isMobile; - bool get _showDesktopAdvancedApkBridgeSection => - Platform.isWindows || Platform.isLinux || Platform.isMacOS; + bool get _showDesktopAdvancedApkBridgeSection => isDesktop; bool get _isInstalled => _serverExists && (!_requiresJre || _jreExists); diff --git a/lib/modules/more/settings/reader/reader_screen.dart b/lib/modules/more/settings/reader/reader_screen.dart index 10c5ee59..1f92e70d 100644 --- a/lib/modules/more/settings/reader/reader_screen.dart +++ b/lib/modules/more/settings/reader/reader_screen.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mangayomi/models/settings.dart'; @@ -367,7 +366,7 @@ class ReaderScreen extends ConsumerWidget { style: TextStyle(fontSize: 11, color: context.secondaryColor), ), ), - if (!(Platform.isAndroid || Platform.isIOS)) + if (!isMobile) SwitchListTile( value: fullScreenReader, title: Text(context.l10n.fullscreen), diff --git a/lib/modules/novel/novel_reader_view.dart b/lib/modules/novel/novel_reader_view.dart index 95081270..6b18109f 100644 --- a/lib/modules/novel/novel_reader_view.dart +++ b/lib/modules/novel/novel_reader_view.dart @@ -12,7 +12,9 @@ import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/anime/widgets/desktop.dart'; -import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart'; +import 'package:mangayomi/modules/manga/reader/mixins/reader_gestures.dart'; +import 'package:mangayomi/modules/manga/reader/widgets/auto_scroll_button.dart'; +import 'package:mangayomi/modules/manga/reader/widgets/reader_app_bar.dart'; import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart'; import 'package:mangayomi/modules/novel/novel_reader_controller_provider.dart'; import 'package:mangayomi/modules/novel/tts/novel_tts_service.dart'; @@ -24,6 +26,8 @@ import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/services/get_html_content.dart'; import 'package:mangayomi/src/rust/api/epub.dart'; import 'package:mangayomi/utils/extensions/dom_extensions.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; +import 'package:mangayomi/utils/system_ui.dart'; import 'package:mangayomi/utils/utils.dart'; import 'package:mangayomi/modules/manga/reader/providers/push_router.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; @@ -72,7 +76,6 @@ class _NovelWebViewState extends ConsumerState double offset = 0; double maxOffset = 0; int fontSize = 14; - bool isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; bool get _ttsSupported => !Platform.isLinux; final Stopwatch _readingStopwatch = Stopwatch(); @@ -99,6 +102,7 @@ class _NovelWebViewState extends ConsumerState _autoScroll.value = false; _autoScroll.dispose(); _autoScrollPage.dispose(); + _keyboardFocusNode.dispose(); _ttsIndexSub?.cancel(); _ttsStateSub?.cancel(); _ttsWordSub?.cancel(); @@ -108,10 +112,7 @@ class _NovelWebViewState extends ConsumerState if (isDesktop) { setFullScreen(value: false); } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } discordRpc?.showIdleText(); super.dispose(); @@ -168,7 +169,7 @@ class _NovelWebViewState extends ConsumerState late bool _isBookmarked = _readerController.getChapterBookmarked(); bool _isView = false; - + final _keyboardFocusNode = FocusNode(); bool _showTts = false; String? _currentHtmlContent; final ValueNotifier<({int paragraph, int wordStart, int wordEnd})> @@ -234,61 +235,35 @@ class _NovelWebViewState extends ConsumerState ); } + /// Goes to either next or previous chapter + /// + /// The [next] parameter determines the navigation direction: + /// - `true` -> navigate to next chapter + /// - `false` -> navigate to previous chapter + /// + /// If the reader is already at the first or last chapter (depending on + /// the direction), the method returns without navigating. + void _goToChapter(bool next) { + if (next && !_readerController.hasNextChapter) return; + if (!next && !_readerController.hasPreviousChapter) return; + pushReplacementMangaReaderView( + context: context, + chapter: next + ? _readerController.getNextChapter() + : _readerController.getPrevChapter(), + ); + } + @override Widget build(BuildContext context) { final backgroundColor = ref.watch(backgroundColorStateProvider); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - return KeyboardListener( - autofocus: true, - focusNode: FocusNode(), - onKeyEvent: (event) { - bool isLogicalKeyPressed(LogicalKeyboardKey key) => - HardwareKeyboard.instance.isLogicalKeyPressed(key); - bool hasNextChapter = _readerController.getChapterIndex().$1 != 0; - bool hasPrevChapter = - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ); - final action = switch (event.logicalKey) { - LogicalKeyboardKey.f11 => - (!isLogicalKeyPressed(LogicalKeyboardKey.f11)) - ? _setFullScreen() - : null, - LogicalKeyboardKey.escape => - (!isLogicalKeyPressed(LogicalKeyboardKey.escape)) - ? _goBack(context) - : null, - LogicalKeyboardKey.backspace => - (!isLogicalKeyPressed(LogicalKeyboardKey.backspace)) - ? _goBack(context) - : null, - LogicalKeyboardKey.keyN || LogicalKeyboardKey.pageDown => - ((!isLogicalKeyPressed(LogicalKeyboardKey.keyN) || - !isLogicalKeyPressed(LogicalKeyboardKey.pageDown))) - ? switch (hasNextChapter) { - true => pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getNextChapter(), - ), - _ => null, - } - : null, - LogicalKeyboardKey.keyP || LogicalKeyboardKey.pageUp => - ((!isLogicalKeyPressed(LogicalKeyboardKey.keyP) || - !isLogicalKeyPressed(LogicalKeyboardKey.pageUp))) - ? switch (hasPrevChapter) { - true => pushReplacementMangaReaderView( - context: context, - chapter: _readerController.getPrevChapter(), - ), - _ => null, - } - : null, - _ => null, - }; - action; - }, + return ReaderKeyboardHandler( + onEscape: () => _goBack(context), + onFullScreen: () => _setFullScreen(), + onNextChapter: () => _goToChapter(true), + onPreviousChapter: () => _goToChapter(false), + ).wrapWithKeyboardListener( child: NotificationListener( onNotification: (notification) { if (notification.direction == ScrollDirection.idle) { @@ -769,7 +744,16 @@ class _NovelWebViewState extends ConsumerState _gestureTopBottom(ref.watch(novelTapToScrollStateProvider)), _appBar(), _bottomBar(backgroundColor), - _autoScrollPlayPauseBtn(), + ReaderAutoScrollButton( + isContinuousMode: true, + isUiVisible: _isView, + autoScrollPage: _autoScrollPage, + autoScroll: _autoScroll, + onToggle: () { + _autoPagescroll(); + _autoScroll.value = !_autoScroll.value; + }, + ), if (_ttsSupported && _showTts && _currentHtmlContent != null) @@ -799,32 +783,7 @@ class _NovelWebViewState extends ConsumerState ), ), ), - ); - } - - Widget _autoScrollPlayPauseBtn() { - return Positioned( - bottom: 0, - right: 0, - child: !_isView - ? ValueListenableBuilder( - valueListenable: _autoScrollPage, - builder: (context, valueT, child) => valueT - ? ValueListenableBuilder( - valueListenable: _autoScroll, - builder: (context, value, child) => IconButton( - onPressed: () { - _autoPagescroll(); - _autoScroll.value = !value; - }, - icon: Icon( - value ? Icons.pause_circle : Icons.play_circle, - ), - ), - ) - : const SizedBox.shrink(), - ) - : const SizedBox.shrink(), + focusNode: _keyboardFocusNode, ); } @@ -840,10 +799,7 @@ class _NovelWebViewState extends ConsumerState leading: BackButton( onPressed: () { if (restoreUi) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } Navigator.of(context).pop(); }, @@ -854,10 +810,7 @@ class _NovelWebViewState extends ConsumerState } void _goBack(BuildContext context) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); Navigator.pop(context); } @@ -944,117 +897,49 @@ class _NovelWebViewState extends ConsumerState } Widget _appBar() { - if (!_isView && Platform.isIOS) { - return const SizedBox.shrink(); - } - final fullScreenReader = ref.watch(fullScreenReaderStateProvider); - double height = _isView - ? Platform.isIOS - ? 120 - : !fullScreenReader && !isDesktop - ? 55 - : 80 - : 0; - return Positioned( - top: 0, - child: AnimatedContainer( - width: context.width(1), - height: height, - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: PreferredSize( - preferredSize: Size.fromHeight(height), - child: AppBar( - centerTitle: false, - automaticallyImplyLeading: false, - titleSpacing: 0, - leading: BackButton( - onPressed: () { - Navigator.pop(context); - }, - ), - title: ListTile( - dense: true, - title: SizedBox( - width: context.width(0.8), - child: Text( - '${_readerController.getMangaName()} ', - style: const TextStyle(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - ), - subtitle: SizedBox( - width: context.width(0.8), - child: Text( - _readerController.getChapterTitle(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - actions: [ - btnToShowChapterListDialog( - context, - context.l10n.chapters, - widget.chapter, - ), - IconButton( - onPressed: () { - _readerController.setChapterBookmarked(); - setState(() { - _isBookmarked = !_isBookmarked; - }); - }, - icon: Icon( - _isBookmarked - ? Icons.bookmark - : Icons.bookmark_border_outlined, - ), - ), - if ((chapter.manga.value!.isLocalArchive ?? false) == false) - IconButton( - onPressed: () async { - final manga = chapter.manga.value!; - final source = getSource( - manga.lang!, - manga.source!, - manga.sourceId, - )!; - String url = chapter.url!.startsWith('/') - ? "${source.baseUrl}/${chapter.url!}" - : chapter.url!; - Map data = { - 'url': url, - 'sourceId': source.id.toString(), - 'title': chapter.name!, - }; - if (Platform.isLinux) { - final urll = Uri.parse(url); - if (!await launchUrl( - urll, - mode: LaunchMode.inAppBrowserView, - )) { - if (!await launchUrl( - urll, - mode: LaunchMode.externalApplication, - )) { - throw 'Could not launch $url'; - } - } - } else { - context.push("/mangawebview", extra: data); - } + return ReaderAppBar( + chapter: chapter, + mangaName: _readerController.getMangaName(), + chapterTitle: _readerController.getChapterTitle(), + isVisible: _isView, + isBookmarked: _isBookmarked, + backgroundColor: _backgroundColor, + onBackPressed: () => Navigator.pop(context), + onBookmarkPressed: () { + _readerController.setChapterBookmarked(); + setState(() => _isBookmarked = !_isBookmarked); + }, + onWebViewPressed: (chapter.manga.value!.isLocalArchive ?? false) + ? null + : () async { + final manga = chapter.manga.value!; + final source = getSource( + manga.lang!, + manga.source!, + manga.sourceId, + )!; + final url = chapter.url!.startsWith('/') + ? '${source.baseUrl}/${chapter.url!}' + : chapter.url!; + if (Platform.isLinux) { + final uri = Uri.parse(url); + await launchUrl( + uri, + mode: LaunchMode.inAppBrowserView, + ).catchError( + (_) => launchUrl(uri, mode: LaunchMode.externalApplication), + ); + } else { + context.push( + '/mangawebview', + extra: { + 'url': url, + 'sourceId': source.id.toString(), + 'title': chapter.name!, }, - icon: const Icon(Icons.public), - ), - ], - backgroundColor: _backgroundColor(context), - ), - ), - ), + ); + } + }, ); } @@ -1062,12 +947,8 @@ class _NovelWebViewState extends ConsumerState if (!_isView && Platform.isIOS) { return const SizedBox.shrink(); } - bool hasPrevChapter = - _readerController.getChapterIndex().$1 + 1 != - _readerController.getChaptersLength( - _readerController.getChapterIndex().$2, - ); - bool hasNextChapter = _readerController.getChapterIndex().$1 != 0; + bool hasPrevChapter = _readerController.hasPreviousChapter; + bool hasNextChapter = _readerController.hasNextChapter; final bodyLargeColor = Theme.of(context).textTheme.bodyLarge!.color; return Positioned( bottom: 0, @@ -1414,10 +1295,7 @@ class _NovelWebViewState extends ConsumerState } if (fullScreenReader) { if (_isView) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); + restoreSystemUI(); } else { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); } diff --git a/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart b/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart index fd73d74a..d5a5787d 100644 --- a/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart +++ b/lib/modules/updates/widgets/update_chapter_list_tile_widget.dart @@ -7,7 +7,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart'; import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/modules/manga/download/download_page_widget.dart'; -import 'package:mangayomi/utils/extensions/chapter.dart'; +import 'package:mangayomi/utils/extensions/chapter_extensions.dart'; import 'package:mangayomi/utils/headers.dart'; class UpdateChapterListTileWidget extends ConsumerWidget { diff --git a/lib/router/router.dart b/lib/router/router.dart index 9a7d004b..306b70b3 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'package:mangayomi/utils/platform_utils.dart'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -295,7 +295,7 @@ class RouterNotifier extends ChangeNotifier { return child!; } }, - pageBuilder: (Platform.isIOS || Platform.isMacOS) + pageBuilder: isApple ? (context, state) { final pageChild = builder != null ? builder(state.extra as T) @@ -312,7 +312,7 @@ Page transitionPage({required LocalKey key, required child}) { } Route createRoute({required Widget page}) { - return (Platform.isIOS || Platform.isMacOS) + return isApple ? CupertinoPageRoute(builder: (context) => page) : MaterialPageRoute(builder: (context) => page); } diff --git a/lib/services/m_extension_server.dart b/lib/services/m_extension_server.dart index 8f6f55ae..b905560e 100644 --- a/lib/services/m_extension_server.dart +++ b/lib/services/m_extension_server.dart @@ -6,6 +6,7 @@ import 'package:m_extension_server/m_extension_server.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/modules/more/settings/browse/providers/browse_state_provider.dart'; +import 'package:mangayomi/utils/platform_utils.dart'; class MExtensionServerPlatform { WidgetRef ref; @@ -31,7 +32,7 @@ class MExtensionServerPlatform { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final port = server.port; await server.close(); - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + if (isDesktop) { final settings = isar.settings.getSync(227); final jrePath = settings?.jrePath; final serverJarPath = settings?.extensionServerPath; diff --git a/lib/services/torrent_server.dart b/lib/services/torrent_server.dart index ffacd92c..5b651ee3 100644 --- a/lib/services/torrent_server.dart +++ b/lib/services/torrent_server.dart @@ -10,6 +10,7 @@ import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/services/http/m_client.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/ffi/torrent_server_ffi.dart' as libmtorrentserver_ffi; +import 'package:mangayomi/utils/platform_utils.dart'; String _buildQueryString(Map> parameters) { final segments = []; @@ -133,7 +134,7 @@ class MTorrentServer { final path = (await StorageProvider().getBtDirectory())!.path; final config = jsonEncode({"path": path, "address": "127.0.0.1:0"}); int port = 0; - if (Platform.isAndroid || Platform.isIOS) { + if (isMobile) { const channel = MethodChannel( 'com.kodjodevf.mangayomi.libmtorrentserver', ); diff --git a/lib/utils/chapter_recognition.dart b/lib/utils/chapter_recognition.dart index c874bb96..f7bebfe4 100644 --- a/lib/utils/chapter_recognition.dart +++ b/lib/utils/chapter_recognition.dart @@ -1,50 +1,81 @@ class ChapterRecognition { - final _numberPattern = r"([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?"; - - final _unwanted = RegExp( - r"\b(?:v|ver|vol|version|volume|season|s)[^a-z]?[0-9]+", + static final _unwanted = RegExp( + r"\b(?:v|ver|vol|version|volume|season|staffel|saison|temporada|s)[^a-z]?[0-9]+", ); + static final _unwantedWhiteSpace = RegExp(r"\s(?=extra|special|omake)"); + static final _seasonKeyword = RegExp( + r"\b(?:staffel|season|saison|temporada)\s*([0-9]+)", + ); + static final _episodeKeyword = RegExp( + r"\b(?:folge|episode|ep\.?)\s*([0-9]+(?:\.[0-9]+)?)", + ); + // lookbehind for "ch." then zero or more spaces. + static final _chNotation = RegExp( + r"(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?", + ); + static final _bareNumber = RegExp(r"([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?"); - final _unwantedWhiteSpace = RegExp(r"\s(?=extra|special|omake)"); + /// Sort key for the UI list. Encodes season into the key so multi-season + /// anime sort correctly: key = season * 100000 + episode. + int parseChapterNumber(String mangaTitle, String chapterName) => + _parse(mangaTitle, chapterName, applySeason: true); - int parseChapterNumber(String mangaTitle, String chapterName) { - var name = chapterName.toLowerCase(); + /// Episode number within a season, for tracker updates (MAL/AniList/Kitsu) + /// and AniSkip results. The tracker entry is already season-specific, + /// so season is stripped. + int parseEpisodeNumber(String mangaTitle, String chapterName) => + _parse(mangaTitle, chapterName, applySeason: false); - name = name.replaceAll(mangaTitle.toLowerCase(), "").trim(); + int _parse( + String mangaTitle, + String chapterName, { + required bool applySeason, + }) { + // Normalize the chapter name by removing title, punctuation noise, etc. + final name = chapterName + .toLowerCase() + .replaceAll(mangaTitle.toLowerCase(), '') + .trim() + .replaceAll(',', '.') + .replaceAll('-', '.') + .replaceAll(_unwantedWhiteSpace, ''); - name = name.replaceAll(',', '.').replaceAll('-', '.'); + final season = applySeason + ? int.tryParse(_seasonKeyword.firstMatch(name)?.group(1) ?? '') ?? 0 + : 0; - name = name.replaceAll(_unwantedWhiteSpace, ""); - - name = name.replaceAll(_unwanted, ""); - final numberPat = "*$_numberPattern"; - const ch = r"(?<=ch\.)"; - var match = RegExp("$ch $numberPat").firstMatch(name); - if (match != null) { - return _getChapterNumberFromMatch(match).toInt(); + final epMatch = _episodeKeyword.firstMatch(name); + if (epMatch != null) { + final ep = double.parse(epMatch.group(1)!).toInt(); + return _withSeason(season, ep); } - match = RegExp(_numberPattern).firstMatch(name); - if (match != null) { - return _getChapterNumberFromMatch(match).toInt(); - } - - return 0; + // strip season/volume noise, then look for ch. or bare number. + final stripped = name.replaceAll(_unwanted, ''); + final ep = _extractNumber(stripped); + return ep != null ? _withSeason(season, ep) : 0; } - double _getChapterNumberFromMatch(Match match) { - final initial = double.parse(match.group(1)!); - final subChapterDecimal = match.group(2); - final subChapterAlpha = match.group(3); - final addition = _checkForDecimal(subChapterDecimal, subChapterAlpha); - return initial + addition; + // Combines season + episode into a sortable integer. + int _withSeason(int season, int ep) => season > 0 ? season * 100000 + ep : ep; + + int? _extractNumber(String name) { + final chMatch = _chNotation.firstMatch(name); + if (chMatch != null) return _fromMatch(chMatch).toInt(); + + final numMatch = _bareNumber.firstMatch(name); + if (numMatch != null) return _fromMatch(numMatch).toInt(); + + return null; } - double _checkForDecimal(String? decimal, String? alpha) { - if (decimal != null && decimal.isNotEmpty) { - return double.parse(decimal); - } + double _fromMatch(Match match) { + final base = double.parse(match.group(1)!); + return base + _decimalAddition(match.group(2), match.group(3)); + } + double _decimalAddition(String? decimal, String? alpha) { + if (decimal != null && decimal.isNotEmpty) return double.parse(decimal); if (alpha != null && alpha.isNotEmpty) { if (alpha.contains("extra")) { return 0.99; diff --git a/lib/utils/extensions/build_context_extensions.dart b/lib/utils/extensions/build_context_extensions.dart index 823d3616..5512d99b 100644 --- a/lib/utils/extensions/build_context_extensions.dart +++ b/lib/utils/extensions/build_context_extensions.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; extension BuildContextExtensions on BuildContext { @@ -23,14 +21,6 @@ extension BuildContextExtensions on BuildContext { return isLight ? Colors.white : Colors.black; } - bool get isDesktop { - return Platform.isMacOS || Platform.isLinux || Platform.isWindows; - } - - bool get isMobile { - return Platform.isIOS || Platform.isAndroid; - } - Color get textColor { return themeData.textTheme.bodyLarge!.color!; } diff --git a/lib/utils/extensions/chapter.dart b/lib/utils/extensions/chapter_extensions.dart similarity index 97% rename from lib/utils/extensions/chapter.dart rename to lib/utils/extensions/chapter_extensions.dart index f469f97c..db4d71ba 100644 --- a/lib/utils/extensions/chapter.dart +++ b/lib/utils/extensions/chapter_extensions.dart @@ -10,7 +10,7 @@ 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/utils/extensions/manga.dart'; +import 'package:mangayomi/utils/extensions/manga_extensions.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'; @@ -96,7 +96,7 @@ extension ChapterExtension on Chapter { ); if (!updateProgressAfterReading) return; final manga = this.manga.value!; - final chapterNumber = ChapterRecognition().parseChapterNumber( + final chapterNumber = ChapterRecognition().parseEpisodeNumber( manga.name!, name!, ); diff --git a/lib/utils/extensions/manga.dart b/lib/utils/extensions/manga_extensions.dart similarity index 69% rename from lib/utils/extensions/manga.dart rename to lib/utils/extensions/manga_extensions.dart index ba6e5ad7..7d17a9ba 100644 --- a/lib/utils/extensions/manga.dart +++ b/lib/utils/extensions/manga_extensions.dart @@ -4,6 +4,7 @@ import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/utils/chapter_recognition.dart'; extension MangaExtensions on Manga { // ── For the READER: always ascending story order, filters applied ────────── @@ -32,12 +33,21 @@ extension MangaExtensions on Manga { .type!; final scanlators = settings.filterScanlatorList ?? []; - final filter = scanlators.where((e) => e.mangaId == id).toList(); + final filter = scanlators.where((e) => e.mangaId == id); final filterScanlator = filter.firstOrNull?.scanlators ?? []; + final recognition = ChapterRecognition(); + final mangaTitle = name ?? ''; - // Canonical ascending order (ch1 ... chN) — reader always moves forward. - final data = chapters - .toList(); // keep DB/insertion order, assumed ascending + // Memoize so each chapter name is parsed at most once during the sort. + final numCache = {}; + int chapNum(Chapter c) => numCache[c.id] ??= recognition.parseChapterNumber( + mangaTitle, + c.name ?? '', + ); + + // Sort by chapter number — DB insertion order is NOT guaranteed to be ascending + final data = chapters.toList() + ..sort((a, b) => chapNum(a).compareTo(chapNum(b))); final chapterIds = data.map((c) => c.id).whereType().toList(); final downloadedIds = (filterDownloaded == 0 || chapterIds.isEmpty) @@ -71,8 +81,6 @@ extension MangaExtensions on Manga { return filterDownloaded == 1 ? dl : !dl; }) .where((e) => !filterScanlator.contains(e.scanlator)) - .toList() - .reversed .toList(); } @@ -82,7 +90,7 @@ extension MangaExtensions on Manga { final sortChapterEntry = settings.sortChapterList!.where((e) => e.mangaId == id).firstOrNull ?? - SortChapter(mangaId: id, index: 1, reverse: false); + SortChapter(mangaId: id, index: 1); final sortIndex = sortChapterEntry.index!; final reverse = sortChapterEntry.reverse!; @@ -90,19 +98,23 @@ extension MangaExtensions on Manga { List list = getChapterListForReading(); switch (sortIndex) { - case 0: // by scanlator, then date + case 0: // by scanlator, then chapter number + // Cache recognition instance — parseChapterNumber is called O(n log n) + // times during sort, so avoid constructing it inside the comparator. + final recognition = ChapterRecognition(); + final mangaTitle = name ?? ''; + + // Returns the parsed chapter number for a chapter, used as the primary + // numeric sort key for cases 0 and 1. + final numCache = {}; + int chapNum(Chapter c) => numCache[c.id] ??= recognition + .parseChapterNumber(mangaTitle, c.name ?? ''); list.sort((a, b) { - if (a.scanlator == null || b.scanlator == null) return 0; - final s = a.scanlator!.compareTo(b.scanlator!); + 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, - ); + return chapNum(a).compareTo(chapNum(b)); }); 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; @@ -117,8 +129,12 @@ extension MangaExtensions on Manga { return a.name!.compareTo(b.name!); }); break; + case 1: + default: + // getChapterListForReading already sorted by chapter number; nothing to do. + break; } - return reverse ? list.reversed.toList() : list; + return reverse ? list : list.reversed.toList(); } } diff --git a/lib/utils/platform_utils.dart b/lib/utils/platform_utils.dart new file mode 100644 index 00000000..d5cb43b0 --- /dev/null +++ b/lib/utils/platform_utils.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +/// macOS, Linux or Windows +final bool isDesktop = + Platform.isMacOS || Platform.isLinux || Platform.isWindows; + +/// Android or iOS +final bool isMobile = Platform.isAndroid || Platform.isIOS; + +/// macOS or iOS +final bool isApple = Platform.isMacOS || Platform.isIOS; diff --git a/lib/utils/system_ui.dart b/lib/utils/system_ui.dart new file mode 100644 index 00000000..2cc9729d --- /dev/null +++ b/lib/utils/system_ui.dart @@ -0,0 +1,6 @@ +import 'package:flutter/services.dart'; + +void restoreSystemUI() => SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, +);