Merge branch 'main' into QoF-webtoon

This commit is contained in:
abdelmonm alsnajleh 2026-04-30 14:25:01 +03:00 committed by GitHub
commit bc19e2d7cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 732 additions and 1014 deletions

View file

@ -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<String> 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<String> args) async {
Future<void> _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<MyApp>
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<MyApp>
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<MyApp>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
if (!(Platform.isAndroid || Platform.isIOS)) {
if (!isMobile) {
windowManager.removeListener(this);
WindowGeometry.save();
}

View file

@ -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 }

View file

@ -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<AnimePlayerView> {
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<AnimePlayerView> {
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<AnimePlayerView> {
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<AnimeStreamPage>
discordRpc?.updateChapterTimestamp(_currentPosition.value, duration);
});
bool get hasNextEpisode => _streamController.getEpisodeIndex().$1 != 0;
bool get hasNextEpisode => _streamController.hasNextEpisode;
late final StreamSubscription<bool> _completed = _player.stream.completed
.listen((val) {
@ -327,7 +318,7 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage>
}
// 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<AnimeStreamPage>
}
}
String? _readMpvString(Pointer<generated.mpv_node> value) {
if (value.ref.format != generated.mpv_format.MPV_FORMAT_STRING) return null;
final text = value.ref.u.string.cast<Utf8>().toDartString();
return text.isEmpty ? null : text;
}
Future<void> _seekTo(int absoluteSeconds) async {
_tempPosition.value = Duration(seconds: absoluteSeconds);
await _player.seek(Duration(seconds: absoluteSeconds));
_tempPosition.value = null;
}
Future<void> _seekBy(int deltaSeconds) async {
final pos = _currentPosition.value.inSeconds + deltaSeconds;
await _seekTo(pos);
}
Future<void> _handleMpvNodeEvents(
String propName,
Pointer<generated.mpv_node> value,
@ -369,272 +377,230 @@ class _AnimeStreamPageState extends riv.ConsumerState<AnimeStreamPage>
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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Utf8>().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<Int64>(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<Int64>(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<AnimeStreamPage>
}
break;
case "mangayomi/selected_shader":
if (value.ref.format == generated.mpv_format.MPV_FORMAT_STRING) {
final text = value.ref.u.string.cast<Utf8>().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<T extends StatefulWidget> on State<T> {
}
// Whether the platform support AlwaysOnTop feature.
bool _supportAlwaysOnTop() =>
!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows);
bool _supportAlwaysOnTop() => !kIsWeb && isDesktop;
}

View file

@ -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!,
),

View file

@ -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<CustomSeekBar> {
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();

View file

@ -918,7 +918,7 @@ List<Widget> mobilePrimaryButtonBar(
),
),
const Spacer(),
CustomPlayOrPauseButton(controller: controller, isDesktop: false),
CustomPlayOrPauseButton(controller: controller),
const Spacer(),
IconButton(
onPressed: hasNextEpisode

View file

@ -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<CustomPlayOrPauseButton>
StreamSubscription<bool>? subscription;
double get iconSize => widget.isDesktop ? 25 : 65;
double get iconSize => isDesktop ? 25 : 65;
@override
void setState(VoidCallback fn) {

View file

@ -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<SubtitlesWidgetSearch> {
padding: const EdgeInsets.symmetric(vertical: 10),
child: TextFormField(
onTap: () {
if (Platform.isAndroid || Platform.isIOS) {
if (isMobile) {
setState(() {
hide = true;
});

View file

@ -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';

View file

@ -4,7 +4,7 @@ import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart';
import 'package:mangayomi/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';

View file

@ -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;

View file

@ -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(

View file

@ -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,

View file

@ -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<MangaDetailView>
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<MangaDetailView>
return true;
},
child: chapters.when(
data: (data) {
List<Chapter> chapters = _filterAndSortChapter(
data: data.reversed.toList(),
filterUnread: filterUnread,
filterBookmarked: filterBookmarked,
filterDownloaded: filterDownloaded,
sortChapter: sortChapter,
filterScanlator: scanlators.$2,
);
data: (_) {
List<Chapter> 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<Chapter> _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<Chapter> _filterAndSortChapter({
required List<Chapter> data,
required int filterUnread,
required int filterBookmarked,
required int filterDownloaded,
required int sortChapter,
required List<String> filterScanlator,
}) {
List<Chapter>? chapterList;
chapterList = data
.where(
(element) => filterUnread == 1
? element.isRead == false
: filterUnread == 2
? element.isRead == true
: true,
)
.where(
(element) => filterBookmarked == 1
? element.isBookmarked == true
: filterBookmarked == 2
? element.isBookmarked == false
: true,
)
.where((element) {
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<Chapter> 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<Chapter> chapters,
required bool reverse,
}) {
Widget _buildWidget({required List<Chapter> chapters}) {
final chapterList = ref.watch(chaptersListStateProvider);
final isLongPressed = ref.watch(isLongPressedStateProvider);
final checkCategoryList = isar.categorys
@ -492,8 +372,8 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
];
},
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<MangaDetailView>
ref.watch(processDownloadsProvider());
}
} else if (value == 4) {
final List<Chapter> unreadChapters =
_getFilteredAndSortedChapters()
.where(
(element) =>
!(element.isRead ?? false),
)
.toList();
final List<Chapter> unreadChapters = widget
.manga!
.getFilteredChapterList()
.where(
(element) => !(element.isRead ?? false),
)
.toList();
isar.chapters
.filter()
.idIsNotNull()
@ -577,8 +457,9 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
}
ref.watch(processDownloadsProvider());
} else if (value == 5) {
final List<Chapter> allChapters =
_getFilteredAndSortedChapters();
final List<Chapter> allChapters = widget
.manga!
.getFilteredChapterList();
for (var chapter in allChapters) {
final entry = isar.downloads
.filter()
@ -900,17 +781,8 @@ class _MangaDetailViewState extends ConsumerState<MangaDetailView>
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,

View file

@ -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;

View file

@ -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<dynamic> 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<dynamic> 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<dynamic> updateMangaDetail(
[];
final imgUrl = getManga.imageUrl.trimmedOrDefault(manga.imageUrl);
final now = DateTime.now().millisecondsSinceEpoch;
manga
..imageUrl = imgUrl == null
? null
@ -64,90 +71,124 @@ Future<dynamic> 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<Chapter> 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 = <String, Chapter>{
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 = <int, bool>{};
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 = <Chapter>[];
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<dynamic> updateMangaDetail(
} else {
rethrow;
}
return;
}
}

View file

@ -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';

View file

@ -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<TrackerWidgetSearch> {
padding: const EdgeInsets.symmetric(vertical: 10),
child: TextFormField(
onTap: () {
if (Platform.isAndroid || Platform.isIOS) {
if (isMobile) {
setState(() {
hide = true;
});

View file

@ -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';

View file

@ -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';

View file

@ -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

View file

@ -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([]);

View file

@ -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<MangaReaderView> {
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<bool> _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<ReaderMode?>(() => 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<UserScrollNotification>(
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;
}
}

View file

@ -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.

View file

@ -4,7 +4,7 @@ import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/modules/manga/reader/providers/push_router.dart';
import 'package:mangayomi/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';

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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<CategoriesTab>
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<void> _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<CategoriesTab>
Widget itemWidget = _buildCategoryCard(context, category, index);
if (_isDesktop &&
if (isDesktop &&
_animatingFromIndex != null &&
_animatingToIndex != null) {
if (index == _animatingFromIndex ||

View file

@ -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 {

View file

@ -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(

View file

@ -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<ExtensionServerScreen> {
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);

View file

@ -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),

View file

@ -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<NovelWebView>
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<NovelWebView>
_autoScroll.value = false;
_autoScroll.dispose();
_autoScrollPage.dispose();
_keyboardFocusNode.dispose();
_ttsIndexSub?.cancel();
_ttsStateSub?.cancel();
_ttsWordSub?.cancel();
@ -108,10 +112,7 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
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<NovelWebView>
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<NovelWebView>
);
}
/// 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<UserScrollNotification>(
onNotification: (notification) {
if (notification.direction == ScrollDirection.idle) {
@ -769,7 +744,16 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
_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<NovelWebView>
),
),
),
);
}
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<NovelWebView>
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<NovelWebView>
}
void _goBack(BuildContext context) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
restoreSystemUI();
Navigator.pop(context);
}
@ -944,117 +897,49 @@ class _NovelWebViewState extends ConsumerState<NovelWebView>
}
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<String, dynamic> 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<NovelWebView>
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<NovelWebView>
}
if (fullScreenReader) {
if (_isView) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
restoreSystemUI();
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}

View file

@ -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 {

View file

@ -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);
}

View file

@ -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;

View file

@ -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<String, List<String>> parameters) {
final segments = <String>[];
@ -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',
);

View file

@ -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;

View file

@ -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!;
}

View file

@ -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!,
);

View file

@ -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?, int>{};
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<int>().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<Chapter> 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?, int>{};
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();
}
}

View file

@ -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;

6
lib/utils/system_ui.dart Normal file
View file

@ -0,0 +1,6 @@
import 'package:flutter/services.dart';
void restoreSystemUI() => SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);