diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png index d659e115..823e1e79 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png index baac3a19..ad0470ec 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png index 83de9884..6d4c1025 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png index 195a54e5..5e610a3c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png index b7b40b05..9d7b47e6 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index b0185997..45fcfab3 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -462,6 +462,46 @@ class PlexClient { } } + /// Select specific audio and subtitle streams for playback + /// This updates which streams are "selected" in the media metadata + /// Uses the part ID from media info for accurate stream selection + Future selectStreams( + int partId, { + int? audioStreamID, + int? subtitleStreamID, + bool allParts = true, + }) async { + try { + final queryParams = {}; + if (audioStreamID != null) { + queryParams['audioStreamID'] = audioStreamID; + } + if (subtitleStreamID != null) { + queryParams['subtitleStreamID'] = subtitleStreamID; + } + if (allParts) { + // If no streams to select, return early + if (queryParams.isEmpty) { + return true; + } + + // Use PUT request on /library/parts/{partId} + final response = await _dio.put( + '/library/parts/$partId', + queryParameters: queryParams, + ); + + return response.statusCode == 200; + } + // Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée) + // Ici, on retourne true par défaut si rien n'est fait + return true; + } catch (e) { + appLogger.e('Failed to select streams', error: e); + return false; + } + } + /// Search across all libraries using the hub search endpoint /// Only returns movies and shows, filtering out seasons and episodes Future> search(String query, {int limit = 10}) async { @@ -923,6 +963,7 @@ class PlexClient { audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: chapters, + partId: part['id'] as int?, ); } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 53d0fb03..47f75c08 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2662 (443 per locale) +/// Strings: 2704 (450 per locale) /// -/// Built on 2025-11-21 at 22:35 UTC +/// Built on 2025-12-01 at 09:54 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -435,6 +435,16 @@ class _StringsSettingsEn { 'Shortcut already assigned to ${action}'; String shortcutUpdated({required Object action}) => 'Shortcut updated for ${action}'; + String get autoSkip => 'Auto Skip'; + String get autoSkipIntro => 'Auto Skip Intro'; + String get autoSkipIntroDescription => + 'Automatically skip intro markers after a few seconds'; + String get autoSkipCredits => 'Auto Skip Credits'; + String get autoSkipCreditsDescription => + 'Automatically skip credits and play next episode'; + String get autoSkipDelay => 'Auto Skip Delay'; + String autoSkipDelayDescription({required Object seconds}) => + 'Wait ${seconds} seconds before auto-skipping'; } // Path: search @@ -1478,6 +1488,23 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String shortcutUpdated({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; + @override + String get autoSkip => 'Automatisches Überspringen'; + @override + String get autoSkipIntro => 'Intro automatisch überspringen'; + @override + String get autoSkipIntroDescription => + 'Intro-Marker nach wenigen Sekunden automatisch überspringen'; + @override + String get autoSkipCredits => 'Abspann automatisch überspringen'; + @override + String get autoSkipCreditsDescription => + 'Abspann automatisch überspringen und nächste Episode abspielen'; + @override + String get autoSkipDelay => 'Verzögerung für automatisches Überspringen'; + @override + String autoSkipDelayDescription({required Object seconds}) => + '${seconds} Sekunden vor dem automatischen Überspringen warten'; } // Path: search @@ -2883,6 +2910,23 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String shortcutUpdated({required Object action}) => 'Scorciatoia aggiornata per ${action}'; + @override + String get autoSkip => 'Salto Automatico'; + @override + String get autoSkipIntro => 'Salta Intro Automaticamente'; + @override + String get autoSkipIntroDescription => + 'Salta automaticamente i marcatori dell\'intro dopo alcuni secondi'; + @override + String get autoSkipCredits => 'Salta Crediti Automaticamente'; + @override + String get autoSkipCreditsDescription => + 'Salta automaticamente i crediti e riproduci l\'episodio successivo'; + @override + String get autoSkipDelay => 'Ritardo Salto Automatico'; + @override + String autoSkipDelayDescription({required Object seconds}) => + 'Aspetta ${seconds} secondi prima del salto automatico'; } // Path: search @@ -4287,6 +4331,23 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String shortcutUpdated({required Object action}) => 'Sneltoets bijgewerkt voor ${action}'; + @override + String get autoSkip => 'Automatisch Overslaan'; + @override + String get autoSkipIntro => 'Intro Automatisch Overslaan'; + @override + String get autoSkipIntroDescription => + 'Intro-markeringen na enkele seconden automatisch overslaan'; + @override + String get autoSkipCredits => 'Credits Automatisch Overslaan'; + @override + String get autoSkipCreditsDescription => + 'Credits automatisch overslaan en volgende aflevering afspelen'; + @override + String get autoSkipDelay => 'Vertraging Automatisch Overslaan'; + @override + String autoSkipDelayDescription({required Object seconds}) => + '${seconds} seconden wachten voor automatisch overslaan'; } // Path: search @@ -5680,6 +5741,23 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String shortcutUpdated({required Object action}) => 'Genväg uppdaterad för ${action}'; + @override + String get autoSkip => 'Auto Hoppa Över'; + @override + String get autoSkipIntro => 'Hoppa Över Intro Automatiskt'; + @override + String get autoSkipIntroDescription => + 'Hoppa automatiskt över intro-markörer efter några sekunder'; + @override + String get autoSkipCredits => 'Hoppa Över Credits Automatiskt'; + @override + String get autoSkipCreditsDescription => + 'Hoppa automatiskt över credits och spela nästa avsnitt'; + @override + String get autoSkipDelay => 'Fördröjning Auto Hoppa Över'; + @override + String autoSkipDelayDescription({required Object seconds}) => + 'Vänta ${seconds} sekunder innan automatisk överhoppning'; } // Path: search @@ -7062,6 +7140,21 @@ class _StringsSettingsZh implements _StringsSettingsEn { '快捷键已被分配给 ${action}'; @override String shortcutUpdated({required Object action}) => '快捷键已为 ${action} 更新'; + @override + String get autoSkip => '自动跳过'; + @override + String get autoSkipIntro => '自动跳过片头'; + @override + String get autoSkipIntroDescription => '几秒钟后自动跳过片头标记'; + @override + String get autoSkipCredits => '自动跳过片尾'; + @override + String get autoSkipCreditsDescription => '自动跳过片尾并播放下一集'; + @override + String get autoSkipDelay => '自动跳过延迟'; + @override + String autoSkipDelayDescription({required Object seconds}) => + '自动跳过前等待 ${seconds} 秒'; } // Path: search @@ -8253,6 +8346,21 @@ extension on Translations { 'Shortcut already assigned to ${action}'; case 'settings.shortcutUpdated': return ({required Object action}) => 'Shortcut updated for ${action}'; + case 'settings.autoSkip': + return 'Auto Skip'; + case 'settings.autoSkipIntro': + return 'Auto Skip Intro'; + case 'settings.autoSkipIntroDescription': + return 'Automatically skip intro markers after a few seconds'; + case 'settings.autoSkipCredits': + return 'Auto Skip Credits'; + case 'settings.autoSkipCreditsDescription': + return 'Automatically skip credits and play next episode'; + case 'settings.autoSkipDelay': + return 'Auto Skip Delay'; + case 'settings.autoSkipDelayDescription': + return ({required Object seconds}) => + 'Wait ${seconds} seconds before auto-skipping'; case 'search.hint': return 'Search movies, shows, music...'; case 'search.tryDifferentTerm': @@ -9189,6 +9297,21 @@ extension on _StringsDe { case 'settings.shortcutUpdated': return ({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; + case 'settings.autoSkip': + return 'Automatisches Überspringen'; + case 'settings.autoSkipIntro': + return 'Intro automatisch überspringen'; + case 'settings.autoSkipIntroDescription': + return 'Intro-Marker nach wenigen Sekunden automatisch überspringen'; + case 'settings.autoSkipCredits': + return 'Abspann automatisch überspringen'; + case 'settings.autoSkipCreditsDescription': + return 'Abspann automatisch überspringen und nächste Episode abspielen'; + case 'settings.autoSkipDelay': + return 'Verzögerung für automatisches Überspringen'; + case 'settings.autoSkipDelayDescription': + return ({required Object seconds}) => + '${seconds} Sekunden vor dem automatischen Überspringen warten'; case 'search.hint': return 'Filme, Serien, Musik suchen...'; case 'search.tryDifferentTerm': @@ -10130,6 +10253,21 @@ extension on _StringsIt { case 'settings.shortcutUpdated': return ({required Object action}) => 'Scorciatoia aggiornata per ${action}'; + case 'settings.autoSkip': + return 'Salto Automatico'; + case 'settings.autoSkipIntro': + return 'Salta Intro Automaticamente'; + case 'settings.autoSkipIntroDescription': + return 'Salta automaticamente i marcatori dell\'intro dopo alcuni secondi'; + case 'settings.autoSkipCredits': + return 'Salta Crediti Automaticamente'; + case 'settings.autoSkipCreditsDescription': + return 'Salta automaticamente i crediti e riproduci l\'episodio successivo'; + case 'settings.autoSkipDelay': + return 'Ritardo Salto Automatico'; + case 'settings.autoSkipDelayDescription': + return ({required Object seconds}) => + 'Aspetta ${seconds} secondi prima del salto automatico'; case 'search.hint': return 'Cerca film. spettacoli, musica...'; case 'search.tryDifferentTerm': @@ -11073,6 +11211,21 @@ extension on _StringsNl { case 'settings.shortcutUpdated': return ({required Object action}) => 'Sneltoets bijgewerkt voor ${action}'; + case 'settings.autoSkip': + return 'Automatisch Overslaan'; + case 'settings.autoSkipIntro': + return 'Intro Automatisch Overslaan'; + case 'settings.autoSkipIntroDescription': + return 'Intro-markeringen na enkele seconden automatisch overslaan'; + case 'settings.autoSkipCredits': + return 'Credits Automatisch Overslaan'; + case 'settings.autoSkipCreditsDescription': + return 'Credits automatisch overslaan en volgende aflevering afspelen'; + case 'settings.autoSkipDelay': + return 'Vertraging Automatisch Overslaan'; + case 'settings.autoSkipDelayDescription': + return ({required Object seconds}) => + '${seconds} seconden wachten voor automatisch overslaan'; case 'search.hint': return 'Zoek films, series, muziek...'; case 'search.tryDifferentTerm': @@ -12017,6 +12170,21 @@ extension on _StringsSv { return ({required Object action}) => 'Genväg redan tilldelad ${action}'; case 'settings.shortcutUpdated': return ({required Object action}) => 'Genväg uppdaterad för ${action}'; + case 'settings.autoSkip': + return 'Auto Hoppa Över'; + case 'settings.autoSkipIntro': + return 'Hoppa Över Intro Automatiskt'; + case 'settings.autoSkipIntroDescription': + return 'Hoppa automatiskt över intro-markörer efter några sekunder'; + case 'settings.autoSkipCredits': + return 'Hoppa Över Credits Automatiskt'; + case 'settings.autoSkipCreditsDescription': + return 'Hoppa automatiskt över credits och spela nästa avsnitt'; + case 'settings.autoSkipDelay': + return 'Fördröjning Auto Hoppa Över'; + case 'settings.autoSkipDelayDescription': + return ({required Object seconds}) => + 'Vänta ${seconds} sekunder innan automatisk överhoppning'; case 'search.hint': return 'Sök filmer, serier, musik...'; case 'search.tryDifferentTerm': @@ -12959,6 +13127,20 @@ extension on _StringsZh { return ({required Object action}) => '快捷键已被分配给 ${action}'; case 'settings.shortcutUpdated': return ({required Object action}) => '快捷键已为 ${action} 更新'; + case 'settings.autoSkip': + return '自动跳过'; + case 'settings.autoSkipIntro': + return '自动跳过片头'; + case 'settings.autoSkipIntroDescription': + return '几秒钟后自动跳过片头标记'; + case 'settings.autoSkipCredits': + return '自动跳过片尾'; + case 'settings.autoSkipCreditsDescription': + return '自动跳过片尾并播放下一集'; + case 'settings.autoSkipDelay': + return '自动跳过延迟'; + case 'settings.autoSkipDelayDescription': + return ({required Object seconds}) => '自动跳过前等待 ${seconds} 秒'; case 'search.hint': return '搜索电影、系列、音乐...'; case 'search.tryDifferentTerm': diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 99d1f4a2..33b36eff 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -132,7 +132,14 @@ "validationErrorEnterNumber": "Please enter a valid number", "validationErrorDuration": "Duration must be between ${min} and ${max} ${unit}", "shortcutAlreadyAssigned": "Shortcut already assigned to ${action}", - "shortcutUpdated": "Shortcut updated for ${action}" + "shortcutUpdated": "Shortcut updated for ${action}", + "autoSkip": "Auto Skip", + "autoSkipIntro": "Auto Skip Intro", + "autoSkipIntroDescription": "Automatically skip intro markers after a few seconds", + "autoSkipCredits": "Auto Skip Credits", + "autoSkipCreditsDescription": "Automatically skip credits and play next episode", + "autoSkipDelay": "Auto Skip Delay", + "autoSkipDelayDescription": "Wait ${seconds} seconds before auto-skipping" }, "search": { "hint": "Search movies, shows, music...", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index c1e36df3..dc504c5a 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -133,7 +133,14 @@ "validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben", "validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen", "shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}", - "shortcutUpdated": "Tastenkürzel aktualisiert für ${action}" + "shortcutUpdated": "Tastenkürzel aktualisiert für ${action}", + "autoSkip": "Automatisches Überspringen", + "autoSkipIntro": "Intro automatisch überspringen", + "autoSkipIntroDescription": "Intro-Marker nach wenigen Sekunden automatisch überspringen", + "autoSkipCredits": "Abspann automatisch überspringen", + "autoSkipCreditsDescription": "Abspann automatisch überspringen und nächste Episode abspielen", + "autoSkipDelay": "Verzögerung für automatisches Überspringen", + "autoSkipDelayDescription": "${seconds} Sekunden vor dem automatischen Überspringen warten" }, "search": { "hint": "Filme, Serien, Musik suchen...", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index e298f688..428111a4 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -133,7 +133,14 @@ "validationErrorEnterNumber": "Inserisci un numero valido", "validationErrorDuration": "la durata deve essere compresa tra ${min} e ${max} ${unit}", "shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}", - "shortcutUpdated": "Scorciatoia aggiornata per ${action}" + "shortcutUpdated": "Scorciatoia aggiornata per ${action}", + "autoSkip": "Salto Automatico", + "autoSkipIntro": "Salta Intro Automaticamente", + "autoSkipIntroDescription": "Salta automaticamente i marcatori dell'intro dopo alcuni secondi", + "autoSkipCredits": "Salta Crediti Automaticamente", + "autoSkipCreditsDescription": "Salta automaticamente i crediti e riproduci l'episodio successivo", + "autoSkipDelay": "Ritardo Salto Automatico", + "autoSkipDelayDescription": "Aspetta ${seconds} secondi prima del salto automatico" }, "search": { "hint": "Cerca film. spettacoli, musica...", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 02360f8f..2d9c8017 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -133,7 +133,14 @@ "validationErrorEnterNumber": "Voer een geldig nummer in", "validationErrorDuration": "Duur moet tussen ${min} en ${max} ${unit} zijn", "shortcutAlreadyAssigned": "Sneltoets al toegewezen aan ${action}", - "shortcutUpdated": "Sneltoets bijgewerkt voor ${action}" + "shortcutUpdated": "Sneltoets bijgewerkt voor ${action}", + "autoSkip": "Automatisch Overslaan", + "autoSkipIntro": "Intro Automatisch Overslaan", + "autoSkipIntroDescription": "Intro-markeringen na enkele seconden automatisch overslaan", + "autoSkipCredits": "Credits Automatisch Overslaan", + "autoSkipCreditsDescription": "Credits automatisch overslaan en volgende aflevering afspelen", + "autoSkipDelay": "Vertraging Automatisch Overslaan", + "autoSkipDelayDescription": "${seconds} seconden wachten voor automatisch overslaan" }, "search": { "hint": "Zoek films, series, muziek...", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index a8d5a0a5..f76d64c5 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -133,7 +133,14 @@ "validationErrorEnterNumber": "Vänligen ange ett giltigt nummer", "validationErrorDuration": "Tiden måste vara mellan ${min} och ${max} ${unit}", "shortcutAlreadyAssigned": "Genväg redan tilldelad ${action}", - "shortcutUpdated": "Genväg uppdaterad för ${action}" + "shortcutUpdated": "Genväg uppdaterad för ${action}", + "autoSkip": "Auto Hoppa Över", + "autoSkipIntro": "Hoppa Över Intro Automatiskt", + "autoSkipIntroDescription": "Hoppa automatiskt över intro-markörer efter några sekunder", + "autoSkipCredits": "Hoppa Över Credits Automatiskt", + "autoSkipCreditsDescription": "Hoppa automatiskt över credits och spela nästa avsnitt", + "autoSkipDelay": "Fördröjning Auto Hoppa Över", + "autoSkipDelayDescription": "Vänta ${seconds} sekunder innan automatisk överhoppning" }, "search": { "hint": "Sök filmer, serier, musik...", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 10b5133c..027455f2 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -133,7 +133,14 @@ "validationErrorEnterNumber": "请输入一个有效的数字", "validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间", "shortcutAlreadyAssigned": "快捷键已被分配给 ${action}", - "shortcutUpdated": "快捷键已为 ${action} 更新" + "shortcutUpdated": "快捷键已为 ${action} 更新", + "autoSkip": "自动跳过", + "autoSkipIntro": "自动跳过片头", + "autoSkipIntroDescription": "几秒钟后自动跳过片头标记", + "autoSkipCredits": "自动跳过片尾", + "autoSkipCreditsDescription": "自动跳过片尾并播放下一集", + "autoSkipDelay": "自动跳过延迟", + "autoSkipDelayDescription": "自动跳过前等待 ${seconds} 秒" }, "search": { "hint": "搜索电影、系列、音乐...", diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 6e05cf73..9981996c 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -3,13 +3,16 @@ class PlexMediaInfo { final List audioTracks; final List subtitleTracks; final List chapters; + final int? partId; PlexMediaInfo({ required this.videoUrl, required this.audioTracks, required this.subtitleTracks, required this.chapters, + this.partId, }); + int? getPartId() => partId; } /// Mixin for building track labels with a consistent pattern diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a3d8cef0..e2f12b24 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -37,6 +37,9 @@ class _SettingsScreenState extends State { int _seekTimeLarge = 30; int _sleepTimerDuration = 30; bool _rememberTrackSelections = true; + bool _autoSkipIntro = true; + bool _autoSkipCredits = true; + int _autoSkipDelay = 5; // Update checking state bool _isCheckingForUpdate = false; @@ -60,6 +63,9 @@ class _SettingsScreenState extends State { _seekTimeLarge = _settingsService.getSeekTimeLarge(); _sleepTimerDuration = _settingsService.getSleepTimerDuration(); _rememberTrackSelections = _settingsService.getRememberTrackSelections(); + _autoSkipIntro = _settingsService.getAutoSkipIntro(); + _autoSkipCredits = _settingsService.getAutoSkipCredits(); + _autoSkipDelay = _settingsService.getAutoSkipDelay(); _isLoading = false; }); } @@ -291,6 +297,52 @@ class _SettingsScreenState extends State { await _settingsService.setRememberTrackSelections(value); }, ), + const Divider(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + t.settings.autoSkip, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.fast_forward), + title: Text(t.settings.autoSkipIntro), + subtitle: Text(t.settings.autoSkipIntroDescription), + value: _autoSkipIntro, + onChanged: (value) async { + setState(() { + _autoSkipIntro = value; + }); + await _settingsService.setAutoSkipIntro(value); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.skip_next), + title: Text(t.settings.autoSkipCredits), + subtitle: Text(t.settings.autoSkipCreditsDescription), + value: _autoSkipCredits, + onChanged: (value) async { + setState(() { + _autoSkipCredits = value; + }); + await _settingsService.setAutoSkipCredits(value); + }, + ), + ListTile( + leading: const Icon(Icons.timer), + title: Text(t.settings.autoSkipDelay), + subtitle: Text( + t.settings.autoSkipDelayDescription( + seconds: _autoSkipDelay.toString(), + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showAutoSkipDelayDialog(), + ), ], ), ); @@ -703,7 +755,7 @@ class _SettingsScreenState extends State { keyboardType: TextInputType.number, decoration: InputDecoration( labelText: t.settings.minutesLabel, - hintText: t.settings.durationHint(min: 5, max: 180), + hintText: t.settings.durationHint(min: 5, max: 240), errorText: errorText, suffixText: t.settings.minutesShort, ), @@ -713,10 +765,10 @@ class _SettingsScreenState extends State { setDialogState(() { if (parsed == null) { errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < 5 || parsed > 180) { + } else if (parsed < 5 || parsed > 240) { errorText = t.settings.validationErrorDuration( min: 5, - max: 180, + max: 240, unit: t.settings.minutesLabel.toLowerCase(), ); } else { @@ -733,11 +785,77 @@ class _SettingsScreenState extends State { TextButton( onPressed: () async { final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= 5 && parsed <= 180) { + if (parsed != null && parsed >= 5 && parsed <= 240) { setState(() { _sleepTimerDuration = parsed; - _settingsService.setSleepTimerDuration(parsed); }); + await _settingsService.setSleepTimerDuration(parsed); + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } + } + }, + child: Text(t.common.save), + ), + ], + ); + }, + ); + }, + ); + } + + void _showAutoSkipDelayDialog() { + final controller = TextEditingController(text: _autoSkipDelay.toString()); + String? errorText; + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(t.settings.autoSkipDelay), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.settings.secondsLabel, + hintText: t.settings.durationHint(min: 1, max: 30), + errorText: errorText, + suffixText: t.settings.secondsShort, + ), + autofocus: true, + onChanged: (value) { + final parsed = int.tryParse(value); + setDialogState(() { + if (parsed == null) { + errorText = t.settings.validationErrorEnterNumber; + } else if (parsed < 1 || parsed > 30) { + errorText = t.settings.validationErrorDuration( + min: 1, + max: 30, + unit: t.settings.secondsLabel.toLowerCase(), + ); + } else { + errorText = null; + } + }); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () async { + final parsed = int.tryParse(controller.text); + if (parsed != null && parsed >= 1 && parsed <= 30) { + setState(() { + _autoSkipDelay = parsed; + }); + await _settingsService.setAutoSkipDelay(parsed); if (dialogContext.mounted) { Navigator.pop(dialogContext); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index aaf9cc59..6e884524 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,6 +12,7 @@ import '../mpv/mpv.dart'; import '../client/plex_client.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_media_info.dart'; import '../providers/playback_state_provider.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; @@ -26,6 +28,14 @@ import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; import '../i18n/strings.g.dart'; +Map? _isoLangTable; + +/// Load the table into memory at widget startup (in initState for example) +Future loadIsoTable() async { + final data = await rootBundle.loadString('lib/data/iso_6369_codes.json'); + _isoLangTable = json.decode(data) as Map; +} + class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; final MpvAudioTrack? preferredAudioTrack; @@ -56,6 +66,7 @@ class VideoPlayerScreenState extends State bool _showPlayNextDialog = false; bool _isPhone = false; List _availableVersions = []; + PlexMediaInfo? _currentMediaInfo; StreamSubscription? _logSubscription; StreamSubscription? _errorSubscription; StreamSubscription? _playingSubscription; @@ -89,6 +100,8 @@ class VideoPlayerScreenState extends State void initState() { super.initState(); + loadIsoTable(); + appLogger.d('VideoPlayerScreen initialized for: ${widget.metadata.title}'); if (widget.preferredAudioTrack != null) { appLogger.d( @@ -211,6 +224,15 @@ class VideoPlayerScreenState extends State } } + /// Converts a 2-letter code like "fr", "nl", "ca" to a Plex 3-letter code, or returns null if unknown + String? _iso6391ToPlex6392(String? code) { + if (code == null || code.isEmpty || _isoLangTable == null) return null; + // Takes the base "fr" from "fr-FR" + final lang = code.split('-').first.toLowerCase(); + final langEntry = _isoLangTable![lang] as Map?; + return langEntry?['639-2'] as String?; + } + Future _initializePlayer() async { try { // Load buffer size from settings @@ -553,6 +575,7 @@ class VideoPlayerScreenState extends State if (mounted) { setState(() { _availableVersions = result.availableVersions.cast(); + _currentMediaInfo = result.mediaInfo; }); // Initialize video filter manager with player and available versions @@ -743,7 +766,7 @@ class VideoPlayerScreenState extends State await _navigateToEpisode(_previousEpisode!); } - /// Handle audio track changes from the user - save as per-media preference if enabled + /// Handle audio track changes from the user - save both stream selection and language preference Future _onAudioTrackChanged(MpvAudioTrack track) async { final settings = await SettingsService.getInstance(); @@ -751,41 +774,101 @@ class VideoPlayerScreenState extends State if (!settings.getRememberTrackSelections()) { return; } - - // Extract language code from the track - final languageCode = track.language; - if (languageCode == null || languageCode.isEmpty) { - appLogger.d('Audio track has no language code, not saving preference'); + if (_currentMediaInfo == null) { + appLogger.w('No media info available, cannot save stream selection'); + return; + } + final partId = _currentMediaInfo!.getPartId(); + if (partId == null) { + appLogger.w('No part ID available, cannot save stream selection'); return; } - // Determine which ratingKey to use - // For TV shows: use grandparentRatingKey (series level) - // For movies: use ratingKey (movie level) + final languageCode = track.language; + int? streamID; + + // === Matching by attributes === + PlexAudioTrack? matched; + final normalizedTrackLang = _iso6391ToPlex6392(track.language); + + appLogger.d( + 'Normalized media_kit language: ${track.language} -> $normalizedTrackLang', + ); + + for (final plexTrack in _currentMediaInfo!.audioTracks) { + final matchLang = plexTrack.languageCode == normalizedTrackLang; + final matchTitle = (track.title == null || track.title!.isEmpty) + ? true + : (plexTrack.displayTitle == track.title || + plexTrack.title == track.title); + + if (matchLang && matchTitle) { + matched = plexTrack; + appLogger.d('Matched audio by lang/title: streamID ${matched.id}'); + break; + } + } + + if (matched != null) { + streamID = matched.id; + appLogger.d('Matched audio by lang/title: streamID $streamID'); + } else { + appLogger.w('Could not match audio track, using fallback index'); + // Fallback - normally no offset for audio + try { + final trackIndex = int.parse(track.id); + + if (trackIndex >= 0 && + trackIndex < _currentMediaInfo!.audioTracks.length) { + streamID = _currentMediaInfo!.audioTracks[trackIndex].id; + appLogger.d( + 'Using fallback: audio index $trackIndex -> streamID $streamID', + ); + } else { + appLogger.e( + 'Fallback index $trackIndex out of bounds (total: ${_currentMediaInfo!.audioTracks.length})', + ); + } + } catch (e) { + appLogger.e('Failed to parse track index', error: e); + } + } + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - final targetRatingKey = isEpisode + final languagePrefRatingKey = isEpisode ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) : widget.metadata.ratingKey; - appLogger.i( - 'Saving audio language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', - ); - try { if (!mounted) return; - // Use server-specific client for this metadata final client = _getClientForMetadata(context); - await client.setMetadataPreferences( - targetRatingKey, - audioLanguage: languageCode, - ); - appLogger.d('Successfully saved audio language preference'); + + final futures = []; + + // 1. Language preference (series/movie level) + if (languageCode != null && languageCode.isNotEmpty) { + futures.add( + client.setMetadataPreferences( + languagePrefRatingKey, + audioLanguage: languageCode, + ), + ); + } + // 2. Exact stream selection (part level) + if (streamID != null) { + futures.add( + client.selectStreams(partId, audioStreamID: streamID, allParts: true), + ); + } + + await Future.wait(futures); + appLogger.d('Successfully saved audio preferences (language + stream)'); } catch (e) { - appLogger.e('Failed to save audio language preference', error: e); + appLogger.e('Failed to save audio preferences', error: e); } } - /// Handle subtitle track changes from the user - save as per-media preference if enabled + /// Handle subtitle track changes from the user - save both stream selection and language preference Future _onSubtitleTrackChanged(MpvSubtitleTrack track) async { final settings = await SettingsService.getInstance(); @@ -794,42 +877,127 @@ class VideoPlayerScreenState extends State return; } - // Handle "Off" selection + if (_currentMediaInfo == null) { + appLogger.w('No media info available, cannot save stream selection'); + return; + } + + final partId = _currentMediaInfo!.getPartId(); + if (partId == null) { + appLogger.w('No part ID available, cannot save stream selection'); + return; + } + String? languageCode; + int? streamID; + if (track.id == 'no') { languageCode = 'none'; + streamID = 0; appLogger.i('User turned subtitles off, saving preference'); } else { languageCode = track.language; - if (languageCode == null || languageCode.isEmpty) { + + // === Matching by attributes === + PlexSubtitleTrack? matched; + final normalizedTrackLang = _iso6391ToPlex6392(track.language); + + appLogger.d( + 'Normalized media_kit language: ${track.language} -> $normalizedTrackLang', + ); + + for (final plexTrack in _currentMediaInfo!.subtitleTracks) { + final matchLang = plexTrack.languageCode == normalizedTrackLang; + final matchTitle = (track.title == null || track.title!.isEmpty) + ? true + : (plexTrack.displayTitle == track.title || + plexTrack.title == track.title); + + appLogger.d('Comparing with streamID ${plexTrack.id}:'); appLogger.d( - 'Subtitle track has no language code, not saving preference', + ' matchLang: $matchLang (${plexTrack.languageCode} == $normalizedTrackLang)', ); - return; + appLogger.d(' matchTitle: $matchTitle'); + + if (matchLang && matchTitle) { + matched = plexTrack; + appLogger.d(' ✅ MATCHED!'); + break; + } + } + + if (matched != null) { + streamID = matched.id; + appLogger.d('Matched subtitle by lang/title: streamID $streamID'); + } else { + appLogger.w('Could not match subtitle track, using fallback index'); + // Fallback with offset correction + try { + final trackIndex = int.parse(track.id); + + // media kit has a "no" (off) at index 0, so real subtitles start at 1 + // We need to subtract 1 to get the actual index in PlexMediaInfo + final plexIndex = trackIndex > 0 ? trackIndex - 1 : 0; + + if (plexIndex >= 0 && + plexIndex < _currentMediaInfo!.subtitleTracks.length) { + streamID = _currentMediaInfo!.subtitleTracks[plexIndex].id; + appLogger.d( + 'Using fallback: media_kit index $trackIndex -> Plex index $plexIndex -> streamID $streamID', + ); + } else { + appLogger.e( + 'Fallback index $plexIndex out of bounds (total: ${_currentMediaInfo!.subtitleTracks.length})', + ); + } + } catch (e) { + appLogger.e('Failed to parse track index', error: e); + } } } - // Determine which ratingKey to use + // Determine ratingKeys final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - final targetRatingKey = isEpisode + final languagePrefRatingKey = isEpisode ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) : widget.metadata.ratingKey; appLogger.i( - 'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + 'Saving subtitle preference: language=$languageCode (ratingKey: $languagePrefRatingKey), streamID=$streamID (partId: $partId)', ); try { if (!mounted) return; - // Use server-specific client for this metadata final client = _getClientForMetadata(context); - await client.setMetadataPreferences( - targetRatingKey, - subtitleLanguage: languageCode, + + final futures = []; + + // 1. Save language preference at series/movie level + if (languageCode != null) { + futures.add( + client.setMetadataPreferences( + languagePrefRatingKey, + subtitleLanguage: languageCode, + ), + ); + } + // 2. Save exact stream selection using part ID + if (streamID != null) { + futures.add( + client.selectStreams( + partId, + subtitleStreamID: streamID, + allParts: true, + ), + ); + } + + await Future.wait(futures); + appLogger.d( + 'Successfully saved subtitle preferences (language + stream)', ); - appLogger.d('Successfully saved subtitle language preference'); } catch (e) { - appLogger.e('Failed to save subtitle language preference', error: e); + appLogger.e('Failed to save subtitle preferences', error: e); } } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 69afccdb..15cc663e 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -1,4 +1,5 @@ import '../client/plex_client.dart'; +import '../models/plex_media_info.dart'; import '../models/plex_metadata.dart'; import '../i18n/strings.g.dart'; @@ -31,7 +32,8 @@ class PlaybackInitializationService { // Return result with available versions and video URL return PlaybackInitializationResult( availableVersions: playbackData.availableVersions, - videoUrl: playbackData.videoUrl!, + videoUrl: playbackData.videoUrl, + mediaInfo: playbackData.mediaInfo, ); } catch (e) { if (e is PlaybackException) { @@ -46,10 +48,12 @@ class PlaybackInitializationService { class PlaybackInitializationResult { final List availableVersions; final String? videoUrl; + final PlexMediaInfo? mediaInfo; PlaybackInitializationResult({ required this.availableVersions, this.videoUrl, + this.mediaInfo, }); } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 422bb120..5d52f291 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -41,6 +41,9 @@ class SettingsService { 'subtitle_background_opacity'; static const String _keyAppLocale = 'app_locale'; static const String _keyRememberTrackSelections = 'remember_track_selections'; + static const String _keyAutoSkipIntro = 'auto_skip_intro'; + static const String _keyAutoSkipCredits = 'auto_skip_credits'; + static const String _keyAutoSkipDelay = 'auto_skip_delay'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -805,12 +808,39 @@ class SettingsService { // Track Selection Settings /// Remember Track Selections - Save per-media audio/subtitle language preferences - Future setRememberTrackSelections(bool enabled) async { - await _prefs.setBool(_keyRememberTrackSelections, enabled); + Future setRememberTrackSelections(bool value) async { + await _prefs.setBool(_keyRememberTrackSelections, value); } bool getRememberTrackSelections() { - return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true + return _prefs.getBool(_keyRememberTrackSelections) ?? true; + } + + // Auto Skip Intro + Future setAutoSkipIntro(bool value) async { + await _prefs.setBool(_keyAutoSkipIntro, value); + } + + bool getAutoSkipIntro() { + return _prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled + } + + // Auto Skip Credits + Future setAutoSkipCredits(bool value) async { + await _prefs.setBool(_keyAutoSkipCredits, value); + } + + bool getAutoSkipCredits() { + return _prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled + } + + // Auto Skip Delay (in seconds) + Future setAutoSkipDelay(int seconds) async { + await _prefs.setInt(_keyAutoSkipDelay, seconds); + } + + int getAutoSkipDelay() { + return _prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds } // Reset all settings to defaults @@ -875,6 +905,9 @@ class SettingsService { (key, value) => MapEntry(key, _serializeHotKey(value)), ), 'rememberTrackSelections': getRememberTrackSelections(), + 'autoSkipIntro': getAutoSkipIntro(), + 'autoSkipCredits': getAutoSkipCredits(), + 'autoSkipDelay': getAutoSkipDelay(), }; } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index c61a97d7..f8e04004 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -121,6 +121,12 @@ class _PlexVideoControlsState extends State // Window resize pause state Timer? _resizeDebounceTimer; bool _wasPlayingBeforeResize = false; + // Auto-skip state + bool _autoSkipIntro = true; + bool _autoSkipCredits = true; + int _autoSkipDelay = 5; + Timer? _autoSkipTimer; + double _autoSkipProgress = 0.0; @override void initState() { @@ -171,6 +177,13 @@ class _PlexVideoControlsState extends State setState(() { _currentMarker = foundMarker; }); + + // Start auto-skip timer for new marker + if (foundMarker != null) { + _startAutoSkipTimer(foundMarker); + } else { + _cancelAutoSkipTimer(); + } } } }); @@ -209,6 +222,75 @@ class _PlexVideoControlsState extends State if (_currentMarker != null) { widget.player.seek(_currentMarker!.endTime); } + _cancelAutoSkipTimer(); + } + + void _startAutoSkipTimer(PlexMarker marker) { + _cancelAutoSkipTimer(); + + final shouldAutoSkip = + (marker.isCredits && _autoSkipCredits) || + (!marker.isCredits && _autoSkipIntro); + + if (!shouldAutoSkip || _autoSkipDelay <= 0) return; + + _autoSkipProgress = 0.0; + const tickDuration = Duration(milliseconds: 50); + final totalTicks = (_autoSkipDelay * 1000) / tickDuration.inMilliseconds; + + if (totalTicks <= 0) return; + + _autoSkipTimer = Timer.periodic(tickDuration, (timer) { + if (!mounted || _currentMarker != marker) { + timer.cancel(); + return; + } + + setState(() { + _autoSkipProgress = (timer.tick / totalTicks).clamp(0.0, 1.0); + }); + + if (timer.tick >= totalTicks) { + timer.cancel(); + try { + _performAutoSkip(); + } catch (e) { + // Handle any errors during skip gracefully + } + } + }); + } + + void _cancelAutoSkipTimer() { + _autoSkipTimer?.cancel(); + _autoSkipTimer = null; + if (mounted) { + setState(() { + _autoSkipProgress = 0.0; + }); + } + } + + /// Perform the appropriate skip action based on marker type and next episode availability + void _performAutoSkip() { + if (_currentMarker == null) return; + + final isCredits = _currentMarker!.isCredits; + final hasNextEpisode = widget.onNext != null; + final showNextEpisode = isCredits && hasNextEpisode; + + if (showNextEpisode) { + widget.onNext?.call(); + } else { + _skipMarker(); + } + } + + /// Check if auto-skip should be active for the current marker + bool _shouldShowAutoSkip() { + if (_currentMarker == null) return false; + return (_currentMarker!.isCredits && _autoSkipCredits) || + (!_currentMarker!.isCredits && _autoSkipIntro); } Future _loadSeekTimes() async { @@ -219,6 +301,9 @@ class _PlexVideoControlsState extends State _audioSyncOffset = settingsService.getAudioSyncOffset(); _subtitleSyncOffset = settingsService.getSubtitleSyncOffset(); _isRotationLocked = settingsService.getRotationLocked(); + _autoSkipIntro = settingsService.getAutoSkipIntro(); + _autoSkipCredits = settingsService.getAutoSkipCredits(); + _autoSkipDelay = settingsService.getAutoSkipDelay(); }); // Apply rotation lock setting @@ -267,6 +352,7 @@ class _PlexVideoControlsState extends State _hideTimer?.cancel(); _feedbackTimer?.cancel(); _resizeDebounceTimer?.cancel(); + _autoSkipTimer?.cancel(); _seekThrottle.cancel(); _playingSubscription?.cancel(); _completedSubscription?.cancel(); @@ -395,6 +481,8 @@ class _PlexVideoControlsState extends State }); if (_showControls) { _startHideTimer(); + // Cancel auto-skip when user manually shows controls + _cancelAutoSkipTimer(); } else if (Platform.isLinux) { // On Linux, fully hide after animation completes (200ms) Future.delayed(const Duration(milliseconds: 250), () { @@ -892,9 +980,24 @@ class _PlexVideoControlsState extends State // Show "Next Episode" for credits when next episode is available final bool showNextEpisode = isCredits && hasNextEpisode; - final String buttonText = showNextEpisode + final String baseButtonText = showNextEpisode ? 'Next Episode' : (isCredits ? 'Skip Credits' : 'Skip Intro'); + + final isAutoSkipActive = _autoSkipTimer?.isActive ?? false; + final shouldShowAutoSkip = _shouldShowAutoSkip(); + + final int remainingSeconds = isAutoSkipActive && shouldShowAutoSkip + ? (_autoSkipDelay - (_autoSkipProgress * _autoSkipDelay)).ceil().clamp( + 0, + _autoSkipDelay, + ) + : 0; + + final String buttonText = + isAutoSkipActive && shouldShowAutoSkip && remainingSeconds > 0 + ? '$baseButtonText ($remainingSeconds)' + : baseButtonText; final IconData buttonIcon = showNextEpisode ? Icons.skip_next : Icons.fast_forward; @@ -902,36 +1005,74 @@ class _PlexVideoControlsState extends State return Material( color: Colors.transparent, child: InkWell( - onTap: showNextEpisode ? widget.onNext : _skipMarker, + onTap: () { + if (isAutoSkipActive) { + _cancelAutoSkipTimer(); + } + // Always perform the skip action when tapped + _performAutoSkip(); + }, borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), + child: Stack( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - buttonText, - style: const TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w600, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + buttonText, + style: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon(buttonIcon, color: Colors.black, size: 20), + ], + ), + ), + // Progress indicator overlay + if (isAutoSkipActive && shouldShowAutoSkip) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + Expanded( + flex: (_autoSkipProgress * 100).round(), + child: Container( + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + Expanded( + flex: ((1.0 - _autoSkipProgress) * 100).round(), + child: Container( + decoration: const BoxDecoration( + color: Colors.transparent, + ), + ), + ), + ], + ), ), ), - const SizedBox(width: 8), - Icon(buttonIcon, color: Colors.black, size: 20), - ], - ), + ], ), ), );