diff --git a/.maestro/media_flows/05_h264_eac3.yaml b/.maestro/media_flows/05_h264_eac3.yaml index 7dac1944..034b3759 100644 --- a/.maestro/media_flows/05_h264_eac3.yaml +++ b/.maestro/media_flows/05_h264_eac3.yaml @@ -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 diff --git a/.maestro/subflows/onboard_jellyfin.yaml b/.maestro/subflows/onboard_jellyfin.yaml index 80b0adc8..881340c7 100644 --- a/.maestro/subflows/onboard_jellyfin.yaml +++ b/.maestro/subflows/onboard_jellyfin.yaml @@ -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 diff --git a/.maestro/subflows/open_codec_sample.yaml b/.maestro/subflows/open_codec_sample.yaml index b77c0c51..fecc53a4 100644 --- a/.maestro/subflows/open_codec_sample.yaml +++ b/.maestro/subflows/open_codec_sample.yaml @@ -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 diff --git a/.maestro/subflows/switch_codec_tracks.yaml b/.maestro/subflows/switch_codec_tracks.yaml index f76db884..5f3409c5 100644 --- a/.maestro/subflows/switch_codec_tracks.yaml +++ b/.maestro/subflows/switch_codec_tracks.yaml @@ -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: diff --git a/.maestro/subflows/switch_codec_tracks_deep.yaml b/.maestro/subflows/switch_codec_tracks_deep.yaml index e1e9d408..c133ada5 100644 --- a/.maestro/subflows/switch_codec_tracks_deep.yaml +++ b/.maestro/subflows/switch_codec_tracks_deep.yaml @@ -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: diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 6411d12f..3fb61d63 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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 -> {} diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 6fc6385f..15653b44 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -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": "Търсене на езици..." }, diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 46f75ef3..3fd9e667 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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..." }, diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 61357e53..f84592b5 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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..." }, diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 1c3fd85f..6eaa184e 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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..." }, diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index f50d8d33..d14a4491 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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..." }, diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index b7608b47..893de761 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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 n’a pas pu être sélectionné", "subtitleDownloadFailed": "Échec du téléchargement du sous-titre", "searchLanguages": "Rechercher des langues..." }, diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 6f3d023c..e78d9fcf 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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..." }, diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 0ad42b6a..33040297 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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": "言語を検索..." }, diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index d176a17e..ea137df9 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "언어 검색..." }, diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index d5971007..885b4f38 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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..." }, diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f80a5317..ac6dcd90 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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..." }, diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index fd1f6975..096e8e02 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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..." }, diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 07dd778e..50267537 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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..." }, diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index f0708626..31a9011f 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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": "Поиск языков..." }, diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 86d25d46..8441ee96 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index d7ccc5e2..4d4f6575 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -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' => 'Все още няма изтегляния', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 3662eecc..96ffac33 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index aac16dc3..e1ff1034 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 51dca5e8..525a2e9b 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 8aacb802..1ef9a586 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index ec5898f7..872d8b30 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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 n’a 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 n’a 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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 21127840..5b76f901 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 52152b4d..39aca32b 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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' => 'ダウンロードなし', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index bb83b8fe..25cec588 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '다운로드 없음', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 4921bf96..bb96f62d 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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å', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index ffb19f61..ed874682 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index e258a089..1c18e63f 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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ń', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 26026ea7..1010e209 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 644083b0..3194167e 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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' => 'Загрузок пока нет', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 3ee27f58..eed8cc89 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 42449c25..500b0458 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '暂无下载', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 865b2fc7..186176df 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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..." }, diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 3f249ef4..d653b57b 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "搜索语言..." }, diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index fee95491..c7e9ef63 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -27,6 +27,8 @@ class PlayerAndroid extends PlayerBase { /// (which resets only on initialize/dispose). bool _usingMpvFallback = false; + bool get usingMpvFallback => _usingMpvFallback; + String? _hiddenSubtitleTrackId; @override diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 8822b3cd..ffbd9878 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -63,6 +63,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { int _nextPropId = 0; final Map _propIdToName = {}; Map _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, diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index 6f3ca0e6..c66b5b27 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -22,6 +22,9 @@ mixin PlayerStreamControllersMixin { final bufferRangesController = StreamController>.broadcast(); final playbackRestartController = StreamController.broadcast(); final fileLoadedController = StreamController.broadcast(); + final fileStartedController = StreamController.broadcast(); + final fileLoadFailedController = StreamController.broadcast(); + final primaryMediaReadyController = StreamController.broadcast(); final backendSwitchedController = StreamController.broadcast(); final trackTransitionController = StreamController.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(); } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index 2eea1e58..5f378439 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -56,6 +56,20 @@ class PlayerStreams { /// Stream that emits when the player has loaded the current media file. final Stream 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 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 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 primaryMediaReady; + /// Stream of seekable buffer ranges from the demuxer cache. final Stream> bufferRanges; @@ -88,6 +102,9 @@ class PlayerStreams { required this.bufferRanges, required this.playbackRestart, this.fileLoaded = const Stream.empty(), + this.fileStarted = const Stream.empty(), + this.fileLoadFailed = const Stream.empty(), + this.primaryMediaReady = const Stream.empty(), required this.backendSwitched, this.trackTransition = const Stream.empty(), }); diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index b62390c1..e5f58747 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -48,21 +48,39 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { }); } - int? _selectedSourceSubtitleStreamIdForControls(List tracks) { + PlaybackSourceSubtitleChoice? _selectedSourceSubtitleChoiceForControls(List 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 _sourceSubtitleTracksForControls() { + final sidecarSourceIds = _sourceSubtitleSidecarIdsForControls(); return selectableSourceSubtitleTracks( _currentMediaInfo?.subtitleTracks ?? const [], isTranscoding: _isTranscoding, + sidecarSourceIds: sidecarSourceIds, + supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex, ); } + Set _sourceSubtitleSidecarIdsForControls() { + return { + for (final sidecar in _playbackSession?.context.result.subtitleSidecars ?? const []) + ?sidecar.sourceStreamId, + }; + } + Widget _buildLoadingSpinner() { return const Scaffold( backgroundColor: Colors.black, @@ -249,6 +267,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; 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, diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index 657d3229..96c74c38 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -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 _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(); diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 0ac7b089..ff460b86 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -129,22 +129,85 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { ); } - Future _switchPlaybackSource({ + Future _switchPlaybackSource({ int? newMediaIndex, TranscodeQualityPreset? newPreset, int? newAudioStreamId, - int? newSubtitleStreamId, + PlaybackSourceSubtitleChoice? newSubtitleChoice, }) async { final currentPlayer = player; - if (!mounted || currentPlayer == null || _playbackTransition != _PlaybackTransition.idle) { - return; + if (!mounted || currentPlayer == null) return PlaybackSourceChangeOutcome.unavailable; + if (widget.isLive) return PlaybackSourceChangeOutcome.unavailable; + final transitionLease = _tryAcquirePlaybackTransition(_PlaybackTransition.switchingSource); + if (transitionLease == null) return PlaybackSourceChangeOutcome.busy; + try { + return await _performPlaybackSourceSwitch( + currentPlayer: currentPlayer, + transitionLease: transitionLease, + newMediaIndex: newMediaIndex, + newPreset: newPreset, + newAudioStreamId: newAudioStreamId, + newSubtitleChoice: newSubtitleChoice, + ); + } finally { + _releasePlaybackTransition(transitionLease); + } + } + + Future _performPlaybackSourceSwitch({ + required Player currentPlayer, + required _PlaybackTransitionLease transitionLease, + int? newMediaIndex, + TranscodeQualityPreset? newPreset, + int? newAudioStreamId, + PlaybackSourceSubtitleChoice? newSubtitleChoice, + }) async { + bool isCurrentSourceSwitch() => + mounted && + player == currentPlayer && + _ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingSource); + bool sourceSwitchWasSuperseded() => !mounted || player != currentPlayer || transitionLease.wasSuperseded; + + // Snapshot the backend client before subtitle selection can cross an + // async boundary or the profile-scoped context can disappear. + final serverId = _currentMetadata.serverId; + final isPlexBacked = _currentMetadata.backend == MediaBackend.plex; + PlexClient? streamSelectClient; + if (isPlexBacked && serverId != null) { + try { + streamSelectClient = context.getPlexClientForServer(ServerId(serverId)); + } catch (_) {} + } + + if (newSubtitleChoice != null && newMediaIndex == null && newPreset == null && newAudioStreamId == null) { + try { + final selected = await _selectDirectPlaySourceSubtitleLocally( + currentPlayer, + newSubtitleChoice, + shouldContinue: isCurrentSourceSwitch, + ); + if (!isCurrentSourceSwitch()) return PlaybackSourceChangeOutcome.superseded; + if (selected) { + return PlaybackSourceChangeOutcome.applied; + } + } catch (e) { + if (sourceSwitchWasSuperseded()) return PlaybackSourceChangeOutcome.superseded; + if (mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + return PlaybackSourceChangeOutcome.failed; + } } final effectiveMediaIndex = newMediaIndex ?? _effectiveSelectedMediaIndex; final effectivePreset = newPreset ?? _selectedQualityPreset; final effectiveAudioStreamId = newAudioStreamId ?? _selectedAudioStreamId; - final currentSubtitleStreamId = _selectedSourceSubtitleStreamIdForControls(_sourceSubtitleTracksForControls()); - final effectiveSubtitleStreamId = newSubtitleStreamId ?? currentSubtitleStreamId; + final currentSubtitleChoice = _selectedSourceSubtitleChoiceForControls(_sourceSubtitleTracksForControls()); + final preferredSubtitleTrackForReload = newSubtitleChoice == null + ? _playbackSession?.subtitleSelection.primaryTrack + : newSubtitleChoice.isOff + ? SubtitleTrack.off + : PlaybackSubtitleResolver.preferredTrackForSource(_currentMediaInfo, newSubtitleChoice.sourceStreamId!); final effectiveMediaSourceId = newMediaIndex != null ? PlaybackSession.mediaSourceIdForIndex(_availableVersions, effectiveMediaIndex) ?? _requestedMediaSourceId : _requestedMediaSourceId; @@ -154,28 +217,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { (_requestedMediaSourceId != null && effectiveMediaSourceId != _requestedMediaSourceId); final isPresetChange = effectivePreset != _selectedQualityPreset; final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId; - final isSubtitleChange = newSubtitleStreamId != null && effectiveSubtitleStreamId != currentSubtitleStreamId; + final isSubtitleChange = newSubtitleChoice != null && newSubtitleChoice != currentSubtitleChoice; if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) { - return; - } - - // Read the client before any await — context across an async gap. A - // missing client leaves this null and the guard below reports it. - final serverId = _currentMetadata.serverId; - final isPlexBacked = _currentMetadata.backend == MediaBackend.plex; - PlexClient? streamSelectClient; - if ((isSubtitleChange || (isAudioChange && isPlexBacked)) && serverId != null) { - try { - streamSelectClient = context.getPlexClientForServer(ServerId(serverId)); - } catch (_) {} + return PlaybackSourceChangeOutcome.unchanged; } try { if (isVersionChange) { await saveMediaVersionPreferenceFor(_currentMetadata, index: effectiveMediaIndex, versions: _availableVersions); + if (!isCurrentSourceSwitch()) return PlaybackSourceChangeOutcome.superseded; } - if (isSubtitleChange || (isAudioChange && isPlexBacked)) { + if ((isSubtitleChange && isPlexBacked) || (isAudioChange && isPlexBacked)) { final partId = _currentMediaInfo?.partId; if (streamSelectClient == null || partId == null) { throw StateError('No Plex part available for stream selection'); @@ -183,15 +236,22 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final saved = await streamSelectClient.selectStreams( partId, audioStreamID: isAudioChange ? effectiveAudioStreamId : null, - subtitleStreamID: isSubtitleChange ? effectiveSubtitleStreamId : null, + // Plex's wire API uses 0 for Off. Keep that convention at this + // backend boundary so it cannot collide with Jellyfin source ids. + subtitleStreamID: isSubtitleChange + ? newSubtitleChoice.isOff + ? 0 + : newSubtitleChoice.sourceStreamId + : null, allParts: true, ); if (!saved) { throw StateError('Failed to select streams'); } + if (!isCurrentSourceSwitch()) return PlaybackSourceChangeOutcome.superseded; } - await _reloadMediaInPlace( + final outcome = await _reloadMediaInPlace( metadata: _currentMetadata.copyWith(viewOffsetMs: currentPlayer.state.position.inMilliseconds), selectedMediaIndex: effectiveMediaIndex, selectedMediaSourceId: effectiveMediaSourceId, @@ -202,15 +262,74 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { useCurrentAudioStreamSelection: !isVersionChange, resumePosition: currentPlayer.state.position, preserveCurrentTrackSelection: false, + preferredSubtitleTrackOverride: preferredSubtitleTrackForReload, + transitionLease: transitionLease, reason: 'source switch', ); + return switch (outcome) { + _MediaReloadOutcome.opened => PlaybackSourceChangeOutcome.applied, + _MediaReloadOutcome.rejected => PlaybackSourceChangeOutcome.busy, + _MediaReloadOutcome.superseded => PlaybackSourceChangeOutcome.superseded, + _MediaReloadOutcome.failed => PlaybackSourceChangeOutcome.failed, + }; } catch (e) { + // A normal switchingSource -> reloadingMedia phase advance (and the + // reload's eventual release) does not mean this operation was replaced. + // Only an explicit force-idle/new playback generation supersedes the + // lease; real errors after an owned phase change must remain failures. + if (sourceSwitchWasSuperseded()) return PlaybackSourceChangeOutcome.superseded; if (mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } + return PlaybackSourceChangeOutcome.failed; } } + Future _selectDirectPlaySourceSubtitleLocally( + Player currentPlayer, + PlaybackSourceSubtitleChoice choice, { + required bool Function() shouldContinue, + }) async { + if (_isTranscoding) return false; + if (choice.isOff) { + await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off); + if (!shouldContinue()) return false; + _onSecondarySubtitleTrackChanged(SubtitleTrack.off); + await currentPlayer.selectSubtitleTrack(SubtitleTrack.off); + if (!shouldContinue()) return false; + await _onSubtitleTrackChanged(SubtitleTrack.off); + return true; + } + + final sourceStreamId = choice.sourceStreamId!; + final info = _currentMediaInfo; + if (info == null) return false; + MediaSubtitleTrack? sourceTrack; + for (final candidate in info.subtitleTracks) { + if (candidate.id == sourceStreamId) { + sourceTrack = candidate; + break; + } + } + if (sourceTrack == null) return false; + + final nativeTracks = currentPlayer.state.tracks.subtitle; + final nativeTrack = PlaybackSubtitleResolver.nativeTrackForDirectPlaySource( + sourceTrack: sourceTrack, + nativeTracks: nativeTracks, + allSourceTracks: info.subtitleTracks, + isResolvedSidecar: _sourceSubtitleSidecarIdsForControls().contains(sourceStreamId), + currentSourceStreamId: _playbackSession?.subtitleSelection.primarySourceStreamId, + selectedNativeTrack: currentPlayer.state.track.subtitle, + ); + if (nativeTrack == null) return false; + + await currentPlayer.selectSubtitleTrack(nativeTrack); + if (!shouldContinue()) return false; + await _onSubtitleTrackChanged(nativeTrack, sourceStreamId: sourceStreamId); + return true; + } + /// Reload a VOD item/source while keeping the route, player instance, and /// native renderer alive. This is the common path for episode navigation, /// queue item jumps, Watch Together media switches, and source changes. @@ -242,9 +361,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { AudioTrack? preservedAudioTrack, SubtitleTrack? preservedSubtitleTrack, SubtitleTrack? preservedSecondarySubtitleTrack, + SubtitleTrack? preferredSubtitleTrackOverride, bool startPaused = false, bool useCurrentAudioStreamSelection = true, bool showErrorUi = true, + _PlaybackTransitionLease? transitionLease, String reason = 'media reload', }) async { if (widget.isLive) { @@ -252,379 +373,443 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { return _MediaReloadOutcome.rejected; } final existingPlayer = player; - if (!mounted || existingPlayer == null || _playbackTransition != _PlaybackTransition.idle) { + if (!mounted || existingPlayer == null) { if (mounted) _clearEpisodeLoadingFlags(); return _MediaReloadOutcome.rejected; } - _playbackTransition = _PlaybackTransition.reloadingMedia; - final currentPlayer = player!; - final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true); - bool isCurrentReload() => attempt.isCurrent; - - // The session itself swaps atomically at the open boundary, so the only - // rollback state is the eagerly-set identity (shown by the loading UI) - // and the first-frame flag. - final previousMetadata = _currentMetadata; - final previousMediaIndex = _effectiveSelectedMediaIndex; - final previousPartId = _currentMediaInfo?.partId; - final previousHasFirstFrame = _hasFirstFrame.value; - final isItemChange = previousMetadata.globalKey != metadata.globalKey; - - final currentAudioTrack = preserveCurrentTrackSelection - ? preservedAudioTrack ?? currentPlayer.state.track.audio + final reloadLease = transitionLease == null + ? _tryAcquirePlaybackTransition(_PlaybackTransition.reloadingMedia) + : _advancePlaybackTransition( + transitionLease, + _PlaybackTransition.reloadingMedia, + expected: _PlaybackTransition.switchingSource, + ) + ? transitionLease : null; - final currentSubtitleTrack = preserveCurrentTrackSelection - ? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle - : null; - final currentSecondarySubtitleTrack = preserveCurrentTrackSelection - ? preservedSecondarySubtitleTrack ?? currentPlayer.state.track.secondarySubtitle - : null; - final wasPlayingBeforeReload = _playbackIntentShouldPlay; - var didOpenReplacement = false; - - // Capture context-dependent values before async gaps. The neutral - // [PlaybackInitializationService] consumes [mediaClient] regardless of - // backend. We still narrow to [plexClient] for [TrackManager]'s - // server-side track persistence, which is Plex-only — Jellyfin - // sessions get a null `getPlexClient` and skip that path. - final offlineWatchService = context.read(); - final userProfileProvider = context.read(); - final playbackState = context.read(); - final database = context.read(); - final serverManager = context.read().serverManager; - // Cycle the Watch Together attachment across every reload: the reload's - // internal pause/open churn must not leak into the sync layer as user - // intents. Readiness re-handshakes on re-attach (item changes start a - // new media epoch; same-item source switches group-wait while we - // reload). - final watchTogether = _activeWatchTogetherSession(); - final watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false; - final cycleWatchTogetherAttachment = watchTogetherWasAttached; - final wtOwnsStart = _watchTogetherOwnsPlaybackStart(); - - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex; - final targetQualityPreset = qualityPreset ?? _selectedQualityPreset; - final targetAudioStreamId = useCurrentAudioStreamSelection - ? selectedAudioStreamId ?? _selectedAudioStreamId - : selectedAudioStreamId; - // Eager identity-only: the loading UI shows the new title immediately, - // while the selection/source state flips with the session commit at the - // open boundary. - _currentMetadata = metadata; - VideoPlayerScreenState._activeId = metadata.id; - VideoPlayerScreenState._activeMediaIndex = targetMediaIndex; - _unfocusPlayNextPrompt(); - _showPlayNextDialog = false; - _autoPlayTimer?.cancel(); - _hasFirstFrame.value = false; + if (reloadLease == null) { + _clearEpisodeLoadingFlags(); + return _MediaReloadOutcome.rejected; + } try { - // Detach before pausing so the reload's internal pause can't broadcast - // a party-wide pause; the finally below restores the attachment. - if (cycleWatchTogetherAttachment) { - watchTogether!.detachPlayer(); - } + final currentPlayer = existingPlayer; + final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true); + bool isCurrentReload() => attempt.isCurrent; + + // The session itself swaps atomically at the open boundary, so the only + // rollback state is the eagerly-set identity (shown by the loading UI) + // and the first-frame flag. + final previousMetadata = _currentMetadata; + final previousMediaIndex = _effectiveSelectedMediaIndex; + final previousPartId = _currentMediaInfo?.partId; + final previousHasFirstFrame = _hasFirstFrame.value; + final isItemChange = previousMetadata.globalKey != metadata.globalKey; + + final currentAudioTrack = preserveCurrentTrackSelection + ? preservedAudioTrack ?? currentPlayer.state.track.audio + : null; + final currentSubtitleTrack = + preferredSubtitleTrackOverride ?? + (preserveCurrentTrackSelection ? preservedSubtitleTrack ?? currentPlayer.state.track.subtitle : null); + final currentSecondarySubtitleTrack = preserveCurrentTrackSelection + ? preservedSecondarySubtitleTrack ?? currentPlayer.state.track.secondarySubtitle + : null; + final wasPlayingBeforeReload = _playbackIntentShouldPlay; + var didOpenReplacement = false; + + // Capture context-dependent values before async gaps. The neutral + // [PlaybackInitializationService] consumes [mediaClient] regardless of + // backend. We still narrow to [plexClient] for [TrackManager]'s + // server-side track persistence, which is Plex-only — Jellyfin + // sessions get a null `getPlexClient` and skip that path. + late final OfflineWatchSyncService offlineWatchService; + late final UserProfileProvider userProfileProvider; + late final PlaybackStateProvider playbackState; + late final AppDatabase database; + late final MultiServerManager serverManager; + late final WatchTogetherProvider? watchTogether; + late final bool watchTogetherWasAttached; + late final bool cycleWatchTogetherAttachment; + late final bool wtOwnsStart; try { - await currentPlayer.pause(); - } catch (e) { - appLogger.w('Failed to pause before $reason', error: e); - } - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - // Overlap the old item's stop report with the resolve round-trip; it - // is awaited again right before the open below. - final stoppedProgressFuture = _sendStoppedProgressOnce(); - - final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); - final playbackContext = await playbackResolver.resolve( - metadata: metadata, - selectedMediaIndex: targetMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - offlineLibraryMode: _offlineLibraryMode, - qualityPreset: targetQualityPreset, - selectedAudioStreamId: targetAudioStreamId, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, - ); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - final result = playbackContext.result; - final mediaClient = playbackContext.reportingClient; - final plexClient = mediaClient is PlexClient ? mediaClient : null; - final streamHeaders = playbackContext.streamHeaders; - - if (result.videoUrl == null) { - throw PlaybackException('No video URL available'); - } - - // Build the replacement session now, commit it only once open() - // succeeds — until then every session-derived getter still describes - // the item that is actually playing. - final session = PlaybackSession.fromContext( - playbackContext, - requestedQualityPreset: targetQualityPreset, - requestedMediaSourceId: selectedMediaSourceId, - ); - if (result.fallbackReason != null && !targetQualityPreset.isOriginal && mounted) { - showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); - } - - final openResumePosition = await _resolveOpenResumePosition( - metadata: metadata, - isOffline: _offlineLibraryMode || result.isOffline, - offlineWatchService: offlineWatchService, - requested: resumePosition, - ); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - final displayCriteria = result.mediaInfo?.displayCriteria; - final settingsService = await SettingsService.getInstance(); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - // Same pre-open frame-rate orchestration as the initial start flow — - // including the Android MPV startup decoder refresh, whose gate is - // armed before open and released after track setup below. - final frameRatePlan = await _prepareFrameRateForOpen( - currentPlayer: currentPlayer, - settingsService: settingsService, - preKnownFps: displayCriteria?.fps, - preKnownWidth: displayCriteria?.width ?? 0, - preKnownHeight: displayCriteria?.height ?? 0, - hasVideoUrl: true, - ensureAudioFocus: () => currentPlayer.requestAudioFocus(), - ); - if (frameRatePlan == null || !isCurrentReload()) { - return _MediaReloadOutcome.superseded; - } - _frameRate.resetForNewItem(); - if (frameRatePlan.countsAsApplied) _frameRate.applied = true; - - await _primeDisplayCriteria( - player: currentPlayer, - settingsService: settingsService, - displayCriteria: displayCriteria, - isTranscoding: result.isTranscoding, - ); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - final openTiming = _playbackOpenTiming( - isTranscoding: result.isTranscoding, - resumePosition: openResumePosition, - durationMs: metadata.durationMs, - ); - await stoppedProgressFuture; - _progressTracker?.stopTracking(); - _progressTracker?.dispose(); - _progressTracker = null; - unawaited(DiscordRPCService.instance.stopPlayback()); - unawaited(TraktScrobbleService.instance.stopPlayback()); - unawaited(TrackerCoordinator.instance.stopPlayback()); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - frameRatePlan.armStartupRefreshGate(currentPlayer); - final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( - player: currentPlayer, - externalSubtitles: result.externalSubtitles, - ); - final didOpen = await _openMediaOnPlayer( - player: currentPlayer, - settingsService: settingsService, - videoUrl: result.videoUrl!, - isTranscoding: result.isTranscoding, - // Not _isOfflinePlayback: the replacement session commits later, in - // onOpened, so the getter still describes the previous item here. - isLocalMedia: _offlineLibraryMode || result.usesLocalMedia, - selectedVersion: result.selectedVersion, - timing: openTiming, - headers: result.usesLocalMedia ? null : streamHeaders, - play: - !frameRatePlan.holdPlaybackStart && - !wtOwnsStart && - !startPaused && - externalSubtitlePlan.canStartBeforeTrackSetup, - externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, - shouldContinue: isCurrentReload, - onOpened: () { - // The player now owns the new file — publish the session at the - // same boundary so identity and source state flip together. - didOpenReplacement = true; - _commitPlaybackSession(session); - }, - ); - // A false didOpen means shouldContinue stopped the sequence pre-open - // (open failures throw into the catch below) — superseded either way. - if (!didOpen || !isCurrentReload()) return _MediaReloadOutcome.superseded; - _completionLatch.reset(); - if (isItemChange) { - // Same-item reloads (including the spurious-EOF recovery itself and - // quality switches) keep the spent budget — that is the loop guard. - _spuriousEofRecoveryAttempts = 0; - _spuriousEofRecoveryBaselineMs = null; - } - - // Versions/mediaInfo come from the committed session; rebuild so the - // controls pick them up. Same-part switches (quality/audio/subtitle) - // keep the scrub-preview source — BIF/trickplay is per part, so a - // reset would re-download identical bytes. - final reusesScrubPreview = - previousMetadata.globalKey == metadata.globalKey && - previousPartId != null && - previousPartId == result.mediaInfo?.partId; - if (reusesScrubPreview) { - _setPlayerState(() {}); - } else { - _resetScrubPreviewForNewItem(metadata: metadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient); - } - _clearEpisodeLoadingFlags(); - if (isItemChange) _showChromeForSwappedItem(); - - _trackManager?.dispose(); - final trackManager = _buildTrackManager( - forPlayer: currentPlayer, - metadata: metadata, - plexClient: plexClient, - getProfileSettings: () => userProfileProvider.profileSettings, - preferredAudioTrack: currentAudioTrack, - preferredSubtitleTrack: currentSubtitleTrack, - preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, - ); - _trackManager = trackManager; - trackManager.cacheExternalSubtitles(result.externalSubtitles); - - final resumeForStartupFrame = - frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart && !startPaused; - await _applyTracksAfterOpen( - trackManager: trackManager, - externalSubtitlePlan: externalSubtitlePlan, - // Same guard as the start path: don't resume a player a newer flow - // owns, and let a pending startup gate (or Watch Together's group - // start) own the resume instead. Post-open external-subtitle paths - // resume once here so the startup refresh gate can observe a frame. - shouldResumeAfterSubtitleLoad: () => - (!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) && - !wtOwnsStart && - !startPaused && - mounted && - player == currentPlayer, - applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart, - ); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - await _releaseFrameRateStartupGate( - currentPlayer: currentPlayer, - settingsService: settingsService, - plan: frameRatePlan, - // startPaused rides the Watch Together yield path: the gate release - // arms track selection but leaves the player paused for the caller. - resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( - currentPlayer: currentPlayer, - externalSubtitlePlan: externalSubtitlePlan, - reason: reason, - wtOwnsStart: wtOwnsStart || startPaused, - ), - playbackResumedForStartupFrame: resumeForStartupFrame, - ); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - // Same helper as the initial start flow, so any future change lands in - // both paths together. - _wirePerItemPlaybackServices( - metadata: metadata, - mediaClient: mediaClient, - offlineWatchService: offlineWatchService, - playSessionId: _playbackPlaySessionId, - playMethod: _playbackPlayMethod, - mediaInfo: _currentMediaInfo, - ); - - try { - playbackState.setCurrentItem(metadata); - } catch (e) { - appLogger.d('playbackState.setCurrentItem failed', error: e); - } - - unawaited(_loadAdjacentEpisodes(metadata: metadata, attempt: attempt)); - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - - if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); - } - return _MediaReloadOutcome.opened; - } catch (e) { - if (!isCurrentReload()) return _MediaReloadOutcome.superseded; - _completionLatch.reset(); - if (!didOpenReplacement) { - // Nothing was opened: the previous session is still committed, so - // only the eagerly-set identity needs restoring before resuming. - _currentMetadata = previousMetadata; - VideoPlayerScreenState._activeId = previousMetadata.id; - VideoPlayerScreenState._activeMediaIndex = previousMediaIndex; - _hasFirstFrame.value = previousHasFirstFrame; - // If the stop report already went out, un-latch the tracker so the - // resumed session keeps reporting (and its eventual real stop sends). - _progressTracker?.resumeAfterStoppedReport(); - if (wasPlayingBeforeReload && mounted && player == currentPlayer) { - unawaited(_playWithPlaybackIntent(currentPlayer)); + offlineWatchService = context.read(); + userProfileProvider = context.read(); + playbackState = context.read(); + database = context.read(); + serverManager = context.read().serverManager; + // Cycle the Watch Together attachment across every reload: the + // reload's internal pause/open churn must not leak into the sync layer + // as user intents. Readiness re-handshakes on re-attach (item changes + // start a new media epoch; same-item source switches group-wait while + // we reload). + watchTogether = _activeWatchTogetherSession(); + watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false; + cycleWatchTogetherAttachment = watchTogetherWasAttached; + wtOwnsStart = _watchTogetherOwnsPlaybackStart(); + } catch (e, stackTrace) { + appLogger.e('Failed to prepare media reload during $reason', error: e, stackTrace: stackTrace); + if (mounted && showErrorUi) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } - } else if (_progressTracker == null && player == currentPlayer) { - // The new file is playing and its session is committed — keep the - // new identity and make sure progress reporting is wired to the - // item actually on screen (the failure may have hit before - // _wirePerItemPlaybackServices ran). + _clearEpisodeLoadingFlags(); + return _MediaReloadOutcome.failed; + } + + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex; + final targetQualityPreset = qualityPreset ?? _selectedQualityPreset; + final targetAudioStreamId = useCurrentAudioStreamSelection + ? selectedAudioStreamId ?? _selectedAudioStreamId + : selectedAudioStreamId; + try { + // Eager identity-only: the loading UI shows the new title immediately, + // while the selection/source state flips with the session commit at + // the open boundary. Keep these writes inside the rollback boundary. + _currentMetadata = metadata; + VideoPlayerScreenState._activeId = metadata.id; + VideoPlayerScreenState._activeMediaIndex = targetMediaIndex; + _unfocusPlayNextPrompt(); + _showPlayNextDialog = false; + _autoPlayTimer?.cancel(); + _hasFirstFrame.value = false; + + // Detach before pausing so the reload's internal pause can't broadcast + // a party-wide pause; the finally below restores the attachment. + if (cycleWatchTogetherAttachment) { + watchTogether!.detachPlayer(); + } + try { + await currentPlayer.pause(); + } catch (e) { + appLogger.w('Failed to pause before $reason', error: e); + } + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + // Overlap the old item's stop report with the resolve round-trip; it + // is awaited again right before the open below. + final stoppedProgressFuture = _sendStoppedProgressOnce(); + + final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); + final playbackContext = await playbackResolver.resolve( + metadata: metadata, + selectedMediaIndex: targetMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, + offlineLibraryMode: _offlineLibraryMode, + qualityPreset: targetQualityPreset, + selectedAudioStreamId: targetAudioStreamId, + preferredSubtitleTrack: currentSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + final result = playbackContext.result; + final mediaClient = playbackContext.reportingClient; + final plexClient = mediaClient is PlexClient ? mediaClient : null; + final streamHeaders = playbackContext.streamHeaders; + + if (result.videoUrl == null) { + throw PlaybackException('No video URL available'); + } + + var subtitleSelection = await _resolveSubtitleSelectionForOpen( + metadata: metadata, + result: result, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + // Build the replacement session now, commit it only once open() + // succeeds — until then every session-derived getter still describes + // the item that is actually playing. + var session = PlaybackSession.fromContext( + playbackContext, + requestedQualityPreset: targetQualityPreset, + requestedMediaSourceId: selectedMediaSourceId, + subtitleSelection: subtitleSelection, + ); + if (result.fallbackReason != null && !targetQualityPreset.isOriginal && mounted) { + showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); + } + + final openResumePosition = await _resolveOpenResumePosition( + metadata: metadata, + isOffline: _offlineLibraryMode || result.isOffline, + offlineWatchService: offlineWatchService, + requested: resumePosition, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + final displayCriteria = result.mediaInfo?.displayCriteria; + final settingsService = await SettingsService.getInstance(); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + // Same pre-open frame-rate orchestration as the initial start flow — + // including the Android MPV startup decoder refresh, whose gate is + // armed before open and released after track setup below. + final frameRatePlan = await _prepareFrameRateForOpen( + currentPlayer: currentPlayer, + settingsService: settingsService, + preKnownFps: displayCriteria?.fps, + preKnownWidth: displayCriteria?.width ?? 0, + preKnownHeight: displayCriteria?.height ?? 0, + hasVideoUrl: true, + ensureAudioFocus: () => currentPlayer.requestAudioFocus(), + ); + if (frameRatePlan == null || !isCurrentReload()) { + return _MediaReloadOutcome.superseded; + } + _frameRate.resetForNewItem(); + if (frameRatePlan.countsAsApplied) _frameRate.applied = true; + + await _primeDisplayCriteria( + player: currentPlayer, + settingsService: settingsService, + displayCriteria: displayCriteria, + isTranscoding: result.isTranscoding, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + final openTiming = _playbackOpenTiming( + isTranscoding: result.isTranscoding, + resumePosition: openResumePosition, + durationMs: metadata.durationMs, + ); + await stoppedProgressFuture; + _progressTracker?.stopTracking(); + _progressTracker?.dispose(); + _progressTracker = null; + unawaited(DiscordRPCService.instance.stopPlayback()); + unawaited(TraktScrobbleService.instance.stopPlayback()); + unawaited(TrackerCoordinator.instance.stopPlayback()); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + frameRatePlan.armStartupRefreshGate(currentPlayer); + final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: subtitleSelection.sidecarsAtOpen, + ); + var effectiveExternalSubtitlePlan = externalSubtitlePlan; + final openResult = await _openMediaOnPlayer( + player: currentPlayer, + settingsService: settingsService, + videoUrl: result.videoUrl!, + isTranscoding: result.isTranscoding, + // Not _isOfflinePlayback: the replacement session commits later, in + // onOpened, so the getter still describes the previous item here. + isLocalMedia: _offlineLibraryMode || result.usesLocalMedia, + selectedVersion: result.selectedVersion, + timing: openTiming, + headers: result.usesLocalMedia ? null : streamHeaders, + play: + !frameRatePlan.holdPlaybackStart && + !wtOwnsStart && + !startPaused && + externalSubtitlePlan.canStartBeforeTrackSetup, + externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, + shouldContinue: isCurrentReload, + onOpened: () { + // The player now owns the new file — publish the session at the + // same boundary so identity and source state flip together. + didOpenReplacement = true; + _commitPlaybackSession(session); + }, + ); + // A false didOpen means shouldContinue stopped the sequence pre-open + // (open failures throw into the catch below) — superseded either way. + if (!openResult.didOpen || !isCurrentReload()) { + return _MediaReloadOutcome.superseded; + } + if (openResult.sidecarFallbackUsed) { + session = _commitSidecarFallbackSession(session); + subtitleSelection = session.subtitleSelection; + effectiveExternalSubtitlePlan = _prepareExternalSubtitleOpenPlan( + player: currentPlayer, + externalSubtitles: const [], + ); + } + _completionLatch.reset(); + if (isItemChange) { + // Same-item reloads (including the spurious-EOF recovery itself and + // quality switches) keep the spent budget — that is the loop guard. + _spuriousEofRecoveryAttempts = 0; + _spuriousEofRecoveryBaselineMs = null; + } + + // Versions/mediaInfo come from the committed session; rebuild so the + // controls pick them up. Same-part switches (quality/audio/subtitle) + // keep the scrub-preview source — BIF/trickplay is per part, so a + // reset would re-download identical bytes. + final reusesScrubPreview = + previousMetadata.globalKey == metadata.globalKey && + previousPartId != null && + previousPartId == result.mediaInfo?.partId; + if (reusesScrubPreview) { + _setPlayerState(() {}); + } else { + _resetScrubPreviewForNewItem(metadata: metadata, mediaInfo: result.mediaInfo, mediaClient: mediaClient); + } + _clearEpisodeLoadingFlags(); + if (isItemChange) _showChromeForSwappedItem(); + + _trackManager?.dispose(); + final trackManager = _buildTrackManager( + forPlayer: currentPlayer, + metadata: metadata, + plexClient: plexClient, + getProfileSettings: () => userProfileProvider.profileSettings, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: subtitleSelection.primaryTrack, + preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack, + ); + _trackManager = trackManager; + trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen); + + final resumeForStartupFrame = + frameRatePlan.needsStartupRefresh && + effectiveExternalSubtitlePlan.requiresPostOpenAdd && + !wtOwnsStart && + !startPaused; + await _applyTracksAfterOpen( + trackManager: trackManager, + externalSubtitlePlan: effectiveExternalSubtitlePlan, + // Same guard as the start path: don't resume a player a newer flow + // owns, and let a pending startup gate (or Watch Together's group + // start) own the resume instead. Post-open external-subtitle paths + // resume once here so the startup refresh gate can observe a frame. + shouldResumeAfterSubtitleLoad: () => + (!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) && + !wtOwnsStart && + !startPaused && + mounted && + player == currentPlayer, + applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + await _releaseFrameRateStartupGate( + currentPlayer: currentPlayer, + settingsService: settingsService, + plan: frameRatePlan, + // startPaused rides the Watch Together yield path: the gate release + // arms track selection but leaves the player paused for the caller. + resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( + currentPlayer: currentPlayer, + externalSubtitlePlan: effectiveExternalSubtitlePlan, + reason: reason, + wtOwnsStart: wtOwnsStart || startPaused, + ), + playbackResumedForStartupFrame: resumeForStartupFrame, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + // Same helper as the initial start flow, so any future change lands in + // both paths together. _wirePerItemPlaybackServices( metadata: metadata, - mediaClient: _playbackSession?.reportingClient, + mediaClient: mediaClient, offlineWatchService: offlineWatchService, playSessionId: _playbackPlaySessionId, playMethod: _playbackPlayMethod, mediaInfo: _currentMediaInfo, ); + + try { + playbackState.setCurrentItem(metadata); + } catch (e) { + appLogger.d('playbackState.setCurrentItem failed', error: e); + } + + unawaited(_loadAdjacentEpisodes(metadata: metadata, attempt: attempt)); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + + if (_autoPipEnabled) { + unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); + } + return _MediaReloadOutcome.opened; + } catch (e) { + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + _completionLatch.reset(); + if (!didOpenReplacement) { + // Nothing was opened: the previous session is still committed, so + // only the eagerly-set identity needs restoring before resuming. + _currentMetadata = previousMetadata; + VideoPlayerScreenState._activeId = previousMetadata.id; + VideoPlayerScreenState._activeMediaIndex = previousMediaIndex; + _hasFirstFrame.value = previousHasFirstFrame; + // If the stop report already went out, un-latch the tracker so the + // resumed session keeps reporting (and its eventual real stop sends). + _progressTracker?.resumeAfterStoppedReport(); + if (wasPlayingBeforeReload && mounted && player == currentPlayer) { + unawaited(_playWithPlaybackIntent(currentPlayer)); + } + } else if (_progressTracker == null && player == currentPlayer) { + // The new file is playing and its session is committed — keep the + // new identity and make sure progress reporting is wired to the + // item actually on screen (the failure may have hit before + // _wirePerItemPlaybackServices ran). + _wirePerItemPlaybackServices( + metadata: metadata, + mediaClient: _playbackSession?.reportingClient, + offlineWatchService: offlineWatchService, + playSessionId: _playbackPlaySessionId, + playMethod: _playbackPlayMethod, + mediaInfo: _currentMediaInfo, + ); + } + // Unconditional setState — beyond the flags this also publishes the + // rolled-back identity (_clearEpisodeLoadingFlags skips the rebuild + // when no loading flags are set). + _setPlayerState(() { + _isLoadingNext = false; + _isLoadingPrevious = false; + }); + if (isItemChange) _showChromeForSwappedItem(); + appLogger.e('Failed to reload media in-place during $reason', error: e); + if (mounted && showErrorUi) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + return didOpenReplacement ? _MediaReloadOutcome.opened : _MediaReloadOutcome.failed; + } finally { + // Restore Watch Together sync on every exit: after a successful item + // change (readiness re-handshakes for the new item), after a failed + // reload (the still-playing old item must stay synced), and when the + // controller auto-detached itself on a mid-reload player failure. + // _currentMetadata is correct on both the success and rollback paths + // by the time we get here. + try { + final reattachServerId = _currentMetadata.serverId; + if (watchTogetherWasAttached && + watchTogether != null && + watchTogether.isInSession && + mounted && + player == currentPlayer && + reattachServerId != null && + !watchTogether.hasAttachedPlayer) { + watchTogether.attachPlayer( + currentPlayer, + ratingKey: _currentMetadata.id, + serverId: reattachServerId, + mediaTitle: _currentMetadata.displayTitle, + hasFirstFrame: _hasFirstFrame.value, + remoteSeek: _seekPlayback, + ); + } + } catch (e, stackTrace) { + // Playback has already reached a definitive opened/failed outcome; + // a best-effort sync reattach must not rewrite it or escape through + // the source-switch error classifier. + appLogger.w('Failed to reattach Watch Together after $reason', error: e, stackTrace: stackTrace); + } } - // Unconditional setState — beyond the flags this also publishes the - // rolled-back identity (_clearEpisodeLoadingFlags skips the rebuild - // when no loading flags are set). - _setPlayerState(() { - _isLoadingNext = false; - _isLoadingPrevious = false; - }); - if (isItemChange) _showChromeForSwappedItem(); - appLogger.e('Failed to reload media in-place during $reason', error: e); - if (mounted && showErrorUi) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - return didOpenReplacement ? _MediaReloadOutcome.opened : _MediaReloadOutcome.failed; } finally { - // Release the reload transition unless a newer flow already took - // ownership (a non-reload attempt force-idles it; a newer reload can - // then re-acquire it). - if (attempt.isCurrent && _playbackTransition == _PlaybackTransition.reloadingMedia) { - _playbackTransition = _PlaybackTransition.idle; - } - // Restore Watch Together sync on every exit: after a successful item - // change (readiness re-handshakes for the new item), after a failed - // reload (the still-playing old item must stay synced), and when the - // controller auto-detached itself on a mid-reload player failure. - // _currentMetadata is correct on both the success and rollback paths - // by the time we get here. - final reattachServerId = _currentMetadata.serverId; - if (watchTogetherWasAttached && - watchTogether != null && - watchTogether.isInSession && - mounted && - player == currentPlayer && - reattachServerId != null && - !watchTogether.hasAttachedPlayer) { - watchTogether.attachPlayer( - currentPlayer, - ratingKey: _currentMetadata.id, - serverId: reattachServerId, - mediaTitle: _currentMetadata.displayTitle, - hasFirstFrame: _hasFirstFrame.value, - remoteSeek: _seekPlayback, - ); - } + // Cover setup as well as async playback work: context/provider reads can + // throw before the operational try/catch is entered. Identity ownership + // prevents this continuation from releasing a newer transition. + _releasePlaybackTransition(reloadLease); } } } diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 84222627..096f604f 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -250,14 +250,17 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { Future _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); } } diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 3158ec95..d96b766b 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -59,6 +59,13 @@ class _ExternalSubtitleOpenPlan { List? 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 _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().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 _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 openMedia({required bool shouldPlay, List? 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); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 01464c16..38bdd870 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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().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; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a6acf378..4a59eac0 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 _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 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? _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 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(); + } + } + + Future _waitForPlaybackTransitionIdle() async { + while (mounted && _playbackTransition != _PlaybackTransition.idle) { + _playbackTransitionIdleCompleter ??= Completer(); + 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 with WidgetsBindin offlineLibraryMode: false, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, ); @@ -1216,6 +1306,10 @@ class VideoPlayerScreenState extends State 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 with WidgetsBindin Future _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track); - Future _onSubtitleTrackChanged(SubtitleTrack track) async => _trackManager?.onSubtitleTrackChanged(track); + Future _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 _sendStoppedProgressOnce({Duration? positionOverride}) { if (widget.isLive) { diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index dd7d8cbd..a31e1d26 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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 [] + final subtitleSidecars = isTrack + ? const [] : _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 _buildExternalSubtitles( + List _buildExternalSubtitles( String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo, { bool includeExternalDelivery = false, }) { - final externalSubtitles = []; + final externalSubtitles = []; 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, + ), ), ); } diff --git a/lib/services/mpv_sidecar_open_guard.dart b/lib/services/mpv_sidecar_open_guard.dart new file mode 100644 index 00000000..a751d23f --- /dev/null +++ b/lib/services/mpv_sidecar_open_guard.dart @@ -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 _primaryReady = Completer(); + final Completer _fileLoaded = Completer(); + final Completer _playbackRestart = Completer(); + final Completer _backendSwitched = Completer(); + final Completer _fileLoadFailed = Completer(); + final Completer _aborted = Completer(); + StreamSubscription? _fileStartedSubscription; + StreamSubscription? _primaryReadySubscription; + StreamSubscription? _fileLoadedSubscription; + StreamSubscription? _fileLoadFailedSubscription; + StreamSubscription? _playbackRestartSubscription; + StreamSubscription? _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? 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 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 _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 _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 dispose() async { + await Future.wait(>[ + ?_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? subtitles) { + for (final subtitle in subtitles ?? const []) { + 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 } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index b5b64df4..79850eb1 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -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> _discoverSidecarSubtitles( + Future> _discoverSidecarSubtitles( String videoPath, { required MediaItem metadata, MediaSourceInfo? mediaInfo, }) async { - final subtitles = []; + final subtitles = []; 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, + ), ), ); } diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 94ea9fa6..efd9ffd9 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -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 availableVersions; final String? videoUrl; final MediaSourceInfo? mediaInfo; - final List 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 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 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, diff --git a/lib/services/playback_session.dart b/lib/services/playback_session.dart index ad6a74a8..f6bb3327 100644 --- a/lib/services/playback_session.dart +++ b/lib/services/playback_session.dart @@ -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, ); } diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index bae80697..dabd4b84 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -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, ); diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart new file mode 100644 index 00000000..9a2b6613 --- /dev/null +++ b/lib/services/playback_subtitle_resolver.dart @@ -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 get sidecarsAtOpen { + final tracks = []; + 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 sidecars, + MediaServerUserProfile? profileSettings, + AudioTrack? preferredAudioTrack, + SubtitleTrack? preferredSubtitleTrack, + SubtitleTrack? preferredSecondarySubtitleTrack, + }) { + final candidates = <_SubtitleCandidate>[]; + final matchedSidecars = {}; + + for (final sourceTrack in mediaInfo?.subtitleTracks ?? const []) { + 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 nativeTracks, + required List 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 tracks, + PlaybackSourceSubtitleChoice currentChoice, + ) { + return advanceSourceChoice(tracks, currentChoice, 1); + } + + static PlaybackSourceSubtitleChoice advanceSourceChoice( + List tracks, + PlaybackSourceSubtitleChoice currentChoice, + int advances, + ) { + final choices = [ + 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 _audioTracksForSource(MediaSourceInfo? mediaInfo) { + return [ + for (final track in mediaInfo?.audioTracks ?? const []) + 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}); +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3d753515..aa7abca4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { + List _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 = []; + final tracks = []; 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 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 _buildExternalSubtitles(MediaSourceInfo? mediaInfo) { - final externalSubtitles = []; + List _buildExternalSubtitles(MediaSourceInfo? mediaInfo) { + final externalSubtitles = []; 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) { diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index d24db2ba..d80d16c1 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -324,7 +324,7 @@ class TrackManager { } /// Handle subtitle track changes — save stream selection and language preference. - Future onSubtitleTrackChanged(SubtitleTrack track) async { + Future 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, diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index ba4b040e..5ff19a7b 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -353,12 +353,12 @@ class TrackSelectionResult { /// 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 _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( 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 { diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 3ebf7d53..0180da1f 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -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 availableVersions; @@ -18,7 +21,9 @@ class TrackControlsState { final List sourceAudioTracks; final int? selectedAudioStreamId; final List sourceSubtitleTracks; - final int? selectedSubtitleStreamId; + final PlaybackSourceSubtitleChoice? selectedSubtitleChoice; + final int? selectedSecondarySubtitleStreamId; + final Set 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? onSwitchQualityPreset; final ValueChanged? onSwitchAudioStreamId; - final ValueChanged? onSwitchSubtitleStreamId; + final ValueChanged? 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 Function()? onSubtitleDownloaded; + final Future 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 {}, 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 get directPlaySourceSidecars => !isTranscoding && onSwitchSubtitle != null + ? sourceSubtitleTracks.where((track) => sourceSubtitleSidecarIds.contains(track.id)).toList(growable: false) + : const []; /// 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 []; - return canUseSourceSubtitles || TrackFilterHelper.hasTracks(playerSubtitles) || canSearchSubtitles; + return canUseSourceSubtitles || + directPlaySourceSidecars.isNotEmpty || + TrackFilterHelper.hasTracks(playerSubtitles) || + canSearchSubtitles; } } diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index f9098318..4d899896 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -67,74 +67,68 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { videoPlayerState?.navigateToQueueItem(item); } - Future _onSubtitleDownloaded() async { - if (!mounted) return; + Future _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) { diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index 87d54887..0fcaad3c 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -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 [], - selectedSubtitleStreamId: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleStreamId : null, + selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null, + selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null, + sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const {}, 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, diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index fee764f9..ec96d8be 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -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 Function()? onSubtitleDownloaded; + final Future Function({required String serverId, required String ratingKey})? + onSubtitleDownloaded; const SubtitleSearchSheet({ super.key, @@ -205,10 +207,17 @@ class _SubtitleSearchSheetState extends State 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); diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 1a03176b..c6bf6896 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -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( 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 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( + 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; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index cdeabae5..80052c91 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 selectableSourceSubtitleTracks( List tracks, { required bool isTranscoding, + required Set 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 tracks, Set 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 sourceAudioTracks, int? selectedAudioStreamId, List sourceSubtitleTracks, - int? selectedSubtitleStreamId, + PlaybackSourceSubtitleChoice? selectedSubtitleChoice, bool canSwitch, }) effectiveVersionQualityControls({ @@ -143,7 +174,7 @@ effectiveVersionQualityControls({ required List sourceAudioTracks, required int? selectedAudioStreamId, required List sourceSubtitleTracks, - required int? selectedSubtitleStreamId, + required PlaybackSourceSubtitleChoice? selectedSubtitleChoice, }) { if (isOfflinePlayback) { return ( @@ -153,7 +184,7 @@ effectiveVersionQualityControls({ sourceAudioTracks: const [], selectedAudioStreamId: null, sourceSubtitleTracks: const [], - 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 Function({ + Future 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 sourceAudioTracks; final int? selectedAudioStreamId; final List sourceSubtitleTracks; - final int? selectedSubtitleStreamId; + final PlaybackSourceSubtitleChoice? selectedSubtitleChoice; + final int? selectedSecondarySubtitleStreamId; + final Set 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 {}, this.sourcePartId, this.onPlaybackSourceChanged, this.boxFitMode = 0, diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index b27ec118..b00fdfbc 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -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"); diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index a9f495b6..9c0271b6 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -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) diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index 44c11d0d..66a5f1f8 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -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.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.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.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.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 = []; diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 2bf6ec3f..413a9376 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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 { diff --git a/test/services/mpv_sidecar_open_guard_test.dart b/test/services/mpv_sidecar_open_guard_test.dart new file mode 100644 index 00000000..dd99322b --- /dev/null +++ b/test/services/mpv_sidecar_open_guard_test.dart @@ -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 fileStarted = const Stream.empty(), + required Stream primaryMediaReady, + required Stream fileLoaded, + Stream fileLoadFailed = const Stream.empty(), + Stream playbackRestart = const Stream.empty(), + Stream backendSwitched = const Stream.empty(), + Stream 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.broadcast(); + final loaded = StreamController.broadcast(); + final started = StreamController.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.broadcast(); + final loaded = StreamController.broadcast(); + final started = StreamController.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.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.broadcast(); + final loaded = StreamController.broadcast(); + final started = StreamController.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.broadcast(); + final loaded = StreamController.broadcast(); + final started = StreamController.broadcast(); + final errors = StreamController.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.broadcast(); + final primary = StreamController.broadcast(); + final loaded = StreamController.broadcast(); + final failed = StreamController.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.broadcast(); + final loaded = StreamController.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.broadcast(); + final loaded = StreamController.broadcast(); + final restarted = StreamController.broadcast(); + final switched = StreamController.broadcast(); + final started = StreamController.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.broadcast(); + final loaded = StreamController.broadcast(); + final failed = StreamController.broadcast(); + final restarted = StreamController.broadcast(); + final switched = StreamController.broadcast(); + final started = StreamController.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.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.broadcast(); + final loaded = StreamController.broadcast(); + final restarted = StreamController.broadcast(); + final switched = StreamController.broadcast(); + final started = StreamController.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.delayed(const Duration(milliseconds: 60)); + switched.add(null); + await Future.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.broadcast(); + final loaded = StreamController.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); + }); +} diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart new file mode 100644 index 00000000..bf36255b --- /dev/null +++ b/test/services/playback_subtitle_resolver_test.dart @@ -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 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'); + }); +} diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index cd437680..115668f0 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -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); diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index 07226414..58a43c61 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -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); + }); }); // ============================================================ diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 8f993d6f..da8f6c67 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -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 _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 _pumpTrackSheet( } class _FakeTrackSheetPlayer implements Player { - _FakeTrackSheetPlayer({required Tracks tracks, required TrackSelection track}) - : _state = PlayerState(tracks: tracks, track: track), - _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: const Stream.empty(), - audioDevice: const Stream.empty(), - audioDevices: const Stream>.empty(), - bufferRanges: const Stream>.empty(), - playbackRestart: const Stream.empty(), - backendSwitched: const Stream.empty(), - ); + _FakeTrackSheetPlayer({ + required Tracks tracks, + required TrackSelection track, + this.supportsSecondarySubtitles = false, + }) : _state = PlayerState(tracks: tracks, track: track), + _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: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.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 selectSubtitleTrack(SubtitleTrack track) async { + lastSelectedSubtitle = track; + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index afd04de6..e58e3252 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -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', () { diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index baad8486..398490a9 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -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");