fix(player): load subtitle sidecars with media

close #1583
This commit is contained in:
edde746
2026-07-17 23:09:07 +02:00
parent 70cd7e8c67
commit a1b6a89714
77 changed files with 2879 additions and 699 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ tags:
file: ../subflows/switch_codec_tracks_deep.yaml
env:
AUDIO_TRACK: "(?is)^English.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*"
SUBTITLE_TRACK: "(?is)^English.*APPLICATION/X-SUBRIP.*"
SUBTITLE_TRACK: "(?is)^English.*(?:SRT|APPLICATION/X-SUBRIP).*"
SHEET_MARKER: "(?is)^Japanese.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*"
- repeat:
times: 2
+5 -3
View File
@@ -22,7 +22,6 @@ appId: com.edde746.plezy
timeout: 10000
- tapOn: "(?s)Server URLs.*"
- inputText: ${JELLYFIN_URL}
- hideKeyboard
- tapOn: "Find server"
- extendedWaitUntil:
visible: "Maestro Jellyfin"
@@ -31,8 +30,11 @@ appId: com.edde746.plezy
- inputText: "maestro"
- tapOn: "Password"
- inputText: "maestro"
- hideKeyboard
- tapOn: "Sign in"
- hideKeyboard:
optional: true
- tapOn:
text: "Sign in"
optional: true
- extendedWaitUntil:
visible: "Discover"
timeout: 30000
-1
View File
@@ -7,7 +7,6 @@ appId: com.edde746.plezy
timeout: 10000
- tapOn: "Search movies, shows, music..."
- inputText: "${SAMPLE_TITLE}"
- hideKeyboard
- extendedWaitUntil:
visible: "(?s).*${SAMPLE_TITLE}$"
timeout: 15000
+1 -1
View File
@@ -13,7 +13,7 @@ appId: com.edde746.plezy
SHEET_MARKER: "${AUDIO_TRACK}"
- assertVisible: "${SUBTITLE_TRACK}"
- tapOn:
point: "70%,66%"
text: "${SUBTITLE_TRACK}"
- runFlow:
file: show_codec_track_sheet.yaml
env:
@@ -21,12 +21,8 @@ appId: com.edde746.plezy
end: 70%, 68%
duration: 300
- assertVisible: "${SUBTITLE_TRACK}"
- swipe:
start: 70%, 78%
end: 70%, 68%
duration: 300
- tapOn:
point: "70%,86%"
text: "${SUBTITLE_TRACK}"
- runFlow:
file: show_codec_track_sheet.yaml
env:
@@ -334,7 +334,10 @@ class MpvPlayerCore(
is MpvEvent.EndFile -> {
delegate?.onEvent("end-file", endFileDiagnostics.onEndFile(event))
}
is MpvEvent.StartFile -> endFileDiagnostics.onStartFile()
is MpvEvent.StartFile -> {
endFileDiagnostics.onStartFile()
delegate?.onEvent("start-file", null)
}
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
else -> {}
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Оригинал",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Транскодирането не е налично — пуска се оригиналното качество",
"subtitleUnavailableFallback": "Избраните субтитри не можаха да се заредят — възпроизвеждането продължава без субтитри",
"pipButton": "Режим картина в картината",
"aspectRatioButton": "Съотношение на страните",
"ambientLighting": "Амбиентно осветление",
@@ -524,11 +525,11 @@
"searchSubtitles": "Търсене на субтитри",
"language": "Език",
"noSubtitlesFound": "Не са намерени субтитри",
"downloadedSubtitle": "Изтеглени субтитри",
"noSubtitlesAvailable": "Няма налични субтитри",
"noAudioTracksAvailable": "Няма налични аудио писти",
"noTracksAvailable": "Няма налични писти",
"subtitleDownloaded": "Субтитърът е изтеглен",
"subtitleDownloadedNotApplied": "Субтитрите са изтеглени, но не можаха да бъдат избрани",
"subtitleDownloadFailed": "Неуспешно изтегляне на субтитър",
"searchLanguages": "Търсене на езици..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodning utilgængelig — afspiller original kvalitet",
"subtitleUnavailableFallback": "De valgte undertekster kunne ikke indlæses — afspilningen fortsætter uden undertekster",
"pipButton": "Billede-i-billede-tilstand",
"aspectRatioButton": "Billedformat",
"ambientLighting": "Omgivelsesbelysning",
@@ -524,11 +525,11 @@
"searchSubtitles": "Søg undertekster",
"language": "Sprog",
"noSubtitlesFound": "Ingen undertekster fundet",
"downloadedSubtitle": "Downloadet",
"noSubtitlesAvailable": "Ingen undertekster tilgængelige",
"noAudioTracksAvailable": "Ingen lydspor tilgængelige",
"noTracksAvailable": "Ingen spor tilgængelige",
"subtitleDownloaded": "Undertekst downloadet",
"subtitleDownloadedNotApplied": "Underteksten blev downloadet, men kunne ikke vælges",
"subtitleDownloadFailed": "Kunne ikke downloade undertekst",
"searchLanguages": "Søg sprog..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodierung nicht verfügbar — Wiedergabe in Originalqualität",
"subtitleUnavailableFallback": "Die ausgewählten Untertitel konnten nicht geladen werden — Wiedergabe ohne Untertitel wird fortgesetzt",
"pipButton": "Bild-in-Bild Modus",
"aspectRatioButton": "Seitenverhältnis",
"ambientLighting": "Umgebungsbeleuchtung",
@@ -524,11 +525,11 @@
"searchSubtitles": "Untertitel suchen",
"language": "Sprache",
"noSubtitlesFound": "Keine Untertitel gefunden",
"downloadedSubtitle": "Heruntergeladen",
"noSubtitlesAvailable": "Keine Untertitel verfügbar",
"noAudioTracksAvailable": "Keine Audiospuren verfügbar",
"noTracksAvailable": "Keine Spuren verfügbar",
"subtitleDownloaded": "Untertitel heruntergeladen",
"subtitleDownloadedNotApplied": "Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden",
"subtitleDownloadFailed": "Untertitel konnte nicht heruntergeladen werden",
"searchLanguages": "Sprachen suchen..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcoding unavailable — playing original quality",
"subtitleUnavailableFallback": "Selected subtitles could not be loaded — continuing without subtitles",
"pipButton": "Picture-in-Picture mode",
"aspectRatioButton": "Aspect ratio",
"ambientLighting": "Ambient lighting",
@@ -524,11 +525,11 @@
"searchSubtitles": "Search Subtitles",
"language": "Language",
"noSubtitlesFound": "No subtitles found",
"downloadedSubtitle": "Downloaded",
"noSubtitlesAvailable": "No subtitles available",
"noAudioTracksAvailable": "No audio tracks available",
"noTracksAvailable": "No tracks available",
"subtitleDownloaded": "Subtitle downloaded",
"subtitleDownloadedNotApplied": "Subtitle downloaded, but it could not be selected",
"subtitleDownloadFailed": "Failed to download subtitle",
"searchLanguages": "Search languages..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodificación no disponible — reproduciendo calidad original",
"subtitleUnavailableFallback": "No se pudieron cargar los subtítulos seleccionados — continuando sin subtítulos",
"pipButton": "Modo PiP (Imagen en Imagen)",
"aspectRatioButton": "Relación de aspecto",
"ambientLighting": "Iluminación ambiental",
@@ -524,11 +525,11 @@
"searchSubtitles": "Buscar subtítulos",
"language": "Idioma",
"noSubtitlesFound": "No se encontraron subtítulos",
"downloadedSubtitle": "Descargado",
"noSubtitlesAvailable": "No hay subtítulos disponibles",
"noAudioTracksAvailable": "No hay pistas de audio disponibles",
"noTracksAvailable": "No hay pistas disponibles",
"subtitleDownloaded": "Subtítulo descargado",
"subtitleDownloadedNotApplied": "El subtítulo se descargó, pero no se pudo seleccionar",
"subtitleDownloadFailed": "Error al descargar subtítulo",
"searchLanguages": "Buscar idiomas..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Originale",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodage indisponible — lecture en qualité originale",
"subtitleUnavailableFallback": "Impossible de charger les sous-titres sélectionnés — poursuite de la lecture sans sous-titres",
"pipButton": "Mode PiP (Picture-in-Picture)",
"aspectRatioButton": "Format d'image",
"ambientLighting": "Éclairage ambiant",
@@ -524,11 +525,11 @@
"searchSubtitles": "Rechercher des sous-titres",
"language": "Langue",
"noSubtitlesFound": "Aucun sous-titre trouvé",
"downloadedSubtitle": "Téléchargé",
"noSubtitlesAvailable": "Aucun sous-titre disponible",
"noAudioTracksAvailable": "Aucune piste audio disponible",
"noTracksAvailable": "Aucune piste disponible",
"subtitleDownloaded": "Sous-titre téléchargé",
"subtitleDownloadedNotApplied": "Le sous-titre a été téléchargé, mais na pas pu être sélectionné",
"subtitleDownloadFailed": "Échec du téléchargement du sous-titre",
"searchLanguages": "Rechercher des langues..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Originale",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodifica non disponibile — riproduzione in qualità originale",
"subtitleUnavailableFallback": "Impossibile caricare i sottotitoli selezionati — la riproduzione continua senza sottotitoli",
"pipButton": "Modalità Picture-in-Picture",
"aspectRatioButton": "Proporzioni",
"ambientLighting": "Illuminazione ambientale",
@@ -524,11 +525,11 @@
"searchSubtitles": "Cerca sottotitoli",
"language": "Lingua",
"noSubtitlesFound": "Nessun sottotitolo trovato",
"downloadedSubtitle": "Scaricato",
"noSubtitlesAvailable": "Nessun sottotitolo disponibile",
"noAudioTracksAvailable": "Nessuna traccia audio disponibile",
"noTracksAvailable": "Nessuna traccia disponibile",
"subtitleDownloaded": "Sottotitolo scaricato",
"subtitleDownloadedNotApplied": "Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo",
"subtitleDownloadFailed": "Impossibile scaricare il sottotitolo",
"searchLanguages": "Cerca lingue..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "オリジナル",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "トランスコードは利用できません — オリジナル画質で再生中",
"subtitleUnavailableFallback": "選択した字幕を読み込めませんでした — 字幕なしで再生を続けます",
"pipButton": "ピクチャーインピクチャーモード",
"aspectRatioButton": "アスペクト比",
"ambientLighting": "アンビエントライティング",
@@ -524,11 +525,11 @@
"searchSubtitles": "字幕を検索",
"language": "言語",
"noSubtitlesFound": "字幕が見つかりません",
"downloadedSubtitle": "ダウンロード済み",
"noSubtitlesAvailable": "利用可能な字幕はありません",
"noAudioTracksAvailable": "利用可能な音声トラックはありません",
"noTracksAvailable": "利用可能なトラックはありません",
"subtitleDownloaded": "字幕をダウンロードしました",
"subtitleDownloadedNotApplied": "字幕はダウンロードされましたが、選択できませんでした",
"subtitleDownloadFailed": "字幕のダウンロードに失敗しました",
"searchLanguages": "言語を検索..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "원본",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "트랜스코딩을 사용할 수 없음 — 원본 화질로 재생",
"subtitleUnavailableFallback": "선택한 자막을 불러올 수 없어 자막 없이 재생을 계속합니다",
"pipButton": "픽처 인 픽처 모드",
"aspectRatioButton": "화면비율",
"ambientLighting": "주변 조명",
@@ -524,11 +525,11 @@
"searchSubtitles": "자막 검색",
"language": "언어",
"noSubtitlesFound": "자막을 찾을 수 없습니다",
"downloadedSubtitle": "다운로드됨",
"noSubtitlesAvailable": "사용 가능한 자막 없음",
"noAudioTracksAvailable": "사용 가능한 오디오 트랙 없음",
"noTracksAvailable": "사용 가능한 트랙 없음",
"subtitleDownloaded": "자막이 다운로드되었습니다",
"subtitleDownloadedNotApplied": "자막을 다운로드했지만 선택할 수 없습니다",
"subtitleDownloadFailed": "자막 다운로드에 실패했습니다",
"searchLanguages": "언어 검색..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkoding utilgjengelig — spiller av i original kvalitet",
"subtitleUnavailableFallback": "De valgte undertekstene kunne ikke lastes inn — avspillingen fortsetter uten undertekster",
"pipButton": "Bilde-i-bilde-modus",
"aspectRatioButton": "Sideforhold",
"ambientLighting": "Omgivelseslys",
@@ -524,11 +525,11 @@
"searchSubtitles": "Søk etter undertekster",
"language": "Språk",
"noSubtitlesFound": "Ingen undertekster funnet",
"downloadedSubtitle": "Lastet ned",
"noSubtitlesAvailable": "Ingen undertekster tilgjengelig",
"noAudioTracksAvailable": "Ingen lydspor tilgjengelig",
"noTracksAvailable": "Ingen spor tilgjengelig",
"subtitleDownloaded": "Undertekst lastet ned",
"subtitleDownloadedNotApplied": "Underteksten ble lastet ned, men kunne ikke velges",
"subtitleDownloadFailed": "Kunne ikke laste ned undertekst",
"searchLanguages": "Søk etter språk..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Origineel",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcoderen niet beschikbaar — originele kwaliteit wordt afgespeeld",
"subtitleUnavailableFallback": "De geselecteerde ondertitels konden niet worden geladen — afspelen gaat door zonder ondertitels",
"pipButton": "Beeld-in-beeld modus",
"aspectRatioButton": "Beeldverhouding",
"ambientLighting": "Omgevingsverlichting",
@@ -524,11 +525,11 @@
"searchSubtitles": "Ondertitels zoeken",
"language": "Taal",
"noSubtitlesFound": "Geen ondertitels gevonden",
"downloadedSubtitle": "Gedownload",
"noSubtitlesAvailable": "Geen ondertitels beschikbaar",
"noAudioTracksAvailable": "Geen audiotracks beschikbaar",
"noTracksAvailable": "Geen tracks beschikbaar",
"subtitleDownloaded": "Ondertitel gedownload",
"subtitleDownloadedNotApplied": "De ondertiteling is gedownload, maar kon niet worden geselecteerd",
"subtitleDownloadFailed": "Ondertitel downloaden mislukt",
"searchLanguages": "Talen zoeken..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Oryginalna",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodowanie niedostępne — odtwarzanie w oryginalnej jakości",
"subtitleUnavailableFallback": "Nie udało się wczytać wybranych napisów — odtwarzanie jest kontynuowane bez napisów",
"pipButton": "Tryb obraz w obrazie",
"aspectRatioButton": "Proporcje",
"ambientLighting": "Oświetlenie otoczenia",
@@ -524,11 +525,11 @@
"searchSubtitles": "Szukaj napisów",
"language": "Język",
"noSubtitlesFound": "Nie znaleziono napisów",
"downloadedSubtitle": "Pobrano",
"noSubtitlesAvailable": "Brak dostępnych napisów",
"noAudioTracksAvailable": "Brak dostępnych ścieżek audio",
"noTracksAvailable": "Brak dostępnych ścieżek",
"subtitleDownloaded": "Napisy pobrane",
"subtitleDownloadedNotApplied": "Napisy zostały pobrane, ale nie można ich było wybrać",
"subtitleDownloadFailed": "Nie udało się pobrać napisów",
"searchLanguages": "Szukaj języków..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodificação indisponível — reproduzindo qualidade original",
"subtitleUnavailableFallback": "Não foi possível carregar as legendas selecionadas — a reprodução continuará sem legendas",
"pipButton": "Modo Picture-in-Picture",
"aspectRatioButton": "Proporção",
"ambientLighting": "Iluminação ambiente",
@@ -524,11 +525,11 @@
"searchSubtitles": "Pesquisar legendas",
"language": "Idioma",
"noSubtitlesFound": "Nenhuma legenda encontrada",
"downloadedSubtitle": "Baixado",
"noSubtitlesAvailable": "Nenhuma legenda disponível",
"noAudioTracksAvailable": "Nenhuma faixa de áudio disponível",
"noTracksAvailable": "Nenhuma faixa disponível",
"subtitleDownloaded": "Legenda baixada",
"subtitleDownloadedNotApplied": "A legenda foi transferida, mas não foi possível selecioná-la",
"subtitleDownloadFailed": "Falha ao baixar legenda",
"searchLanguages": "Pesquisar idiomas..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Оригинал",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Транскодирование недоступно — воспроизведение в оригинальном качестве",
"subtitleUnavailableFallback": "Не удалось загрузить выбранные субтитры — воспроизведение продолжится без субтитров",
"pipButton": "Режим «картинка в картинке»",
"aspectRatioButton": "Соотношение сторон",
"ambientLighting": "Фоновая подсветка",
@@ -524,11 +525,11 @@
"searchSubtitles": "Поиск субтитров",
"language": "Язык",
"noSubtitlesFound": "Субтитры не найдены",
"downloadedSubtitle": "Загружено",
"noSubtitlesAvailable": "Нет доступных субтитров",
"noAudioTracksAvailable": "Нет доступных аудиодорожек",
"noTracksAvailable": "Нет доступных дорожек",
"subtitleDownloaded": "Субтитры загружены",
"subtitleDownloadedNotApplied": "Субтитры загружены, но их не удалось выбрать",
"subtitleDownloadFailed": "Не удалось загрузить субтитры",
"searchLanguages": "Поиск языков..."
},
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 22835 (1427 per locale)
/// Strings: 22851 (1428 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsBg extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Оригинал';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Транскодирането не е налично — пуска се оригиналното качество';
@override String get subtitleUnavailableFallback => 'Избраните субтитри не можаха да се заредят — възпроизвеждането продължава без субтитри';
@override String get pipButton => 'Режим картина в картината';
@override String get aspectRatioButton => 'Съотношение на страните';
@override String get ambientLighting => 'Амбиентно осветление';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsBg extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Търсене на субтитри';
@override String get language => 'Език';
@override String get noSubtitlesFound => 'Не са намерени субтитри';
@override String get downloadedSubtitle => 'Изтеглени субтитри';
@override String get noSubtitlesAvailable => 'Няма налични субтитри';
@override String get noAudioTracksAvailable => 'Няма налични аудио писти';
@override String get noTracksAvailable => 'Няма налични писти';
@override String get subtitleDownloaded => 'Субтитърът е изтеглен';
@override String get subtitleDownloadedNotApplied => 'Субтитрите са изтеглени, но не можаха да бъдат избрани';
@override String get subtitleDownloadFailed => 'Неуспешно изтегляне на субтитър';
@override String get searchLanguages => 'Търсене на езици...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsBg {
'videoControls.qualityOriginal' => 'Оригинал',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Транскодирането не е налично — пуска се оригиналното качество',
'videoControls.subtitleUnavailableFallback' => 'Избраните субтитри не можаха да се заредят — възпроизвеждането продължава без субтитри',
'videoControls.pipButton' => 'Режим картина в картината',
'videoControls.aspectRatioButton' => 'Съотношение на страните',
'videoControls.ambientLighting' => 'Амбиентно осветление',
@@ -2616,11 +2618,11 @@ extension on TranslationsBg {
'videoControls.searchSubtitles' => 'Търсене на субтитри',
'videoControls.language' => 'Език',
'videoControls.noSubtitlesFound' => 'Не са намерени субтитри',
'videoControls.downloadedSubtitle' => 'Изтеглени субтитри',
'videoControls.noSubtitlesAvailable' => 'Няма налични субтитри',
'videoControls.noAudioTracksAvailable' => 'Няма налични аудио писти',
'videoControls.noTracksAvailable' => 'Няма налични писти',
'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен',
'videoControls.subtitleDownloadedNotApplied' => 'Субтитрите са изтеглени, но не можаха да бъдат избрани',
'videoControls.subtitleDownloadFailed' => 'Неуспешно изтегляне на субтитър',
'videoControls.searchLanguages' => 'Търсене на езици...',
'messages.markedAsWatched' => 'Маркирано като гледано',
@@ -2633,9 +2635,9 @@ extension on TranslationsBg {
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.',
'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.',
'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}',
'messages.errorLoadingSeries' => 'Грешка при зареждане на сериала',
'messages.musicNotSupported' => 'Възпроизвеждането на музика все още не се поддържа',
@@ -3147,9 +3149,9 @@ extension on TranslationsBg {
'downloads.title' => 'Изтегляния',
'downloads.manage' => 'Управление',
'downloads.tvShows' => 'ТВ сериали',
'downloads.movies' => 'Филми',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Филми',
'downloads.music' => 'Музика',
'downloads.tracksQueued' => ({required Object count}) => '${count} песни в опашката за изтегляне',
'downloads.noDownloads' => 'Все още няма изтегляния',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsDa extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodning utilgængelig — afspiller original kvalitet';
@override String get subtitleUnavailableFallback => 'De valgte undertekster kunne ikke indlæses — afspilningen fortsætter uden undertekster';
@override String get pipButton => 'Billede-i-billede-tilstand';
@override String get aspectRatioButton => 'Billedformat';
@override String get ambientLighting => 'Omgivelsesbelysning';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsDa extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Søg undertekster';
@override String get language => 'Sprog';
@override String get noSubtitlesFound => 'Ingen undertekster fundet';
@override String get downloadedSubtitle => 'Downloadet';
@override String get noSubtitlesAvailable => 'Ingen undertekster tilgængelige';
@override String get noAudioTracksAvailable => 'Ingen lydspor tilgængelige';
@override String get noTracksAvailable => 'Ingen spor tilgængelige';
@override String get subtitleDownloaded => 'Undertekst downloadet';
@override String get subtitleDownloadedNotApplied => 'Underteksten blev downloadet, men kunne ikke vælges';
@override String get subtitleDownloadFailed => 'Kunne ikke downloade undertekst';
@override String get searchLanguages => 'Søg sprog...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsDa {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodning utilgængelig — afspiller original kvalitet',
'videoControls.subtitleUnavailableFallback' => 'De valgte undertekster kunne ikke indlæses — afspilningen fortsætter uden undertekster',
'videoControls.pipButton' => 'Billede-i-billede-tilstand',
'videoControls.aspectRatioButton' => 'Billedformat',
'videoControls.ambientLighting' => 'Omgivelsesbelysning',
@@ -2616,11 +2618,11 @@ extension on TranslationsDa {
'videoControls.searchSubtitles' => 'Søg undertekster',
'videoControls.language' => 'Sprog',
'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet',
'videoControls.downloadedSubtitle' => 'Downloadet',
'videoControls.noSubtitlesAvailable' => 'Ingen undertekster tilgængelige',
'videoControls.noAudioTracksAvailable' => 'Ingen lydspor tilgængelige',
'videoControls.noTracksAvailable' => 'Ingen spor tilgængelige',
'videoControls.subtitleDownloaded' => 'Undertekst downloadet',
'videoControls.subtitleDownloadedNotApplied' => 'Underteksten blev downloadet, men kunne ikke vælges',
'videoControls.subtitleDownloadFailed' => 'Kunne ikke downloade undertekst',
'videoControls.searchLanguages' => 'Søg sprog...',
'messages.markedAsWatched' => 'Markeret som set',
@@ -2633,9 +2635,9 @@ extension on TranslationsDa {
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.',
'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.',
'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}',
'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie',
'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke',
@@ -3147,9 +3149,9 @@ extension on TranslationsDa {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Administrer',
'downloads.tvShows' => 'TV-serier',
'downloads.movies' => 'Film',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Film',
'downloads.music' => 'Musik',
'downloads.tracksQueued' => ({required Object count}) => '${count} numre i kø til download',
'downloads.noDownloads' => 'Ingen downloads endnu',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsDe extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodierung nicht verfügbar — Wiedergabe in Originalqualität';
@override String get subtitleUnavailableFallback => 'Die ausgewählten Untertitel konnten nicht geladen werden — Wiedergabe ohne Untertitel wird fortgesetzt';
@override String get pipButton => 'Bild-in-Bild Modus';
@override String get aspectRatioButton => 'Seitenverhältnis';
@override String get ambientLighting => 'Umgebungsbeleuchtung';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsDe extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Untertitel suchen';
@override String get language => 'Sprache';
@override String get noSubtitlesFound => 'Keine Untertitel gefunden';
@override String get downloadedSubtitle => 'Heruntergeladen';
@override String get noSubtitlesAvailable => 'Keine Untertitel verfügbar';
@override String get noAudioTracksAvailable => 'Keine Audiospuren verfügbar';
@override String get noTracksAvailable => 'Keine Spuren verfügbar';
@override String get subtitleDownloaded => 'Untertitel heruntergeladen';
@override String get subtitleDownloadedNotApplied => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden';
@override String get subtitleDownloadFailed => 'Untertitel konnte nicht heruntergeladen werden';
@override String get searchLanguages => 'Sprachen suchen...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsDe {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodierung nicht verfügbar — Wiedergabe in Originalqualität',
'videoControls.subtitleUnavailableFallback' => 'Die ausgewählten Untertitel konnten nicht geladen werden — Wiedergabe ohne Untertitel wird fortgesetzt',
'videoControls.pipButton' => 'Bild-in-Bild Modus',
'videoControls.aspectRatioButton' => 'Seitenverhältnis',
'videoControls.ambientLighting' => 'Umgebungsbeleuchtung',
@@ -2616,11 +2618,11 @@ extension on TranslationsDe {
'videoControls.searchSubtitles' => 'Untertitel suchen',
'videoControls.language' => 'Sprache',
'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden',
'videoControls.downloadedSubtitle' => 'Heruntergeladen',
'videoControls.noSubtitlesAvailable' => 'Keine Untertitel verfügbar',
'videoControls.noAudioTracksAvailable' => 'Keine Audiospuren verfügbar',
'videoControls.noTracksAvailable' => 'Keine Spuren verfügbar',
'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen',
'videoControls.subtitleDownloadedNotApplied' => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden',
'videoControls.subtitleDownloadFailed' => 'Untertitel konnte nicht heruntergeladen werden',
'videoControls.searchLanguages' => 'Sprachen suchen...',
'messages.markedAsWatched' => 'Als gesehen markiert',
@@ -2633,9 +2635,9 @@ extension on TranslationsDe {
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}',
'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie',
'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt',
@@ -3147,9 +3149,9 @@ extension on TranslationsDe {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Verwalten',
'downloads.tvShows' => 'Serien',
'downloads.movies' => 'Filme',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Filme',
'downloads.music' => 'Musik',
'downloads.tracksQueued' => ({required Object count}) => '${count} Titel zum Download in Warteschlange',
'downloads.noDownloads' => 'Noch keine Downloads',
+10 -6
View File
@@ -1531,6 +1531,9 @@ class TranslationsVideoControlsEn {
/// en: 'Transcoding unavailable — playing original quality'
String get transcodeUnavailableFallback => 'Transcoding unavailable — playing original quality';
/// en: 'Selected subtitles could not be loaded — continuing without subtitles'
String get subtitleUnavailableFallback => 'Selected subtitles could not be loaded — continuing without subtitles';
/// en: 'Picture-in-Picture mode'
String get pipButton => 'Picture-in-Picture mode';
@@ -1605,9 +1608,6 @@ class TranslationsVideoControlsEn {
/// en: 'No subtitles found'
String get noSubtitlesFound => 'No subtitles found';
/// en: 'Downloaded'
String get downloadedSubtitle => 'Downloaded';
/// en: 'No subtitles available'
String get noSubtitlesAvailable => 'No subtitles available';
@@ -1620,6 +1620,9 @@ class TranslationsVideoControlsEn {
/// en: 'Subtitle downloaded'
String get subtitleDownloaded => 'Subtitle downloaded';
/// en: 'Subtitle downloaded, but it could not be selected'
String get subtitleDownloadedNotApplied => 'Subtitle downloaded, but it could not be selected';
/// en: 'Failed to download subtitle'
String get subtitleDownloadFailed => 'Failed to download subtitle';
@@ -5440,6 +5443,7 @@ extension on Translations {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcoding unavailable — playing original quality',
'videoControls.subtitleUnavailableFallback' => 'Selected subtitles could not be loaded — continuing without subtitles',
'videoControls.pipButton' => 'Picture-in-Picture mode',
'videoControls.aspectRatioButton' => 'Aspect ratio',
'videoControls.ambientLighting' => 'Ambient lighting',
@@ -5471,11 +5475,11 @@ extension on Translations {
'videoControls.searchSubtitles' => 'Search Subtitles',
'videoControls.language' => 'Language',
'videoControls.noSubtitlesFound' => 'No subtitles found',
'videoControls.downloadedSubtitle' => 'Downloaded',
'videoControls.noSubtitlesAvailable' => 'No subtitles available',
'videoControls.noAudioTracksAvailable' => 'No audio tracks available',
'videoControls.noTracksAvailable' => 'No tracks available',
'videoControls.subtitleDownloaded' => 'Subtitle downloaded',
'videoControls.subtitleDownloadedNotApplied' => 'Subtitle downloaded, but it could not be selected',
'videoControls.subtitleDownloadFailed' => 'Failed to download subtitle',
'videoControls.searchLanguages' => 'Search languages...',
'messages.markedAsWatched' => 'Marked as watched',
@@ -5488,9 +5492,9 @@ extension on Translations {
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
'messages.fileInfoNotAvailable' => 'File information not available',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'File information not available',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
'messages.musicNotSupported' => 'Music playback is not yet supported',
@@ -6002,9 +6006,9 @@ extension on Translations {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows',
'downloads.movies' => 'Movies',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Movies',
'downloads.music' => 'Music',
'downloads.tracksQueued' => ({required Object count}) => '${count} tracks queued for download',
'downloads.noDownloads' => 'No downloads yet',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsEs extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodificación no disponible — reproduciendo calidad original';
@override String get subtitleUnavailableFallback => 'No se pudieron cargar los subtítulos seleccionados — continuando sin subtítulos';
@override String get pipButton => 'Modo PiP (Imagen en Imagen)';
@override String get aspectRatioButton => 'Relación de aspecto';
@override String get ambientLighting => 'Iluminación ambiental';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsEs extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Buscar subtítulos';
@override String get language => 'Idioma';
@override String get noSubtitlesFound => 'No se encontraron subtítulos';
@override String get downloadedSubtitle => 'Descargado';
@override String get noSubtitlesAvailable => 'No hay subtítulos disponibles';
@override String get noAudioTracksAvailable => 'No hay pistas de audio disponibles';
@override String get noTracksAvailable => 'No hay pistas disponibles';
@override String get subtitleDownloaded => 'Subtítulo descargado';
@override String get subtitleDownloadedNotApplied => 'El subtítulo se descargó, pero no se pudo seleccionar';
@override String get subtitleDownloadFailed => 'Error al descargar subtítulo';
@override String get searchLanguages => 'Buscar idiomas...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsEs {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodificación no disponible — reproduciendo calidad original',
'videoControls.subtitleUnavailableFallback' => 'No se pudieron cargar los subtítulos seleccionados — continuando sin subtítulos',
'videoControls.pipButton' => 'Modo PiP (Imagen en Imagen)',
'videoControls.aspectRatioButton' => 'Relación de aspecto',
'videoControls.ambientLighting' => 'Iluminación ambiental',
@@ -2616,11 +2618,11 @@ extension on TranslationsEs {
'videoControls.searchSubtitles' => 'Buscar subtítulos',
'videoControls.language' => 'Idioma',
'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos',
'videoControls.downloadedSubtitle' => 'Descargado',
'videoControls.noSubtitlesAvailable' => 'No hay subtítulos disponibles',
'videoControls.noAudioTracksAvailable' => 'No hay pistas de audio disponibles',
'videoControls.noTracksAvailable' => 'No hay pistas disponibles',
'videoControls.subtitleDownloaded' => 'Subtítulo descargado',
'videoControls.subtitleDownloadedNotApplied' => 'El subtítulo se descargó, pero no se pudo seleccionar',
'videoControls.subtitleDownloadFailed' => 'Error al descargar subtítulo',
'videoControls.searchLanguages' => 'Buscar idiomas...',
'messages.markedAsWatched' => 'Marcado como visto',
@@ -2633,9 +2635,9 @@ extension on TranslationsEs {
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.',
'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.',
'messages.fileInfoNotAvailable' => 'Información de archivo no disponible',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Información de archivo no disponible',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error al cargar info de archivo: ${error}',
'messages.errorLoadingSeries' => 'Error al cargar la serie',
'messages.musicNotSupported' => 'La reproducción de música aún no está soportada',
@@ -3147,9 +3149,9 @@ extension on TranslationsEs {
'downloads.title' => 'Descargas',
'downloads.manage' => 'Gestionar',
'downloads.tvShows' => 'Series de TV',
'downloads.movies' => 'Películas',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Películas',
'downloads.music' => 'Música',
'downloads.tracksQueued' => ({required Object count}) => '${count} canciones en cola para descargar',
'downloads.noDownloads' => 'No hay descargas aún',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsFr extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Originale';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodage indisponible — lecture en qualité originale';
@override String get subtitleUnavailableFallback => 'Impossible de charger les sous-titres sélectionnés — poursuite de la lecture sans sous-titres';
@override String get pipButton => 'Mode PiP (Picture-in-Picture)';
@override String get aspectRatioButton => 'Format d\'image';
@override String get ambientLighting => 'Éclairage ambiant';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsFr extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Rechercher des sous-titres';
@override String get language => 'Langue';
@override String get noSubtitlesFound => 'Aucun sous-titre trouvé';
@override String get downloadedSubtitle => 'Téléchargé';
@override String get noSubtitlesAvailable => 'Aucun sous-titre disponible';
@override String get noAudioTracksAvailable => 'Aucune piste audio disponible';
@override String get noTracksAvailable => 'Aucune piste disponible';
@override String get subtitleDownloaded => 'Sous-titre téléchargé';
@override String get subtitleDownloadedNotApplied => 'Le sous-titre a été téléchargé, mais na pas pu être sélectionné';
@override String get subtitleDownloadFailed => 'Échec du téléchargement du sous-titre';
@override String get searchLanguages => 'Rechercher des langues...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsFr {
'videoControls.qualityOriginal' => 'Originale',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodage indisponible — lecture en qualité originale',
'videoControls.subtitleUnavailableFallback' => 'Impossible de charger les sous-titres sélectionnés — poursuite de la lecture sans sous-titres',
'videoControls.pipButton' => 'Mode PiP (Picture-in-Picture)',
'videoControls.aspectRatioButton' => 'Format d\'image',
'videoControls.ambientLighting' => 'Éclairage ambiant',
@@ -2616,11 +2618,11 @@ extension on TranslationsFr {
'videoControls.searchSubtitles' => 'Rechercher des sous-titres',
'videoControls.language' => 'Langue',
'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé',
'videoControls.downloadedSubtitle' => 'Téléchargé',
'videoControls.noSubtitlesAvailable' => 'Aucun sous-titre disponible',
'videoControls.noAudioTracksAvailable' => 'Aucune piste audio disponible',
'videoControls.noTracksAvailable' => 'Aucune piste disponible',
'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé',
'videoControls.subtitleDownloadedNotApplied' => 'Le sous-titre a été téléchargé, mais na pas pu être sélectionné',
'videoControls.subtitleDownloadFailed' => 'Échec du téléchargement du sous-titre',
'videoControls.searchLanguages' => 'Rechercher des langues...',
'messages.markedAsWatched' => 'Marqué comme vu',
@@ -2633,9 +2635,9 @@ extension on TranslationsFr {
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.',
'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.',
'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}',
'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série',
'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge',
@@ -3147,9 +3149,9 @@ extension on TranslationsFr {
'downloads.title' => 'Téléchargements',
'downloads.manage' => 'Gérer',
'downloads.tvShows' => 'Show TV',
'downloads.movies' => 'Films',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Films',
'downloads.music' => 'Musique',
'downloads.tracksQueued' => ({required Object count}) => '${count} titres en file d\'attente de téléchargement',
'downloads.noDownloads' => 'Aucun téléchargement pour le moment',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsIt extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Originale';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodifica non disponibile — riproduzione in qualità originale';
@override String get subtitleUnavailableFallback => 'Impossibile caricare i sottotitoli selezionati — la riproduzione continua senza sottotitoli';
@override String get pipButton => 'Modalità Picture-in-Picture';
@override String get aspectRatioButton => 'Proporzioni';
@override String get ambientLighting => 'Illuminazione ambientale';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsIt extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Cerca sottotitoli';
@override String get language => 'Lingua';
@override String get noSubtitlesFound => 'Nessun sottotitolo trovato';
@override String get downloadedSubtitle => 'Scaricato';
@override String get noSubtitlesAvailable => 'Nessun sottotitolo disponibile';
@override String get noAudioTracksAvailable => 'Nessuna traccia audio disponibile';
@override String get noTracksAvailable => 'Nessuna traccia disponibile';
@override String get subtitleDownloaded => 'Sottotitolo scaricato';
@override String get subtitleDownloadedNotApplied => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo';
@override String get subtitleDownloadFailed => 'Impossibile scaricare il sottotitolo';
@override String get searchLanguages => 'Cerca lingue...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsIt {
'videoControls.qualityOriginal' => 'Originale',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodifica non disponibile — riproduzione in qualità originale',
'videoControls.subtitleUnavailableFallback' => 'Impossibile caricare i sottotitoli selezionati — la riproduzione continua senza sottotitoli',
'videoControls.pipButton' => 'Modalità Picture-in-Picture',
'videoControls.aspectRatioButton' => 'Proporzioni',
'videoControls.ambientLighting' => 'Illuminazione ambientale',
@@ -2616,11 +2618,11 @@ extension on TranslationsIt {
'videoControls.searchSubtitles' => 'Cerca sottotitoli',
'videoControls.language' => 'Lingua',
'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato',
'videoControls.downloadedSubtitle' => 'Scaricato',
'videoControls.noSubtitlesAvailable' => 'Nessun sottotitolo disponibile',
'videoControls.noAudioTracksAvailable' => 'Nessuna traccia audio disponibile',
'videoControls.noTracksAvailable' => 'Nessuna traccia disponibile',
'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato',
'videoControls.subtitleDownloadedNotApplied' => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo',
'videoControls.subtitleDownloadFailed' => 'Impossibile scaricare il sottotitolo',
'videoControls.searchLanguages' => 'Cerca lingue...',
'messages.markedAsWatched' => 'Segna come visto',
@@ -2633,9 +2635,9 @@ extension on TranslationsIt {
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.',
'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.',
'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Errore caricamento informazioni sul file: ${error}',
'messages.errorLoadingSeries' => 'Errore caricamento serie',
'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata',
@@ -3147,9 +3149,9 @@ extension on TranslationsIt {
'downloads.title' => 'Download',
'downloads.manage' => 'Gestisci',
'downloads.tvShows' => 'Serie TV',
'downloads.movies' => 'Film',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Film',
'downloads.music' => 'Musica',
'downloads.tracksQueued' => ({required Object count}) => '${count} brani in coda per il download',
'downloads.noDownloads' => 'Nessun download',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsJa extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'オリジナル';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'トランスコードは利用できません — オリジナル画質で再生中';
@override String get subtitleUnavailableFallback => '選択した字幕を読み込めませんでした — 字幕なしで再生を続けます';
@override String get pipButton => 'ピクチャーインピクチャーモード';
@override String get aspectRatioButton => 'アスペクト比';
@override String get ambientLighting => 'アンビエントライティング';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsJa extends TranslationsVideoControlsEn {
@override String get searchSubtitles => '字幕を検索';
@override String get language => '言語';
@override String get noSubtitlesFound => '字幕が見つかりません';
@override String get downloadedSubtitle => 'ダウンロード済み';
@override String get noSubtitlesAvailable => '利用可能な字幕はありません';
@override String get noAudioTracksAvailable => '利用可能な音声トラックはありません';
@override String get noTracksAvailable => '利用可能なトラックはありません';
@override String get subtitleDownloaded => '字幕をダウンロードしました';
@override String get subtitleDownloadedNotApplied => '字幕はダウンロードされましたが、選択できませんでした';
@override String get subtitleDownloadFailed => '字幕のダウンロードに失敗しました';
@override String get searchLanguages => '言語を検索...';
}
@@ -2582,6 +2583,7 @@ extension on TranslationsJa {
'videoControls.qualityOriginal' => 'オリジナル',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'トランスコードは利用できません — オリジナル画質で再生中',
'videoControls.subtitleUnavailableFallback' => '選択した字幕を読み込めませんでした — 字幕なしで再生を続けます',
'videoControls.pipButton' => 'ピクチャーインピクチャーモード',
'videoControls.aspectRatioButton' => 'アスペクト比',
'videoControls.ambientLighting' => 'アンビエントライティング',
@@ -2613,11 +2615,11 @@ extension on TranslationsJa {
'videoControls.searchSubtitles' => '字幕を検索',
'videoControls.language' => '言語',
'videoControls.noSubtitlesFound' => '字幕が見つかりません',
'videoControls.downloadedSubtitle' => 'ダウンロード済み',
'videoControls.noSubtitlesAvailable' => '利用可能な字幕はありません',
'videoControls.noAudioTracksAvailable' => '利用可能な音声トラックはありません',
'videoControls.noTracksAvailable' => '利用可能なトラックはありません',
'videoControls.subtitleDownloaded' => '字幕をダウンロードしました',
'videoControls.subtitleDownloadedNotApplied' => '字幕はダウンロードされましたが、選択できませんでした',
'videoControls.subtitleDownloadFailed' => '字幕のダウンロードに失敗しました',
'videoControls.searchLanguages' => '言語を検索...',
'messages.markedAsWatched' => '視聴済みにしました',
@@ -2630,9 +2632,9 @@ extension on TranslationsJa {
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。',
'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。',
'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'ファイル情報の読み込みエラー: ${error}',
'messages.errorLoadingSeries' => 'シリーズの読み込みエラー',
'messages.musicNotSupported' => '音楽の再生はまだサポートされていません',
@@ -3144,9 +3146,9 @@ extension on TranslationsJa {
'downloads.title' => 'ダウンロード',
'downloads.manage' => '管理',
'downloads.tvShows' => 'テレビ番組',
'downloads.movies' => '映画',
_ => null,
} ?? switch (path) {
'downloads.movies' => '映画',
'downloads.music' => '音楽',
'downloads.tracksQueued' => ({required Object count}) => '${count} 曲をダウンロード待機中',
'downloads.noDownloads' => 'ダウンロードなし',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsKo extends TranslationsVideoControlsEn {
@override String get qualityOriginal => '원본';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => '트랜스코딩을 사용할 수 없음 — 원본 화질로 재생';
@override String get subtitleUnavailableFallback => '선택한 자막을 불러올 수 없어 자막 없이 재생을 계속합니다';
@override String get pipButton => '픽처 인 픽처 모드';
@override String get aspectRatioButton => '화면비율';
@override String get ambientLighting => '주변 조명';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsKo extends TranslationsVideoControlsEn {
@override String get searchSubtitles => '자막 검색';
@override String get language => '언어';
@override String get noSubtitlesFound => '자막을 찾을 수 없습니다';
@override String get downloadedSubtitle => '다운로드됨';
@override String get noSubtitlesAvailable => '사용 가능한 자막 없음';
@override String get noAudioTracksAvailable => '사용 가능한 오디오 트랙 없음';
@override String get noTracksAvailable => '사용 가능한 트랙 없음';
@override String get subtitleDownloaded => '자막이 다운로드되었습니다';
@override String get subtitleDownloadedNotApplied => '자막을 다운로드했지만 선택할 수 없습니다';
@override String get subtitleDownloadFailed => '자막 다운로드에 실패했습니다';
@override String get searchLanguages => '언어 검색...';
}
@@ -2582,6 +2583,7 @@ extension on TranslationsKo {
'videoControls.qualityOriginal' => '원본',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => '트랜스코딩을 사용할 수 없음 — 원본 화질로 재생',
'videoControls.subtitleUnavailableFallback' => '선택한 자막을 불러올 수 없어 자막 없이 재생을 계속합니다',
'videoControls.pipButton' => '픽처 인 픽처 모드',
'videoControls.aspectRatioButton' => '화면비율',
'videoControls.ambientLighting' => '주변 조명',
@@ -2613,11 +2615,11 @@ extension on TranslationsKo {
'videoControls.searchSubtitles' => '자막 검색',
'videoControls.language' => '언어',
'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다',
'videoControls.downloadedSubtitle' => '다운로드됨',
'videoControls.noSubtitlesAvailable' => '사용 가능한 자막 없음',
'videoControls.noAudioTracksAvailable' => '사용 가능한 오디오 트랙 없음',
'videoControls.noTracksAvailable' => '사용 가능한 트랙 없음',
'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다',
'videoControls.subtitleDownloadedNotApplied' => '자막을 다운로드했지만 선택할 수 없습니다',
'videoControls.subtitleDownloadFailed' => '자막 다운로드에 실패했습니다',
'videoControls.searchLanguages' => '언어 검색...',
'messages.markedAsWatched' => '시청 완료로 표시됨',
@@ -2630,9 +2632,9 @@ extension on TranslationsKo {
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.',
'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.',
'messages.fileInfoNotAvailable' => '파일 정보가 없습니다',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => '파일 정보가 없습니다',
'messages.errorLoadingFileInfo' => ({required Object error}) => '파일 정보 로딩 중 오류: ${error}',
'messages.errorLoadingSeries' => '시리즈 로딩 중 오류',
'messages.musicNotSupported' => '음악 재생 미지원',
@@ -3144,9 +3146,9 @@ extension on TranslationsKo {
'downloads.title' => '다운로드',
'downloads.manage' => '관리',
'downloads.tvShows' => 'TV 프로그램',
'downloads.movies' => '영화',
_ => null,
} ?? switch (path) {
'downloads.movies' => '영화',
'downloads.music' => '음악',
'downloads.tracksQueued' => ({required Object count}) => '${count}곡 다운로드 대기 중',
'downloads.noDownloads' => '다운로드 없음',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsNb extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkoding utilgjengelig — spiller av i original kvalitet';
@override String get subtitleUnavailableFallback => 'De valgte undertekstene kunne ikke lastes inn — avspillingen fortsetter uten undertekster';
@override String get pipButton => 'Bilde-i-bilde-modus';
@override String get aspectRatioButton => 'Sideforhold';
@override String get ambientLighting => 'Omgivelseslys';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsNb extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Søk etter undertekster';
@override String get language => 'Språk';
@override String get noSubtitlesFound => 'Ingen undertekster funnet';
@override String get downloadedSubtitle => 'Lastet ned';
@override String get noSubtitlesAvailable => 'Ingen undertekster tilgjengelig';
@override String get noAudioTracksAvailable => 'Ingen lydspor tilgjengelig';
@override String get noTracksAvailable => 'Ingen spor tilgjengelig';
@override String get subtitleDownloaded => 'Undertekst lastet ned';
@override String get subtitleDownloadedNotApplied => 'Underteksten ble lastet ned, men kunne ikke velges';
@override String get subtitleDownloadFailed => 'Kunne ikke laste ned undertekst';
@override String get searchLanguages => 'Søk etter språk...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsNb {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkoding utilgjengelig — spiller av i original kvalitet',
'videoControls.subtitleUnavailableFallback' => 'De valgte undertekstene kunne ikke lastes inn — avspillingen fortsetter uten undertekster',
'videoControls.pipButton' => 'Bilde-i-bilde-modus',
'videoControls.aspectRatioButton' => 'Sideforhold',
'videoControls.ambientLighting' => 'Omgivelseslys',
@@ -2616,11 +2618,11 @@ extension on TranslationsNb {
'videoControls.searchSubtitles' => 'Søk etter undertekster',
'videoControls.language' => 'Språk',
'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet',
'videoControls.downloadedSubtitle' => 'Lastet ned',
'videoControls.noSubtitlesAvailable' => 'Ingen undertekster tilgjengelig',
'videoControls.noAudioTracksAvailable' => 'Ingen lydspor tilgjengelig',
'videoControls.noTracksAvailable' => 'Ingen spor tilgjengelig',
'videoControls.subtitleDownloaded' => 'Undertekst lastet ned',
'videoControls.subtitleDownloadedNotApplied' => 'Underteksten ble lastet ned, men kunne ikke velges',
'videoControls.subtitleDownloadFailed' => 'Kunne ikke laste ned undertekst',
'videoControls.searchLanguages' => 'Søk etter språk...',
'messages.markedAsWatched' => 'Merket som sett',
@@ -2633,9 +2635,9 @@ extension on TranslationsNb {
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.',
'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.',
'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}',
'messages.errorLoadingSeries' => 'Feil ved lasting av serie',
'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå',
@@ -3147,9 +3149,9 @@ extension on TranslationsNb {
'downloads.title' => 'Nedlastinger',
'downloads.manage' => 'Administrer',
'downloads.tvShows' => 'TV-serier',
'downloads.movies' => 'Filmer',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Filmer',
'downloads.music' => 'Musikk',
'downloads.tracksQueued' => ({required Object count}) => '${count} spor i nedlastingskø',
'downloads.noDownloads' => 'Ingen nedlastinger ennå',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsNl extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Origineel';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcoderen niet beschikbaar — originele kwaliteit wordt afgespeeld';
@override String get subtitleUnavailableFallback => 'De geselecteerde ondertitels konden niet worden geladen — afspelen gaat door zonder ondertitels';
@override String get pipButton => 'Beeld-in-beeld modus';
@override String get aspectRatioButton => 'Beeldverhouding';
@override String get ambientLighting => 'Omgevingsverlichting';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsNl extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Ondertitels zoeken';
@override String get language => 'Taal';
@override String get noSubtitlesFound => 'Geen ondertitels gevonden';
@override String get downloadedSubtitle => 'Gedownload';
@override String get noSubtitlesAvailable => 'Geen ondertitels beschikbaar';
@override String get noAudioTracksAvailable => 'Geen audiotracks beschikbaar';
@override String get noTracksAvailable => 'Geen tracks beschikbaar';
@override String get subtitleDownloaded => 'Ondertitel gedownload';
@override String get subtitleDownloadedNotApplied => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd';
@override String get subtitleDownloadFailed => 'Ondertitel downloaden mislukt';
@override String get searchLanguages => 'Talen zoeken...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsNl {
'videoControls.qualityOriginal' => 'Origineel',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcoderen niet beschikbaar — originele kwaliteit wordt afgespeeld',
'videoControls.subtitleUnavailableFallback' => 'De geselecteerde ondertitels konden niet worden geladen — afspelen gaat door zonder ondertitels',
'videoControls.pipButton' => 'Beeld-in-beeld modus',
'videoControls.aspectRatioButton' => 'Beeldverhouding',
'videoControls.ambientLighting' => 'Omgevingsverlichting',
@@ -2616,11 +2618,11 @@ extension on TranslationsNl {
'videoControls.searchSubtitles' => 'Ondertitels zoeken',
'videoControls.language' => 'Taal',
'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden',
'videoControls.downloadedSubtitle' => 'Gedownload',
'videoControls.noSubtitlesAvailable' => 'Geen ondertitels beschikbaar',
'videoControls.noAudioTracksAvailable' => 'Geen audiotracks beschikbaar',
'videoControls.noTracksAvailable' => 'Geen tracks beschikbaar',
'videoControls.subtitleDownloaded' => 'Ondertitel gedownload',
'videoControls.subtitleDownloadedNotApplied' => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd',
'videoControls.subtitleDownloadFailed' => 'Ondertitel downloaden mislukt',
'videoControls.searchLanguages' => 'Talen zoeken...',
'messages.markedAsWatched' => 'Gemarkeerd als gekeken',
@@ -2633,9 +2635,9 @@ extension on TranslationsNl {
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.',
'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.',
'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fout bij laden bestand info: ${error}',
'messages.errorLoadingSeries' => 'Fout bij laden serie',
'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund',
@@ -3147,9 +3149,9 @@ extension on TranslationsNl {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Beheren',
'downloads.tvShows' => 'Series',
'downloads.movies' => 'Films',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Films',
'downloads.music' => 'Muziek',
'downloads.tracksQueued' => ({required Object count}) => '${count} nummers in wachtrij voor download',
'downloads.noDownloads' => 'Nog geen downloads',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsPl extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Oryginalna';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodowanie niedostępne — odtwarzanie w oryginalnej jakości';
@override String get subtitleUnavailableFallback => 'Nie udało się wczytać wybranych napisów — odtwarzanie jest kontynuowane bez napisów';
@override String get pipButton => 'Tryb obraz w obrazie';
@override String get aspectRatioButton => 'Proporcje';
@override String get ambientLighting => 'Oświetlenie otoczenia';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsPl extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Szukaj napisów';
@override String get language => 'Język';
@override String get noSubtitlesFound => 'Nie znaleziono napisów';
@override String get downloadedSubtitle => 'Pobrano';
@override String get noSubtitlesAvailable => 'Brak dostępnych napisów';
@override String get noAudioTracksAvailable => 'Brak dostępnych ścieżek audio';
@override String get noTracksAvailable => 'Brak dostępnych ścieżek';
@override String get subtitleDownloaded => 'Napisy pobrane';
@override String get subtitleDownloadedNotApplied => 'Napisy zostały pobrane, ale nie można ich było wybrać';
@override String get subtitleDownloadFailed => 'Nie udało się pobrać napisów';
@override String get searchLanguages => 'Szukaj języków...';
}
@@ -2591,6 +2592,7 @@ extension on TranslationsPl {
'videoControls.qualityOriginal' => 'Oryginalna',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodowanie niedostępne — odtwarzanie w oryginalnej jakości',
'videoControls.subtitleUnavailableFallback' => 'Nie udało się wczytać wybranych napisów — odtwarzanie jest kontynuowane bez napisów',
'videoControls.pipButton' => 'Tryb obraz w obrazie',
'videoControls.aspectRatioButton' => 'Proporcje',
'videoControls.ambientLighting' => 'Oświetlenie otoczenia',
@@ -2622,11 +2624,11 @@ extension on TranslationsPl {
'videoControls.searchSubtitles' => 'Szukaj napisów',
'videoControls.language' => 'Język',
'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów',
'videoControls.downloadedSubtitle' => 'Pobrano',
'videoControls.noSubtitlesAvailable' => 'Brak dostępnych napisów',
'videoControls.noAudioTracksAvailable' => 'Brak dostępnych ścieżek audio',
'videoControls.noTracksAvailable' => 'Brak dostępnych ścieżek',
'videoControls.subtitleDownloaded' => 'Napisy pobrane',
'videoControls.subtitleDownloadedNotApplied' => 'Napisy zostały pobrane, ale nie można ich było wybrać',
'videoControls.subtitleDownloadFailed' => 'Nie udało się pobrać napisów',
'videoControls.searchLanguages' => 'Szukaj języków...',
'messages.markedAsWatched' => 'Oznaczono jako obejrzane',
@@ -2639,9 +2641,9 @@ extension on TranslationsPl {
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.',
'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.',
'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}',
'messages.errorLoadingSeries' => 'Błąd ładowania serialu',
'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane',
@@ -3153,9 +3155,9 @@ extension on TranslationsPl {
'downloads.title' => 'Pobrania',
'downloads.manage' => 'Zarządzaj',
'downloads.tvShows' => 'Seriale TV',
'downloads.movies' => 'Filmy',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Filmy',
'downloads.music' => 'Muzyka',
'downloads.tracksQueued' => ({required Object count}) => '${count} utworów w kolejce do pobrania',
'downloads.noDownloads' => 'Brak pobrań',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsPt extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodificação indisponível — reproduzindo qualidade original';
@override String get subtitleUnavailableFallback => 'Não foi possível carregar as legendas selecionadas — a reprodução continuará sem legendas';
@override String get pipButton => 'Modo Picture-in-Picture';
@override String get aspectRatioButton => 'Proporção';
@override String get ambientLighting => 'Iluminação ambiente';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsPt extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Pesquisar legendas';
@override String get language => 'Idioma';
@override String get noSubtitlesFound => 'Nenhuma legenda encontrada';
@override String get downloadedSubtitle => 'Baixado';
@override String get noSubtitlesAvailable => 'Nenhuma legenda disponível';
@override String get noAudioTracksAvailable => 'Nenhuma faixa de áudio disponível';
@override String get noTracksAvailable => 'Nenhuma faixa disponível';
@override String get subtitleDownloaded => 'Legenda baixada';
@override String get subtitleDownloadedNotApplied => 'A legenda foi transferida, mas não foi possível selecioná-la';
@override String get subtitleDownloadFailed => 'Falha ao baixar legenda';
@override String get searchLanguages => 'Pesquisar idiomas...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsPt {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodificação indisponível — reproduzindo qualidade original',
'videoControls.subtitleUnavailableFallback' => 'Não foi possível carregar as legendas selecionadas — a reprodução continuará sem legendas',
'videoControls.pipButton' => 'Modo Picture-in-Picture',
'videoControls.aspectRatioButton' => 'Proporção',
'videoControls.ambientLighting' => 'Iluminação ambiente',
@@ -2616,11 +2618,11 @@ extension on TranslationsPt {
'videoControls.searchSubtitles' => 'Pesquisar legendas',
'videoControls.language' => 'Idioma',
'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada',
'videoControls.downloadedSubtitle' => 'Baixado',
'videoControls.noSubtitlesAvailable' => 'Nenhuma legenda disponível',
'videoControls.noAudioTracksAvailable' => 'Nenhuma faixa de áudio disponível',
'videoControls.noTracksAvailable' => 'Nenhuma faixa disponível',
'videoControls.subtitleDownloaded' => 'Legenda baixada',
'videoControls.subtitleDownloadedNotApplied' => 'A legenda foi transferida, mas não foi possível selecioná-la',
'videoControls.subtitleDownloadFailed' => 'Falha ao baixar legenda',
'videoControls.searchLanguages' => 'Pesquisar idiomas...',
'messages.markedAsWatched' => 'Marcado como assistido',
@@ -2633,9 +2635,9 @@ extension on TranslationsPt {
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.',
'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.',
'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erro ao carregar info do arquivo: ${error}',
'messages.errorLoadingSeries' => 'Erro ao carregar série',
'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada',
@@ -3147,9 +3149,9 @@ extension on TranslationsPt {
'downloads.title' => 'Downloads',
'downloads.manage' => 'Gerenciar',
'downloads.tvShows' => 'Séries de TV',
'downloads.movies' => 'Filmes',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Filmes',
'downloads.music' => 'Música',
'downloads.tracksQueued' => ({required Object count}) => '${count} faixas na fila para download',
'downloads.noDownloads' => 'Nenhum download ainda',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsRu extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Оригинал';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Транскодирование недоступно — воспроизведение в оригинальном качестве';
@override String get subtitleUnavailableFallback => 'Не удалось загрузить выбранные субтитры — воспроизведение продолжится без субтитров';
@override String get pipButton => 'Режим «картинка в картинке»';
@override String get aspectRatioButton => 'Соотношение сторон';
@override String get ambientLighting => 'Фоновая подсветка';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsRu extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Поиск субтитров';
@override String get language => 'Язык';
@override String get noSubtitlesFound => 'Субтитры не найдены';
@override String get downloadedSubtitle => 'Загружено';
@override String get noSubtitlesAvailable => 'Нет доступных субтитров';
@override String get noAudioTracksAvailable => 'Нет доступных аудиодорожек';
@override String get noTracksAvailable => 'Нет доступных дорожек';
@override String get subtitleDownloaded => 'Субтитры загружены';
@override String get subtitleDownloadedNotApplied => 'Субтитры загружены, но их не удалось выбрать';
@override String get subtitleDownloadFailed => 'Не удалось загрузить субтитры';
@override String get searchLanguages => 'Поиск языков...';
}
@@ -2591,6 +2592,7 @@ extension on TranslationsRu {
'videoControls.qualityOriginal' => 'Оригинал',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Транскодирование недоступно — воспроизведение в оригинальном качестве',
'videoControls.subtitleUnavailableFallback' => 'Не удалось загрузить выбранные субтитры — воспроизведение продолжится без субтитров',
'videoControls.pipButton' => 'Режим «картинка в картинке»',
'videoControls.aspectRatioButton' => 'Соотношение сторон',
'videoControls.ambientLighting' => 'Фоновая подсветка',
@@ -2622,11 +2624,11 @@ extension on TranslationsRu {
'videoControls.searchSubtitles' => 'Поиск субтитров',
'videoControls.language' => 'Язык',
'videoControls.noSubtitlesFound' => 'Субтитры не найдены',
'videoControls.downloadedSubtitle' => 'Загружено',
'videoControls.noSubtitlesAvailable' => 'Нет доступных субтитров',
'videoControls.noAudioTracksAvailable' => 'Нет доступных аудиодорожек',
'videoControls.noTracksAvailable' => 'Нет доступных дорожек',
'videoControls.subtitleDownloaded' => 'Субтитры загружены',
'videoControls.subtitleDownloadedNotApplied' => 'Субтитры загружены, но их не удалось выбрать',
'videoControls.subtitleDownloadFailed' => 'Не удалось загрузить субтитры',
'videoControls.searchLanguages' => 'Поиск языков...',
'messages.markedAsWatched' => 'Отмечено как просмотренное',
@@ -2639,9 +2641,9 @@ extension on TranslationsRu {
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.',
'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.',
'messages.fileInfoNotAvailable' => 'Информация о файле недоступна',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Информация о файле недоступна',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Ошибка загрузки информации о файле: ${error}',
'messages.errorLoadingSeries' => 'Ошибка загрузки сериала',
'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается',
@@ -3153,9 +3155,9 @@ extension on TranslationsRu {
'downloads.title' => 'Загрузки',
'downloads.manage' => 'Управление',
'downloads.tvShows' => 'Сериалы',
'downloads.movies' => 'Фильмы',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Фильмы',
'downloads.music' => 'Музыка',
'downloads.tracksQueued' => ({required Object count}) => '${count} треков в очереди на загрузку',
'downloads.noDownloads' => 'Загрузок пока нет',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsSv extends TranslationsVideoControlsEn {
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodning otillgänglig — spelar upp i originalkvalitet';
@override String get subtitleUnavailableFallback => 'De valda undertexterna kunde inte läsas in — uppspelningen fortsätter utan undertexter';
@override String get pipButton => 'Bild-i-bild läge';
@override String get aspectRatioButton => 'Bildförhållande';
@override String get ambientLighting => 'Ambientbelysning';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsSv extends TranslationsVideoControlsEn {
@override String get searchSubtitles => 'Sök undertexter';
@override String get language => 'Språk';
@override String get noSubtitlesFound => 'Inga undertexter hittades';
@override String get downloadedSubtitle => 'Nedladdad';
@override String get noSubtitlesAvailable => 'Inga undertexter tillgängliga';
@override String get noAudioTracksAvailable => 'Inga ljudspår tillgängliga';
@override String get noTracksAvailable => 'Inga spår tillgängliga';
@override String get subtitleDownloaded => 'Undertext nedladdad';
@override String get subtitleDownloadedNotApplied => 'Undertexten hämtades, men kunde inte väljas';
@override String get subtitleDownloadFailed => 'Kunde inte ladda ner undertext';
@override String get searchLanguages => 'Sök språk...';
}
@@ -2585,6 +2586,7 @@ extension on TranslationsSv {
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodning otillgänglig — spelar upp i originalkvalitet',
'videoControls.subtitleUnavailableFallback' => 'De valda undertexterna kunde inte läsas in — uppspelningen fortsätter utan undertexter',
'videoControls.pipButton' => 'Bild-i-bild läge',
'videoControls.aspectRatioButton' => 'Bildförhållande',
'videoControls.ambientLighting' => 'Ambientbelysning',
@@ -2616,11 +2618,11 @@ extension on TranslationsSv {
'videoControls.searchSubtitles' => 'Sök undertexter',
'videoControls.language' => 'Språk',
'videoControls.noSubtitlesFound' => 'Inga undertexter hittades',
'videoControls.downloadedSubtitle' => 'Nedladdad',
'videoControls.noSubtitlesAvailable' => 'Inga undertexter tillgängliga',
'videoControls.noAudioTracksAvailable' => 'Inga ljudspår tillgängliga',
'videoControls.noTracksAvailable' => 'Inga spår tillgängliga',
'videoControls.subtitleDownloaded' => 'Undertext nedladdad',
'videoControls.subtitleDownloadedNotApplied' => 'Undertexten hämtades, men kunde inte väljas',
'videoControls.subtitleDownloadFailed' => 'Kunde inte ladda ner undertext',
'videoControls.searchLanguages' => 'Sök språk...',
'messages.markedAsWatched' => 'Markerad som sedd',
@@ -2633,9 +2635,9 @@ extension on TranslationsSv {
'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}',
'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.',
'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.',
'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fel vid laddning av filinformation: ${error}',
'messages.errorLoadingSeries' => 'Fel vid laddning av serie',
'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu',
@@ -3147,9 +3149,9 @@ extension on TranslationsSv {
'downloads.title' => 'Nedladdningar',
'downloads.manage' => 'Hantera',
'downloads.tvShows' => 'TV-serier',
'downloads.movies' => 'Filmer',
_ => null,
} ?? switch (path) {
'downloads.movies' => 'Filmer',
'downloads.music' => 'Musik',
'downloads.tracksQueued' => ({required Object count}) => '${count} låtar i nedladdningskö',
'downloads.noDownloads' => 'Inga nedladdningar ännu',
+6 -4
View File
@@ -652,6 +652,7 @@ class _TranslationsVideoControlsZh extends TranslationsVideoControlsEn {
@override String get qualityOriginal => '原始';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String get transcodeUnavailableFallback => '转码不可用 — 以原始画质播放';
@override String get subtitleUnavailableFallback => '无法加载所选字幕 — 将继续无字幕播放';
@override String get pipButton => '画中画模式';
@override String get aspectRatioButton => '宽高比';
@override String get ambientLighting => '氛围灯光';
@@ -677,11 +678,11 @@ class _TranslationsVideoControlsZh extends TranslationsVideoControlsEn {
@override String get searchSubtitles => '搜索字幕';
@override String get language => '语言';
@override String get noSubtitlesFound => '未找到字幕';
@override String get downloadedSubtitle => '已下载';
@override String get noSubtitlesAvailable => '没有可用字幕';
@override String get noAudioTracksAvailable => '没有可用音轨';
@override String get noTracksAvailable => '没有可用轨道';
@override String get subtitleDownloaded => '字幕已下载';
@override String get subtitleDownloadedNotApplied => '字幕已下载,但无法选择';
@override String get subtitleDownloadFailed => '字幕下载失败';
@override String get searchLanguages => '搜索语言...';
}
@@ -2582,6 +2583,7 @@ extension on TranslationsZh {
'videoControls.qualityOriginal' => '原始',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => '转码不可用 — 以原始画质播放',
'videoControls.subtitleUnavailableFallback' => '无法加载所选字幕 — 将继续无字幕播放',
'videoControls.pipButton' => '画中画模式',
'videoControls.aspectRatioButton' => '宽高比',
'videoControls.ambientLighting' => '氛围灯光',
@@ -2613,11 +2615,11 @@ extension on TranslationsZh {
'videoControls.searchSubtitles' => '搜索字幕',
'videoControls.language' => '语言',
'videoControls.noSubtitlesFound' => '未找到字幕',
'videoControls.downloadedSubtitle' => '已下载',
'videoControls.noSubtitlesAvailable' => '没有可用字幕',
'videoControls.noAudioTracksAvailable' => '没有可用音轨',
'videoControls.noTracksAvailable' => '没有可用轨道',
'videoControls.subtitleDownloaded' => '字幕已下载',
'videoControls.subtitleDownloadedNotApplied' => '字幕已下载,但无法选择',
'videoControls.subtitleDownloadFailed' => '字幕下载失败',
'videoControls.searchLanguages' => '搜索语言...',
'messages.markedAsWatched' => '已标记为已观看',
@@ -2630,9 +2632,9 @@ extension on TranslationsZh {
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。',
'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。',
'messages.fileInfoNotAvailable' => '文件信息不可用',
_ => null,
} ?? switch (path) {
'messages.fileInfoNotAvailable' => '文件信息不可用',
'messages.errorLoadingFileInfo' => ({required Object error}) => '加载文件信息时出错: ${error}',
'messages.errorLoadingSeries' => '加载系列时出错',
'messages.musicNotSupported' => '尚不支持播放音乐',
@@ -3144,9 +3146,9 @@ extension on TranslationsZh {
'downloads.title' => '下载',
'downloads.manage' => '管理',
'downloads.tvShows' => '电视剧',
'downloads.movies' => '电影',
_ => null,
} ?? switch (path) {
'downloads.movies' => '电影',
'downloads.music' => '音乐',
'downloads.tracksQueued' => ({required Object count}) => '${count} 首曲目已加入下载队列',
'downloads.noDownloads' => '暂无下载',
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodning otillgänglig — spelar upp i originalkvalitet",
"subtitleUnavailableFallback": "De valda undertexterna kunde inte läsas in — uppspelningen fortsätter utan undertexter",
"pipButton": "Bild-i-bild läge",
"aspectRatioButton": "Bildförhållande",
"ambientLighting": "Ambientbelysning",
@@ -524,11 +525,11 @@
"searchSubtitles": "Sök undertexter",
"language": "Språk",
"noSubtitlesFound": "Inga undertexter hittades",
"downloadedSubtitle": "Nedladdad",
"noSubtitlesAvailable": "Inga undertexter tillgängliga",
"noAudioTracksAvailable": "Inga ljudspår tillgängliga",
"noTracksAvailable": "Inga spår tillgängliga",
"subtitleDownloaded": "Undertext nedladdad",
"subtitleDownloadedNotApplied": "Undertexten hämtades, men kunde inte väljas",
"subtitleDownloadFailed": "Kunde inte ladda ner undertext",
"searchLanguages": "Sök språk..."
},
+2 -1
View File
@@ -491,6 +491,7 @@
"qualityOriginal": "原始",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"transcodeUnavailableFallback": "转码不可用 — 以原始画质播放",
"subtitleUnavailableFallback": "无法加载所选字幕 — 将继续无字幕播放",
"pipButton": "画中画模式",
"aspectRatioButton": "宽高比",
"ambientLighting": "氛围灯光",
@@ -524,11 +525,11 @@
"searchSubtitles": "搜索字幕",
"language": "语言",
"noSubtitlesFound": "未找到字幕",
"downloadedSubtitle": "已下载",
"noSubtitlesAvailable": "没有可用字幕",
"noAudioTracksAvailable": "没有可用音轨",
"noTracksAvailable": "没有可用轨道",
"subtitleDownloaded": "字幕已下载",
"subtitleDownloadedNotApplied": "字幕已下载,但无法选择",
"subtitleDownloadFailed": "字幕下载失败",
"searchLanguages": "搜索语言..."
},
@@ -27,6 +27,8 @@ class PlayerAndroid extends PlayerBase {
/// (which resets only on initialize/dispose).
bool _usingMpvFallback = false;
bool get usingMpvFallback => _usingMpvFallback;
String? _hiddenSubtitleTrackId;
@override
+27
View File
@@ -63,6 +63,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
Map<String, SubtitleTrack> _externalSubtitleMetadataByUri = const {};
bool _primaryMediaLoadStarted = false;
bool _primaryMediaReadyEmitted = false;
@protected
bool initialized = false;
@@ -260,6 +262,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
case 'track-list':
final trackList = MpvNodeDecoder.decodeList(value);
if (trackList != null) {
if (_primaryMediaLoadStarted && !_primaryMediaReadyEmitted && _hasPrimaryMediaTrack(trackList)) {
_primaryMediaReadyEmitted = true;
primaryMediaReadyController.add(null);
}
final result = parseTrackList(trackList);
_state = _state.copyWith(tracks: result.tracks);
tracksController.add(result.tracks);
@@ -354,7 +360,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
void handlePlayerEvent(String name, Map? data) {
if (_disposed) return;
switch (name) {
case 'start-file':
_primaryMediaLoadStarted = true;
_primaryMediaReadyEmitted = false;
fileStartedController.add(null);
break;
case 'end-file':
_primaryMediaLoadStarted = false;
setSeekable(false);
final rawReason = data?['reason'];
final reason = switch (rawReason) {
@@ -370,6 +383,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_state = _state.copyWith(completed: true);
completedController.add(true);
} else if (reason == 'error') {
fileLoadFailedController.add(null);
errorController.add(
PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?),
);
@@ -396,6 +410,19 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
}
bool _hasPrimaryMediaTrack(List trackList) {
for (final track in trackList) {
if (track is! Map || track['external'] == true) continue;
final type = track['type'];
if (type == 'audio') return true;
// mpv exposes embedded/external cover art as a video track. It is not
// evidence that the primary audio file has finished discovery, so it
// must not start the external-sidecar timeout on its own.
if (type == 'video' && track['albumart'] != true) return true;
}
return false;
}
PlayerLogLevel parseLogLevel(String level) {
return switch (level) {
'fatal' => PlayerLogLevel.fatal,
@@ -22,6 +22,9 @@ mixin PlayerStreamControllersMixin {
final bufferRangesController = StreamController<List<BufferRange>>.broadcast();
final playbackRestartController = StreamController<void>.broadcast();
final fileLoadedController = StreamController<void>.broadcast();
final fileStartedController = StreamController<void>.broadcast();
final fileLoadFailedController = StreamController<void>.broadcast();
final primaryMediaReadyController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast();
final trackTransitionController = StreamController<String>.broadcast();
@@ -45,6 +48,9 @@ mixin PlayerStreamControllersMixin {
bufferRanges: bufferRangesController.stream,
playbackRestart: playbackRestartController.stream,
fileLoaded: fileLoadedController.stream,
fileStarted: fileStartedController.stream,
fileLoadFailed: fileLoadFailedController.stream,
primaryMediaReady: primaryMediaReadyController.stream,
backendSwitched: backendSwitchedController.stream,
trackTransition: trackTransitionController.stream,
);
@@ -69,6 +75,9 @@ mixin PlayerStreamControllersMixin {
await bufferRangesController.close();
await playbackRestartController.close();
await fileLoadedController.close();
await fileStartedController.close();
await fileLoadFailedController.close();
await primaryMediaReadyController.close();
await backendSwitchedController.close();
await trackTransitionController.close();
}
+17
View File
@@ -56,6 +56,20 @@ class PlayerStreams {
/// Stream that emits when the player has loaded the current media file.
final Stream<void> fileLoaded;
/// Emits when mpv starts loading a new file. This delimits load-scoped
/// readiness and failure signals for callers that arm before [Player.open].
final Stream<void> fileStarted;
/// Emits only when the active file ends because loading or playback failed.
/// Generic platform/property errors remain on [error] and must not be
/// mistaken for a media-open failure.
final Stream<void> fileLoadFailed;
/// Emits once mpv has discovered a non-external audio or video track for
/// the current file. Unlike [fileLoaded], this can fire before remote
/// subtitle sidecars finish opening.
final Stream<void> primaryMediaReady;
/// Stream of seekable buffer ranges from the demuxer cache.
final Stream<List<BufferRange>> bufferRanges;
@@ -88,6 +102,9 @@ class PlayerStreams {
required this.bufferRanges,
required this.playbackRestart,
this.fileLoaded = const Stream<void>.empty(),
this.fileStarted = const Stream<void>.empty(),
this.fileLoadFailed = const Stream<void>.empty(),
this.primaryMediaReady = const Stream<void>.empty(),
required this.backendSwitched,
this.trackTransition = const Stream<String>.empty(),
});
+26 -5
View File
@@ -48,21 +48,39 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
});
}
int? _selectedSourceSubtitleStreamIdForControls(List<MediaSubtitleTrack> tracks) {
PlaybackSourceSubtitleChoice? _selectedSourceSubtitleChoiceForControls(List<MediaSubtitleTrack> tracks) {
if (tracks.isEmpty) return null;
for (final track in tracks) {
if (track.selected) return track.id;
final selection = _playbackSession?.subtitleSelection;
if (selection != null) {
if (selection.isOff) return const PlaybackSourceSubtitleChoice.off();
final sourceId = selection.primarySourceStreamId;
if (sourceId != null && tracks.any((track) => track.id == sourceId)) {
return PlaybackSourceSubtitleChoice.source(sourceId);
}
}
return 0;
for (final track in tracks) {
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
}
return const PlaybackSourceSubtitleChoice.off();
}
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
final sidecarSourceIds = _sourceSubtitleSidecarIdsForControls();
return selectableSourceSubtitleTracks(
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
isTranscoding: _isTranscoding,
sidecarSourceIds: sidecarSourceIds,
supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex,
);
}
Set<int> _sourceSubtitleSidecarIdsForControls() {
return {
for (final sidecar in _playbackSession?.context.result.subtitleSidecars ?? const <PlaybackSubtitleSidecar>[])
?sidecar.sourceStreamId,
};
}
Widget _buildLoadingSpinner() {
return const Scaffold(
backgroundColor: Colors.black,
@@ -249,6 +267,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
final sourceSubtitleSidecarIds = _sourceSubtitleSidecarIdsForControls();
return Video(
player: player!,
@@ -267,7 +286,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: _selectedAudioStreamId,
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleStreamId: _selectedSourceSubtitleStreamIdForControls(sourceSubtitleTracks),
selectedSubtitleChoice: _selectedSourceSubtitleChoiceForControls(sourceSubtitleTracks),
selectedSecondarySubtitleStreamId: _playbackSession?.subtitleSelection.secondarySourceStreamId,
sourceSubtitleSidecarIds: sourceSubtitleSidecarIds,
sourcePartId: _currentMediaInfo?.partId,
onPlaybackSourceChanged: _switchPlaybackSource,
onTogglePIPMode: _togglePIPMode,
@@ -92,7 +92,49 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
_companionRemoteProvider = null;
}
void _cycleSubtitleTrack() => _trackManager?.cycleSubtitleTrack();
void _cycleSubtitleTrack() {
final sourceTracks = _sourceSubtitleTracksForControls();
if (!_isOfflinePlayback && sourceTracks.isNotEmpty) {
_pendingSubtitleCycleCount++;
if (!_subtitleCycleDrainActive) unawaited(_drainSubtitleCycles());
return;
}
_trackManager?.cycleSubtitleTrack();
}
Future<void> _drainSubtitleCycles() async {
if (_subtitleCycleDrainActive) return;
_subtitleCycleDrainActive = true;
try {
while (mounted && _pendingSubtitleCycleCount > 0) {
await _waitForPlaybackTransitionIdle();
if (!mounted || _pendingSubtitleCycleCount == 0) break;
// Collapse every press queued before this dispatch into one target.
// Presses arriving during the reopen remain queued for the next pass.
final advances = _pendingSubtitleCycleCount;
final sourceTracks = _sourceSubtitleTracksForControls();
if (_isOfflinePlayback || sourceTracks.isEmpty) {
_pendingSubtitleCycleCount -= advances;
for (var i = 0; i < advances; i++) {
_trackManager?.cycleSubtitleTrack();
}
continue;
}
final currentChoice =
_selectedSourceSubtitleChoiceForControls(sourceTracks) ?? const PlaybackSourceSubtitleChoice.off();
final targetChoice = PlaybackSubtitleResolver.advanceSourceChoice(sourceTracks, currentChoice, advances);
final outcome = await _switchPlaybackSource(newSubtitleChoice: targetChoice);
if (outcome == PlaybackSourceChangeOutcome.busy) {
await _waitForPlaybackTransitionIdle();
continue;
}
_pendingSubtitleCycleCount -= advances;
}
} finally {
_subtitleCycleDrainActive = false;
}
}
void _cycleAudioTrack() => _trackManager?.cycleAudioTrack();
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -250,14 +250,17 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
Future<void> _switchLiveChannel(int delta) async {
final channels = widget.live?.channels;
if (channels == null || channels.isEmpty) return;
if (_playbackTransition != _PlaybackTransition.idle) return; // debounce concurrent switches
final newIndex = _live.channelIndex + delta;
if (newIndex < 0 || newIndex >= channels.length) return;
final currentPlayer = player;
if (currentPlayer == null) return;
_playbackTransition = _PlaybackTransition.switchingChannel;
final transitionLease = _tryAcquirePlaybackTransition(_PlaybackTransition.switchingChannel);
if (transitionLease == null) return; // debounce concurrent switches
bool isCurrentChannelSwitch() =>
mounted &&
player == currentPlayer &&
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingChannel);
_liveSeek.cancel();
final previousSession = _live.session;
@@ -273,19 +276,19 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
// tuner/transcode session.
session = await _startLiveSession(channel);
if (session == null) return;
if (!mounted || player != currentPlayer) {
if (!isCurrentChannelSwitch()) {
_abandonLiveSession(session);
return;
}
final streamUrl = await session.streamUrlAt();
if (streamUrl == null || !mounted || player != currentPlayer) {
if (streamUrl == null || !isCurrentChannelSwitch()) {
_abandonLiveSession(session);
return;
}
await _setLiveStreamOptions(currentPlayer);
if (!mounted || player != currentPlayer) {
if (!isCurrentChannelSwitch()) {
_abandonLiveSession(session);
return;
}
@@ -293,7 +296,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
_setPlayerState(() => _hasFirstFrame.value = false);
replacementOpenStarted = true;
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (!mounted || player != currentPlayer) {
if (!isCurrentChannelSwitch()) {
_abandonLiveSession(session);
return;
}
@@ -304,6 +307,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
if (previousSession != null) {
await _sendLiveTimeline('stopped');
}
if (!isCurrentChannelSwitch()) {
_abandonLiveSession(session);
return;
}
_live.adoptSession(session);
_live.fallbackLevel = 0;
@@ -322,13 +329,14 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
// would otherwise hold its server-side tuner until the backend times out.
final orphan = session;
if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan);
if (!isCurrentChannelSwitch()) return;
if (replacementOpenStarted && mounted && _live.session == previousSession) {
_setPlayerState(() => _hasFirstFrame.value = true);
}
appLogger.e('Failed to switch channel', error: e);
if (mounted) showErrorSnackBar(context, e.toString());
} finally {
_playbackTransition = _PlaybackTransition.idle;
_releasePlaybackTransition(transitionLease);
}
}
@@ -59,6 +59,13 @@ class _ExternalSubtitleOpenPlan {
List<SubtitleTrack>? get subtitlesAtOpen => attachesAtOpen && hasExternalSubtitles ? externalSubtitles : null;
}
class _MediaOpenResult {
const _MediaOpenResult({required this.didOpen, this.sidecarFallbackUsed = false});
final bool didOpen;
final bool sidecarFallbackUsed;
}
/// Shared building blocks for opening media on the live player.
///
/// The initial start flow ([_startPlayback]) and in-place reload flow
@@ -68,6 +75,31 @@ class _ExternalSubtitleOpenPlan {
/// This is also the only place that reads
/// [SettingsService.displaySwitchDelay].
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
PlaybackSession _commitSidecarFallbackSession(PlaybackSession session) {
return _updatePlaybackSessionSubtitleSelection(session, const PlaybackSubtitleSelection.off());
}
Future<PlaybackSubtitleSelection> _resolveSubtitleSelectionForOpen({
required MediaItem metadata,
required PlaybackInitializationResult result,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
}) async {
await _waitForProfileSettingsIfNeeded();
if (!mounted) return const PlaybackSubtitleSelection.off();
return PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: result.mediaInfo,
sidecars: result.subtitleSidecars,
profileSettings: context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
);
}
/// Prime native display matching (tvOS HDMI mode) from server metadata
/// before the decoder emits stream properties. The native side resolves
/// only after any resulting display-mode switch has settled, plus the
@@ -543,9 +575,10 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
/// after open() returns (before styling) so callers can flip rollback
/// bookkeeping at the exact ownership boundary.
///
/// Returns false if [shouldContinue] stopped the sequence before open;
/// true once open() has been issued (even if styling was skipped).
Future<bool> _openMediaOnPlayer({
/// Returns whether open was issued and whether a selected remote sidecar
/// stalled after the primary media was ready, requiring an automatic reopen
/// without subtitles.
Future<_MediaOpenResult> _openMediaOnPlayer({
required Player player,
required SettingsService settingsService,
required String videoUrl,
@@ -565,17 +598,53 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
isTranscoding: isTranscoding,
selectedVersion: selectedVersion,
);
if (shouldContinue != null && !shouldContinue()) return false;
await player.open(
Media(videoUrl, start: timing.mediaStart, headers: headers),
play: play,
externalSubtitles: externalSubtitlesAtOpen,
timelineOffset: timing.timelineOffset,
timelineDuration: timing.timelineDuration,
);
onOpened?.call();
if (shouldContinue != null && !shouldContinue()) return true;
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: false);
final media = Media(videoUrl, start: timing.mediaStart, headers: headers);
final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen);
Future<void> openMedia({required bool shouldPlay, List<SubtitleTrack>? externalSubtitles}) {
return player.open(
media,
play: shouldPlay,
externalSubtitles: externalSubtitles,
timelineOffset: timing.timelineOffset,
timelineDuration: timing.timelineDuration,
);
}
try {
await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen);
onOpened?.call();
} catch (_) {
await sidecarOpenGuard?.dispose();
rethrow;
}
var sidecarFallbackUsed = false;
final sidecarOutcome = await sidecarOpenGuard?.wait();
if (sidecarOutcome == MpvSidecarOpenOutcome.aborted) {
return const _MediaOpenResult(didOpen: true);
}
if (sidecarOutcome == MpvSidecarOpenOutcome.stalled) {
appLogger.w('Selected subtitle sidecar stalled after primary media discovery; reopening without subtitles');
if (shouldContinue != null && !shouldContinue()) {
return const _MediaOpenResult(didOpen: true);
}
await player.stop();
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true);
// Respect a pause requested while mpv was waiting on the sidecar. A
// startup gate encoded by [play] remains authoritative when it is false.
await openMedia(shouldPlay: play && _playbackIntentShouldPlay);
sidecarFallbackUsed = true;
if (mounted && (shouldContinue == null || shouldContinue())) {
showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback);
}
}
if (shouldContinue != null && !shouldContinue()) {
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
await _applyNativeSubtitleStyle(player, settingsService);
return true;
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
}
}
@@ -113,6 +113,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
offlineLibraryMode: true,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
@@ -138,16 +139,24 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
}
final result = playbackContext.result;
final streamHeaders = playbackContext.streamHeaders;
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
metadata: _currentMetadata,
result: result,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
);
if (!attempt.isCurrent) return;
// Initial start has no previous session to protect, so commit as soon
// as the resolve lands (reload-style flows commit at the open
// boundary instead).
_commitPlaybackSession(
PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
),
var session = PlaybackSession.fromContext(
playbackContext,
requestedQualityPreset: _selectedQualityPreset,
requestedMediaSourceId: _requestedMediaSourceId,
subtitleSelection: subtitleSelection,
);
_commitPlaybackSession(session);
// Primary refresh-rate path: when metadata provides FPS, Android players
// can switch before creating decoders. MPV still needs a startup refresh
@@ -221,7 +230,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
frameRatePlan.armStartupRefreshGate(currentPlayer);
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
);
final shouldAutoPlay =
!shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup;
@@ -234,7 +243,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
final didOpen = await _openMediaOnPlayer(
final openResult = await _openMediaOnPlayer(
player: currentPlayer,
settingsService: settingsService,
videoUrl: result.videoUrl!,
@@ -247,7 +256,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => attempt.isCurrent,
);
if (!didOpen || !attempt.isCurrent) return;
if (!openResult.didOpen || !attempt.isCurrent) return;
if (openResult.sidecarFallbackUsed) {
session = _commitSidecarFallbackSession(session);
subtitleSelection = session.subtitleSelection;
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []);
}
// Attach player to Watch Together session for sync (if in session).
// With a frame-rate startup gate pending, sync readiness waits for
@@ -262,7 +276,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
} else {
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
player: currentPlayer,
externalSubtitles: result.externalSubtitles,
externalSubtitles: subtitleSelection.sidecarsAtOpen,
waitForFileLoaded: false,
);
}
@@ -316,12 +330,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
plexClient: mediaClient is PlexClient ? mediaClient : null,
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
preferredAudioTrack: _preferredAudioTrack,
preferredSubtitleTrack: _preferredSubtitleTrack,
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
preferredSubtitleTrack: subtitleSelection.primaryTrack,
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
);
// Store external subtitles for re-use after backend fallback
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
// Store only the active sidecars for re-use after backend fallback.
_trackManager!.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
final resumeForStartupFrame =
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart;
+193 -4
View File
@@ -51,13 +51,17 @@ import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart';
import '../services/local_playback_history.dart';
import '../services/playback_session.dart';
import '../services/playback_subtitle_resolver.dart';
import '../services/mpv_sidecar_open_guard.dart';
import '../services/playback_progress_tracker.dart';
import '../services/playback_source_resolver.dart';
import '../services/multi_server_manager.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/display_mode_service.dart';
import '../services/settings_service.dart';
import '../services/sleep_timer_service.dart';
import '../services/track_manager.dart';
import '../services/track_selection_service.dart';
import '../services/ambient_lighting_service.dart';
import '../services/video_filter_manager.dart';
import '../services/video_pip_manager.dart';
@@ -133,7 +137,24 @@ Future<void> _setWakelock(bool enabled) async {
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
/// They are mutually exclusive by construction — entry points bail while a
/// transition is in flight.
enum _PlaybackTransition { idle, reloadingMedia, switchingChannel }
enum _PlaybackTransition { idle, switchingSource, reloadingMedia, switchingChannel }
enum _SubtitleSelectionSlot { primary, secondary }
/// Identity token for one owner of the in-place playback transition lock.
///
/// The enum describes what the current owner is doing; it is not itself an
/// ownership token because a superseded async flow can outlive a newer flow
/// that has since acquired the same enum value.
final class _PlaybackTransitionLease {
_PlaybackTransitionLease();
bool _wasSuperseded = false;
bool get wasSuperseded => _wasSuperseded;
void _markSuperseded() => _wasSuperseded = true;
}
/// Outcome of [VideoPlayerScreenState._reloadMediaInPlace].
enum _MediaReloadOutcome {
@@ -281,7 +302,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// In-flight media-source transition. At most one can run at a time: reloads
// and channel switches are mutually exclusive.
_PlaybackTransition _playbackTransition = _PlaybackTransition.idle;
_PlaybackTransitionLease? _playbackTransitionLease;
Completer<void>? _playbackTransitionIdleCompleter;
bool _playbackIntentShouldPlay = true;
int _pendingSubtitleCycleCount = 0;
bool _subtitleCycleDrainActive = false;
/// Media key of the last Watch Together switch failure the user was
/// toasted about — the heartbeat retry loop must not re-toast every 2s.
@@ -523,13 +548,77 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
PlaybackSession _updatePlaybackSessionSubtitleSelection(
PlaybackSession session,
PlaybackSubtitleSelection selection,
) {
final updated = session.withSubtitleSelection(selection);
_playbackSession = updated;
return updated;
}
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
int _beginPlaybackGeneration({bool isMediaReload = false}) {
if (!isMediaReload) _playbackTransition = _PlaybackTransition.idle;
if (!isMediaReload) _forcePlaybackTransitionIdle();
return ++_playbackGeneration;
}
_PlaybackTransitionLease? _tryAcquirePlaybackTransition(_PlaybackTransition transition) {
assert(transition != _PlaybackTransition.idle);
if (_playbackTransition != _PlaybackTransition.idle) return null;
final lease = _PlaybackTransitionLease();
_playbackTransitionLease = lease;
_changePlaybackTransition(transition);
return lease;
}
bool _ownsPlaybackTransition(_PlaybackTransitionLease lease, {_PlaybackTransition? expected}) {
return identical(_playbackTransitionLease, lease) && (expected == null || _playbackTransition == expected);
}
bool _advancePlaybackTransition(
_PlaybackTransitionLease lease,
_PlaybackTransition transition, {
_PlaybackTransition? expected,
}) {
assert(transition != _PlaybackTransition.idle);
if (!_ownsPlaybackTransition(lease, expected: expected)) return false;
_changePlaybackTransition(transition);
return true;
}
void _releasePlaybackTransition(_PlaybackTransitionLease lease) {
if (!identical(_playbackTransitionLease, lease)) return;
_playbackTransitionLease = null;
_changePlaybackTransition(_PlaybackTransition.idle);
}
void _forcePlaybackTransitionIdle() {
_playbackTransitionLease?._markSuperseded();
_playbackTransitionLease = null;
_changePlaybackTransition(_PlaybackTransition.idle);
}
void _changePlaybackTransition(_PlaybackTransition transition) {
if (_playbackTransition == transition) return;
_playbackTransition = transition;
if (transition == _PlaybackTransition.idle) {
final completer = _playbackTransitionIdleCompleter;
_playbackTransitionIdleCompleter = null;
if (completer != null && !completer.isCompleted) completer.complete();
} else {
_playbackTransitionIdleCompleter ??= Completer<void>();
}
}
Future<void> _waitForPlaybackTransitionIdle() async {
while (mounted && _playbackTransition != _PlaybackTransition.idle) {
_playbackTransitionIdleCompleter ??= Completer<void>();
await _playbackTransitionIdleCompleter!.future;
}
}
/// Start a new playback attempt: bumps the generation and captures the
/// owning player so async continuations can check [_PlaybackAttempt.isCurrent]
/// uniformly instead of threading (generation, player) pairs around.
@@ -820,6 +909,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
@@ -1216,6 +1306,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void dispose() {
WidgetsBinding.instance.removeObserver(this);
final transitionCompleter = _playbackTransitionIdleCompleter;
_playbackTransitionIdleCompleter = null;
if (transitionCompleter != null && !transitionCompleter.isCompleted) transitionCompleter.complete();
_cleanupCompanionRemoteCallbacks();
// Notify Watch Together guests that host is exiting the player.
@@ -1481,9 +1575,104 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track);
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async => _trackManager?.onSubtitleTrackChanged(track);
Future<void> _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
await _trackManager?.onSubtitleTrackChanged(track, sourceStreamId: sourceStreamId);
}
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
void _rememberNativeSubtitleSelection(SubtitleTrack track, {int? sourceStreamId}) {
_rememberNativeSubtitleSelectionForSlot(
track,
slot: _SubtitleSelectionSlot.primary,
sourceStreamId: sourceStreamId,
);
}
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) {
_rememberNativeSubtitleSelectionForSlot(track, slot: _SubtitleSelectionSlot.secondary);
_trackManager?.onSecondarySubtitleTrackChanged(track);
}
void _rememberNativeSubtitleSelectionForSlot(
SubtitleTrack track, {
required _SubtitleSelectionSlot slot,
int? sourceStreamId,
}) {
final session = _playbackSession;
if (session == null) return;
final currentSelection = session.subtitleSelection;
if (track.id == SubtitleTrack.off.id) {
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
_SubtitleSelectionSlot.secondary => PlaybackSubtitleSelection(
primaryTrack: currentSelection.primaryTrack,
primarySourceStreamId: currentSelection.primarySourceStreamId,
primarySidecar: currentSelection.primarySidecar,
),
});
if (mounted) _setPlayerState(() {});
return;
}
final info = _currentMediaInfo;
final currentPlayer = player;
if (info == null || currentPlayer == null) return;
MediaSubtitleTrack? sourceTrack;
final currentSourceId = switch (slot) {
_SubtitleSelectionSlot.primary => currentSelection.primarySourceStreamId,
_SubtitleSelectionSlot.secondary => currentSelection.secondarySourceStreamId,
};
final currentSidecar = switch (slot) {
_SubtitleSelectionSlot.primary => currentSelection.primarySidecar,
_SubtitleSelectionSlot.secondary => currentSelection.secondarySidecar,
};
if (sourceStreamId != null) {
for (final candidate in info.subtitleTracks) {
if (candidate.id == sourceStreamId) {
sourceTrack = candidate;
break;
}
}
} else if (track.isExternal && currentSourceId != null && currentSidecar?.track.uri == track.uri) {
for (final candidate in info.subtitleTracks) {
if (candidate.id == currentSourceId) {
sourceTrack = candidate;
break;
}
}
}
sourceTrack ??= findPlexTrackForMpvSubtitle(
track,
info.subtitleTracks,
allMpvTracks: currentPlayer.state.tracks.subtitle,
);
if (sourceTrack == null) return;
final sidecar = _sidecarForSourceStreamId(session, sourceTrack.id);
final resolvedTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sidecar);
final selection = PlaybackSubtitleSelection(
primaryTrack: slot == _SubtitleSelectionSlot.primary ? resolvedTrack : currentSelection.primaryTrack,
primarySourceStreamId: slot == _SubtitleSelectionSlot.primary
? sourceTrack.id
: currentSelection.primarySourceStreamId,
primarySidecar: slot == _SubtitleSelectionSlot.primary ? sidecar : currentSelection.primarySidecar,
secondaryTrack: slot == _SubtitleSelectionSlot.secondary ? resolvedTrack : currentSelection.secondaryTrack,
secondarySourceStreamId: slot == _SubtitleSelectionSlot.secondary
? sourceTrack.id
: currentSelection.secondarySourceStreamId,
secondarySidecar: slot == _SubtitleSelectionSlot.secondary ? sidecar : currentSelection.secondarySidecar,
);
_updatePlaybackSessionSubtitleSelection(session, selection);
if (mounted) _setPlayerState(() {});
}
PlaybackSubtitleSidecar? _sidecarForSourceStreamId(PlaybackSession session, int sourceStreamId) {
for (final candidate in session.context.result.subtitleSidecars) {
if (candidate.sourceStreamId == sourceStreamId) return candidate;
}
return null;
}
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
if (widget.isLive) {
@@ -227,6 +227,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
capturePlaySessionId(directStreamUrl);
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
// DirectStream remuxes the selected streams into a new container.
// Subtitle streams marked for external delivery are not present in
// that container, so expose their server URLs as sidecars just as we
// do for transcoded playback. True DirectPlay keeps using the
// embedded native tracks and does not incur a sidecar fetch.
includeExternalSubtitleDelivery = true;
} else {
if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
@@ -241,8 +247,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
// present, but lyrics flow through fetchLyrics, not the subtitle path).
final externalSubtitles = isTrack
? const <SubtitleTrack>[]
final subtitleSidecars = isTrack
? const <PlaybackSubtitleSidecar>[]
: _buildExternalSubtitles(
metadata.id,
effectiveSourceId,
@@ -258,7 +264,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
availableVersions: bundle.availableVersions,
videoUrl: videoUrl,
mediaInfo: mediaInfo,
externalSubtitles: externalSubtitles,
subtitleSidecars: subtitleSidecars,
isOffline: false,
isTranscoding: isTranscoding,
fallbackReason: fallbackReason,
@@ -344,30 +350,35 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
return path.startsWith('/') ? path : '/$path';
}
List<SubtitleTrack> _buildExternalSubtitles(
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
String itemId,
String? mediaSourceId,
MediaSourceInfo mediaInfo, {
bool includeExternalDelivery = false,
}) {
final externalSubtitles = <SubtitleTrack>[];
final externalSubtitles = <PlaybackSubtitleSidecar>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) continue;
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
continue;
}
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
// absolute URL with the api_key query param.
final url = _withApiKey(path);
externalSubtitles.add(
SubtitleTrack.uri(
url,
title:
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
cleanTrackMetadataValue(track.language),
language: cleanTrackMetadataValue(track.languageCode),
codec: track.codec,
isDefault: track.selected,
isForced: track.forced,
PlaybackSubtitleSidecar(
sourceStreamId: track.id,
track: SubtitleTrack.uri(
url,
title:
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
cleanTrackMetadataValue(track.language),
language: cleanTrackMetadataValue(track.languageCode),
codec: track.codec,
isDefault: track.selected,
isForced: track.forced,
),
),
);
}
+194
View File
@@ -0,0 +1,194 @@
import 'dart:async';
import '../mpv/mpv.dart';
import '../mpv/player/platform/player_android.dart';
import '../mpv/player/player_native.dart';
enum MpvSidecarOpenOutcome { loaded, stalled, inconclusive, aborted }
enum _MpvSidecarOpenMode { directMpv, androidFallback }
/// Watches an mpv open that includes remote subtitle sidecars.
///
/// mpv discovers the primary audio/video tracks before it synchronously waits
/// for external files. Once that milestone is observed, a missing file-loaded
/// event can be attributed to the sidecar phase and recovered safely.
class MpvSidecarOpenGuard {
final Player player;
final Duration discoveryTimeout;
final Duration fileLoadedTimeout;
final _MpvSidecarOpenMode _mode;
final Completer<void> _primaryReady = Completer<void>();
final Completer<void> _fileLoaded = Completer<void>();
final Completer<void> _playbackRestart = Completer<void>();
final Completer<void> _backendSwitched = Completer<void>();
final Completer<void> _fileLoadFailed = Completer<void>();
final Completer<void> _aborted = Completer<void>();
StreamSubscription<void>? _fileStartedSubscription;
StreamSubscription<void>? _primaryReadySubscription;
StreamSubscription<void>? _fileLoadedSubscription;
StreamSubscription<void>? _fileLoadFailedSubscription;
StreamSubscription<void>? _playbackRestartSubscription;
StreamSubscription<void>? _backendSwitchedSubscription;
bool _mpvLoadStarted = false;
MpvSidecarOpenGuard._(this.player, this._mode, this.discoveryTimeout, this.fileLoadedTimeout) {
_fileStartedSubscription = player.streams.fileStarted.listen((_) {
if (_mpvSignalsAreActive) _mpvLoadStarted = true;
});
_primaryReadySubscription = player.streams.primaryMediaReady.listen((_) {
if (_mpvLoadStarted && !_primaryReady.isCompleted) _primaryReady.complete();
}, onDone: _abort);
_fileLoadedSubscription = player.streams.fileLoaded.listen((_) {
if (_mpvLoadStarted && !_fileLoaded.isCompleted) _fileLoaded.complete();
}, onDone: _abort);
_fileLoadFailedSubscription = player.streams.fileLoadFailed.listen((_) {
if (_mpvLoadStarted && !_fileLoadFailed.isCompleted) {
_fileLoadFailed.complete();
}
});
if (_mode == _MpvSidecarOpenMode.androidFallback) {
_playbackRestartSubscription = player.streams.playbackRestart.listen((_) {
if (!_backendSwitched.isCompleted && !_playbackRestart.isCompleted) {
_playbackRestart.complete();
}
}, onDone: _abort);
_backendSwitchedSubscription = player.streams.backendSwitched.listen((_) {
if (!_backendSwitched.isCompleted) _backendSwitched.complete();
}, onDone: _abort);
}
}
static MpvSidecarOpenGuard? armIfNeeded({
required Player player,
required List<SubtitleTrack>? subtitles,
Duration discoveryTimeout = const Duration(seconds: 10),
Duration fileLoadedTimeout = const Duration(seconds: 10),
}) {
if (!_hasRemoteSidecar(subtitles)) return null;
final mode = switch (player) {
PlayerNative() => _MpvSidecarOpenMode.directMpv,
PlayerAndroid(usingMpvFallback: true) => _MpvSidecarOpenMode.directMpv,
PlayerAndroid() => _MpvSidecarOpenMode.androidFallback,
_ => null,
};
if (mode == null) return null;
return MpvSidecarOpenGuard._(player, mode, discoveryTimeout, fileLoadedTimeout);
}
static MpvSidecarOpenGuard armForTesting({
required Player player,
required Duration discoveryTimeout,
required Duration fileLoadedTimeout,
bool startsOnAndroidExoPlayer = false,
}) {
return MpvSidecarOpenGuard._(
player,
startsOnAndroidExoPlayer ? _MpvSidecarOpenMode.androidFallback : _MpvSidecarOpenMode.directMpv,
discoveryTimeout,
fileLoadedTimeout,
);
}
Future<MpvSidecarOpenOutcome> wait() async {
final discoveryClock = Stopwatch()..start();
try {
if (_mode == _MpvSidecarOpenMode.androidFallback) {
final androidOutcome = await _waitForAndroidBackendDecision();
if (androidOutcome != null) return androidOutcome;
}
final remainingDiscoveryTime = discoveryTimeout - discoveryClock.elapsed;
if (remainingDiscoveryTime <= Duration.zero) return MpvSidecarOpenOutcome.inconclusive;
return await _waitForMpvLoad(remainingDiscoveryTime);
} finally {
discoveryClock.stop();
await dispose();
}
}
Future<MpvSidecarOpenOutcome?> _waitForAndroidBackendDecision() async {
try {
final signal = await Future.any([
_playbackRestart.future.then((_) => _MpvSidecarOpenSignal.playbackRestart),
_backendSwitched.future.then((_) => _MpvSidecarOpenSignal.backendSwitched),
_aborted.future.then((_) => _MpvSidecarOpenSignal.aborted),
]).timeout(discoveryTimeout);
return switch (signal) {
_MpvSidecarOpenSignal.playbackRestart => MpvSidecarOpenOutcome.loaded,
_MpvSidecarOpenSignal.backendSwitched => null,
_MpvSidecarOpenSignal.aborted => MpvSidecarOpenOutcome.aborted,
_ => throw StateError('Unexpected Android sidecar-open signal: $signal'),
};
} on TimeoutException {
return MpvSidecarOpenOutcome.inconclusive;
}
}
Future<MpvSidecarOpenOutcome> _waitForMpvLoad(Duration remainingDiscoveryTime) async {
final _MpvSidecarOpenSignal first;
try {
first = await Future.any([
_primaryReady.future.then((_) => _MpvSidecarOpenSignal.primaryReady),
_fileLoaded.future.then((_) => _MpvSidecarOpenSignal.fileLoaded),
_fileLoadFailed.future.then((_) => _MpvSidecarOpenSignal.fileLoadFailed),
_aborted.future.then((_) => _MpvSidecarOpenSignal.aborted),
]).timeout(remainingDiscoveryTime);
} on TimeoutException {
return MpvSidecarOpenOutcome.inconclusive;
}
if (first == _MpvSidecarOpenSignal.fileLoaded) return MpvSidecarOpenOutcome.loaded;
if (first == _MpvSidecarOpenSignal.aborted) return MpvSidecarOpenOutcome.aborted;
if (first == _MpvSidecarOpenSignal.fileLoadFailed) return MpvSidecarOpenOutcome.inconclusive;
try {
final afterPrimary = await Future.any([
_fileLoaded.future.then((_) => _MpvSidecarOpenSignal.fileLoaded),
_fileLoadFailed.future.then((_) => _MpvSidecarOpenSignal.fileLoadFailed),
_aborted.future.then((_) => _MpvSidecarOpenSignal.aborted),
]).timeout(fileLoadedTimeout);
return switch (afterPrimary) {
_MpvSidecarOpenSignal.fileLoaded => MpvSidecarOpenOutcome.loaded,
_MpvSidecarOpenSignal.fileLoadFailed => MpvSidecarOpenOutcome.inconclusive,
_MpvSidecarOpenSignal.aborted => MpvSidecarOpenOutcome.aborted,
_ => throw StateError('Unexpected post-discovery sidecar-open signal: $afterPrimary'),
};
} on TimeoutException {
return MpvSidecarOpenOutcome.stalled;
}
}
Future<void> dispose() async {
await Future.wait(<Future<void>>[
?_fileStartedSubscription?.cancel(),
?_primaryReadySubscription?.cancel(),
?_fileLoadedSubscription?.cancel(),
?_fileLoadFailedSubscription?.cancel(),
?_playbackRestartSubscription?.cancel(),
?_backendSwitchedSubscription?.cancel(),
]);
_fileStartedSubscription = null;
_primaryReadySubscription = null;
_fileLoadedSubscription = null;
_fileLoadFailedSubscription = null;
_playbackRestartSubscription = null;
_backendSwitchedSubscription = null;
}
void _abort() {
if (!_aborted.isCompleted) _aborted.complete();
}
bool get _mpvSignalsAreActive => _mode == _MpvSidecarOpenMode.directMpv || _backendSwitched.isCompleted;
static bool _hasRemoteSidecar(List<SubtitleTrack>? subtitles) {
for (final subtitle in subtitles ?? const <SubtitleTrack>[]) {
final uri = Uri.tryParse(subtitle.uri ?? '');
if (uri?.scheme == 'http' || uri?.scheme == 'https') return true;
}
return false;
}
}
enum _MpvSidecarOpenSignal { primaryReady, fileLoaded, playbackRestart, backendSwitched, fileLoadFailed, aborted }
@@ -159,6 +159,7 @@ class PlaybackInitializationService {
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId,
SubtitleTrack? preferredSubtitleTrack,
String? sessionIdentifier,
String? transcodeSessionId,
}) async {
@@ -204,6 +205,7 @@ class PlaybackInitializationService {
qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
preferredSubtitleTrack: preferredSubtitleTrack,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
),
@@ -238,7 +240,7 @@ class PlaybackInitializationService {
appLogger.d('Could not load cached media info for offline playback', error: e);
}
final sidecarSubtitles = await _discoverSidecarSubtitles(
final subtitleSidecars = await _discoverSidecarSubtitles(
offlineVideoPath,
metadata: metadata,
mediaInfo: mediaInfo,
@@ -248,7 +250,7 @@ class PlaybackInitializationService {
availableVersions: const [],
videoUrl: _formatVideoUrl(offlineVideoPath),
mediaInfo: mediaInfo,
externalSubtitles: sidecarSubtitles,
subtitleSidecars: subtitleSidecars,
isOffline: true,
playMethod: 'DirectPlay',
selectedMediaIndex: selectedMediaIndex,
@@ -279,12 +281,12 @@ class PlaybackInitializationService {
/// use `{video}_subs/{trackId}.{ext}` with a legacy `{videoDir}/subtitles/*`
/// fallback. SAF videos are `content://` URIs, so sidecars live in the
/// app-managed subtitle directory keyed by server/item id.
Future<List<SubtitleTrack>> _discoverSidecarSubtitles(
Future<List<PlaybackSubtitleSidecar>> _discoverSidecarSubtitles(
String videoPath, {
required MediaItem metadata,
MediaSourceInfo? mediaInfo,
}) async {
final subtitles = <SubtitleTrack>[];
final subtitles = <PlaybackSubtitleSidecar>[];
final dirs = videoPath.startsWith('content://')
? await _safSidecarSubtitleDirs(metadata)
: await _fileSidecarSubtitleDirs(videoPath);
@@ -302,13 +304,16 @@ class PlaybackInitializationService {
: null;
subtitles.add(
SubtitleTrack.uri(
Uri.file(entity.path).toString(),
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? 'Subtitle $fileName',
language: cachedTrack?.languageCode,
codec: cachedTrack?.codec,
isDefault: cachedTrack?.selected ?? false,
isForced: cachedTrack?.forced ?? false,
PlaybackSubtitleSidecar(
sourceStreamId: trackId,
track: SubtitleTrack.uri(
Uri.file(entity.path).toString(),
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? 'Subtitle $fileName',
language: cachedTrack?.languageCode,
codec: cachedTrack?.codec,
isDefault: cachedTrack?.selected ?? false,
isForced: cachedTrack?.forced ?? false,
),
),
);
}
@@ -39,6 +39,11 @@ class PlaybackInitializationOptions {
/// server pick".
final int? selectedAudioStreamId;
/// Preferred subtitle carried across navigation/reloads. Backends that put
/// embedded subtitles in the rendition can use this during negotiation;
/// sidecar-capable backends keep subtitle delivery independent.
final SubtitleTrack? preferredSubtitleTrack;
/// Plex transcode `X-Plex-Session-Identifier`. Required for Plex transcode.
final String? sessionIdentifier;
@@ -54,11 +59,25 @@ class PlaybackInitializationOptions {
this.qualityPreset = TranscodeQualityPreset.original,
this.audioQualityPreset,
this.selectedAudioStreamId,
this.preferredSubtitleTrack,
this.sessionIdentifier,
this.transcodeSessionId,
});
}
/// A subtitle sidecar that can be attached independently of the primary
/// media stream.
///
/// [sourceStreamId] links the playable URI back to the authoritative server
/// subtitle catalog. It is nullable for legacy/offline files whose filename
/// cannot be mapped to cached stream metadata.
class PlaybackSubtitleSidecar {
final int? sourceStreamId;
final SubtitleTrack track;
const PlaybackSubtitleSidecar({required this.sourceStreamId, required this.track});
}
/// Reason the transcode branch fell back to direct play.
enum TranscodeFallbackReason {
/// Plex decision said only direct-play is available.
@@ -73,7 +92,10 @@ class PlaybackInitializationResult {
final List<MediaVersion> availableVersions;
final String? videoUrl;
final MediaSourceInfo? mediaInfo;
final List<SubtitleTrack> externalSubtitles;
/// Complete sidecar catalog for this source. Callers must resolve the active
/// subtitle choice and attach only the selected sidecar(s) at open time.
final List<PlaybackSubtitleSidecar> subtitleSidecars;
final bool isOffline;
/// `true` when [videoUrl] points at a backend transcoding stream.
@@ -113,11 +135,14 @@ class PlaybackInitializationResult {
? availableVersions[selectedMediaIndex]
: null;
/// Compatibility view for consumers that only need the playable tracks.
List<SubtitleTrack> get externalSubtitles => subtitleSidecars.map((sidecar) => sidecar.track).toList(growable: false);
PlaybackInitializationResult({
required this.availableVersions,
this.videoUrl,
this.mediaInfo,
this.externalSubtitles = const [],
this.subtitleSidecars = const [],
this.isOffline = false,
this.isTranscoding = false,
this.fallbackReason,
+19 -1
View File
@@ -5,6 +5,7 @@ import '../media/media_version.dart';
import '../models/transcode_quality_preset.dart';
import 'playback_context.dart';
import 'playback_initialization_types.dart';
import 'playback_subtitle_resolver.dart';
/// Immutable snapshot of everything that describes the item currently loaded
/// in the player: the resolver output plus the effective (post-fallback,
@@ -24,8 +25,14 @@ class PlaybackSession {
/// Effective media version id, refined from the resolver's clamped
/// version index when the version list provides one.
final String? mediaSourceId;
final PlaybackSubtitleSelection subtitleSelection;
const PlaybackSession({required this.context, required this.qualityPreset, this.mediaSourceId});
const PlaybackSession({
required this.context,
required this.qualityPreset,
this.mediaSourceId,
this.subtitleSelection = const PlaybackSubtitleSelection.off(),
});
/// Derives the effective selections from a resolved [context]:
/// quality falls back to original when the backend rejected the requested
@@ -34,6 +41,7 @@ class PlaybackSession {
PlaybackContext context, {
required TranscodeQualityPreset requestedQualityPreset,
String? requestedMediaSourceId,
PlaybackSubtitleSelection subtitleSelection = const PlaybackSubtitleSelection.off(),
}) {
final result = context.result;
final fellBackToOriginal = result.fallbackReason != null && !requestedQualityPreset.isOriginal;
@@ -44,6 +52,16 @@ class PlaybackSession {
result.selectedMediaSourceId ??
mediaSourceIdForIndex(result.availableVersions, result.selectedMediaIndex) ??
requestedMediaSourceId,
subtitleSelection: subtitleSelection,
);
}
PlaybackSession withSubtitleSelection(PlaybackSubtitleSelection selection) {
return PlaybackSession(
context: context,
qualityPreset: qualityPreset,
mediaSourceId: mediaSourceId,
subtitleSelection: selection,
);
}
@@ -5,6 +5,7 @@ import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../models/audio_quality_preset.dart';
import '../models/transcode_quality_preset.dart';
import '../mpv/mpv.dart';
import 'multi_server_manager.dart';
import 'playback_context.dart';
import 'playback_initialization_service.dart';
@@ -31,6 +32,7 @@ class PlaybackSourceResolver {
required TranscodeQualityPreset qualityPreset,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId,
SubtitleTrack? preferredSubtitleTrack,
String? sessionIdentifier,
String? transcodeSessionId,
bool? preferOffline,
@@ -46,6 +48,7 @@ class PlaybackSourceResolver {
qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
preferredSubtitleTrack: preferredSubtitleTrack,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
);
@@ -0,0 +1,241 @@
import 'package:collection/collection.dart';
import '../media/media_item.dart';
import '../media/media_server_user_profile.dart';
import '../media/media_source_info.dart';
import '../mpv/mpv.dart';
import 'playback_initialization_types.dart';
import 'track_selection_service.dart';
/// A source-catalog subtitle choice.
///
/// Keeping "off" distinct from a numeric stream id prevents backend wire
/// conventions (notably Plex's `0`) from colliding with real Jellyfin ids.
final class PlaybackSourceSubtitleChoice {
final bool isOff;
final int? sourceStreamId;
const PlaybackSourceSubtitleChoice.off() : this._(isOff: true);
const PlaybackSourceSubtitleChoice.source(int sourceStreamId) : this._(isOff: false, sourceStreamId: sourceStreamId);
const PlaybackSourceSubtitleChoice._({required this.isOff, this.sourceStreamId});
@override
bool operator ==(Object other) =>
other is PlaybackSourceSubtitleChoice && other.isOff == isOff && other.sourceStreamId == sourceStreamId;
@override
int get hashCode => Object.hash(isOff, sourceStreamId);
}
/// Effective subtitle choice for one player open.
///
/// The source IDs remain stable across player reloads, while [primaryTrack]
/// and [secondaryTrack] carry the matching metadata used to select the newly
/// discovered native tracks after open.
class PlaybackSubtitleSelection {
final SubtitleTrack primaryTrack;
final int? primarySourceStreamId;
final PlaybackSubtitleSidecar? primarySidecar;
final SubtitleTrack? secondaryTrack;
final int? secondarySourceStreamId;
final PlaybackSubtitleSidecar? secondarySidecar;
const PlaybackSubtitleSelection({
required this.primaryTrack,
this.primarySourceStreamId,
this.primarySidecar,
this.secondaryTrack,
this.secondarySourceStreamId,
this.secondarySidecar,
});
const PlaybackSubtitleSelection.off()
: primaryTrack = SubtitleTrack.off,
primarySourceStreamId = null,
primarySidecar = null,
secondaryTrack = null,
secondarySourceStreamId = null,
secondarySidecar = null;
bool get isOff => primaryTrack.id == SubtitleTrack.off.id;
List<SubtitleTrack> get sidecarsAtOpen {
final tracks = <SubtitleTrack>[];
final primary = primarySidecar?.track;
if (primary != null) tracks.add(primary);
final secondary = secondarySidecar?.track;
if (secondary != null && secondary.uri != primary?.uri) tracks.add(secondary);
return tracks;
}
}
/// Resolves the server subtitle catalog before opening the native player, so
/// only the active sidecar is part of the open operation.
class PlaybackSubtitleResolver {
const PlaybackSubtitleResolver._();
static PlaybackSubtitleSelection resolve({
required MediaItem metadata,
required MediaSourceInfo? mediaInfo,
required List<PlaybackSubtitleSidecar> sidecars,
MediaServerUserProfile? profileSettings,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
}) {
final candidates = <_SubtitleCandidate>[];
final matchedSidecars = <PlaybackSubtitleSidecar>{};
for (final sourceTrack in mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[]) {
final sidecar = sidecars.where((candidate) => candidate.sourceStreamId == sourceTrack.id).firstOrNull;
if (sidecar != null) matchedSidecars.add(sidecar);
candidates.add(
_SubtitleCandidate(
track: subtitleTrackForSource(sourceTrack, sidecar: sidecar),
sourceStreamId: sourceTrack.id,
sidecar: sidecar,
),
);
}
// Legacy/offline sidecars may not have cached source metadata. They still
// participate in normal default/profile selection and remain playable.
for (final sidecar in sidecars) {
if (matchedSidecars.contains(sidecar)) continue;
candidates.add(
_SubtitleCandidate(track: sidecar.track, sourceStreamId: sidecar.sourceStreamId, sidecar: sidecar),
);
}
final availableTracks = candidates.map((candidate) => candidate.track).toList(growable: false);
final service = TrackSelectionService(
profileSettings: profileSettings,
metadata: metadata,
plexMediaInfo: mediaInfo,
);
final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track;
final primaryResult = service.selectSubtitleTrack(availableTracks, preferredSubtitleTrack, selectedAudio);
final primary = primaryResult.track;
if (primary.id == SubtitleTrack.off.id) return const PlaybackSubtitleSelection.off();
final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull;
if (primaryCandidate == null) return const PlaybackSubtitleSelection.off();
_SubtitleCandidate? secondaryCandidate;
if (preferredSecondarySubtitleTrack != null && preferredSecondarySubtitleTrack.id != SubtitleTrack.off.id) {
final secondary = service.findBestSubtitleMatch(availableTracks, preferredSecondarySubtitleTrack);
secondaryCandidate = candidates
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
.firstOrNull;
}
return PlaybackSubtitleSelection(
primaryTrack: primaryCandidate.track,
primarySourceStreamId: primaryCandidate.sourceStreamId,
primarySidecar: primaryCandidate.sidecar,
secondaryTrack: secondaryCandidate?.track,
secondarySourceStreamId: secondaryCandidate?.sourceStreamId,
secondarySidecar: secondaryCandidate?.sidecar,
);
}
/// Stable source descriptor used for an explicit user selection. Supplying
/// this as the next open's preferred track makes it the highest-priority
/// choice without retaining a stale sidecar URL.
static SubtitleTrack? preferredTrackForSource(MediaSourceInfo? mediaInfo, int sourceStreamId) {
final sourceTrack = mediaInfo?.subtitleTracks.where((track) => track.id == sourceStreamId).firstOrNull;
return sourceTrack == null ? null : subtitleTrackForSource(sourceTrack);
}
static SubtitleTrack subtitleTrackForSource(MediaSubtitleTrack sourceTrack, {PlaybackSubtitleSidecar? sidecar}) {
final playable = sidecar?.track;
return SubtitleTrack(
id: 'source:${sourceTrack.id}',
title: playable?.title ?? sourceTrack.displayTitle ?? sourceTrack.title ?? sourceTrack.language,
language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language,
codec: playable?.codec ?? sourceTrack.codec,
isDefault: sourceTrack.selected,
isForced: sourceTrack.forced,
isExternal: playable != null,
uri: playable?.uri,
);
}
/// Resolve a server source row to a track already loaded by direct play.
/// Resolved sidecars only match by their stable URL key (or the current
/// source identity); fuzzy language/codec matching must not select a
/// different sidecar that happens to have similar metadata. A server's
/// `external delivery` flag is not sufficient to classify a direct-play
/// row as a sidecar: Jellyfin applies it to embedded tracks that mpv still
/// discovers in the original file.
static SubtitleTrack? nativeTrackForDirectPlaySource({
required MediaSubtitleTrack sourceTrack,
required List<SubtitleTrack> nativeTracks,
required List<MediaSubtitleTrack> allSourceTracks,
required bool isResolvedSidecar,
int? currentSourceStreamId,
SubtitleTrack? selectedNativeTrack,
}) {
if (isResolvedSidecar) {
final key = sourceTrack.key;
if (key != null && key.isNotEmpty) {
for (final candidate in nativeTracks) {
if (candidate.isExternal && candidate.uri?.contains(key) == true) return candidate;
}
}
if (currentSourceStreamId == sourceTrack.id &&
selectedNativeTrack != null &&
selectedNativeTrack.id != SubtitleTrack.off.id &&
selectedNativeTrack.isExternal) {
return selectedNativeTrack;
}
return null;
}
return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks);
}
static PlaybackSourceSubtitleChoice nextSourceChoice(
List<MediaSubtitleTrack> tracks,
PlaybackSourceSubtitleChoice currentChoice,
) {
return advanceSourceChoice(tracks, currentChoice, 1);
}
static PlaybackSourceSubtitleChoice advanceSourceChoice(
List<MediaSubtitleTrack> tracks,
PlaybackSourceSubtitleChoice currentChoice,
int advances,
) {
final choices = <PlaybackSourceSubtitleChoice>[
const PlaybackSourceSubtitleChoice.off(),
...tracks.map((track) => PlaybackSourceSubtitleChoice.source(track.id)),
];
final currentIndex = choices.indexOf(currentChoice);
final normalizedCurrentIndex = currentIndex < 0 ? 0 : currentIndex;
return choices[(normalizedCurrentIndex + advances) % choices.length];
}
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
return [
for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[])
AudioTrack(
id: 'source:${track.id}',
title: track.displayTitle ?? track.title ?? track.language,
language: track.languageCode ?? track.language,
codec: track.codec,
channels: track.channels,
isDefault: track.selected,
),
];
}
}
class _SubtitleCandidate {
final SubtitleTrack track;
final int? sourceStreamId;
final PlaybackSubtitleSidecar? sidecar;
const _SubtitleCandidate({required this.track, required this.sourceStreamId, required this.sidecar});
}
+49 -18
View File
@@ -74,6 +74,7 @@ import 'plex_lyrics_parser.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
import 'track_selection_service.dart';
part 'plex_client/parts/live_tv.dart';
part 'plex_client/parts/playlists.dart';
@@ -3019,7 +3020,7 @@ class PlexClient
}
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
final selectedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack);
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
@@ -3033,12 +3034,12 @@ class PlexClient
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo);
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: transcodeUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: sidecarSubs,
subtitleSidecars: subtitleSidecars,
isOffline: false,
isTranscoding: true,
activeAudioStreamId: resolvedAudioId,
@@ -3055,7 +3056,7 @@ class PlexClient
availableVersions: data.availableVersions,
videoUrl: data.videoUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
@@ -3084,7 +3085,7 @@ class PlexClient
availableVersions: data.availableVersions,
videoUrl: data.videoUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
isTranscoding: false,
fallbackReason: fallbackReason,
@@ -3115,6 +3116,33 @@ class PlexClient
return null;
}
MediaSubtitleTrack? _resolveTranscodeSubtitleTrack(MediaSourceInfo? info, SubtitleTrack? preferred) {
if (info == null) return null;
if (preferred == null) return _selectedSubtitleTrack(info);
if (preferred.id == SubtitleTrack.off.id) return null;
MediaSubtitleTrack? matched;
if (preferred.id.startsWith('source:')) {
final sourceId = int.tryParse(preferred.id.substring('source:'.length));
if (sourceId != null) {
for (final track in info.subtitleTracks) {
if (track.id == sourceId) {
matched = track;
break;
}
}
}
}
matched ??= findPlexTrackForMpvSubtitle(preferred, info.subtitleTracks);
return matched ?? _selectedSubtitleTrack(info);
}
@visibleForTesting
MediaSubtitleTrack? resolveTranscodeSubtitleTrackForTesting(MediaSourceInfo? info, SubtitleTrack? preferred) {
return _resolveTranscodeSubtitleTrack(info, preferred);
}
/// Build the absolute URL for an external subtitle track on this Plex
/// server. Returns `null` for tracks that aren't external (no `/library/
/// streams/{id}` key) or when the server has no auth token.
@@ -3167,19 +3195,19 @@ class PlexClient
/// Build subtitle sidecars for Plex transcode playback. Keyed tracks remain
/// external; the selected internal track is delivered by the HLS rendition.
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
if (mediaInfo == null) return const [];
if (config.token == null) {
appLogger.w('No auth token available for transcode sidecar subtitles');
return const [];
}
final tracks = <SubtitleTrack>[];
final tracks = <PlaybackSubtitleSidecar>[];
for (final sub in mediaInfo.subtitleTracks) {
try {
final url = _buildSidecarSubtitleUrl(sub);
if (url == null) continue;
tracks.add(_subtitleTrackFromMediaTrack(sub, url));
tracks.add(PlaybackSubtitleSidecar(sourceStreamId: sub.id, track: _subtitleTrackFromMediaTrack(sub, url)));
} catch (e) {
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
}
@@ -3189,12 +3217,12 @@ class PlexClient
@visibleForTesting
List<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
return _buildTranscodeSidecarSubtitles(mediaInfo);
return _buildTranscodeSidecarSubtitles(mediaInfo).map((sidecar) => sidecar.track).toList(growable: false);
}
/// Build list of external subtitle tracks from media info
List<SubtitleTrack> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
final externalSubtitles = <PlaybackSubtitleSidecar>[];
if (mediaInfo == null) {
return externalSubtitles;
@@ -3215,13 +3243,16 @@ class PlexClient
}
externalSubtitles.add(
SubtitleTrack.uri(
url,
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
language: plexTrack.languageCode,
codec: plexTrack.codec,
isDefault: plexTrack.selected,
isForced: plexTrack.forced,
PlaybackSubtitleSidecar(
sourceStreamId: plexTrack.id,
track: SubtitleTrack.uri(
url,
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
language: plexTrack.languageCode,
codec: plexTrack.codec,
isDefault: plexTrack.selected,
isForced: plexTrack.forced,
),
),
);
} catch (e) {
+4 -1
View File
@@ -324,7 +324,7 @@ class TrackManager {
}
/// Handle subtitle track changes — save stream selection and language preference.
Future<void> onSubtitleTrackChanged(SubtitleTrack track) async {
Future<void> onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
final info = mediaInfo;
final partId = await _guardTrackChange(info);
if (partId == null) return;
@@ -334,6 +334,9 @@ class TrackManager {
if (track.id == 'no') {
streamID = 0;
appLogger.i('User turned subtitles off, saving preference');
} else if (sourceStreamId != null) {
streamID = sourceStreamId;
appLogger.d('Using authoritative subtitle streamID $streamID');
} else if (info != null) {
final matchedPlex = findPlexTrackForMpvSubtitle(
track,
+14 -2
View File
@@ -353,12 +353,12 @@ class TrackSelectionResult<T> {
/// Service for selecting and applying audio and subtitle tracks based on
/// preferences, user profiles, and per-media settings.
class TrackSelectionService {
final Player player;
final Player? player;
final MediaServerUserProfile? profileSettings;
final MediaItem metadata;
final MediaSourceInfo? plexMediaInfo;
TrackSelectionService({required this.player, this.profileSettings, required this.metadata, this.plexMediaInfo});
TrackSelectionService({this.player, this.profileSettings, required this.metadata, this.plexMediaInfo});
/// Build list of preferred languages from a user profile
List<String> _buildPreferredLanguages(MediaServerUserProfile profile, {required bool isAudio}) {
@@ -568,6 +568,13 @@ class TrackSelectionService {
return SubtitleTrack.off;
}
final preferredUri = preferred.uri;
if (preferredUri != null) {
for (final track in availableTracks) {
if (track.uri == preferredUri) return track;
}
}
return findBestTrackMatch<SubtitleTrack>(
availableTracks,
preferred,
@@ -801,6 +808,11 @@ class TrackSelectionService {
Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged,
}) async {
final player = this.player;
if (player == null) {
throw StateError('A player is required to apply track selections');
}
// Wait for tracks to be loaded
if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
try {
@@ -5,9 +5,12 @@ import '../../../media/media_version.dart';
import '../../../media/media_source_info.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../mpv/mpv.dart';
import '../../../services/playback_subtitle_resolver.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
enum SubtitleDownloadApplyOutcome { applied, timedOut, busy, superseded, unavailable, failed }
/// Immutable configuration for track/chapter control widgets.
class TrackControlsState {
final List<MediaVersion> availableVersions;
@@ -18,7 +21,9 @@ class TrackControlsState {
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final int? selectedSubtitleStreamId;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final Set<int> sourceSubtitleSidecarIds;
final int? sourcePartId;
/// Total media duration in milliseconds. Used by the version/quality sheet
@@ -43,7 +48,7 @@ class TrackControlsState {
final Function(int)? onSwitchVersion;
final ValueChanged<TranscodeQualityPreset>? onSwitchQualityPreset;
final ValueChanged<int>? onSwitchAudioStreamId;
final ValueChanged<int>? onSwitchSubtitleStreamId;
final ValueChanged<PlaybackSourceSubtitleChoice>? onSwitchSubtitle;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
@@ -63,7 +68,8 @@ class TrackControlsState {
final Function(MediaItem)? onQueueItemSelected;
final String ratingKey;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Future<SubtitleDownloadApplyOutcome> Function({required String serverId, required String ratingKey})?
onSubtitleDownloaded;
/// Whether OpenSubtitles search is reachable for this server. The Plex
/// server proxies the OpenSubtitles plugin; Jellyfin doesn't expose an
@@ -80,7 +86,9 @@ class TrackControlsState {
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.sourceSubtitleTracks = const [],
this.selectedSubtitleStreamId,
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecarIds = const <int>{},
this.sourcePartId,
this.sourceDurationMs,
this.boxFitMode = 0,
@@ -102,7 +110,7 @@ class TrackControlsState {
this.onSwitchVersion,
this.onSwitchQualityPreset,
this.onSwitchAudioStreamId,
this.onSwitchSubtitleStreamId,
this.onSwitchSubtitle,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
@@ -126,10 +134,15 @@ class TrackControlsState {
this.subtitleSearchSupported = true,
});
/// Source subtitles can only be selected when playback can be re-opened with
/// a Plex source subtitle stream id.
bool get canUseSourceSubtitles =>
isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null;
/// Transcoded subtitle choices must be negotiated with the server and
/// therefore replace the native rendition track list.
bool get canUseSourceSubtitles => isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitle != null;
/// Direct play keeps embedded/native switching instant while still exposing
/// unloaded server sidecars that require one source reopen when selected.
List<MediaSubtitleTrack> get directPlaySourceSidecars => !isTranscoding && onSwitchSubtitle != null
? sourceSubtitleTracks.where((track) => sourceSubtitleSidecarIds.contains(track.id)).toList(growable: false)
: const <MediaSubtitleTrack>[];
/// External subtitle search needs both a searchable media item and a server
/// that can proxy the OpenSubtitles request.
@@ -140,6 +153,9 @@ class TrackControlsState {
/// the single source of truth shared by the toolbar icon and the sheet layout.
bool hasSubtitleControls(Tracks? tracks) {
final playerSubtitles = tracks?.subtitle ?? const <SubtitleTrack>[];
return canUseSourceSubtitles || TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) || canSearchSubtitles;
return canUseSourceSubtitles ||
directPlaySourceSidecars.isNotEmpty ||
TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) ||
canSearchSubtitles;
}
}
@@ -67,74 +67,68 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
videoPlayerState?.navigateToQueueItem(item);
}
Future<void> _onSubtitleDownloaded() async {
if (!mounted) return;
Future<SubtitleDownloadApplyOutcome> _onSubtitleDownloaded({
required String serverId,
required String ratingKey,
}) async {
if (!mounted) return SubtitleDownloadApplyOutcome.unavailable;
// Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData]
// and the Plex token. Jellyfin has no analogue and the entry point
// (`subtitleSearchSupported`) is already gated on backend, but guard
// here too in case a future caller wires the same handler elsewhere.
if (widget.metadata.backend != MediaBackend.plex) return;
final serverId = widget.metadata.serverId;
if (serverId == null) return;
if (widget.metadata.backend != MediaBackend.plex) return SubtitleDownloadApplyOutcome.unavailable;
if (widget.metadata.serverId != serverId || widget.metadata.id != ratingKey) {
return SubtitleDownloadApplyOutcome.superseded;
}
final switchSource = widget.onPlaybackSourceChanged;
if (switchSource == null) return SubtitleDownloadApplyOutcome.unavailable;
final itemKey = widget.metadata.globalKey;
bool targetIsCurrent() =>
mounted &&
widget.metadata.globalKey == itemKey &&
widget.metadata.serverId == serverId &&
widget.metadata.id == ratingKey;
try {
final client = context.getPlexClientForServer(ServerId(serverId));
final token = client.config.token;
if (token == null) return;
// Plex's OpenSubtitles download is asynchronous: the PUT returns immediately
// but the new stream entry shows up in metadata seconds later. Poll until it
// appears. Up to 15s matches what Plex-web tolerates before giving up.
// Snapshot what's already attached so we can identify the new download.
final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet();
// Snapshot the authoritative source IDs so we can identify the new
// download without asking mpv to synchronously open its remote URL.
final existingSourceIds = widget.sourceSubtitleTracks.map((track) => track.id).toSet();
final deadline = DateTime.now().add(const Duration(seconds: 15));
MediaSubtitleTrack? newTrack;
String? newUrl;
MediaSourceInfo? latestInfo;
while (mounted && DateTime.now().isBefore(deadline)) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
if (!mounted) return SubtitleDownloadApplyOutcome.superseded;
try {
final data = await client.getVideoPlaybackData(widget.metadata.id);
if (!mounted) return;
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
final data = await client.getVideoPlaybackData(ratingKey);
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
if (data.mediaInfo == null) continue;
latestInfo = data.mediaInfo;
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
if (!plexTrack.isExternal) continue;
final url = client.buildExternalSubtitleUrl(plexTrack);
if (url == null) continue;
if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue;
newTrack = plexTrack;
newUrl = url;
break;
}
newTrack = findNewExternalSubtitleTrack(data.mediaInfo!.subtitleTracks, existingSourceIds);
if (newTrack != null) break;
} catch (e) {
appLogger.w('Subtitle download poll iteration failed', error: e);
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
}
}
if (!mounted || newTrack == null || newUrl == null) return;
await widget.player.addSubtitleTrack(
uri: newUrl,
title: newTrack.displayTitle ?? newTrack.language ?? t.videoControls.downloadedSubtitle,
language: newTrack.languageCode,
select: true,
);
final partId = latestInfo?.partId;
if (partId != null) {
await client.selectStreams(partId, subtitleStreamID: newTrack.id);
}
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
if (newTrack == null) return SubtitleDownloadApplyOutcome.timedOut;
final outcome = await switchSource(newSubtitleChoice: PlaybackSourceSubtitleChoice.source(newTrack.id));
return subtitleDownloadApplyOutcomeFor(outcome);
} catch (e) {
appLogger.w('Failed to refresh subtitles after download', error: e);
return SubtitleDownloadApplyOutcome.failed;
}
}
@@ -145,7 +139,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
}) async {
final onPlaybackSourceChanged = widget.onPlaybackSourceChanged;
if (onPlaybackSourceChanged == null) return;
@@ -154,7 +148,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
newMediaIndex: newMediaIndex,
newPreset: newPreset,
newAudioStreamId: newAudioStreamId,
newSubtitleStreamId: newSubtitleStreamId,
newSubtitleChoice: newSubtitleChoice,
);
} catch (e) {
if (mounted) {
@@ -86,10 +86,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
sourceAudioTracks: widget.sourceAudioTracks,
selectedAudioStreamId: widget.selectedAudioStreamId,
sourceSubtitleTracks: widget.sourceSubtitleTracks,
selectedSubtitleStreamId: widget.selectedSubtitleStreamId,
selectedSubtitleChoice: widget.selectedSubtitleChoice,
);
final canSwitchSourceSubtitles =
versionQuality.canSwitch && versionQuality.isTranscoding && widget.metadata.backend == MediaBackend.plex;
final canSwitchSourceSubtitles = versionQuality.canSwitch && versionQuality.sourceSubtitleTracks.isNotEmpty;
return TrackControlsState(
availableVersions: versionQuality.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
@@ -101,7 +100,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
sourceSubtitleTracks: canSwitchSourceSubtitles
? versionQuality.sourceSubtitleTracks
: const <MediaSubtitleTrack>[],
selectedSubtitleStreamId: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleStreamId : null,
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const <int>{},
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
sourceDurationMs: widget.metadata.durationMs,
boxFitMode: widget.boxFitMode,
@@ -123,8 +124,8 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null,
onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null,
onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null,
onSwitchSubtitleStreamId: canSwitchSourceSubtitles
? (id) => _switchVersionAndQuality(newSubtitleStreamId: id)
onSwitchSubtitle: canSwitchSourceSubtitles
? (choice) => _switchVersionAndQuality(newSubtitleChoice: choice)
: null,
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
@@ -21,6 +21,7 @@ import '../../../widgets/overlay_sheet.dart';
import '../../../widgets/pill_input_decoration.dart';
import 'base_video_control_sheet.dart';
import '../../loading_indicator_box.dart';
import '../models/track_controls_state.dart';
@visibleForTesting
String resolveSubtitleSearchLanguageCode({String? savedLanguageCode, required Locale systemLocale}) {
@@ -33,7 +34,8 @@ class SubtitleSearchSheet extends StatefulWidget {
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Future<SubtitleDownloadApplyOutcome> Function({required String serverId, required String ratingKey})?
onSubtitleDownloaded;
const SubtitleSearchSheet({
super.key,
@@ -205,10 +207,17 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
if (!mounted) return;
if (success) {
await widget.onSubtitleDownloaded?.call();
final outcome =
await widget.onSubtitleDownloaded?.call(serverId: widget.serverId, ratingKey: widget.ratingKey) ??
SubtitleDownloadApplyOutcome.unavailable;
if (!mounted) return;
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
OverlaySheetController.of(context).close();
if (outcome == SubtitleDownloadApplyOutcome.applied) {
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
OverlaySheetController.of(context).close();
} else {
showErrorSnackBar(context, t.videoControls.subtitleDownloadedNotApplied);
setState(() => _downloadingKey = null);
}
} else {
showErrorSnackBar(context, t.videoControls.subtitleDownloadFailed);
setState(() => _downloadingKey = null);
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../media/media_source_info.dart';
import '../../../mpv/mpv.dart';
import '../../../services/playback_subtitle_resolver.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/scroll_utils.dart';
import '../../../utils/track_label_builder.dart';
@@ -103,6 +104,7 @@ class TrackSheet extends StatelessWidget {
supportsSecondary: supportsSecondary,
showHeader: showHeader,
trackControlsState: state,
sourceSidecars: state.directPlaySourceSidecars,
);
}
@@ -221,8 +223,9 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
@override
Widget build(BuildContext context) {
final selectedId = _effectiveSelectedStreamId();
final selectedIndex = selectedId == 0 ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
final selectedChoice = _effectiveSelectedChoice();
final selectedId = selectedChoice.sourceStreamId;
final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
_initialScroll.maybeScrollTo(selectedIndex);
return Column(
@@ -237,10 +240,10 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
context: context,
key: _initialScroll.firstItemKey,
isSelected: selectedId == 0,
isSelected: selectedChoice.isOff,
onTap: () {
OverlaySheetController.of(context).close();
widget.trackControlsState.onSwitchSubtitleStreamId!(0);
widget.trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off());
},
);
}
@@ -252,7 +255,7 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
isSelected: track.id == selectedId,
onTap: () {
OverlaySheetController.of(context).close();
widget.trackControlsState.onSwitchSubtitleStreamId!(track.id);
widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id));
},
);
},
@@ -263,13 +266,15 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
);
}
int _effectiveSelectedStreamId() {
final explicit = widget.trackControlsState.selectedSubtitleStreamId;
if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit;
for (final track in widget.tracks) {
if (track.selected) return track.id;
PlaybackSourceSubtitleChoice _effectiveSelectedChoice() {
final explicit = widget.trackControlsState.selectedSubtitleChoice;
if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) {
return explicit;
}
return 0;
for (final track in widget.tracks) {
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
}
return const PlaybackSourceSubtitleChoice.off();
}
}
@@ -349,6 +354,7 @@ class _SubtitleColumn extends StatefulWidget {
final bool supportsSecondary;
final bool showHeader;
final TrackControlsState trackControlsState;
final List<MediaSubtitleTrack> sourceSidecars;
const _SubtitleColumn({
required this.tracks,
@@ -357,6 +363,7 @@ class _SubtitleColumn extends StatefulWidget {
this.supportsSecondary = false,
required this.showHeader,
required this.trackControlsState,
this.sourceSidecars = const [],
});
@override
@@ -378,9 +385,15 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
final secondarySub = widget.selection.secondarySubtitle;
final isOffSelected = selectedSub == null || selectedSub.id == 'no';
final hasSecondary = widget.supportsSecondary && secondarySub != null;
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId;
final unloadedSourceSidecars = widget.sourceSidecars
.where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId)
.toList(growable: false);
// +1 for "Off" row
final itemCount = widget.tracks.length + 1;
// +1 for "Off" row. The selected direct-play sidecar is already present
// in [tracks], so only the other server sidecars are appended.
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
_initialScroll.maybeScrollTo(selectedIndex);
@@ -423,7 +436,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
);
}
final track = widget.tracks[index - 1];
final trackIndex = index - 1;
if (trackIndex >= widget.tracks.length) {
final sourceIndex = trackIndex - widget.tracks.length;
final sourceTrack = unloadedSourceSidecars[sourceIndex];
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
context: context,
label: sourceTrack.labelForIndex(trackIndex),
isSelected: false,
onTap: () {
OverlaySheetController.of(context).close();
widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id));
},
);
}
final track = widget.tracks[trackIndex];
final isPrimary = !isOffSelected && track.id == selectedSub.id;
final isSecondary = hasSecondary && track.id == secondarySub.id;
final label = TrackLabelBuilder.subtitleLabel(
@@ -431,7 +459,7 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
language: track.language,
codec: track.codec,
forced: track.isForced,
index: index - 1,
index: trackIndex,
);
Widget? badge;
+54 -17
View File
@@ -30,6 +30,7 @@ import 'package:flutter/services.dart'
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
import '../../services/pip_service.dart';
import '../../services/playback_subtitle_resolver.dart';
import 'package:window_manager/window_manager.dart';
import '../../mixins/settings_effect_mixin.dart';
@@ -96,23 +97,53 @@ part 'parts/visibility.dart';
/// Subtitle tracks offered in the player's "source" subtitle list.
///
/// While transcoding, only tracks Plex can deliver in HLS are shown: keyed
/// sidecars, text codecs that can become WebVTT, and image codecs that can be
/// burned into the rendition. Outside transcode the full list is returned
/// unchanged, since the player has direct access to every embedded stream.
/// Direct play exposes embedded tracks in the native player and can attach
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
/// or embedded codecs that the server can convert/burn into the rendition.
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
List<MediaSubtitleTrack> tracks, {
required bool isTranscoding,
required Set<int> sidecarSourceIds,
required bool supportsEmbeddedTranscodeSelection,
}) {
if (!isTranscoding) return tracks;
if (!isTranscoding) {
return tracks
.where((track) {
final requiresSidecar =
track.isExternalFile || (!track.usesExternalDelivery && track.key != null && track.key!.isNotEmpty);
return !requiresSidecar || sidecarSourceIds.contains(track.id);
})
.toList(growable: false);
}
return tracks
.where((track) {
final hasKey = track.key != null && track.key!.isNotEmpty;
return hasKey || CodecUtils.isTranscodableSubtitleCodec(track.codec);
})
.where(
(track) =>
sidecarSourceIds.contains(track.id) ||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
)
.toList(growable: false);
}
@visibleForTesting
MediaSubtitleTrack? findNewExternalSubtitleTrack(List<MediaSubtitleTrack> tracks, Set<int> existingSourceIds) {
for (final track in tracks) {
if (track.isExternal && !existingSourceIds.contains(track.id)) return track;
}
return null;
}
@visibleForTesting
SubtitleDownloadApplyOutcome subtitleDownloadApplyOutcomeFor(PlaybackSourceChangeOutcome outcome) {
return switch (outcome) {
PlaybackSourceChangeOutcome.applied ||
PlaybackSourceChangeOutcome.unchanged => SubtitleDownloadApplyOutcome.applied,
PlaybackSourceChangeOutcome.busy => SubtitleDownloadApplyOutcome.busy,
PlaybackSourceChangeOutcome.superseded => SubtitleDownloadApplyOutcome.superseded,
PlaybackSourceChangeOutcome.unavailable => SubtitleDownloadApplyOutcome.unavailable,
PlaybackSourceChangeOutcome.failed => SubtitleDownloadApplyOutcome.failed,
};
}
@visibleForTesting
ShaderPreset resolveShaderTogglePreset({
required ShaderPreset currentPreset,
@@ -132,7 +163,7 @@ ShaderPreset resolveShaderTogglePreset({
List<MediaAudioTrack> sourceAudioTracks,
int? selectedAudioStreamId,
List<MediaSubtitleTrack> sourceSubtitleTracks,
int? selectedSubtitleStreamId,
PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
bool canSwitch,
})
effectiveVersionQualityControls({
@@ -143,7 +174,7 @@ effectiveVersionQualityControls({
required List<MediaAudioTrack> sourceAudioTracks,
required int? selectedAudioStreamId,
required List<MediaSubtitleTrack> sourceSubtitleTracks,
required int? selectedSubtitleStreamId,
required PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
}) {
if (isOfflinePlayback) {
return (
@@ -153,7 +184,7 @@ effectiveVersionQualityControls({
sourceAudioTracks: const <MediaAudioTrack>[],
selectedAudioStreamId: null,
sourceSubtitleTracks: const <MediaSubtitleTrack>[],
selectedSubtitleStreamId: null,
selectedSubtitleChoice: null,
canSwitch: false,
);
}
@@ -164,7 +195,7 @@ effectiveVersionQualityControls({
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: selectedAudioStreamId,
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleStreamId: selectedSubtitleStreamId,
selectedSubtitleChoice: selectedSubtitleChoice,
canSwitch: true,
);
}
@@ -358,13 +389,15 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req
}
typedef PlaybackSourceChangeCallback =
Future<void> Function({
Future<PlaybackSourceChangeOutcome> Function({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
int? newSubtitleStreamId,
PlaybackSourceSubtitleChoice? newSubtitleChoice,
});
enum PlaybackSourceChangeOutcome { applied, unchanged, busy, unavailable, superseded, failed }
typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide? side, double value});
class PlexVideoControls extends StatefulWidget {
@@ -381,7 +414,9 @@ class PlexVideoControls extends StatefulWidget {
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final int? selectedSubtitleStreamId;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final Set<int> sourceSubtitleSidecarIds;
final int? sourcePartId;
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
final int boxFitMode;
@@ -492,7 +527,9 @@ class PlexVideoControls extends StatefulWidget {
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.sourceSubtitleTracks = const [],
this.selectedSubtitleStreamId,
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecarIds = const <int>{},
this.sourcePartId,
this.onPlaybackSourceChanged,
this.boxFitMode = 0,
+4
View File
@@ -627,6 +627,10 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
fl_value_unref(data);
break;
}
case MPV_EVENT_START_FILE: {
SendEvent("start-file");
break;
}
case MPV_EVENT_FILE_LOADED: {
audio_recovery_.SetFileLoaded(true);
SendEvent("file-loaded");
@@ -971,6 +971,11 @@ class MpvPlayerCoreBase: NSObject {
case MPV_EVENT_GET_PROPERTY_REPLY:
completeGetPropertyRequest(event)
case MPV_EVENT_START_FILE:
DispatchQueue.main.async {
self.delegate?.onEvent(name: "start-file", data: nil)
}
case MPV_EVENT_FILE_LOADED:
DispatchQueue.main.async {
self.delegate?.onEvent(name: "file-loaded", data: nil)
+65
View File
@@ -545,6 +545,71 @@ void main() {
);
});
test('MPV exposes load-scoped start and terminal failure events', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
try {
final fileStarted = expectLater(player.streams.fileStarted, emits(isNull));
player.handlePlayerEvent('start-file', null);
await fileStarted;
final fileLoadFailed = expectLater(player.streams.fileLoadFailed, emits(isNull));
player.handlePlayerEvent('end-file', {'reason': 'error'});
await fileLoadFailed;
} finally {
await player.dispose();
}
},
);
});
test('MPV exposes primary media readiness before external subtitles finish', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
testBody: () async {
final player = PlayerNative();
var emissionCount = 0;
final subscription = player.streams.primaryMediaReady.listen((_) => emissionCount++);
try {
player.handlePlayerEvent('start-file', null);
player.handlePropertyChange('track-list', const [
{'type': 'sub', 'id': '2', 'external': true},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 0);
player.handlePropertyChange('track-list', const [
{'type': 'video', 'id': '4', 'external': false, 'image': true, 'albumart': true},
{'type': 'sub', 'id': '2', 'external': true},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 0);
player.handlePropertyChange('track-list', const [
{'type': 'video', 'id': '1', 'external': false},
{'type': 'sub', 'id': '2', 'external': true},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 1);
player.handlePropertyChange('track-list', const [
{'type': 'video', 'id': '1', 'external': false},
{'type': 'audio', 'id': '3', 'external': false},
]);
await Future<void>.delayed(Duration.zero);
expect(emissionCount, 1);
} finally {
await subscription.cancel();
await player.dispose();
}
},
);
});
test('MPV maps server-offset streams to absolute timeline positions', () async {
final calls = <MethodCall>[];
+7 -2
View File
@@ -565,6 +565,7 @@ void main() {
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue);
expect(result.externalSubtitles, hasLength(1));
expect(result.subtitleSidecars.single.sourceStreamId, 2);
expect(result.externalSubtitles.single.title, 'English');
expect(result.externalSubtitles.single.language, 'eng');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
@@ -732,13 +733,14 @@ void main() {
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.externalSubtitles, hasLength(1));
expect(result.subtitleSidecars.single.sourceStreamId, 3);
expect(result.externalSubtitles.single.title, 'English');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization skips negotiated subtitle delivery for original playback', () async {
test('getPlaybackInitialization exposes negotiated subtitle delivery for DirectStream playback', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
@@ -819,7 +821,10 @@ void main() {
expect(result.playMethod, 'DirectStream');
expect(result.mediaInfo!.subtitleTracks, hasLength(2));
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
expect(result.externalSubtitles, isEmpty);
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4]);
expect(result.externalSubtitles, hasLength(2));
expect(Uri.parse(result.externalSubtitles.first.uri!).path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
expect(Uri.parse(result.externalSubtitles.last.uri!).path, '/Videos/item-1/src-1/Subtitles/4/Stream.srt');
});
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
@@ -0,0 +1,301 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/mpv_sidecar_open_guard.dart';
class _StreamPlayer implements Player {
@override
final PlayerStreams streams;
_StreamPlayer({
Stream<void> fileStarted = const Stream.empty(),
required Stream<void> primaryMediaReady,
required Stream<void> fileLoaded,
Stream<void> fileLoadFailed = const Stream.empty(),
Stream<void> playbackRestart = const Stream.empty(),
Stream<void> backendSwitched = const Stream.empty(),
Stream<PlayerError> error = const Stream.empty(),
}) : streams = PlayerStreams(
playing: const Stream.empty(),
completed: const Stream.empty(),
buffering: const Stream.empty(),
position: const Stream.empty(),
duration: const Stream.empty(),
seekable: const Stream.empty(),
buffer: const Stream.empty(),
volume: const Stream.empty(),
rate: const Stream.empty(),
tracks: const Stream.empty(),
track: const Stream.empty(),
log: const Stream.empty(),
error: error,
audioDevice: const Stream.empty(),
audioDevices: const Stream.empty(),
bufferRanges: const Stream.empty(),
playbackRestart: playbackRestart,
fileStarted: fileStarted,
fileLoaded: fileLoaded,
fileLoadFailed: fileLoadFailed,
primaryMediaReady: primaryMediaReady,
backendSwitched: backendSwitched,
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('file-loaded before primary discovery completes normally', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(started.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(fileStarted: started.stream, primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
discoveryTimeout: const Duration(milliseconds: 20),
fileLoadedTimeout: const Duration(milliseconds: 20),
);
final outcome = guard.wait();
started.add(null);
loaded.add(null);
expect(await outcome, MpvSidecarOpenOutcome.loaded);
});
test('file-loaded after primary discovery completes normally', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(started.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(fileStarted: started.stream, primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
discoveryTimeout: const Duration(milliseconds: 50),
fileLoadedTimeout: const Duration(milliseconds: 50),
);
final outcome = guard.wait();
started.add(null);
primary.add(null);
await Future<void>.delayed(Duration.zero);
loaded.add(null);
expect(await outcome, MpvSidecarOpenOutcome.loaded);
});
test('missing file-loaded after primary discovery is classified as a sidecar stall', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(started.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(fileStarted: started.stream, primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
discoveryTimeout: const Duration(milliseconds: 50),
fileLoadedTimeout: const Duration(milliseconds: 10),
);
final outcome = guard.wait();
started.add(null);
primary.add(null);
expect(await outcome, MpvSidecarOpenOutcome.stalled);
});
test('unrelated player errors do not suppress sidecar-stall recovery', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
final errors = StreamController<PlayerError>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(started.close);
addTearDown(errors.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(
fileStarted: started.stream,
primaryMediaReady: primary.stream,
fileLoaded: loaded.stream,
error: errors.stream,
),
discoveryTimeout: const Duration(milliseconds: 50),
fileLoadedTimeout: const Duration(milliseconds: 10),
);
final outcome = guard.wait();
started.add(null);
primary.add(null);
errors.add(const PlayerError('setVisible failed'));
expect(await outcome, MpvSidecarOpenOutcome.stalled);
});
test('load-scoped end-file error suppresses sidecar-stall recovery', () async {
final started = StreamController<void>.broadcast();
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final failed = StreamController<void>.broadcast();
addTearDown(started.close);
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(failed.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(
fileStarted: started.stream,
primaryMediaReady: primary.stream,
fileLoaded: loaded.stream,
fileLoadFailed: failed.stream,
),
discoveryTimeout: const Duration(milliseconds: 50),
fileLoadedTimeout: const Duration(milliseconds: 10),
);
final outcome = guard.wait();
started.add(null);
primary.add(null);
failed.add(null);
expect(await outcome, MpvSidecarOpenOutcome.inconclusive);
});
test('missing primary discovery times out without claiming a sidecar stall', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
discoveryTimeout: const Duration(milliseconds: 10),
fileLoadedTimeout: const Duration(milliseconds: 10),
);
expect(await guard.wait(), MpvSidecarOpenOutcome.inconclusive);
});
test('Android ExoPlayer success completes without waiting for mpv signals', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final restarted = StreamController<void>.broadcast();
final switched = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(restarted.close);
addTearDown(switched.close);
addTearDown(started.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(
fileStarted: started.stream,
primaryMediaReady: primary.stream,
fileLoaded: loaded.stream,
playbackRestart: restarted.stream,
backendSwitched: switched.stream,
),
discoveryTimeout: const Duration(milliseconds: 50),
fileLoadedTimeout: const Duration(milliseconds: 10),
startsOnAndroidExoPlayer: true,
);
final outcome = guard.wait();
loaded.add(null); // ExoPlayer's media-item transition is not mpv readiness.
restarted.add(null);
expect(await outcome, MpvSidecarOpenOutcome.loaded);
});
test('Android fallback ignores stale Exo terminal events and detects an mpv sidecar stall', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final failed = StreamController<void>.broadcast();
final restarted = StreamController<void>.broadcast();
final switched = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(failed.close);
addTearDown(restarted.close);
addTearDown(switched.close);
addTearDown(started.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(
fileStarted: started.stream,
primaryMediaReady: primary.stream,
fileLoaded: loaded.stream,
fileLoadFailed: failed.stream,
playbackRestart: restarted.stream,
backendSwitched: switched.stream,
),
discoveryTimeout: const Duration(milliseconds: 50),
fileLoadedTimeout: const Duration(milliseconds: 10),
startsOnAndroidExoPlayer: true,
);
final outcome = guard.wait();
loaded.add(null);
failed.add(null);
switched.add(null);
await Future<void>.delayed(Duration.zero);
started.add(null);
primary.add(null);
expect(await outcome, MpvSidecarOpenOutcome.stalled);
});
test('Android fallback shares one bounded primary-discovery budget with mpv', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
final restarted = StreamController<void>.broadcast();
final switched = StreamController<void>.broadcast();
final started = StreamController<void>.broadcast();
addTearDown(primary.close);
addTearDown(loaded.close);
addTearDown(restarted.close);
addTearDown(switched.close);
addTearDown(started.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(
fileStarted: started.stream,
primaryMediaReady: primary.stream,
fileLoaded: loaded.stream,
playbackRestart: restarted.stream,
backendSwitched: switched.stream,
),
discoveryTimeout: const Duration(milliseconds: 100),
fileLoadedTimeout: const Duration(milliseconds: 20),
startsOnAndroidExoPlayer: true,
);
final clock = Stopwatch()..start();
final outcome = guard.wait();
await Future<void>.delayed(const Duration(milliseconds: 60));
switched.add(null);
await Future<void>.delayed(Duration.zero);
started.add(null);
expect(await outcome, MpvSidecarOpenOutcome.inconclusive);
clock.stop();
expect(clock.elapsed, lessThan(const Duration(milliseconds: 145)));
});
test('closing player streams aborts the guard without waiting forever', () async {
final primary = StreamController<void>.broadcast();
final loaded = StreamController<void>.broadcast();
addTearDown(loaded.close);
final guard = MpvSidecarOpenGuard.armForTesting(
player: _StreamPlayer(primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
discoveryTimeout: const Duration(milliseconds: 20),
fileLoadedTimeout: const Duration(milliseconds: 10),
);
final outcome = guard.wait();
await primary.close();
expect(await outcome, MpvSidecarOpenOutcome.aborted);
});
}
@@ -0,0 +1,230 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import '../test_helpers/media_items.dart';
MediaSubtitleTrack _sourceSubtitle(
int id, {
String language = 'eng',
bool selected = false,
bool external = false,
bool usesExternalDelivery = false,
}) {
return MediaSubtitleTrack(
id: id,
language: language,
languageCode: language,
title: 'Subtitle $id',
selected: selected,
forced: false,
external: external,
usesExternalDelivery: usesExternalDelivery,
);
}
PlaybackSubtitleSidecar _sidecar(int id, {String language = 'eng', bool isDefault = false}) {
return PlaybackSubtitleSidecar(
sourceStreamId: id,
track: SubtitleTrack.uri(
'https://example.test/subtitles/$id.srt',
title: 'Subtitle $id',
language: language,
codec: 'srt',
isDefault: isDefault,
),
);
}
MediaSourceInfo _mediaInfo(List<MediaSubtitleTrack> subtitles) {
return MediaSourceInfo(videoUrl: '', audioTracks: const [], subtitleTracks: subtitles, chapters: const []);
}
void main() {
group('direct-play source routing', () {
test('matches an embedded source to its loaded native track', () {
final source = _sourceSubtitle(2, language: 'eng');
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
expect(
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
sourceTrack: source,
nativeTracks: const [native],
allSourceTracks: [source],
isResolvedSidecar: false,
),
native,
);
});
test('does not fuzzy-match a different loaded external sidecar', () {
final source = MediaSubtitleTrack(
id: 2,
codec: 'srt',
languageCode: 'eng',
key: '/library/streams/2',
external: true,
selected: false,
forced: false,
);
const other = SubtitleTrack(
id: '9',
language: 'eng',
codec: 'srt',
isExternal: true,
uri: 'https://server/library/streams/9.srt',
);
expect(
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
sourceTrack: source,
nativeTracks: const [other],
allSourceTracks: [source],
isResolvedSidecar: true,
),
isNull,
);
});
test('cycles through typed off and every authoritative source id, including zero', () {
final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)];
expect(
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off()),
const PlaybackSourceSubtitleChoice.source(0),
);
expect(
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0)),
const PlaybackSourceSubtitleChoice.source(2),
);
expect(
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2)),
const PlaybackSourceSubtitleChoice.off(),
);
expect(
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 4),
const PlaybackSourceSubtitleChoice.source(0),
);
});
test('fuzzy-matches Jellyfin external-delivery rows that remain embedded in direct play', () {
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
expect(
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
sourceTrack: source,
nativeTracks: const [native],
allSourceTracks: [source],
isResolvedSidecar: false,
),
native,
);
});
});
final metadata = testMediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
test('attaches only the server-selected sidecar from the full catalog', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
]),
sidecars: [
_sidecar(2),
_sidecar(3, language: 'swe'),
],
);
expect(result.primarySourceStreamId, 2);
expect(result.sidecarsAtOpen, hasLength(1));
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
});
test('explicit off produces an open with zero sidecars', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
sidecars: [_sidecar(2)],
preferredSubtitleTrack: SubtitleTrack.off,
);
expect(result.isOff, isTrue);
expect(result.sidecarsAtOpen, isEmpty);
});
test('explicit source selection wins over the server default', () {
final mediaInfo = _mediaInfo([
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
]);
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: mediaInfo,
sidecars: [
_sidecar(2),
_sidecar(3, language: 'swe'),
],
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
);
expect(result.primarySourceStreamId, 3);
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt');
});
test('selected embedded subtitle keeps sidecars out of the open', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true)]),
sidecars: const [],
);
expect(result.isOff, isFalse);
expect(result.primarySourceStreamId, 2);
expect(result.primarySidecar, isNull);
expect(result.sidecarsAtOpen, isEmpty);
});
test('preferred secondary subtitle attaches a second distinct sidecar', () {
final mediaInfo = _mediaInfo([
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
]);
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: mediaInfo,
sidecars: [
_sidecar(2),
_sidecar(3, language: 'swe'),
],
preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
);
expect(result.primarySourceStreamId, 2);
expect(result.secondarySourceStreamId, 3);
expect(result.sidecarsAtOpen, hasLength(2));
});
test('legacy sidecar without source metadata still follows default selection', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: null,
sidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: null,
track: SubtitleTrack.uri('file:///tmp/subtitle.srt', title: 'Local', isDefault: true),
),
],
);
expect(result.isOff, isFalse);
expect(result.primarySourceStreamId, isNull);
expect(result.sidecarsAtOpen.single.uri, 'file:///tmp/subtitle.srt');
});
}
@@ -346,6 +346,39 @@ void main() {
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
});
test('transcode negotiation honors an explicit source subtitle preference', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final info = mediaInfoWithSubtitles([
MediaSubtitleTrack(id: 401, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, languageCode: 'swe', selected: false, forced: false),
]);
final selected = client.resolveTranscodeSubtitleTrackForTesting(
info,
const SubtitleTrack(id: 'source:402', language: 'swe'),
);
expect(selected?.id, 402);
expect(client.resolveTranscodeSubtitleTrackForTesting(info, SubtitleTrack.off), isNull);
});
test('transcode negotiation falls back to the new source default for a stale source id', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final info = mediaInfoWithSubtitles([
MediaSubtitleTrack(id: 501, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 502, languageCode: 'swe', selected: false, forced: false),
]);
final selected = client.resolveTranscodeSubtitleTrackForTesting(
info,
const SubtitleTrack(id: 'source:402', language: 'und'),
);
expect(selected?.id, 501);
});
test('selected internal text subtitles are not attached as external sidecars', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
+17
View File
@@ -525,6 +525,23 @@ void main() {
await mgr.onSubtitleTrackChanged(SubtitleTrack.off);
expect(captured, 0);
});
test('persists a known source stream id without reverse-matching native metadata', () async {
await SettingsService.getInstance();
final player = _FakePlayer(tracks: const Tracks(subtitle: playerSubs));
int? captured;
final mgr = _make(
player: player,
mediaInfo: info(),
persister: ({required int partId, required String trackType, int? streamID}) async {
captured = streamID;
},
);
addTearDown(mgr.dispose);
await mgr.onSubtitleTrackChanged(const SubtitleTrack(id: 'native-without-metadata'), sourceStreamId: 32);
expect(captured, 32);
});
});
// ============================================================
+199 -29
View File
@@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart';
@@ -89,6 +91,143 @@ void main() {
expect(find.text('Search Subtitles'), findsNothing);
});
testWidgets('keeps source stream zero distinct from turning subtitles off', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(),
track: const TrackSelection(subtitle: SubtitleTrack.off),
);
PlaybackSourceSubtitleChoice? switchedChoice;
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
isTranscoding: true,
sourceSubtitleTracks: [MediaSubtitleTrack(id: 0, title: 'Stream zero', selected: false, forced: false)],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.off(),
onSwitchSubtitle: (choice) => switchedChoice = choice,
subtitleSearchSupported: false,
),
);
await tester.tap(find.text('Stream zero'));
expect(switchedChoice, const PlaybackSourceSubtitleChoice.source(0));
});
testWidgets('direct play keeps native tracks and appends unloaded source sidecars', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
),
);
PlaybackSourceSubtitleChoice? switchedSourceChoice;
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false),
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
onSwitchSubtitle: (choice) => switchedSourceChoice = choice,
subtitleSearchSupported: false,
),
);
expect(find.text('English'), findsOneWidget);
expect(find.text('Remote sidecar'), findsOneWidget);
await tester.tap(find.text('Remote sidecar'));
expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2));
});
testWidgets('direct-play embedded selection stays on the native player path', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 's2', language: 'swe', codec: 'srt'),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
),
);
PlaybackSourceSubtitleChoice? switchedSourceChoice;
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(id: 1, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 2, languageCode: 'swe', selected: false, forced: false),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
onSwitchSubtitle: (choice) => switchedSourceChoice = choice,
subtitleSearchSupported: false,
),
);
await tester.tap(find.text('Swedish'));
expect(player.lastSelectedSubtitle?.id, 's2');
expect(switchedSourceChoice, isNull);
});
testWidgets('does not append a source sidecar already loaded as the secondary track', (tester) async {
final player = _FakeTrackSheetPlayer(
supportsSecondarySubtitles: true,
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
secondarySubtitle: SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
),
);
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
selectedSecondarySubtitleStreamId: 2,
sourceSubtitleSidecarIds: const {2},
onSwitchSubtitle: (_) {},
subtitleSearchSupported: false,
),
);
expect(find.text('Remote sidecar'), findsOneWidget);
});
});
group('TrackSheet two-line labels', () {
@@ -145,12 +284,32 @@ void main() {
TrackControlsState(
isTranscoding: true,
sourceSubtitleTracks: [sourceSubtitle],
onSwitchSubtitleStreamId: (_) {},
onSwitchSubtitle: (_) {},
).hasSubtitleControls(const Tracks()),
isTrue,
);
});
test('counts direct-play source sidecars without replacing native tracks', () {
final sourceSidecar = MediaSubtitleTrack(id: 1, external: true, selected: false, forced: false);
final state = TrackControlsState(
sourceSubtitleTracks: [sourceSidecar],
sourceSubtitleSidecarIds: {sourceSidecar.id},
onSwitchSubtitle: (_) {},
);
expect(state.canUseSourceSubtitles, isFalse);
expect(state.directPlaySourceSidecars, [sourceSidecar]);
expect(state.hasSubtitleControls(const Tracks()), isTrue);
});
test('does not misclassify a Jellyfin external-delivery embedded row as a sidecar', () {
final sourceTrack = MediaSubtitleTrack(id: 1, usesExternalDelivery: true, selected: false, forced: false);
final state = TrackControlsState(sourceSubtitleTracks: [sourceTrack], onSwitchSubtitle: (_) {});
expect(state.directPlaySourceSidecars, isEmpty);
});
test('ignores player subtitle placeholders', () {
const state = TrackControlsState(subtitleSearchSupported: false);
@@ -168,11 +327,13 @@ Future<void> _pumpTrackSheet(
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
height: 400,
child: TrackSheet(player: player, trackControlsState: trackControlsState),
home: OverlaySheetHost(
child: Scaffold(
body: SizedBox(
width: 700,
height: 400,
child: TrackSheet(player: player, trackControlsState: trackControlsState),
),
),
),
),
@@ -181,31 +342,38 @@ Future<void> _pumpTrackSheet(
}
class _FakeTrackSheetPlayer implements Player {
_FakeTrackSheetPlayer({required Tracks tracks, required TrackSelection track})
: _state = PlayerState(tracks: tracks, track: track),
_streams = PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
_FakeTrackSheetPlayer({
required Tracks tracks,
required TrackSelection track,
this.supportsSecondarySubtitles = false,
}) : _state = PlayerState(tracks: tracks, track: track),
_streams = PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
final PlayerState _state;
final PlayerStreams _streams;
SubtitleTrack? lastSelectedSubtitle;
@override
final bool supportsSecondarySubtitles;
@override
PlayerState get state => _state;
@@ -214,7 +382,9 @@ class _FakeTrackSheetPlayer implements Player {
PlayerStreams get streams => _streams;
@override
bool get supportsSecondarySubtitles => false;
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
lastSelectedSubtitle = track;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+93 -8
View File
@@ -10,8 +10,10 @@ import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/painters/buffer_range_painter.dart';
import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
@@ -91,7 +93,7 @@ void main() {
sourceAudioTracks: [audio],
selectedAudioStreamId: 1,
sourceSubtitleTracks: [subtitle],
selectedSubtitleStreamId: 2,
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(2),
);
expect(result.canSwitch, isFalse);
@@ -101,7 +103,7 @@ void main() {
expect(result.sourceAudioTracks, isEmpty);
expect(result.selectedAudioStreamId, isNull);
expect(result.sourceSubtitleTracks, isEmpty);
expect(result.selectedSubtitleStreamId, isNull);
expect(result.selectedSubtitleChoice, isNull);
});
test('keeps switchable state during online playback', () {
@@ -117,7 +119,7 @@ void main() {
sourceAudioTracks: [audio],
selectedAudioStreamId: 1,
sourceSubtitleTracks: [subtitle],
selectedSubtitleStreamId: 2,
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(2),
);
expect(result.canSwitch, isTrue);
@@ -127,7 +129,7 @@ void main() {
expect(result.sourceAudioTracks, [audio]);
expect(result.selectedAudioStreamId, 1);
expect(result.sourceSubtitleTracks, [subtitle]);
expect(result.selectedSubtitleStreamId, 2);
expect(result.selectedSubtitleChoice, const PlaybackSourceSubtitleChoice.source(2));
});
});
@@ -137,23 +139,106 @@ void main() {
test('returns the full list unchanged when not transcoding', () {
final tracks = [sub(1, codec: 'srt'), sub(2, codec: 'pgs'), sub(3, codec: 'weird')];
expect(selectableSourceSubtitleTracks(tracks, isTranscoding: false), same(tracks));
expect(
selectableSourceSubtitleTracks(
tracks,
isTranscoding: false,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: false,
),
tracks,
);
});
test('keeps text, image and keyed tracks while transcoding', () {
final text = sub(1, codec: 'srt');
final image = sub(2, codec: 'pgs');
final keyed = sub(3, codec: 'weird', key: '/library/streams/3');
final result = selectableSourceSubtitleTracks([text, image, keyed], isTranscoding: true);
final result = selectableSourceSubtitleTracks(
[text, image, keyed],
isTranscoding: true,
sidecarSourceIds: {keyed.id},
supportsEmbeddedTranscodeSelection: true,
);
expect(result, [text, image, keyed]);
});
test('drops non-keyed unsupported codecs while transcoding', () {
test('drops unsupported embedded codecs while transcoding', () {
final text = sub(1, codec: 'ass');
final unsupported = sub(2, codec: 'weird');
final result = selectableSourceSubtitleTracks([text, unsupported], isTranscoding: true);
final result = selectableSourceSubtitleTracks(
[text, unsupported],
isTranscoding: true,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: true,
);
expect(result, [text]);
});
test('only offers resolved sidecars when embedded transcode selection is unsupported', () {
final external = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt');
final embedded = sub(2, codec: 'srt');
final unavailableExternal = sub(3, codec: 'srt', key: '/missing');
final result = selectableSourceSubtitleTracks(
[external, embedded, unavailableExternal],
isTranscoding: true,
sidecarSourceIds: {external.id},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [external]);
});
test('only offers resolved file sidecars during direct play', () {
final embedded = sub(1, codec: 'srt');
final availableExternal = sub(2, codec: 'srt', key: '/available');
final unavailableExternal = sub(3, codec: 'srt', key: '/missing');
final result = selectableSourceSubtitleTracks(
[embedded, availableExternal, unavailableExternal],
isTranscoding: false,
sidecarSourceIds: {availableExternal.id},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [embedded, availableExternal]);
});
test('keeps Jellyfin external-delivery rows that remain embedded during direct play', () {
final deliveryExternalEmbedded = MediaSubtitleTrack(
id: 1,
codec: 'srt',
key: '/Videos/item/source/Subtitles/1/Stream.srt',
usesExternalDelivery: true,
selected: false,
forced: false,
);
final result = selectableSourceSubtitleTracks(
[deliveryExternalEmbedded],
isTranscoding: false,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [deliveryExternalEmbedded]);
});
});
test('findNewExternalSubtitleTrack ignores embedded and existing source rows', () {
final embedded = MediaSubtitleTrack(id: 1, selected: false, forced: false);
final existing = MediaSubtitleTrack(id: 2, external: true, selected: false, forced: false);
final downloaded = MediaSubtitleTrack(id: 3, external: true, selected: false, forced: false);
expect(findNewExternalSubtitleTrack([embedded, existing, downloaded], {1, 2}), downloaded);
});
test('subtitle download treats an already-selected source as applied', () {
expect(
subtitleDownloadApplyOutcomeFor(PlaybackSourceChangeOutcome.unchanged),
SubtitleDownloadApplyOutcome.applied,
);
});
group('shouldShowSkipMarkerButton', () {
+4
View File
@@ -544,6 +544,10 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
SendEvent("end-file", data);
break;
}
case MPV_EVENT_START_FILE: {
SendEvent("start-file");
break;
}
case MPV_EVENT_FILE_LOADED: {
audio_recovery_.SetFileLoaded(true);
SendEvent("file-loaded");