Merge remote-tracking branch 'origin/main' into passthrough

# Conflicts:
#	lib/screens/video_player_screen.dart
#	lib/services/playback_initialization_service.dart
#	lib/widgets/video_controls/video_controls.dart
This commit is contained in:
edde746
2025-12-02 15:58:04 +01:00
19 changed files with 811 additions and 79 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

+41
View File
@@ -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<bool> selectStreams(
int partId, {
int? audioStreamID,
int? subtitleStreamID,
bool allParts = true,
}) async {
try {
final queryParams = <String, dynamic>{};
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<List<PlexMetadata>> search(String query, {int limit = 10}) async {
@@ -923,6 +963,7 @@ class PlexClient {
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: chapters,
partId: part['id'] as int?,
);
}
}
+184 -2
View File
@@ -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':
+8 -1
View File
@@ -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...",
+8 -1
View File
@@ -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...",
+8 -1
View File
@@ -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...",
+8 -1
View File
@@ -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...",
+8 -1
View File
@@ -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...",
+8 -1
View File
@@ -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": "搜索电影、系列、音乐...",
+3
View File
@@ -3,13 +3,16 @@ class PlexMediaInfo {
final List<PlexAudioTrack> audioTracks;
final List<PlexSubtitleTrack> subtitleTracks;
final List<PlexChapter> 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
+123 -5
View File
@@ -37,6 +37,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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);
}
+203 -35
View File
@@ -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<String, dynamic>? _isoLangTable;
/// Load the table into memory at widget startup (in initState for example)
Future<void> loadIsoTable() async {
final data = await rootBundle.loadString('lib/data/iso_6369_codes.json');
_isoLangTable = json.decode(data) as Map<String, dynamic>;
}
class VideoPlayerScreen extends StatefulWidget {
final PlexMetadata metadata;
final MpvAudioTrack? preferredAudioTrack;
@@ -56,6 +66,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
bool _showPlayNextDialog = false;
bool _isPhone = false;
List<PlexMediaVersion> _availableVersions = [];
PlexMediaInfo? _currentMediaInfo;
StreamSubscription<MpvLog>? _logSubscription;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<bool>? _playingSubscription;
@@ -89,6 +100,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
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<VideoPlayerScreen>
}
}
/// 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<String, dynamic>?;
return langEntry?['639-2'] as String?;
}
Future<void> _initializePlayer() async {
try {
// Load buffer size from settings
@@ -553,6 +575,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
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<VideoPlayerScreen>
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<void> _onAudioTrackChanged(MpvAudioTrack track) async {
final settings = await SettingsService.getInstance();
@@ -751,41 +774,101 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
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 = <Future>[];
// 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<void> _onSubtitleTrackChanged(MpvSubtitleTrack track) async {
final settings = await SettingsService.getInstance();
@@ -794,42 +877,127 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
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 = <Future>[];
// 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);
}
}
@@ -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<dynamic> availableVersions;
final String? videoUrl;
final PlexMediaInfo? mediaInfo;
PlaybackInitializationResult({
required this.availableVersions,
this.videoUrl,
this.mediaInfo,
});
}
+36 -3
View File
@@ -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<void> setRememberTrackSelections(bool enabled) async {
await _prefs.setBool(_keyRememberTrackSelections, enabled);
Future<void> 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<void> setAutoSkipIntro(bool value) async {
await _prefs.setBool(_keyAutoSkipIntro, value);
}
bool getAutoSkipIntro() {
return _prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled
}
// Auto Skip Credits
Future<void> setAutoSkipCredits(bool value) async {
await _prefs.setBool(_keyAutoSkipCredits, value);
}
bool getAutoSkipCredits() {
return _prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled
}
// Auto Skip Delay (in seconds)
Future<void> 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(),
};
}
}
+168 -27
View File
@@ -121,6 +121,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// 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<PlexVideoControls>
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<PlexVideoControls>
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<void> _loadSeekTimes() async {
@@ -219,6 +301,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_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<PlexVideoControls>
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_resizeDebounceTimer?.cancel();
_autoSkipTimer?.cancel();
_seekThrottle.cancel();
_playingSubscription?.cancel();
_completedSubscription?.cancel();
@@ -395,6 +481,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
});
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<PlexVideoControls>
// 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<PlexVideoControls>
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),
],
),
],
),
),
);