@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ appId: com.edde746.plezy
|
||||
SHEET_MARKER: "${AUDIO_TRACK}"
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
point: "70%,66%"
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
|
||||
@@ -21,12 +21,8 @@ appId: com.edde746.plezy
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- swipe:
|
||||
start: 70%, 78%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- tapOn:
|
||||
point: "70%,86%"
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- runFlow:
|
||||
file: show_codec_track_sheet.yaml
|
||||
env:
|
||||
|
||||
@@ -334,7 +334,10 @@ class MpvPlayerCore(
|
||||
is MpvEvent.EndFile -> {
|
||||
delegate?.onEvent("end-file", endFileDiagnostics.onEndFile(event))
|
||||
}
|
||||
is MpvEvent.StartFile -> endFileDiagnostics.onStartFile()
|
||||
is MpvEvent.StartFile -> {
|
||||
endFileDiagnostics.onStartFile()
|
||||
delegate?.onEvent("start-file", null)
|
||||
}
|
||||
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
|
||||
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
|
||||
else -> {}
|
||||
|
||||
@@ -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": "Търсене на езици..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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": "言語を検索..."
|
||||
},
|
||||
|
||||
@@ -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": "언어 검색..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -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": "Поиск языков..."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => 'Все още няма изтегляния',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'ダウンロードなし',
|
||||
|
||||
@@ -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' => '다운로드 없음',
|
||||
|
||||
@@ -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å',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ń',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Загрузок пока нет',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '暂无下载',
|
||||
|
||||
@@ -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..."
|
||||
},
|
||||
|
||||
@@ -491,6 +491,7 @@
|
||||
"qualityOriginal": "原始",
|
||||
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
|
||||
"transcodeUnavailableFallback": "转码不可用 — 以原始画质播放",
|
||||
"subtitleUnavailableFallback": "无法加载所选字幕 — 将继续无字幕播放",
|
||||
"pipButton": "画中画模式",
|
||||
"aspectRatioButton": "宽高比",
|
||||
"ambientLighting": "氛围灯光",
|
||||
@@ -524,11 +525,11 @@
|
||||
"searchSubtitles": "搜索字幕",
|
||||
"language": "语言",
|
||||
"noSubtitlesFound": "未找到字幕",
|
||||
"downloadedSubtitle": "已下载",
|
||||
"noSubtitlesAvailable": "没有可用字幕",
|
||||
"noAudioTracksAvailable": "没有可用音轨",
|
||||
"noTracksAvailable": "没有可用轨道",
|
||||
"subtitleDownloaded": "字幕已下载",
|
||||
"subtitleDownloadedNotApplied": "字幕已下载,但无法选择",
|
||||
"subtitleDownloadFailed": "字幕下载失败",
|
||||
"searchLanguages": "搜索语言..."
|
||||
},
|
||||
|
||||
@@ -27,6 +27,8 @@ class PlayerAndroid extends PlayerBase {
|
||||
/// (which resets only on initialize/dispose).
|
||||
bool _usingMpvFallback = false;
|
||||
|
||||
bool get usingMpvFallback => _usingMpvFallback;
|
||||
|
||||
String? _hiddenSubtitleTrackId;
|
||||
|
||||
@override
|
||||
|
||||
@@ -63,6 +63,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
int _nextPropId = 0;
|
||||
final Map<int, String> _propIdToName = {};
|
||||
Map<String, SubtitleTrack> _externalSubtitleMetadataByUri = const {};
|
||||
bool _primaryMediaLoadStarted = false;
|
||||
bool _primaryMediaReadyEmitted = false;
|
||||
|
||||
@protected
|
||||
bool initialized = false;
|
||||
@@ -260,6 +262,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
case 'track-list':
|
||||
final trackList = MpvNodeDecoder.decodeList(value);
|
||||
if (trackList != null) {
|
||||
if (_primaryMediaLoadStarted && !_primaryMediaReadyEmitted && _hasPrimaryMediaTrack(trackList)) {
|
||||
_primaryMediaReadyEmitted = true;
|
||||
primaryMediaReadyController.add(null);
|
||||
}
|
||||
final result = parseTrackList(trackList);
|
||||
_state = _state.copyWith(tracks: result.tracks);
|
||||
tracksController.add(result.tracks);
|
||||
@@ -354,7 +360,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
void handlePlayerEvent(String name, Map? data) {
|
||||
if (_disposed) return;
|
||||
switch (name) {
|
||||
case 'start-file':
|
||||
_primaryMediaLoadStarted = true;
|
||||
_primaryMediaReadyEmitted = false;
|
||||
fileStartedController.add(null);
|
||||
break;
|
||||
|
||||
case 'end-file':
|
||||
_primaryMediaLoadStarted = false;
|
||||
setSeekable(false);
|
||||
final rawReason = data?['reason'];
|
||||
final reason = switch (rawReason) {
|
||||
@@ -370,6 +383,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
_state = _state.copyWith(completed: true);
|
||||
completedController.add(true);
|
||||
} else if (reason == 'error') {
|
||||
fileLoadFailedController.add(null);
|
||||
errorController.add(
|
||||
PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?),
|
||||
);
|
||||
@@ -396,6 +410,19 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasPrimaryMediaTrack(List trackList) {
|
||||
for (final track in trackList) {
|
||||
if (track is! Map || track['external'] == true) continue;
|
||||
final type = track['type'];
|
||||
if (type == 'audio') return true;
|
||||
// mpv exposes embedded/external cover art as a video track. It is not
|
||||
// evidence that the primary audio file has finished discovery, so it
|
||||
// must not start the external-sidecar timeout on its own.
|
||||
if (type == 'video' && track['albumart'] != true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayerLogLevel parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'fatal' => PlayerLogLevel.fatal,
|
||||
|
||||
@@ -22,6 +22,9 @@ mixin PlayerStreamControllersMixin {
|
||||
final bufferRangesController = StreamController<List<BufferRange>>.broadcast();
|
||||
final playbackRestartController = StreamController<void>.broadcast();
|
||||
final fileLoadedController = StreamController<void>.broadcast();
|
||||
final fileStartedController = StreamController<void>.broadcast();
|
||||
final fileLoadFailedController = StreamController<void>.broadcast();
|
||||
final primaryMediaReadyController = StreamController<void>.broadcast();
|
||||
final backendSwitchedController = StreamController<void>.broadcast();
|
||||
final trackTransitionController = StreamController<String>.broadcast();
|
||||
|
||||
@@ -45,6 +48,9 @@ mixin PlayerStreamControllersMixin {
|
||||
bufferRanges: bufferRangesController.stream,
|
||||
playbackRestart: playbackRestartController.stream,
|
||||
fileLoaded: fileLoadedController.stream,
|
||||
fileStarted: fileStartedController.stream,
|
||||
fileLoadFailed: fileLoadFailedController.stream,
|
||||
primaryMediaReady: primaryMediaReadyController.stream,
|
||||
backendSwitched: backendSwitchedController.stream,
|
||||
trackTransition: trackTransitionController.stream,
|
||||
);
|
||||
@@ -69,6 +75,9 @@ mixin PlayerStreamControllersMixin {
|
||||
await bufferRangesController.close();
|
||||
await playbackRestartController.close();
|
||||
await fileLoadedController.close();
|
||||
await fileStartedController.close();
|
||||
await fileLoadFailedController.close();
|
||||
await primaryMediaReadyController.close();
|
||||
await backendSwitchedController.close();
|
||||
await trackTransitionController.close();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,20 @@ class PlayerStreams {
|
||||
/// Stream that emits when the player has loaded the current media file.
|
||||
final Stream<void> fileLoaded;
|
||||
|
||||
/// Emits when mpv starts loading a new file. This delimits load-scoped
|
||||
/// readiness and failure signals for callers that arm before [Player.open].
|
||||
final Stream<void> fileStarted;
|
||||
|
||||
/// Emits only when the active file ends because loading or playback failed.
|
||||
/// Generic platform/property errors remain on [error] and must not be
|
||||
/// mistaken for a media-open failure.
|
||||
final Stream<void> fileLoadFailed;
|
||||
|
||||
/// Emits once mpv has discovered a non-external audio or video track for
|
||||
/// the current file. Unlike [fileLoaded], this can fire before remote
|
||||
/// subtitle sidecars finish opening.
|
||||
final Stream<void> primaryMediaReady;
|
||||
|
||||
/// Stream of seekable buffer ranges from the demuxer cache.
|
||||
final Stream<List<BufferRange>> bufferRanges;
|
||||
|
||||
@@ -88,6 +102,9 @@ class PlayerStreams {
|
||||
required this.bufferRanges,
|
||||
required this.playbackRestart,
|
||||
this.fileLoaded = const Stream<void>.empty(),
|
||||
this.fileStarted = const Stream<void>.empty(),
|
||||
this.fileLoadFailed = const Stream<void>.empty(),
|
||||
this.primaryMediaReady = const Stream<void>.empty(),
|
||||
required this.backendSwitched,
|
||||
this.trackTransition = const Stream<String>.empty(),
|
||||
});
|
||||
|
||||
@@ -48,21 +48,39 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
});
|
||||
}
|
||||
|
||||
int? _selectedSourceSubtitleStreamIdForControls(List<MediaSubtitleTrack> tracks) {
|
||||
PlaybackSourceSubtitleChoice? _selectedSourceSubtitleChoiceForControls(List<MediaSubtitleTrack> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
for (final track in tracks) {
|
||||
if (track.selected) return track.id;
|
||||
final selection = _playbackSession?.subtitleSelection;
|
||||
if (selection != null) {
|
||||
if (selection.isOff) return const PlaybackSourceSubtitleChoice.off();
|
||||
final sourceId = selection.primarySourceStreamId;
|
||||
if (sourceId != null && tracks.any((track) => track.id == sourceId)) {
|
||||
return PlaybackSourceSubtitleChoice.source(sourceId);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
for (final track in tracks) {
|
||||
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
|
||||
}
|
||||
return const PlaybackSourceSubtitleChoice.off();
|
||||
}
|
||||
|
||||
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
|
||||
final sidecarSourceIds = _sourceSubtitleSidecarIdsForControls();
|
||||
return selectableSourceSubtitleTracks(
|
||||
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
||||
isTranscoding: _isTranscoding,
|
||||
sidecarSourceIds: sidecarSourceIds,
|
||||
supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex,
|
||||
);
|
||||
}
|
||||
|
||||
Set<int> _sourceSubtitleSidecarIdsForControls() {
|
||||
return {
|
||||
for (final sidecar in _playbackSession?.context.result.subtitleSidecars ?? const <PlaybackSubtitleSidecar>[])
|
||||
?sidecar.sourceStreamId,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildLoadingSpinner() {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -249,6 +267,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
|
||||
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
|
||||
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
|
||||
final sourceSubtitleSidecarIds = _sourceSubtitleSidecarIdsForControls();
|
||||
|
||||
return Video(
|
||||
player: player!,
|
||||
@@ -267,7 +286,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
sourceAudioTracks: sourceAudioTracks,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
sourceSubtitleTracks: sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: _selectedSourceSubtitleStreamIdForControls(sourceSubtitleTracks),
|
||||
selectedSubtitleChoice: _selectedSourceSubtitleChoiceForControls(sourceSubtitleTracks),
|
||||
selectedSecondarySubtitleStreamId: _playbackSession?.subtitleSelection.secondarySourceStreamId,
|
||||
sourceSubtitleSidecarIds: sourceSubtitleSidecarIds,
|
||||
sourcePartId: _currentMediaInfo?.partId,
|
||||
onPlaybackSourceChanged: _switchPlaybackSource,
|
||||
onTogglePIPMode: _togglePIPMode,
|
||||
|
||||
@@ -92,7 +92,49 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
|
||||
_companionRemoteProvider = null;
|
||||
}
|
||||
|
||||
void _cycleSubtitleTrack() => _trackManager?.cycleSubtitleTrack();
|
||||
void _cycleSubtitleTrack() {
|
||||
final sourceTracks = _sourceSubtitleTracksForControls();
|
||||
if (!_isOfflinePlayback && sourceTracks.isNotEmpty) {
|
||||
_pendingSubtitleCycleCount++;
|
||||
if (!_subtitleCycleDrainActive) unawaited(_drainSubtitleCycles());
|
||||
return;
|
||||
}
|
||||
_trackManager?.cycleSubtitleTrack();
|
||||
}
|
||||
|
||||
Future<void> _drainSubtitleCycles() async {
|
||||
if (_subtitleCycleDrainActive) return;
|
||||
_subtitleCycleDrainActive = true;
|
||||
try {
|
||||
while (mounted && _pendingSubtitleCycleCount > 0) {
|
||||
await _waitForPlaybackTransitionIdle();
|
||||
if (!mounted || _pendingSubtitleCycleCount == 0) break;
|
||||
|
||||
// Collapse every press queued before this dispatch into one target.
|
||||
// Presses arriving during the reopen remain queued for the next pass.
|
||||
final advances = _pendingSubtitleCycleCount;
|
||||
final sourceTracks = _sourceSubtitleTracksForControls();
|
||||
if (_isOfflinePlayback || sourceTracks.isEmpty) {
|
||||
_pendingSubtitleCycleCount -= advances;
|
||||
for (var i = 0; i < advances; i++) {
|
||||
_trackManager?.cycleSubtitleTrack();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final currentChoice =
|
||||
_selectedSourceSubtitleChoiceForControls(sourceTracks) ?? const PlaybackSourceSubtitleChoice.off();
|
||||
final targetChoice = PlaybackSubtitleResolver.advanceSourceChoice(sourceTracks, currentChoice, advances);
|
||||
final outcome = await _switchPlaybackSource(newSubtitleChoice: targetChoice);
|
||||
if (outcome == PlaybackSourceChangeOutcome.busy) {
|
||||
await _waitForPlaybackTransitionIdle();
|
||||
continue;
|
||||
}
|
||||
_pendingSubtitleCycleCount -= advances;
|
||||
}
|
||||
} finally {
|
||||
_subtitleCycleDrainActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _cycleAudioTrack() => _trackManager?.cycleAudioTrack();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -250,14 +250,17 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
Future<void> _switchLiveChannel(int delta) async {
|
||||
final channels = widget.live?.channels;
|
||||
if (channels == null || channels.isEmpty) return;
|
||||
if (_playbackTransition != _PlaybackTransition.idle) return; // debounce concurrent switches
|
||||
|
||||
final newIndex = _live.channelIndex + delta;
|
||||
if (newIndex < 0 || newIndex >= channels.length) return;
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) return;
|
||||
|
||||
_playbackTransition = _PlaybackTransition.switchingChannel;
|
||||
final transitionLease = _tryAcquirePlaybackTransition(_PlaybackTransition.switchingChannel);
|
||||
if (transitionLease == null) return; // debounce concurrent switches
|
||||
bool isCurrentChannelSwitch() =>
|
||||
mounted &&
|
||||
player == currentPlayer &&
|
||||
_ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingChannel);
|
||||
_liveSeek.cancel();
|
||||
|
||||
final previousSession = _live.session;
|
||||
@@ -273,19 +276,19 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
// tuner/transcode session.
|
||||
session = await _startLiveSession(channel);
|
||||
if (session == null) return;
|
||||
if (!mounted || player != currentPlayer) {
|
||||
if (!isCurrentChannelSwitch()) {
|
||||
_abandonLiveSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
final streamUrl = await session.streamUrlAt();
|
||||
if (streamUrl == null || !mounted || player != currentPlayer) {
|
||||
if (streamUrl == null || !isCurrentChannelSwitch()) {
|
||||
_abandonLiveSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
await _setLiveStreamOptions(currentPlayer);
|
||||
if (!mounted || player != currentPlayer) {
|
||||
if (!isCurrentChannelSwitch()) {
|
||||
_abandonLiveSession(session);
|
||||
return;
|
||||
}
|
||||
@@ -293,7 +296,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
_setPlayerState(() => _hasFirstFrame.value = false);
|
||||
replacementOpenStarted = true;
|
||||
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
||||
if (!mounted || player != currentPlayer) {
|
||||
if (!isCurrentChannelSwitch()) {
|
||||
_abandonLiveSession(session);
|
||||
return;
|
||||
}
|
||||
@@ -304,6 +307,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
if (previousSession != null) {
|
||||
await _sendLiveTimeline('stopped');
|
||||
}
|
||||
if (!isCurrentChannelSwitch()) {
|
||||
_abandonLiveSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
_live.adoptSession(session);
|
||||
_live.fallbackLevel = 0;
|
||||
@@ -322,13 +329,14 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
||||
// would otherwise hold its server-side tuner until the backend times out.
|
||||
final orphan = session;
|
||||
if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan);
|
||||
if (!isCurrentChannelSwitch()) return;
|
||||
if (replacementOpenStarted && mounted && _live.session == previousSession) {
|
||||
_setPlayerState(() => _hasFirstFrame.value = true);
|
||||
}
|
||||
appLogger.e('Failed to switch channel', error: e);
|
||||
if (mounted) showErrorSnackBar(context, e.toString());
|
||||
} finally {
|
||||
_playbackTransition = _PlaybackTransition.idle;
|
||||
_releasePlaybackTransition(transitionLease);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ class _ExternalSubtitleOpenPlan {
|
||||
List<SubtitleTrack>? get subtitlesAtOpen => attachesAtOpen && hasExternalSubtitles ? externalSubtitles : null;
|
||||
}
|
||||
|
||||
class _MediaOpenResult {
|
||||
const _MediaOpenResult({required this.didOpen, this.sidecarFallbackUsed = false});
|
||||
|
||||
final bool didOpen;
|
||||
final bool sidecarFallbackUsed;
|
||||
}
|
||||
|
||||
/// Shared building blocks for opening media on the live player.
|
||||
///
|
||||
/// The initial start flow ([_startPlayback]) and in-place reload flow
|
||||
@@ -68,6 +75,31 @@ class _ExternalSubtitleOpenPlan {
|
||||
/// This is also the only place that reads
|
||||
/// [SettingsService.displaySwitchDelay].
|
||||
extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
PlaybackSession _commitSidecarFallbackSession(PlaybackSession session) {
|
||||
return _updatePlaybackSessionSubtitleSelection(session, const PlaybackSubtitleSelection.off());
|
||||
}
|
||||
|
||||
Future<PlaybackSubtitleSelection> _resolveSubtitleSelectionForOpen({
|
||||
required MediaItem metadata,
|
||||
required PlaybackInitializationResult result,
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
}) async {
|
||||
await _waitForProfileSettingsIfNeeded();
|
||||
if (!mounted) return const PlaybackSubtitleSelection.off();
|
||||
|
||||
return PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: result.mediaInfo,
|
||||
sidecars: result.subtitleSidecars,
|
||||
profileSettings: context.read<UserProfileProvider>().profileSettings,
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
);
|
||||
}
|
||||
|
||||
/// Prime native display matching (tvOS HDMI mode) from server metadata
|
||||
/// before the decoder emits stream properties. The native side resolves
|
||||
/// only after any resulting display-mode switch has settled, plus the
|
||||
@@ -543,9 +575,10 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
/// after open() returns (before styling) so callers can flip rollback
|
||||
/// bookkeeping at the exact ownership boundary.
|
||||
///
|
||||
/// Returns false if [shouldContinue] stopped the sequence before open;
|
||||
/// true once open() has been issued (even if styling was skipped).
|
||||
Future<bool> _openMediaOnPlayer({
|
||||
/// Returns whether open was issued and whether a selected remote sidecar
|
||||
/// stalled after the primary media was ready, requiring an automatic reopen
|
||||
/// without subtitles.
|
||||
Future<_MediaOpenResult> _openMediaOnPlayer({
|
||||
required Player player,
|
||||
required SettingsService settingsService,
|
||||
required String videoUrl,
|
||||
@@ -565,17 +598,53 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
isTranscoding: isTranscoding,
|
||||
selectedVersion: selectedVersion,
|
||||
);
|
||||
if (shouldContinue != null && !shouldContinue()) return false;
|
||||
await player.open(
|
||||
Media(videoUrl, start: timing.mediaStart, headers: headers),
|
||||
play: play,
|
||||
externalSubtitles: externalSubtitlesAtOpen,
|
||||
timelineOffset: timing.timelineOffset,
|
||||
timelineDuration: timing.timelineDuration,
|
||||
);
|
||||
onOpened?.call();
|
||||
if (shouldContinue != null && !shouldContinue()) return true;
|
||||
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: false);
|
||||
|
||||
final media = Media(videoUrl, start: timing.mediaStart, headers: headers);
|
||||
final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen);
|
||||
Future<void> openMedia({required bool shouldPlay, List<SubtitleTrack>? externalSubtitles}) {
|
||||
return player.open(
|
||||
media,
|
||||
play: shouldPlay,
|
||||
externalSubtitles: externalSubtitles,
|
||||
timelineOffset: timing.timelineOffset,
|
||||
timelineDuration: timing.timelineDuration,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen);
|
||||
onOpened?.call();
|
||||
} catch (_) {
|
||||
await sidecarOpenGuard?.dispose();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
var sidecarFallbackUsed = false;
|
||||
final sidecarOutcome = await sidecarOpenGuard?.wait();
|
||||
if (sidecarOutcome == MpvSidecarOpenOutcome.aborted) {
|
||||
return const _MediaOpenResult(didOpen: true);
|
||||
}
|
||||
if (sidecarOutcome == MpvSidecarOpenOutcome.stalled) {
|
||||
appLogger.w('Selected subtitle sidecar stalled after primary media discovery; reopening without subtitles');
|
||||
if (shouldContinue != null && !shouldContinue()) {
|
||||
return const _MediaOpenResult(didOpen: true);
|
||||
}
|
||||
await player.stop();
|
||||
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true);
|
||||
// Respect a pause requested while mpv was waiting on the sidecar. A
|
||||
// startup gate encoded by [play] remains authoritative when it is false.
|
||||
await openMedia(shouldPlay: play && _playbackIntentShouldPlay);
|
||||
sidecarFallbackUsed = true;
|
||||
if (mounted && (shouldContinue == null || shouldContinue())) {
|
||||
showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldContinue != null && !shouldContinue()) {
|
||||
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
|
||||
}
|
||||
await _applyNativeSubtitleStyle(player, settingsService);
|
||||
return true;
|
||||
return _MediaOpenResult(didOpen: true, sidecarFallbackUsed: sidecarFallbackUsed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
offlineLibraryMode: true,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
);
|
||||
@@ -138,16 +139,24 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
}
|
||||
final result = playbackContext.result;
|
||||
final streamHeaders = playbackContext.streamHeaders;
|
||||
var subtitleSelection = await _resolveSubtitleSelectionForOpen(
|
||||
metadata: _currentMetadata,
|
||||
result: result,
|
||||
preferredAudioTrack: _preferredAudioTrack,
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
|
||||
);
|
||||
if (!attempt.isCurrent) return;
|
||||
// Initial start has no previous session to protect, so commit as soon
|
||||
// as the resolve lands (reload-style flows commit at the open
|
||||
// boundary instead).
|
||||
_commitPlaybackSession(
|
||||
PlaybackSession.fromContext(
|
||||
playbackContext,
|
||||
requestedQualityPreset: _selectedQualityPreset,
|
||||
requestedMediaSourceId: _requestedMediaSourceId,
|
||||
),
|
||||
var session = PlaybackSession.fromContext(
|
||||
playbackContext,
|
||||
requestedQualityPreset: _selectedQualityPreset,
|
||||
requestedMediaSourceId: _requestedMediaSourceId,
|
||||
subtitleSelection: subtitleSelection,
|
||||
);
|
||||
_commitPlaybackSession(session);
|
||||
|
||||
// Primary refresh-rate path: when metadata provides FPS, Android players
|
||||
// can switch before creating decoders. MPV still needs a startup refresh
|
||||
@@ -221,7 +230,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
frameRatePlan.armStartupRefreshGate(currentPlayer);
|
||||
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
|
||||
player: currentPlayer,
|
||||
externalSubtitles: result.externalSubtitles,
|
||||
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
||||
);
|
||||
final shouldAutoPlay =
|
||||
!shouldHoldPlaybackStart && !wtOwnsStart && externalSubtitlePlan.canStartBeforeTrackSetup;
|
||||
@@ -234,7 +243,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
resumePosition: resumePosition,
|
||||
durationMs: _currentMetadata.durationMs,
|
||||
);
|
||||
final didOpen = await _openMediaOnPlayer(
|
||||
final openResult = await _openMediaOnPlayer(
|
||||
player: currentPlayer,
|
||||
settingsService: settingsService,
|
||||
videoUrl: result.videoUrl!,
|
||||
@@ -247,7 +256,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
|
||||
shouldContinue: () => attempt.isCurrent,
|
||||
);
|
||||
if (!didOpen || !attempt.isCurrent) return;
|
||||
if (!openResult.didOpen || !attempt.isCurrent) return;
|
||||
if (openResult.sidecarFallbackUsed) {
|
||||
session = _commitSidecarFallbackSession(session);
|
||||
subtitleSelection = session.subtitleSelection;
|
||||
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(player: currentPlayer, externalSubtitles: const []);
|
||||
}
|
||||
|
||||
// Attach player to Watch Together session for sync (if in session).
|
||||
// With a frame-rate startup gate pending, sync readiness waits for
|
||||
@@ -262,7 +276,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
} else {
|
||||
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
|
||||
player: currentPlayer,
|
||||
externalSubtitles: result.externalSubtitles,
|
||||
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
||||
waitForFileLoaded: false,
|
||||
);
|
||||
}
|
||||
@@ -316,12 +330,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
plexClient: mediaClient is PlexClient ? mediaClient : null,
|
||||
getProfileSettings: () => context.read<UserProfileProvider>().profileSettings,
|
||||
preferredAudioTrack: _preferredAudioTrack,
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: _preferredSecondarySubtitleTrack,
|
||||
preferredSubtitleTrack: subtitleSelection.primaryTrack,
|
||||
preferredSecondarySubtitleTrack: subtitleSelection.secondaryTrack,
|
||||
);
|
||||
|
||||
// Store external subtitles for re-use after backend fallback
|
||||
_trackManager!.cacheExternalSubtitles(result.externalSubtitles);
|
||||
// Store only the active sidecars for re-use after backend fallback.
|
||||
_trackManager!.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
|
||||
|
||||
final resumeForStartupFrame =
|
||||
frameRatePlan.needsStartupRefresh && externalSubtitlePlan.requiresPostOpenAdd && !wtOwnsStart;
|
||||
|
||||
@@ -51,13 +51,17 @@ import '../services/playback_initialization_service.dart';
|
||||
import '../services/playback_context.dart';
|
||||
import '../services/local_playback_history.dart';
|
||||
import '../services/playback_session.dart';
|
||||
import '../services/playback_subtitle_resolver.dart';
|
||||
import '../services/mpv_sidecar_open_guard.dart';
|
||||
import '../services/playback_progress_tracker.dart';
|
||||
import '../services/playback_source_resolver.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/display_mode_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/sleep_timer_service.dart';
|
||||
import '../services/track_manager.dart';
|
||||
import '../services/track_selection_service.dart';
|
||||
import '../services/ambient_lighting_service.dart';
|
||||
import '../services/video_filter_manager.dart';
|
||||
import '../services/video_pip_manager.dart';
|
||||
@@ -133,7 +137,24 @@ Future<void> _setWakelock(bool enabled) async {
|
||||
/// The in-place media-source transitions a [VideoPlayerScreenState] can run.
|
||||
/// They are mutually exclusive by construction — entry points bail while a
|
||||
/// transition is in flight.
|
||||
enum _PlaybackTransition { idle, reloadingMedia, switchingChannel }
|
||||
enum _PlaybackTransition { idle, switchingSource, reloadingMedia, switchingChannel }
|
||||
|
||||
enum _SubtitleSelectionSlot { primary, secondary }
|
||||
|
||||
/// Identity token for one owner of the in-place playback transition lock.
|
||||
///
|
||||
/// The enum describes what the current owner is doing; it is not itself an
|
||||
/// ownership token because a superseded async flow can outlive a newer flow
|
||||
/// that has since acquired the same enum value.
|
||||
final class _PlaybackTransitionLease {
|
||||
_PlaybackTransitionLease();
|
||||
|
||||
bool _wasSuperseded = false;
|
||||
|
||||
bool get wasSuperseded => _wasSuperseded;
|
||||
|
||||
void _markSuperseded() => _wasSuperseded = true;
|
||||
}
|
||||
|
||||
/// Outcome of [VideoPlayerScreenState._reloadMediaInPlace].
|
||||
enum _MediaReloadOutcome {
|
||||
@@ -281,7 +302,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// In-flight media-source transition. At most one can run at a time: reloads
|
||||
// and channel switches are mutually exclusive.
|
||||
_PlaybackTransition _playbackTransition = _PlaybackTransition.idle;
|
||||
_PlaybackTransitionLease? _playbackTransitionLease;
|
||||
Completer<void>? _playbackTransitionIdleCompleter;
|
||||
bool _playbackIntentShouldPlay = true;
|
||||
int _pendingSubtitleCycleCount = 0;
|
||||
bool _subtitleCycleDrainActive = false;
|
||||
|
||||
/// Media key of the last Watch Together switch failure the user was
|
||||
/// toasted about — the heartbeat retry loop must not re-toast every 2s.
|
||||
@@ -523,13 +548,77 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackSession _updatePlaybackSessionSubtitleSelection(
|
||||
PlaybackSession session,
|
||||
PlaybackSubtitleSelection selection,
|
||||
) {
|
||||
final updated = session.withSubtitleSelection(selection);
|
||||
_playbackSession = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
|
||||
|
||||
int _beginPlaybackGeneration({bool isMediaReload = false}) {
|
||||
if (!isMediaReload) _playbackTransition = _PlaybackTransition.idle;
|
||||
if (!isMediaReload) _forcePlaybackTransitionIdle();
|
||||
return ++_playbackGeneration;
|
||||
}
|
||||
|
||||
_PlaybackTransitionLease? _tryAcquirePlaybackTransition(_PlaybackTransition transition) {
|
||||
assert(transition != _PlaybackTransition.idle);
|
||||
if (_playbackTransition != _PlaybackTransition.idle) return null;
|
||||
final lease = _PlaybackTransitionLease();
|
||||
_playbackTransitionLease = lease;
|
||||
_changePlaybackTransition(transition);
|
||||
return lease;
|
||||
}
|
||||
|
||||
bool _ownsPlaybackTransition(_PlaybackTransitionLease lease, {_PlaybackTransition? expected}) {
|
||||
return identical(_playbackTransitionLease, lease) && (expected == null || _playbackTransition == expected);
|
||||
}
|
||||
|
||||
bool _advancePlaybackTransition(
|
||||
_PlaybackTransitionLease lease,
|
||||
_PlaybackTransition transition, {
|
||||
_PlaybackTransition? expected,
|
||||
}) {
|
||||
assert(transition != _PlaybackTransition.idle);
|
||||
if (!_ownsPlaybackTransition(lease, expected: expected)) return false;
|
||||
_changePlaybackTransition(transition);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _releasePlaybackTransition(_PlaybackTransitionLease lease) {
|
||||
if (!identical(_playbackTransitionLease, lease)) return;
|
||||
_playbackTransitionLease = null;
|
||||
_changePlaybackTransition(_PlaybackTransition.idle);
|
||||
}
|
||||
|
||||
void _forcePlaybackTransitionIdle() {
|
||||
_playbackTransitionLease?._markSuperseded();
|
||||
_playbackTransitionLease = null;
|
||||
_changePlaybackTransition(_PlaybackTransition.idle);
|
||||
}
|
||||
|
||||
void _changePlaybackTransition(_PlaybackTransition transition) {
|
||||
if (_playbackTransition == transition) return;
|
||||
_playbackTransition = transition;
|
||||
if (transition == _PlaybackTransition.idle) {
|
||||
final completer = _playbackTransitionIdleCompleter;
|
||||
_playbackTransitionIdleCompleter = null;
|
||||
if (completer != null && !completer.isCompleted) completer.complete();
|
||||
} else {
|
||||
_playbackTransitionIdleCompleter ??= Completer<void>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForPlaybackTransitionIdle() async {
|
||||
while (mounted && _playbackTransition != _PlaybackTransition.idle) {
|
||||
_playbackTransitionIdleCompleter ??= Completer<void>();
|
||||
await _playbackTransitionIdleCompleter!.future;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new playback attempt: bumps the generation and captures the
|
||||
/// owning player so async continuations can check [_PlaybackAttempt.isCurrent]
|
||||
/// uniformly instead of threading (generation, player) pairs around.
|
||||
@@ -820,6 +909,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
offlineLibraryMode: false,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
);
|
||||
@@ -1216,6 +1306,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
final transitionCompleter = _playbackTransitionIdleCompleter;
|
||||
_playbackTransitionIdleCompleter = null;
|
||||
if (transitionCompleter != null && !transitionCompleter.isCompleted) transitionCompleter.complete();
|
||||
|
||||
_cleanupCompanionRemoteCallbacks();
|
||||
|
||||
// Notify Watch Together guests that host is exiting the player.
|
||||
@@ -1481,9 +1575,104 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
Future<void> _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackChanged(track);
|
||||
|
||||
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async => _trackManager?.onSubtitleTrackChanged(track);
|
||||
Future<void> _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
|
||||
_rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId);
|
||||
await _trackManager?.onSubtitleTrackChanged(track, sourceStreamId: sourceStreamId);
|
||||
}
|
||||
|
||||
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
|
||||
void _rememberNativeSubtitleSelection(SubtitleTrack track, {int? sourceStreamId}) {
|
||||
_rememberNativeSubtitleSelectionForSlot(
|
||||
track,
|
||||
slot: _SubtitleSelectionSlot.primary,
|
||||
sourceStreamId: sourceStreamId,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) {
|
||||
_rememberNativeSubtitleSelectionForSlot(track, slot: _SubtitleSelectionSlot.secondary);
|
||||
_trackManager?.onSecondarySubtitleTrackChanged(track);
|
||||
}
|
||||
|
||||
void _rememberNativeSubtitleSelectionForSlot(
|
||||
SubtitleTrack track, {
|
||||
required _SubtitleSelectionSlot slot,
|
||||
int? sourceStreamId,
|
||||
}) {
|
||||
final session = _playbackSession;
|
||||
if (session == null) return;
|
||||
final currentSelection = session.subtitleSelection;
|
||||
if (track.id == SubtitleTrack.off.id) {
|
||||
_updatePlaybackSessionSubtitleSelection(session, switch (slot) {
|
||||
_SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(),
|
||||
_SubtitleSelectionSlot.secondary => PlaybackSubtitleSelection(
|
||||
primaryTrack: currentSelection.primaryTrack,
|
||||
primarySourceStreamId: currentSelection.primarySourceStreamId,
|
||||
primarySidecar: currentSelection.primarySidecar,
|
||||
),
|
||||
});
|
||||
if (mounted) _setPlayerState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
final info = _currentMediaInfo;
|
||||
final currentPlayer = player;
|
||||
if (info == null || currentPlayer == null) return;
|
||||
|
||||
MediaSubtitleTrack? sourceTrack;
|
||||
final currentSourceId = switch (slot) {
|
||||
_SubtitleSelectionSlot.primary => currentSelection.primarySourceStreamId,
|
||||
_SubtitleSelectionSlot.secondary => currentSelection.secondarySourceStreamId,
|
||||
};
|
||||
final currentSidecar = switch (slot) {
|
||||
_SubtitleSelectionSlot.primary => currentSelection.primarySidecar,
|
||||
_SubtitleSelectionSlot.secondary => currentSelection.secondarySidecar,
|
||||
};
|
||||
if (sourceStreamId != null) {
|
||||
for (final candidate in info.subtitleTracks) {
|
||||
if (candidate.id == sourceStreamId) {
|
||||
sourceTrack = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (track.isExternal && currentSourceId != null && currentSidecar?.track.uri == track.uri) {
|
||||
for (final candidate in info.subtitleTracks) {
|
||||
if (candidate.id == currentSourceId) {
|
||||
sourceTrack = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceTrack ??= findPlexTrackForMpvSubtitle(
|
||||
track,
|
||||
info.subtitleTracks,
|
||||
allMpvTracks: currentPlayer.state.tracks.subtitle,
|
||||
);
|
||||
if (sourceTrack == null) return;
|
||||
|
||||
final sidecar = _sidecarForSourceStreamId(session, sourceTrack.id);
|
||||
final resolvedTrack = PlaybackSubtitleResolver.subtitleTrackForSource(sourceTrack, sidecar: sidecar);
|
||||
final selection = PlaybackSubtitleSelection(
|
||||
primaryTrack: slot == _SubtitleSelectionSlot.primary ? resolvedTrack : currentSelection.primaryTrack,
|
||||
primarySourceStreamId: slot == _SubtitleSelectionSlot.primary
|
||||
? sourceTrack.id
|
||||
: currentSelection.primarySourceStreamId,
|
||||
primarySidecar: slot == _SubtitleSelectionSlot.primary ? sidecar : currentSelection.primarySidecar,
|
||||
secondaryTrack: slot == _SubtitleSelectionSlot.secondary ? resolvedTrack : currentSelection.secondaryTrack,
|
||||
secondarySourceStreamId: slot == _SubtitleSelectionSlot.secondary
|
||||
? sourceTrack.id
|
||||
: currentSelection.secondarySourceStreamId,
|
||||
secondarySidecar: slot == _SubtitleSelectionSlot.secondary ? sidecar : currentSelection.secondarySidecar,
|
||||
);
|
||||
_updatePlaybackSessionSubtitleSelection(session, selection);
|
||||
if (mounted) _setPlayerState(() {});
|
||||
}
|
||||
|
||||
PlaybackSubtitleSidecar? _sidecarForSourceStreamId(PlaybackSession session, int sourceStreamId) {
|
||||
for (final candidate in session.context.result.subtitleSidecars) {
|
||||
if (candidate.sourceStreamId == sourceStreamId) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
|
||||
if (widget.isLive) {
|
||||
|
||||
@@ -227,6 +227,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
capturePlaySessionId(directStreamUrl);
|
||||
videoUrl = _withApiKey(directStreamUrl);
|
||||
playMethod = 'DirectStream';
|
||||
// DirectStream remuxes the selected streams into a new container.
|
||||
// Subtitle streams marked for external delivery are not present in
|
||||
// that container, so expose their server URLs as sidecars just as we
|
||||
// do for transcoded playback. True DirectPlay keeps using the
|
||||
// embedded native tracks and does not incur a sidecar fetch.
|
||||
includeExternalSubtitleDelivery = true;
|
||||
} else {
|
||||
if (!wantsOriginal) {
|
||||
fallbackReason = TranscodeFallbackReason.directPlayOnly;
|
||||
@@ -241,8 +247,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
|
||||
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
|
||||
// present, but lyrics flow through fetchLyrics, not the subtitle path).
|
||||
final externalSubtitles = isTrack
|
||||
? const <SubtitleTrack>[]
|
||||
final subtitleSidecars = isTrack
|
||||
? const <PlaybackSubtitleSidecar>[]
|
||||
: _buildExternalSubtitles(
|
||||
metadata.id,
|
||||
effectiveSourceId,
|
||||
@@ -258,7 +264,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
availableVersions: bundle.availableVersions,
|
||||
videoUrl: videoUrl,
|
||||
mediaInfo: mediaInfo,
|
||||
externalSubtitles: externalSubtitles,
|
||||
subtitleSidecars: subtitleSidecars,
|
||||
isOffline: false,
|
||||
isTranscoding: isTranscoding,
|
||||
fallbackReason: fallbackReason,
|
||||
@@ -344,30 +350,35 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
return path.startsWith('/') ? path : '/$path';
|
||||
}
|
||||
|
||||
List<SubtitleTrack> _buildExternalSubtitles(
|
||||
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
|
||||
String itemId,
|
||||
String? mediaSourceId,
|
||||
MediaSourceInfo mediaInfo, {
|
||||
bool includeExternalDelivery = false,
|
||||
}) {
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
final externalSubtitles = <PlaybackSubtitleSidecar>[];
|
||||
for (final track in mediaInfo.subtitleTracks) {
|
||||
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) continue;
|
||||
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
|
||||
continue;
|
||||
}
|
||||
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
|
||||
if (path == null) continue;
|
||||
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
|
||||
// absolute URL with the api_key query param.
|
||||
final url = _withApiKey(path);
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
|
||||
cleanTrackMetadataValue(track.language),
|
||||
language: cleanTrackMetadataValue(track.languageCode),
|
||||
codec: track.codec,
|
||||
isDefault: track.selected,
|
||||
isForced: track.forced,
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: track.id,
|
||||
track: SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
|
||||
cleanTrackMetadataValue(track.language),
|
||||
language: cleanTrackMetadataValue(track.languageCode),
|
||||
codec: track.codec,
|
||||
isDefault: track.selected,
|
||||
isForced: track.forced,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../mpv/mpv.dart';
|
||||
import '../mpv/player/platform/player_android.dart';
|
||||
import '../mpv/player/player_native.dart';
|
||||
|
||||
enum MpvSidecarOpenOutcome { loaded, stalled, inconclusive, aborted }
|
||||
|
||||
enum _MpvSidecarOpenMode { directMpv, androidFallback }
|
||||
|
||||
/// Watches an mpv open that includes remote subtitle sidecars.
|
||||
///
|
||||
/// mpv discovers the primary audio/video tracks before it synchronously waits
|
||||
/// for external files. Once that milestone is observed, a missing file-loaded
|
||||
/// event can be attributed to the sidecar phase and recovered safely.
|
||||
class MpvSidecarOpenGuard {
|
||||
final Player player;
|
||||
final Duration discoveryTimeout;
|
||||
final Duration fileLoadedTimeout;
|
||||
final _MpvSidecarOpenMode _mode;
|
||||
|
||||
final Completer<void> _primaryReady = Completer<void>();
|
||||
final Completer<void> _fileLoaded = Completer<void>();
|
||||
final Completer<void> _playbackRestart = Completer<void>();
|
||||
final Completer<void> _backendSwitched = Completer<void>();
|
||||
final Completer<void> _fileLoadFailed = Completer<void>();
|
||||
final Completer<void> _aborted = Completer<void>();
|
||||
StreamSubscription<void>? _fileStartedSubscription;
|
||||
StreamSubscription<void>? _primaryReadySubscription;
|
||||
StreamSubscription<void>? _fileLoadedSubscription;
|
||||
StreamSubscription<void>? _fileLoadFailedSubscription;
|
||||
StreamSubscription<void>? _playbackRestartSubscription;
|
||||
StreamSubscription<void>? _backendSwitchedSubscription;
|
||||
bool _mpvLoadStarted = false;
|
||||
|
||||
MpvSidecarOpenGuard._(this.player, this._mode, this.discoveryTimeout, this.fileLoadedTimeout) {
|
||||
_fileStartedSubscription = player.streams.fileStarted.listen((_) {
|
||||
if (_mpvSignalsAreActive) _mpvLoadStarted = true;
|
||||
});
|
||||
_primaryReadySubscription = player.streams.primaryMediaReady.listen((_) {
|
||||
if (_mpvLoadStarted && !_primaryReady.isCompleted) _primaryReady.complete();
|
||||
}, onDone: _abort);
|
||||
_fileLoadedSubscription = player.streams.fileLoaded.listen((_) {
|
||||
if (_mpvLoadStarted && !_fileLoaded.isCompleted) _fileLoaded.complete();
|
||||
}, onDone: _abort);
|
||||
_fileLoadFailedSubscription = player.streams.fileLoadFailed.listen((_) {
|
||||
if (_mpvLoadStarted && !_fileLoadFailed.isCompleted) {
|
||||
_fileLoadFailed.complete();
|
||||
}
|
||||
});
|
||||
if (_mode == _MpvSidecarOpenMode.androidFallback) {
|
||||
_playbackRestartSubscription = player.streams.playbackRestart.listen((_) {
|
||||
if (!_backendSwitched.isCompleted && !_playbackRestart.isCompleted) {
|
||||
_playbackRestart.complete();
|
||||
}
|
||||
}, onDone: _abort);
|
||||
_backendSwitchedSubscription = player.streams.backendSwitched.listen((_) {
|
||||
if (!_backendSwitched.isCompleted) _backendSwitched.complete();
|
||||
}, onDone: _abort);
|
||||
}
|
||||
}
|
||||
|
||||
static MpvSidecarOpenGuard? armIfNeeded({
|
||||
required Player player,
|
||||
required List<SubtitleTrack>? subtitles,
|
||||
Duration discoveryTimeout = const Duration(seconds: 10),
|
||||
Duration fileLoadedTimeout = const Duration(seconds: 10),
|
||||
}) {
|
||||
if (!_hasRemoteSidecar(subtitles)) return null;
|
||||
final mode = switch (player) {
|
||||
PlayerNative() => _MpvSidecarOpenMode.directMpv,
|
||||
PlayerAndroid(usingMpvFallback: true) => _MpvSidecarOpenMode.directMpv,
|
||||
PlayerAndroid() => _MpvSidecarOpenMode.androidFallback,
|
||||
_ => null,
|
||||
};
|
||||
if (mode == null) return null;
|
||||
return MpvSidecarOpenGuard._(player, mode, discoveryTimeout, fileLoadedTimeout);
|
||||
}
|
||||
|
||||
static MpvSidecarOpenGuard armForTesting({
|
||||
required Player player,
|
||||
required Duration discoveryTimeout,
|
||||
required Duration fileLoadedTimeout,
|
||||
bool startsOnAndroidExoPlayer = false,
|
||||
}) {
|
||||
return MpvSidecarOpenGuard._(
|
||||
player,
|
||||
startsOnAndroidExoPlayer ? _MpvSidecarOpenMode.androidFallback : _MpvSidecarOpenMode.directMpv,
|
||||
discoveryTimeout,
|
||||
fileLoadedTimeout,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MpvSidecarOpenOutcome> wait() async {
|
||||
final discoveryClock = Stopwatch()..start();
|
||||
try {
|
||||
if (_mode == _MpvSidecarOpenMode.androidFallback) {
|
||||
final androidOutcome = await _waitForAndroidBackendDecision();
|
||||
if (androidOutcome != null) return androidOutcome;
|
||||
}
|
||||
final remainingDiscoveryTime = discoveryTimeout - discoveryClock.elapsed;
|
||||
if (remainingDiscoveryTime <= Duration.zero) return MpvSidecarOpenOutcome.inconclusive;
|
||||
return await _waitForMpvLoad(remainingDiscoveryTime);
|
||||
} finally {
|
||||
discoveryClock.stop();
|
||||
await dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<MpvSidecarOpenOutcome?> _waitForAndroidBackendDecision() async {
|
||||
try {
|
||||
final signal = await Future.any([
|
||||
_playbackRestart.future.then((_) => _MpvSidecarOpenSignal.playbackRestart),
|
||||
_backendSwitched.future.then((_) => _MpvSidecarOpenSignal.backendSwitched),
|
||||
_aborted.future.then((_) => _MpvSidecarOpenSignal.aborted),
|
||||
]).timeout(discoveryTimeout);
|
||||
return switch (signal) {
|
||||
_MpvSidecarOpenSignal.playbackRestart => MpvSidecarOpenOutcome.loaded,
|
||||
_MpvSidecarOpenSignal.backendSwitched => null,
|
||||
_MpvSidecarOpenSignal.aborted => MpvSidecarOpenOutcome.aborted,
|
||||
_ => throw StateError('Unexpected Android sidecar-open signal: $signal'),
|
||||
};
|
||||
} on TimeoutException {
|
||||
return MpvSidecarOpenOutcome.inconclusive;
|
||||
}
|
||||
}
|
||||
|
||||
Future<MpvSidecarOpenOutcome> _waitForMpvLoad(Duration remainingDiscoveryTime) async {
|
||||
final _MpvSidecarOpenSignal first;
|
||||
try {
|
||||
first = await Future.any([
|
||||
_primaryReady.future.then((_) => _MpvSidecarOpenSignal.primaryReady),
|
||||
_fileLoaded.future.then((_) => _MpvSidecarOpenSignal.fileLoaded),
|
||||
_fileLoadFailed.future.then((_) => _MpvSidecarOpenSignal.fileLoadFailed),
|
||||
_aborted.future.then((_) => _MpvSidecarOpenSignal.aborted),
|
||||
]).timeout(remainingDiscoveryTime);
|
||||
} on TimeoutException {
|
||||
return MpvSidecarOpenOutcome.inconclusive;
|
||||
}
|
||||
|
||||
if (first == _MpvSidecarOpenSignal.fileLoaded) return MpvSidecarOpenOutcome.loaded;
|
||||
if (first == _MpvSidecarOpenSignal.aborted) return MpvSidecarOpenOutcome.aborted;
|
||||
if (first == _MpvSidecarOpenSignal.fileLoadFailed) return MpvSidecarOpenOutcome.inconclusive;
|
||||
|
||||
try {
|
||||
final afterPrimary = await Future.any([
|
||||
_fileLoaded.future.then((_) => _MpvSidecarOpenSignal.fileLoaded),
|
||||
_fileLoadFailed.future.then((_) => _MpvSidecarOpenSignal.fileLoadFailed),
|
||||
_aborted.future.then((_) => _MpvSidecarOpenSignal.aborted),
|
||||
]).timeout(fileLoadedTimeout);
|
||||
return switch (afterPrimary) {
|
||||
_MpvSidecarOpenSignal.fileLoaded => MpvSidecarOpenOutcome.loaded,
|
||||
_MpvSidecarOpenSignal.fileLoadFailed => MpvSidecarOpenOutcome.inconclusive,
|
||||
_MpvSidecarOpenSignal.aborted => MpvSidecarOpenOutcome.aborted,
|
||||
_ => throw StateError('Unexpected post-discovery sidecar-open signal: $afterPrimary'),
|
||||
};
|
||||
} on TimeoutException {
|
||||
return MpvSidecarOpenOutcome.stalled;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await Future.wait(<Future<void>>[
|
||||
?_fileStartedSubscription?.cancel(),
|
||||
?_primaryReadySubscription?.cancel(),
|
||||
?_fileLoadedSubscription?.cancel(),
|
||||
?_fileLoadFailedSubscription?.cancel(),
|
||||
?_playbackRestartSubscription?.cancel(),
|
||||
?_backendSwitchedSubscription?.cancel(),
|
||||
]);
|
||||
_fileStartedSubscription = null;
|
||||
_primaryReadySubscription = null;
|
||||
_fileLoadedSubscription = null;
|
||||
_fileLoadFailedSubscription = null;
|
||||
_playbackRestartSubscription = null;
|
||||
_backendSwitchedSubscription = null;
|
||||
}
|
||||
|
||||
void _abort() {
|
||||
if (!_aborted.isCompleted) _aborted.complete();
|
||||
}
|
||||
|
||||
bool get _mpvSignalsAreActive => _mode == _MpvSidecarOpenMode.directMpv || _backendSwitched.isCompleted;
|
||||
|
||||
static bool _hasRemoteSidecar(List<SubtitleTrack>? subtitles) {
|
||||
for (final subtitle in subtitles ?? const <SubtitleTrack>[]) {
|
||||
final uri = Uri.tryParse(subtitle.uri ?? '');
|
||||
if (uri?.scheme == 'http' || uri?.scheme == 'https') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum _MpvSidecarOpenSignal { primaryReady, fileLoaded, playbackRestart, backendSwitched, fileLoadFailed, aborted }
|
||||
@@ -159,6 +159,7 @@ class PlaybackInitializationService {
|
||||
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
|
||||
AudioQualityPreset? audioQualityPreset,
|
||||
int? selectedAudioStreamId,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
String? sessionIdentifier,
|
||||
String? transcodeSessionId,
|
||||
}) async {
|
||||
@@ -204,6 +205,7 @@ class PlaybackInitializationService {
|
||||
qualityPreset: qualityPreset,
|
||||
audioQualityPreset: audioQualityPreset,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
),
|
||||
@@ -238,7 +240,7 @@ class PlaybackInitializationService {
|
||||
appLogger.d('Could not load cached media info for offline playback', error: e);
|
||||
}
|
||||
|
||||
final sidecarSubtitles = await _discoverSidecarSubtitles(
|
||||
final subtitleSidecars = await _discoverSidecarSubtitles(
|
||||
offlineVideoPath,
|
||||
metadata: metadata,
|
||||
mediaInfo: mediaInfo,
|
||||
@@ -248,7 +250,7 @@ class PlaybackInitializationService {
|
||||
availableVersions: const [],
|
||||
videoUrl: _formatVideoUrl(offlineVideoPath),
|
||||
mediaInfo: mediaInfo,
|
||||
externalSubtitles: sidecarSubtitles,
|
||||
subtitleSidecars: subtitleSidecars,
|
||||
isOffline: true,
|
||||
playMethod: 'DirectPlay',
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
@@ -279,12 +281,12 @@ class PlaybackInitializationService {
|
||||
/// use `{video}_subs/{trackId}.{ext}` with a legacy `{videoDir}/subtitles/*`
|
||||
/// fallback. SAF videos are `content://` URIs, so sidecars live in the
|
||||
/// app-managed subtitle directory keyed by server/item id.
|
||||
Future<List<SubtitleTrack>> _discoverSidecarSubtitles(
|
||||
Future<List<PlaybackSubtitleSidecar>> _discoverSidecarSubtitles(
|
||||
String videoPath, {
|
||||
required MediaItem metadata,
|
||||
MediaSourceInfo? mediaInfo,
|
||||
}) async {
|
||||
final subtitles = <SubtitleTrack>[];
|
||||
final subtitles = <PlaybackSubtitleSidecar>[];
|
||||
final dirs = videoPath.startsWith('content://')
|
||||
? await _safSidecarSubtitleDirs(metadata)
|
||||
: await _fileSidecarSubtitleDirs(videoPath);
|
||||
@@ -302,13 +304,16 @@ class PlaybackInitializationService {
|
||||
: null;
|
||||
|
||||
subtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
Uri.file(entity.path).toString(),
|
||||
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? 'Subtitle $fileName',
|
||||
language: cachedTrack?.languageCode,
|
||||
codec: cachedTrack?.codec,
|
||||
isDefault: cachedTrack?.selected ?? false,
|
||||
isForced: cachedTrack?.forced ?? false,
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: trackId,
|
||||
track: SubtitleTrack.uri(
|
||||
Uri.file(entity.path).toString(),
|
||||
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? 'Subtitle $fileName',
|
||||
language: cachedTrack?.languageCode,
|
||||
codec: cachedTrack?.codec,
|
||||
isDefault: cachedTrack?.selected ?? false,
|
||||
isForced: cachedTrack?.forced ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ class PlaybackInitializationOptions {
|
||||
/// server pick".
|
||||
final int? selectedAudioStreamId;
|
||||
|
||||
/// Preferred subtitle carried across navigation/reloads. Backends that put
|
||||
/// embedded subtitles in the rendition can use this during negotiation;
|
||||
/// sidecar-capable backends keep subtitle delivery independent.
|
||||
final SubtitleTrack? preferredSubtitleTrack;
|
||||
|
||||
/// Plex transcode `X-Plex-Session-Identifier`. Required for Plex transcode.
|
||||
final String? sessionIdentifier;
|
||||
|
||||
@@ -54,11 +59,25 @@ class PlaybackInitializationOptions {
|
||||
this.qualityPreset = TranscodeQualityPreset.original,
|
||||
this.audioQualityPreset,
|
||||
this.selectedAudioStreamId,
|
||||
this.preferredSubtitleTrack,
|
||||
this.sessionIdentifier,
|
||||
this.transcodeSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// A subtitle sidecar that can be attached independently of the primary
|
||||
/// media stream.
|
||||
///
|
||||
/// [sourceStreamId] links the playable URI back to the authoritative server
|
||||
/// subtitle catalog. It is nullable for legacy/offline files whose filename
|
||||
/// cannot be mapped to cached stream metadata.
|
||||
class PlaybackSubtitleSidecar {
|
||||
final int? sourceStreamId;
|
||||
final SubtitleTrack track;
|
||||
|
||||
const PlaybackSubtitleSidecar({required this.sourceStreamId, required this.track});
|
||||
}
|
||||
|
||||
/// Reason the transcode branch fell back to direct play.
|
||||
enum TranscodeFallbackReason {
|
||||
/// Plex decision said only direct-play is available.
|
||||
@@ -73,7 +92,10 @@ class PlaybackInitializationResult {
|
||||
final List<MediaVersion> availableVersions;
|
||||
final String? videoUrl;
|
||||
final MediaSourceInfo? mediaInfo;
|
||||
final List<SubtitleTrack> externalSubtitles;
|
||||
|
||||
/// Complete sidecar catalog for this source. Callers must resolve the active
|
||||
/// subtitle choice and attach only the selected sidecar(s) at open time.
|
||||
final List<PlaybackSubtitleSidecar> subtitleSidecars;
|
||||
final bool isOffline;
|
||||
|
||||
/// `true` when [videoUrl] points at a backend transcoding stream.
|
||||
@@ -113,11 +135,14 @@ class PlaybackInitializationResult {
|
||||
? availableVersions[selectedMediaIndex]
|
||||
: null;
|
||||
|
||||
/// Compatibility view for consumers that only need the playable tracks.
|
||||
List<SubtitleTrack> get externalSubtitles => subtitleSidecars.map((sidecar) => sidecar.track).toList(growable: false);
|
||||
|
||||
PlaybackInitializationResult({
|
||||
required this.availableVersions,
|
||||
this.videoUrl,
|
||||
this.mediaInfo,
|
||||
this.externalSubtitles = const [],
|
||||
this.subtitleSidecars = const [],
|
||||
this.isOffline = false,
|
||||
this.isTranscoding = false,
|
||||
this.fallbackReason,
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../media/media_version.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import 'playback_context.dart';
|
||||
import 'playback_initialization_types.dart';
|
||||
import 'playback_subtitle_resolver.dart';
|
||||
|
||||
/// Immutable snapshot of everything that describes the item currently loaded
|
||||
/// in the player: the resolver output plus the effective (post-fallback,
|
||||
@@ -24,8 +25,14 @@ class PlaybackSession {
|
||||
/// Effective media version id, refined from the resolver's clamped
|
||||
/// version index when the version list provides one.
|
||||
final String? mediaSourceId;
|
||||
final PlaybackSubtitleSelection subtitleSelection;
|
||||
|
||||
const PlaybackSession({required this.context, required this.qualityPreset, this.mediaSourceId});
|
||||
const PlaybackSession({
|
||||
required this.context,
|
||||
required this.qualityPreset,
|
||||
this.mediaSourceId,
|
||||
this.subtitleSelection = const PlaybackSubtitleSelection.off(),
|
||||
});
|
||||
|
||||
/// Derives the effective selections from a resolved [context]:
|
||||
/// quality falls back to original when the backend rejected the requested
|
||||
@@ -34,6 +41,7 @@ class PlaybackSession {
|
||||
PlaybackContext context, {
|
||||
required TranscodeQualityPreset requestedQualityPreset,
|
||||
String? requestedMediaSourceId,
|
||||
PlaybackSubtitleSelection subtitleSelection = const PlaybackSubtitleSelection.off(),
|
||||
}) {
|
||||
final result = context.result;
|
||||
final fellBackToOriginal = result.fallbackReason != null && !requestedQualityPreset.isOriginal;
|
||||
@@ -44,6 +52,16 @@ class PlaybackSession {
|
||||
result.selectedMediaSourceId ??
|
||||
mediaSourceIdForIndex(result.availableVersions, result.selectedMediaIndex) ??
|
||||
requestedMediaSourceId,
|
||||
subtitleSelection: subtitleSelection,
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackSession withSubtitleSelection(PlaybackSubtitleSelection selection) {
|
||||
return PlaybackSession(
|
||||
context: context,
|
||||
qualityPreset: qualityPreset,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleSelection: selection,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../models/audio_quality_preset.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
import 'playback_context.dart';
|
||||
import 'playback_initialization_service.dart';
|
||||
@@ -31,6 +32,7 @@ class PlaybackSourceResolver {
|
||||
required TranscodeQualityPreset qualityPreset,
|
||||
AudioQualityPreset? audioQualityPreset,
|
||||
int? selectedAudioStreamId,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
String? sessionIdentifier,
|
||||
String? transcodeSessionId,
|
||||
bool? preferOffline,
|
||||
@@ -46,6 +48,7 @@ class PlaybackSourceResolver {
|
||||
qualityPreset: qualityPreset,
|
||||
audioQualityPreset: audioQualityPreset,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_user_profile.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import 'playback_initialization_types.dart';
|
||||
import 'track_selection_service.dart';
|
||||
|
||||
/// A source-catalog subtitle choice.
|
||||
///
|
||||
/// Keeping "off" distinct from a numeric stream id prevents backend wire
|
||||
/// conventions (notably Plex's `0`) from colliding with real Jellyfin ids.
|
||||
final class PlaybackSourceSubtitleChoice {
|
||||
final bool isOff;
|
||||
final int? sourceStreamId;
|
||||
|
||||
const PlaybackSourceSubtitleChoice.off() : this._(isOff: true);
|
||||
|
||||
const PlaybackSourceSubtitleChoice.source(int sourceStreamId) : this._(isOff: false, sourceStreamId: sourceStreamId);
|
||||
|
||||
const PlaybackSourceSubtitleChoice._({required this.isOff, this.sourceStreamId});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PlaybackSourceSubtitleChoice && other.isOff == isOff && other.sourceStreamId == sourceStreamId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isOff, sourceStreamId);
|
||||
}
|
||||
|
||||
/// Effective subtitle choice for one player open.
|
||||
///
|
||||
/// The source IDs remain stable across player reloads, while [primaryTrack]
|
||||
/// and [secondaryTrack] carry the matching metadata used to select the newly
|
||||
/// discovered native tracks after open.
|
||||
class PlaybackSubtitleSelection {
|
||||
final SubtitleTrack primaryTrack;
|
||||
final int? primarySourceStreamId;
|
||||
final PlaybackSubtitleSidecar? primarySidecar;
|
||||
final SubtitleTrack? secondaryTrack;
|
||||
final int? secondarySourceStreamId;
|
||||
final PlaybackSubtitleSidecar? secondarySidecar;
|
||||
|
||||
const PlaybackSubtitleSelection({
|
||||
required this.primaryTrack,
|
||||
this.primarySourceStreamId,
|
||||
this.primarySidecar,
|
||||
this.secondaryTrack,
|
||||
this.secondarySourceStreamId,
|
||||
this.secondarySidecar,
|
||||
});
|
||||
|
||||
const PlaybackSubtitleSelection.off()
|
||||
: primaryTrack = SubtitleTrack.off,
|
||||
primarySourceStreamId = null,
|
||||
primarySidecar = null,
|
||||
secondaryTrack = null,
|
||||
secondarySourceStreamId = null,
|
||||
secondarySidecar = null;
|
||||
|
||||
bool get isOff => primaryTrack.id == SubtitleTrack.off.id;
|
||||
|
||||
List<SubtitleTrack> get sidecarsAtOpen {
|
||||
final tracks = <SubtitleTrack>[];
|
||||
final primary = primarySidecar?.track;
|
||||
if (primary != null) tracks.add(primary);
|
||||
final secondary = secondarySidecar?.track;
|
||||
if (secondary != null && secondary.uri != primary?.uri) tracks.add(secondary);
|
||||
return tracks;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the server subtitle catalog before opening the native player, so
|
||||
/// only the active sidecar is part of the open operation.
|
||||
class PlaybackSubtitleResolver {
|
||||
const PlaybackSubtitleResolver._();
|
||||
|
||||
static PlaybackSubtitleSelection resolve({
|
||||
required MediaItem metadata,
|
||||
required MediaSourceInfo? mediaInfo,
|
||||
required List<PlaybackSubtitleSidecar> sidecars,
|
||||
MediaServerUserProfile? profileSettings,
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
}) {
|
||||
final candidates = <_SubtitleCandidate>[];
|
||||
final matchedSidecars = <PlaybackSubtitleSidecar>{};
|
||||
|
||||
for (final sourceTrack in mediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[]) {
|
||||
final sidecar = sidecars.where((candidate) => candidate.sourceStreamId == sourceTrack.id).firstOrNull;
|
||||
if (sidecar != null) matchedSidecars.add(sidecar);
|
||||
candidates.add(
|
||||
_SubtitleCandidate(
|
||||
track: subtitleTrackForSource(sourceTrack, sidecar: sidecar),
|
||||
sourceStreamId: sourceTrack.id,
|
||||
sidecar: sidecar,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy/offline sidecars may not have cached source metadata. They still
|
||||
// participate in normal default/profile selection and remain playable.
|
||||
for (final sidecar in sidecars) {
|
||||
if (matchedSidecars.contains(sidecar)) continue;
|
||||
candidates.add(
|
||||
_SubtitleCandidate(track: sidecar.track, sourceStreamId: sidecar.sourceStreamId, sidecar: sidecar),
|
||||
);
|
||||
}
|
||||
|
||||
final availableTracks = candidates.map((candidate) => candidate.track).toList(growable: false);
|
||||
final service = TrackSelectionService(
|
||||
profileSettings: profileSettings,
|
||||
metadata: metadata,
|
||||
plexMediaInfo: mediaInfo,
|
||||
);
|
||||
final selectedAudio = service.selectAudioTrack(_audioTracksForSource(mediaInfo), preferredAudioTrack)?.track;
|
||||
final primaryResult = service.selectSubtitleTrack(availableTracks, preferredSubtitleTrack, selectedAudio);
|
||||
final primary = primaryResult.track;
|
||||
if (primary.id == SubtitleTrack.off.id) return const PlaybackSubtitleSelection.off();
|
||||
|
||||
final primaryCandidate = candidates.where((candidate) => candidate.track.id == primary.id).firstOrNull;
|
||||
if (primaryCandidate == null) return const PlaybackSubtitleSelection.off();
|
||||
|
||||
_SubtitleCandidate? secondaryCandidate;
|
||||
if (preferredSecondarySubtitleTrack != null && preferredSecondarySubtitleTrack.id != SubtitleTrack.off.id) {
|
||||
final secondary = service.findBestSubtitleMatch(availableTracks, preferredSecondarySubtitleTrack);
|
||||
secondaryCandidate = candidates
|
||||
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
return PlaybackSubtitleSelection(
|
||||
primaryTrack: primaryCandidate.track,
|
||||
primarySourceStreamId: primaryCandidate.sourceStreamId,
|
||||
primarySidecar: primaryCandidate.sidecar,
|
||||
secondaryTrack: secondaryCandidate?.track,
|
||||
secondarySourceStreamId: secondaryCandidate?.sourceStreamId,
|
||||
secondarySidecar: secondaryCandidate?.sidecar,
|
||||
);
|
||||
}
|
||||
|
||||
/// Stable source descriptor used for an explicit user selection. Supplying
|
||||
/// this as the next open's preferred track makes it the highest-priority
|
||||
/// choice without retaining a stale sidecar URL.
|
||||
static SubtitleTrack? preferredTrackForSource(MediaSourceInfo? mediaInfo, int sourceStreamId) {
|
||||
final sourceTrack = mediaInfo?.subtitleTracks.where((track) => track.id == sourceStreamId).firstOrNull;
|
||||
return sourceTrack == null ? null : subtitleTrackForSource(sourceTrack);
|
||||
}
|
||||
|
||||
static SubtitleTrack subtitleTrackForSource(MediaSubtitleTrack sourceTrack, {PlaybackSubtitleSidecar? sidecar}) {
|
||||
final playable = sidecar?.track;
|
||||
return SubtitleTrack(
|
||||
id: 'source:${sourceTrack.id}',
|
||||
title: playable?.title ?? sourceTrack.displayTitle ?? sourceTrack.title ?? sourceTrack.language,
|
||||
language: playable?.language ?? sourceTrack.languageCode ?? sourceTrack.language,
|
||||
codec: playable?.codec ?? sourceTrack.codec,
|
||||
isDefault: sourceTrack.selected,
|
||||
isForced: sourceTrack.forced,
|
||||
isExternal: playable != null,
|
||||
uri: playable?.uri,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve a server source row to a track already loaded by direct play.
|
||||
/// Resolved sidecars only match by their stable URL key (or the current
|
||||
/// source identity); fuzzy language/codec matching must not select a
|
||||
/// different sidecar that happens to have similar metadata. A server's
|
||||
/// `external delivery` flag is not sufficient to classify a direct-play
|
||||
/// row as a sidecar: Jellyfin applies it to embedded tracks that mpv still
|
||||
/// discovers in the original file.
|
||||
static SubtitleTrack? nativeTrackForDirectPlaySource({
|
||||
required MediaSubtitleTrack sourceTrack,
|
||||
required List<SubtitleTrack> nativeTracks,
|
||||
required List<MediaSubtitleTrack> allSourceTracks,
|
||||
required bool isResolvedSidecar,
|
||||
int? currentSourceStreamId,
|
||||
SubtitleTrack? selectedNativeTrack,
|
||||
}) {
|
||||
if (isResolvedSidecar) {
|
||||
final key = sourceTrack.key;
|
||||
if (key != null && key.isNotEmpty) {
|
||||
for (final candidate in nativeTracks) {
|
||||
if (candidate.isExternal && candidate.uri?.contains(key) == true) return candidate;
|
||||
}
|
||||
}
|
||||
if (currentSourceStreamId == sourceTrack.id &&
|
||||
selectedNativeTrack != null &&
|
||||
selectedNativeTrack.id != SubtitleTrack.off.id &&
|
||||
selectedNativeTrack.isExternal) {
|
||||
return selectedNativeTrack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks);
|
||||
}
|
||||
|
||||
static PlaybackSourceSubtitleChoice nextSourceChoice(
|
||||
List<MediaSubtitleTrack> tracks,
|
||||
PlaybackSourceSubtitleChoice currentChoice,
|
||||
) {
|
||||
return advanceSourceChoice(tracks, currentChoice, 1);
|
||||
}
|
||||
|
||||
static PlaybackSourceSubtitleChoice advanceSourceChoice(
|
||||
List<MediaSubtitleTrack> tracks,
|
||||
PlaybackSourceSubtitleChoice currentChoice,
|
||||
int advances,
|
||||
) {
|
||||
final choices = <PlaybackSourceSubtitleChoice>[
|
||||
const PlaybackSourceSubtitleChoice.off(),
|
||||
...tracks.map((track) => PlaybackSourceSubtitleChoice.source(track.id)),
|
||||
];
|
||||
final currentIndex = choices.indexOf(currentChoice);
|
||||
final normalizedCurrentIndex = currentIndex < 0 ? 0 : currentIndex;
|
||||
return choices[(normalizedCurrentIndex + advances) % choices.length];
|
||||
}
|
||||
|
||||
static List<AudioTrack> _audioTracksForSource(MediaSourceInfo? mediaInfo) {
|
||||
return [
|
||||
for (final track in mediaInfo?.audioTracks ?? const <MediaAudioTrack>[])
|
||||
AudioTrack(
|
||||
id: 'source:${track.id}',
|
||||
title: track.displayTitle ?? track.title ?? track.language,
|
||||
language: track.languageCode ?? track.language,
|
||||
codec: track.codec,
|
||||
channels: track.channels,
|
||||
isDefault: track.selected,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _SubtitleCandidate {
|
||||
final SubtitleTrack track;
|
||||
final int? sourceStreamId;
|
||||
final PlaybackSubtitleSidecar? sidecar;
|
||||
|
||||
const _SubtitleCandidate({required this.track, required this.sourceStreamId, required this.sidecar});
|
||||
}
|
||||
@@ -74,6 +74,7 @@ import 'plex_lyrics_parser.dart';
|
||||
import 'plex_mappers.dart';
|
||||
import 'plex_playback_mapper.dart';
|
||||
import 'playback_initialization_types.dart';
|
||||
import 'track_selection_service.dart';
|
||||
|
||||
part 'plex_client/parts/live_tv.dart';
|
||||
part 'plex_client/parts/playlists.dart';
|
||||
@@ -3019,7 +3020,7 @@ class PlexClient
|
||||
}
|
||||
|
||||
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
|
||||
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
|
||||
final selectedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack);
|
||||
final result = await buildTranscodeStartPath(
|
||||
ratingKey: options.metadata.id,
|
||||
mediaIndex: data.selectedMediaIndex,
|
||||
@@ -3033,12 +3034,12 @@ class PlexClient
|
||||
|
||||
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
||||
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
||||
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
||||
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: transcodeUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: sidecarSubs,
|
||||
subtitleSidecars: subtitleSidecars,
|
||||
isOffline: false,
|
||||
isTranscoding: true,
|
||||
activeAudioStreamId: resolvedAudioId,
|
||||
@@ -3055,7 +3056,7 @@ class PlexClient
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: data.videoUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
||||
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
|
||||
isOffline: false,
|
||||
playMethod: 'DirectPlay',
|
||||
playSessionId: options.sessionIdentifier,
|
||||
@@ -3084,7 +3085,7 @@ class PlexClient
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: data.videoUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
||||
subtitleSidecars: _buildExternalSubtitles(data.mediaInfo),
|
||||
isOffline: false,
|
||||
isTranscoding: false,
|
||||
fallbackReason: fallbackReason,
|
||||
@@ -3115,6 +3116,33 @@ class PlexClient
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaSubtitleTrack? _resolveTranscodeSubtitleTrack(MediaSourceInfo? info, SubtitleTrack? preferred) {
|
||||
if (info == null) return null;
|
||||
if (preferred == null) return _selectedSubtitleTrack(info);
|
||||
if (preferred.id == SubtitleTrack.off.id) return null;
|
||||
|
||||
MediaSubtitleTrack? matched;
|
||||
if (preferred.id.startsWith('source:')) {
|
||||
final sourceId = int.tryParse(preferred.id.substring('source:'.length));
|
||||
if (sourceId != null) {
|
||||
for (final track in info.subtitleTracks) {
|
||||
if (track.id == sourceId) {
|
||||
matched = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched ??= findPlexTrackForMpvSubtitle(preferred, info.subtitleTracks);
|
||||
return matched ?? _selectedSubtitleTrack(info);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
MediaSubtitleTrack? resolveTranscodeSubtitleTrackForTesting(MediaSourceInfo? info, SubtitleTrack? preferred) {
|
||||
return _resolveTranscodeSubtitleTrack(info, preferred);
|
||||
}
|
||||
|
||||
/// Build the absolute URL for an external subtitle track on this Plex
|
||||
/// server. Returns `null` for tracks that aren't external (no `/library/
|
||||
/// streams/{id}` key) or when the server has no auth token.
|
||||
@@ -3167,19 +3195,19 @@ class PlexClient
|
||||
|
||||
/// Build subtitle sidecars for Plex transcode playback. Keyed tracks remain
|
||||
/// external; the selected internal track is delivered by the HLS rendition.
|
||||
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
if (mediaInfo == null) return const [];
|
||||
if (config.token == null) {
|
||||
appLogger.w('No auth token available for transcode sidecar subtitles');
|
||||
return const [];
|
||||
}
|
||||
|
||||
final tracks = <SubtitleTrack>[];
|
||||
final tracks = <PlaybackSubtitleSidecar>[];
|
||||
for (final sub in mediaInfo.subtitleTracks) {
|
||||
try {
|
||||
final url = _buildSidecarSubtitleUrl(sub);
|
||||
if (url == null) continue;
|
||||
tracks.add(_subtitleTrackFromMediaTrack(sub, url));
|
||||
tracks.add(PlaybackSubtitleSidecar(sourceStreamId: sub.id, track: _subtitleTrackFromMediaTrack(sub, url)));
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
||||
}
|
||||
@@ -3189,12 +3217,12 @@ class PlexClient
|
||||
|
||||
@visibleForTesting
|
||||
List<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
|
||||
return _buildTranscodeSidecarSubtitles(mediaInfo);
|
||||
return _buildTranscodeSidecarSubtitles(mediaInfo).map((sidecar) => sidecar.track).toList(growable: false);
|
||||
}
|
||||
|
||||
/// Build list of external subtitle tracks from media info
|
||||
List<SubtitleTrack> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
|
||||
final externalSubtitles = <PlaybackSubtitleSidecar>[];
|
||||
|
||||
if (mediaInfo == null) {
|
||||
return externalSubtitles;
|
||||
@@ -3215,13 +3243,16 @@ class PlexClient
|
||||
}
|
||||
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
||||
language: plexTrack.languageCode,
|
||||
codec: plexTrack.codec,
|
||||
isDefault: plexTrack.selected,
|
||||
isForced: plexTrack.forced,
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: plexTrack.id,
|
||||
track: SubtitleTrack.uri(
|
||||
url,
|
||||
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
||||
language: plexTrack.languageCode,
|
||||
codec: plexTrack.codec,
|
||||
isDefault: plexTrack.selected,
|
||||
isForced: plexTrack.forced,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -324,7 +324,7 @@ class TrackManager {
|
||||
}
|
||||
|
||||
/// Handle subtitle track changes — save stream selection and language preference.
|
||||
Future<void> onSubtitleTrackChanged(SubtitleTrack track) async {
|
||||
Future<void> onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async {
|
||||
final info = mediaInfo;
|
||||
final partId = await _guardTrackChange(info);
|
||||
if (partId == null) return;
|
||||
@@ -334,6 +334,9 @@ class TrackManager {
|
||||
if (track.id == 'no') {
|
||||
streamID = 0;
|
||||
appLogger.i('User turned subtitles off, saving preference');
|
||||
} else if (sourceStreamId != null) {
|
||||
streamID = sourceStreamId;
|
||||
appLogger.d('Using authoritative subtitle streamID $streamID');
|
||||
} else if (info != null) {
|
||||
final matchedPlex = findPlexTrackForMpvSubtitle(
|
||||
track,
|
||||
|
||||
@@ -353,12 +353,12 @@ class TrackSelectionResult<T> {
|
||||
/// Service for selecting and applying audio and subtitle tracks based on
|
||||
/// preferences, user profiles, and per-media settings.
|
||||
class TrackSelectionService {
|
||||
final Player player;
|
||||
final Player? player;
|
||||
final MediaServerUserProfile? profileSettings;
|
||||
final MediaItem metadata;
|
||||
final MediaSourceInfo? plexMediaInfo;
|
||||
|
||||
TrackSelectionService({required this.player, this.profileSettings, required this.metadata, this.plexMediaInfo});
|
||||
TrackSelectionService({this.player, this.profileSettings, required this.metadata, this.plexMediaInfo});
|
||||
|
||||
/// Build list of preferred languages from a user profile
|
||||
List<String> _buildPreferredLanguages(MediaServerUserProfile profile, {required bool isAudio}) {
|
||||
@@ -568,6 +568,13 @@ class TrackSelectionService {
|
||||
return SubtitleTrack.off;
|
||||
}
|
||||
|
||||
final preferredUri = preferred.uri;
|
||||
if (preferredUri != null) {
|
||||
for (final track in availableTracks) {
|
||||
if (track.uri == preferredUri) return track;
|
||||
}
|
||||
}
|
||||
|
||||
return findBestTrackMatch<SubtitleTrack>(
|
||||
availableTracks,
|
||||
preferred,
|
||||
@@ -801,6 +808,11 @@ class TrackSelectionService {
|
||||
Function(AudioTrack)? onAudioTrackChanged,
|
||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||
}) async {
|
||||
final player = this.player;
|
||||
if (player == null) {
|
||||
throw StateError('A player is required to apply track selections');
|
||||
}
|
||||
|
||||
// Wait for tracks to be loaded
|
||||
if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
|
||||
try {
|
||||
|
||||
@@ -5,9 +5,12 @@ import '../../../media/media_version.dart';
|
||||
import '../../../media/media_source_info.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/playback_subtitle_resolver.dart';
|
||||
import '../../../services/shader_service.dart';
|
||||
import '../helpers/track_filter_helper.dart';
|
||||
|
||||
enum SubtitleDownloadApplyOutcome { applied, timedOut, busy, superseded, unavailable, failed }
|
||||
|
||||
/// Immutable configuration for track/chapter control widgets.
|
||||
class TrackControlsState {
|
||||
final List<MediaVersion> availableVersions;
|
||||
@@ -18,7 +21,9 @@ class TrackControlsState {
|
||||
final List<MediaAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||
final int? selectedSubtitleStreamId;
|
||||
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
|
||||
final int? selectedSecondarySubtitleStreamId;
|
||||
final Set<int> sourceSubtitleSidecarIds;
|
||||
final int? sourcePartId;
|
||||
|
||||
/// Total media duration in milliseconds. Used by the version/quality sheet
|
||||
@@ -43,7 +48,7 @@ class TrackControlsState {
|
||||
final Function(int)? onSwitchVersion;
|
||||
final ValueChanged<TranscodeQualityPreset>? onSwitchQualityPreset;
|
||||
final ValueChanged<int>? onSwitchAudioStreamId;
|
||||
final ValueChanged<int>? onSwitchSubtitleStreamId;
|
||||
final ValueChanged<PlaybackSourceSubtitleChoice>? onSwitchSubtitle;
|
||||
final Function(AudioTrack)? onAudioTrackChanged;
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
|
||||
@@ -63,7 +68,8 @@ class TrackControlsState {
|
||||
final Function(MediaItem)? onQueueItemSelected;
|
||||
final String ratingKey;
|
||||
final String? mediaTitle;
|
||||
final Future<void> Function()? onSubtitleDownloaded;
|
||||
final Future<SubtitleDownloadApplyOutcome> Function({required String serverId, required String ratingKey})?
|
||||
onSubtitleDownloaded;
|
||||
|
||||
/// Whether OpenSubtitles search is reachable for this server. The Plex
|
||||
/// server proxies the OpenSubtitles plugin; Jellyfin doesn't expose an
|
||||
@@ -80,7 +86,9 @@ class TrackControlsState {
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.sourceSubtitleTracks = const [],
|
||||
this.selectedSubtitleStreamId,
|
||||
this.selectedSubtitleChoice,
|
||||
this.selectedSecondarySubtitleStreamId,
|
||||
this.sourceSubtitleSidecarIds = const <int>{},
|
||||
this.sourcePartId,
|
||||
this.sourceDurationMs,
|
||||
this.boxFitMode = 0,
|
||||
@@ -102,7 +110,7 @@ class TrackControlsState {
|
||||
this.onSwitchVersion,
|
||||
this.onSwitchQualityPreset,
|
||||
this.onSwitchAudioStreamId,
|
||||
this.onSwitchSubtitleStreamId,
|
||||
this.onSwitchSubtitle,
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
this.onSecondarySubtitleTrackChanged,
|
||||
@@ -126,10 +134,15 @@ class TrackControlsState {
|
||||
this.subtitleSearchSupported = true,
|
||||
});
|
||||
|
||||
/// Source subtitles can only be selected when playback can be re-opened with
|
||||
/// a Plex source subtitle stream id.
|
||||
bool get canUseSourceSubtitles =>
|
||||
isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null;
|
||||
/// Transcoded subtitle choices must be negotiated with the server and
|
||||
/// therefore replace the native rendition track list.
|
||||
bool get canUseSourceSubtitles => isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitle != null;
|
||||
|
||||
/// Direct play keeps embedded/native switching instant while still exposing
|
||||
/// unloaded server sidecars that require one source reopen when selected.
|
||||
List<MediaSubtitleTrack> get directPlaySourceSidecars => !isTranscoding && onSwitchSubtitle != null
|
||||
? sourceSubtitleTracks.where((track) => sourceSubtitleSidecarIds.contains(track.id)).toList(growable: false)
|
||||
: const <MediaSubtitleTrack>[];
|
||||
|
||||
/// External subtitle search needs both a searchable media item and a server
|
||||
/// that can proxy the OpenSubtitles request.
|
||||
@@ -140,6 +153,9 @@ class TrackControlsState {
|
||||
/// the single source of truth shared by the toolbar icon and the sheet layout.
|
||||
bool hasSubtitleControls(Tracks? tracks) {
|
||||
final playerSubtitles = tracks?.subtitle ?? const <SubtitleTrack>[];
|
||||
return canUseSourceSubtitles || TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) || canSearchSubtitles;
|
||||
return canUseSourceSubtitles ||
|
||||
directPlaySourceSidecars.isNotEmpty ||
|
||||
TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) ||
|
||||
canSearchSubtitles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,74 +67,68 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
videoPlayerState?.navigateToQueueItem(item);
|
||||
}
|
||||
|
||||
Future<void> _onSubtitleDownloaded() async {
|
||||
if (!mounted) return;
|
||||
Future<SubtitleDownloadApplyOutcome> _onSubtitleDownloaded({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
}) async {
|
||||
if (!mounted) return SubtitleDownloadApplyOutcome.unavailable;
|
||||
|
||||
// Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData]
|
||||
// and the Plex token. Jellyfin has no analogue and the entry point
|
||||
// (`subtitleSearchSupported`) is already gated on backend, but guard
|
||||
// here too in case a future caller wires the same handler elsewhere.
|
||||
if (widget.metadata.backend != MediaBackend.plex) return;
|
||||
final serverId = widget.metadata.serverId;
|
||||
if (serverId == null) return;
|
||||
if (widget.metadata.backend != MediaBackend.plex) return SubtitleDownloadApplyOutcome.unavailable;
|
||||
if (widget.metadata.serverId != serverId || widget.metadata.id != ratingKey) {
|
||||
return SubtitleDownloadApplyOutcome.superseded;
|
||||
}
|
||||
final switchSource = widget.onPlaybackSourceChanged;
|
||||
if (switchSource == null) return SubtitleDownloadApplyOutcome.unavailable;
|
||||
|
||||
final itemKey = widget.metadata.globalKey;
|
||||
bool targetIsCurrent() =>
|
||||
mounted &&
|
||||
widget.metadata.globalKey == itemKey &&
|
||||
widget.metadata.serverId == serverId &&
|
||||
widget.metadata.id == ratingKey;
|
||||
|
||||
try {
|
||||
final client = context.getPlexClientForServer(ServerId(serverId));
|
||||
final token = client.config.token;
|
||||
if (token == null) return;
|
||||
|
||||
// Plex's OpenSubtitles download is asynchronous: the PUT returns immediately
|
||||
// but the new stream entry shows up in metadata seconds later. Poll until it
|
||||
// appears. Up to 15s matches what Plex-web tolerates before giving up.
|
||||
// Snapshot what's already attached so we can identify the new download.
|
||||
final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet();
|
||||
// Snapshot the authoritative source IDs so we can identify the new
|
||||
// download without asking mpv to synchronously open its remote URL.
|
||||
final existingSourceIds = widget.sourceSubtitleTracks.map((track) => track.id).toSet();
|
||||
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 15));
|
||||
MediaSubtitleTrack? newTrack;
|
||||
String? newUrl;
|
||||
MediaSourceInfo? latestInfo;
|
||||
|
||||
while (mounted && DateTime.now().isBefore(deadline)) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
if (!mounted) return SubtitleDownloadApplyOutcome.superseded;
|
||||
|
||||
try {
|
||||
final data = await client.getVideoPlaybackData(widget.metadata.id);
|
||||
if (!mounted) return;
|
||||
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
|
||||
final data = await client.getVideoPlaybackData(ratingKey);
|
||||
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
|
||||
if (data.mediaInfo == null) continue;
|
||||
latestInfo = data.mediaInfo;
|
||||
|
||||
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
|
||||
if (!plexTrack.isExternal) continue;
|
||||
final url = client.buildExternalSubtitleUrl(plexTrack);
|
||||
if (url == null) continue;
|
||||
if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue;
|
||||
|
||||
newTrack = plexTrack;
|
||||
newUrl = url;
|
||||
break;
|
||||
}
|
||||
newTrack = findNewExternalSubtitleTrack(data.mediaInfo!.subtitleTracks, existingSourceIds);
|
||||
if (newTrack != null) break;
|
||||
} catch (e) {
|
||||
appLogger.w('Subtitle download poll iteration failed', error: e);
|
||||
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || newTrack == null || newUrl == null) return;
|
||||
|
||||
await widget.player.addSubtitleTrack(
|
||||
uri: newUrl,
|
||||
title: newTrack.displayTitle ?? newTrack.language ?? t.videoControls.downloadedSubtitle,
|
||||
language: newTrack.languageCode,
|
||||
select: true,
|
||||
);
|
||||
|
||||
final partId = latestInfo?.partId;
|
||||
if (partId != null) {
|
||||
await client.selectStreams(partId, subtitleStreamID: newTrack.id);
|
||||
}
|
||||
if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded;
|
||||
if (newTrack == null) return SubtitleDownloadApplyOutcome.timedOut;
|
||||
final outcome = await switchSource(newSubtitleChoice: PlaybackSourceSubtitleChoice.source(newTrack.id));
|
||||
return subtitleDownloadApplyOutcomeFor(outcome);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to refresh subtitles after download', error: e);
|
||||
return SubtitleDownloadApplyOutcome.failed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +139,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
int? newMediaIndex,
|
||||
TranscodeQualityPreset? newPreset,
|
||||
int? newAudioStreamId,
|
||||
int? newSubtitleStreamId,
|
||||
PlaybackSourceSubtitleChoice? newSubtitleChoice,
|
||||
}) async {
|
||||
final onPlaybackSourceChanged = widget.onPlaybackSourceChanged;
|
||||
if (onPlaybackSourceChanged == null) return;
|
||||
@@ -154,7 +148,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
newMediaIndex: newMediaIndex,
|
||||
newPreset: newPreset,
|
||||
newAudioStreamId: newAudioStreamId,
|
||||
newSubtitleStreamId: newSubtitleStreamId,
|
||||
newSubtitleChoice: newSubtitleChoice,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -86,10 +86,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
sourceAudioTracks: widget.sourceAudioTracks,
|
||||
selectedAudioStreamId: widget.selectedAudioStreamId,
|
||||
sourceSubtitleTracks: widget.sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: widget.selectedSubtitleStreamId,
|
||||
selectedSubtitleChoice: widget.selectedSubtitleChoice,
|
||||
);
|
||||
final canSwitchSourceSubtitles =
|
||||
versionQuality.canSwitch && versionQuality.isTranscoding && widget.metadata.backend == MediaBackend.plex;
|
||||
final canSwitchSourceSubtitles = versionQuality.canSwitch && versionQuality.sourceSubtitleTracks.isNotEmpty;
|
||||
return TrackControlsState(
|
||||
availableVersions: versionQuality.availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
@@ -101,7 +100,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
sourceSubtitleTracks: canSwitchSourceSubtitles
|
||||
? versionQuality.sourceSubtitleTracks
|
||||
: const <MediaSubtitleTrack>[],
|
||||
selectedSubtitleStreamId: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleStreamId : null,
|
||||
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
|
||||
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
|
||||
sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const <int>{},
|
||||
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
|
||||
sourceDurationMs: widget.metadata.durationMs,
|
||||
boxFitMode: widget.boxFitMode,
|
||||
@@ -123,8 +124,8 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null,
|
||||
onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null,
|
||||
onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null,
|
||||
onSwitchSubtitleStreamId: canSwitchSourceSubtitles
|
||||
? (id) => _switchVersionAndQuality(newSubtitleStreamId: id)
|
||||
onSwitchSubtitle: canSwitchSourceSubtitles
|
||||
? (choice) => _switchVersionAndQuality(newSubtitleChoice: choice)
|
||||
: null,
|
||||
onAudioTrackChanged: widget.onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../../widgets/overlay_sheet.dart';
|
||||
import '../../../widgets/pill_input_decoration.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import '../../loading_indicator_box.dart';
|
||||
import '../models/track_controls_state.dart';
|
||||
|
||||
@visibleForTesting
|
||||
String resolveSubtitleSearchLanguageCode({String? savedLanguageCode, required Locale systemLocale}) {
|
||||
@@ -33,7 +34,8 @@ class SubtitleSearchSheet extends StatefulWidget {
|
||||
final String ratingKey;
|
||||
final String serverId;
|
||||
final String? mediaTitle;
|
||||
final Future<void> Function()? onSubtitleDownloaded;
|
||||
final Future<SubtitleDownloadApplyOutcome> Function({required String serverId, required String ratingKey})?
|
||||
onSubtitleDownloaded;
|
||||
|
||||
const SubtitleSearchSheet({
|
||||
super.key,
|
||||
@@ -205,10 +207,17 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
await widget.onSubtitleDownloaded?.call();
|
||||
final outcome =
|
||||
await widget.onSubtitleDownloaded?.call(serverId: widget.serverId, ratingKey: widget.ratingKey) ??
|
||||
SubtitleDownloadApplyOutcome.unavailable;
|
||||
if (!mounted) return;
|
||||
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
|
||||
OverlaySheetController.of(context).close();
|
||||
if (outcome == SubtitleDownloadApplyOutcome.applied) {
|
||||
showSuccessSnackBar(context, t.videoControls.subtitleDownloaded);
|
||||
OverlaySheetController.of(context).close();
|
||||
} else {
|
||||
showErrorSnackBar(context, t.videoControls.subtitleDownloadedNotApplied);
|
||||
setState(() => _downloadingKey = null);
|
||||
}
|
||||
} else {
|
||||
showErrorSnackBar(context, t.videoControls.subtitleDownloadFailed);
|
||||
setState(() => _downloadingKey = null);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../media/media_source_info.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/playback_subtitle_resolver.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
import '../../../utils/track_label_builder.dart';
|
||||
@@ -103,6 +104,7 @@ class TrackSheet extends StatelessWidget {
|
||||
supportsSecondary: supportsSecondary,
|
||||
showHeader: showHeader,
|
||||
trackControlsState: state,
|
||||
sourceSidecars: state.directPlaySourceSidecars,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,8 +223,9 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = _effectiveSelectedStreamId();
|
||||
final selectedIndex = selectedId == 0 ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
|
||||
final selectedChoice = _effectiveSelectedChoice();
|
||||
final selectedId = selectedChoice.sourceStreamId;
|
||||
final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1;
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
|
||||
return Column(
|
||||
@@ -237,10 +240,10 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
|
||||
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
|
||||
context: context,
|
||||
key: _initialScroll.firstItemKey,
|
||||
isSelected: selectedId == 0,
|
||||
isSelected: selectedChoice.isOff,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.trackControlsState.onSwitchSubtitleStreamId!(0);
|
||||
widget.trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -252,7 +255,7 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.trackControlsState.onSwitchSubtitleStreamId!(track.id);
|
||||
widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id));
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -263,13 +266,15 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
|
||||
);
|
||||
}
|
||||
|
||||
int _effectiveSelectedStreamId() {
|
||||
final explicit = widget.trackControlsState.selectedSubtitleStreamId;
|
||||
if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit;
|
||||
for (final track in widget.tracks) {
|
||||
if (track.selected) return track.id;
|
||||
PlaybackSourceSubtitleChoice _effectiveSelectedChoice() {
|
||||
final explicit = widget.trackControlsState.selectedSubtitleChoice;
|
||||
if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) {
|
||||
return explicit;
|
||||
}
|
||||
return 0;
|
||||
for (final track in widget.tracks) {
|
||||
if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id);
|
||||
}
|
||||
return const PlaybackSourceSubtitleChoice.off();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +354,7 @@ class _SubtitleColumn extends StatefulWidget {
|
||||
final bool supportsSecondary;
|
||||
final bool showHeader;
|
||||
final TrackControlsState trackControlsState;
|
||||
final List<MediaSubtitleTrack> sourceSidecars;
|
||||
|
||||
const _SubtitleColumn({
|
||||
required this.tracks,
|
||||
@@ -357,6 +363,7 @@ class _SubtitleColumn extends StatefulWidget {
|
||||
this.supportsSecondary = false,
|
||||
required this.showHeader,
|
||||
required this.trackControlsState,
|
||||
this.sourceSidecars = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -378,9 +385,15 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
final secondarySub = widget.selection.secondarySubtitle;
|
||||
final isOffSelected = selectedSub == null || selectedSub.id == 'no';
|
||||
final hasSecondary = widget.supportsSecondary && secondarySub != null;
|
||||
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
|
||||
final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId;
|
||||
final unloadedSourceSidecars = widget.sourceSidecars
|
||||
.where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId)
|
||||
.toList(growable: false);
|
||||
|
||||
// +1 for "Off" row
|
||||
final itemCount = widget.tracks.length + 1;
|
||||
// +1 for "Off" row. The selected direct-play sidecar is already present
|
||||
// in [tracks], so only the other server sidecars are appended.
|
||||
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
|
||||
|
||||
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
@@ -423,7 +436,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
);
|
||||
}
|
||||
|
||||
final track = widget.tracks[index - 1];
|
||||
final trackIndex = index - 1;
|
||||
if (trackIndex >= widget.tracks.length) {
|
||||
final sourceIndex = trackIndex - widget.tracks.length;
|
||||
final sourceTrack = unloadedSourceSidecars[sourceIndex];
|
||||
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
|
||||
context: context,
|
||||
label: sourceTrack.labelForIndex(trackIndex),
|
||||
isSelected: false,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final track = widget.tracks[trackIndex];
|
||||
final isPrimary = !isOffSelected && track.id == selectedSub.id;
|
||||
final isSecondary = hasSecondary && track.id == secondarySub.id;
|
||||
final label = TrackLabelBuilder.subtitleLabel(
|
||||
@@ -431,7 +459,7 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
forced: track.isForced,
|
||||
index: index - 1,
|
||||
index: trackIndex,
|
||||
);
|
||||
|
||||
Widget? badge;
|
||||
|
||||
@@ -30,6 +30,7 @@ import 'package:flutter/services.dart'
|
||||
import '../../services/fullscreen_state_manager.dart';
|
||||
import '../../services/macos_window_service.dart';
|
||||
import '../../services/pip_service.dart';
|
||||
import '../../services/playback_subtitle_resolver.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../mixins/settings_effect_mixin.dart';
|
||||
@@ -96,23 +97,53 @@ part 'parts/visibility.dart';
|
||||
|
||||
/// Subtitle tracks offered in the player's "source" subtitle list.
|
||||
///
|
||||
/// While transcoding, only tracks Plex can deliver in HLS are shown: keyed
|
||||
/// sidecars, text codecs that can become WebVTT, and image codecs that can be
|
||||
/// burned into the rendition. Outside transcode the full list is returned
|
||||
/// unchanged, since the player has direct access to every embedded stream.
|
||||
/// Direct play exposes embedded tracks in the native player and can attach
|
||||
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
|
||||
/// or embedded codecs that the server can convert/burn into the rendition.
|
||||
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
|
||||
List<MediaSubtitleTrack> tracks, {
|
||||
required bool isTranscoding,
|
||||
required Set<int> sidecarSourceIds,
|
||||
required bool supportsEmbeddedTranscodeSelection,
|
||||
}) {
|
||||
if (!isTranscoding) return tracks;
|
||||
if (!isTranscoding) {
|
||||
return tracks
|
||||
.where((track) {
|
||||
final requiresSidecar =
|
||||
track.isExternalFile || (!track.usesExternalDelivery && track.key != null && track.key!.isNotEmpty);
|
||||
return !requiresSidecar || sidecarSourceIds.contains(track.id);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
return tracks
|
||||
.where((track) {
|
||||
final hasKey = track.key != null && track.key!.isNotEmpty;
|
||||
return hasKey || CodecUtils.isTranscodableSubtitleCodec(track.codec);
|
||||
})
|
||||
.where(
|
||||
(track) =>
|
||||
sidecarSourceIds.contains(track.id) ||
|
||||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
MediaSubtitleTrack? findNewExternalSubtitleTrack(List<MediaSubtitleTrack> tracks, Set<int> existingSourceIds) {
|
||||
for (final track in tracks) {
|
||||
if (track.isExternal && !existingSourceIds.contains(track.id)) return track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
SubtitleDownloadApplyOutcome subtitleDownloadApplyOutcomeFor(PlaybackSourceChangeOutcome outcome) {
|
||||
return switch (outcome) {
|
||||
PlaybackSourceChangeOutcome.applied ||
|
||||
PlaybackSourceChangeOutcome.unchanged => SubtitleDownloadApplyOutcome.applied,
|
||||
PlaybackSourceChangeOutcome.busy => SubtitleDownloadApplyOutcome.busy,
|
||||
PlaybackSourceChangeOutcome.superseded => SubtitleDownloadApplyOutcome.superseded,
|
||||
PlaybackSourceChangeOutcome.unavailable => SubtitleDownloadApplyOutcome.unavailable,
|
||||
PlaybackSourceChangeOutcome.failed => SubtitleDownloadApplyOutcome.failed,
|
||||
};
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
ShaderPreset resolveShaderTogglePreset({
|
||||
required ShaderPreset currentPreset,
|
||||
@@ -132,7 +163,7 @@ ShaderPreset resolveShaderTogglePreset({
|
||||
List<MediaAudioTrack> sourceAudioTracks,
|
||||
int? selectedAudioStreamId,
|
||||
List<MediaSubtitleTrack> sourceSubtitleTracks,
|
||||
int? selectedSubtitleStreamId,
|
||||
PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
|
||||
bool canSwitch,
|
||||
})
|
||||
effectiveVersionQualityControls({
|
||||
@@ -143,7 +174,7 @@ effectiveVersionQualityControls({
|
||||
required List<MediaAudioTrack> sourceAudioTracks,
|
||||
required int? selectedAudioStreamId,
|
||||
required List<MediaSubtitleTrack> sourceSubtitleTracks,
|
||||
required int? selectedSubtitleStreamId,
|
||||
required PlaybackSourceSubtitleChoice? selectedSubtitleChoice,
|
||||
}) {
|
||||
if (isOfflinePlayback) {
|
||||
return (
|
||||
@@ -153,7 +184,7 @@ effectiveVersionQualityControls({
|
||||
sourceAudioTracks: const <MediaAudioTrack>[],
|
||||
selectedAudioStreamId: null,
|
||||
sourceSubtitleTracks: const <MediaSubtitleTrack>[],
|
||||
selectedSubtitleStreamId: null,
|
||||
selectedSubtitleChoice: null,
|
||||
canSwitch: false,
|
||||
);
|
||||
}
|
||||
@@ -164,7 +195,7 @@ effectiveVersionQualityControls({
|
||||
sourceAudioTracks: sourceAudioTracks,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
sourceSubtitleTracks: sourceSubtitleTracks,
|
||||
selectedSubtitleStreamId: selectedSubtitleStreamId,
|
||||
selectedSubtitleChoice: selectedSubtitleChoice,
|
||||
canSwitch: true,
|
||||
);
|
||||
}
|
||||
@@ -358,13 +389,15 @@ bool shouldSkipDuplicateTimelineSeek({required Duration? lastDispatchedSeek, req
|
||||
}
|
||||
|
||||
typedef PlaybackSourceChangeCallback =
|
||||
Future<void> Function({
|
||||
Future<PlaybackSourceChangeOutcome> Function({
|
||||
int? newMediaIndex,
|
||||
TranscodeQualityPreset? newPreset,
|
||||
int? newAudioStreamId,
|
||||
int? newSubtitleStreamId,
|
||||
PlaybackSourceSubtitleChoice? newSubtitleChoice,
|
||||
});
|
||||
|
||||
enum PlaybackSourceChangeOutcome { applied, unchanged, busy, unavailable, superseded, failed }
|
||||
|
||||
typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide? side, double value});
|
||||
|
||||
class PlexVideoControls extends StatefulWidget {
|
||||
@@ -381,7 +414,9 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final List<MediaAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||
final int? selectedSubtitleStreamId;
|
||||
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
|
||||
final int? selectedSecondarySubtitleStreamId;
|
||||
final Set<int> sourceSubtitleSidecarIds;
|
||||
final int? sourcePartId;
|
||||
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
|
||||
final int boxFitMode;
|
||||
@@ -492,7 +527,9 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.sourceSubtitleTracks = const [],
|
||||
this.selectedSubtitleStreamId,
|
||||
this.selectedSubtitleChoice,
|
||||
this.selectedSecondarySubtitleStreamId,
|
||||
this.sourceSubtitleSidecarIds = const <int>{},
|
||||
this.sourcePartId,
|
||||
this.onPlaybackSourceChanged,
|
||||
this.boxFitMode = 0,
|
||||
|
||||
@@ -627,6 +627,10 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
||||
fl_value_unref(data);
|
||||
break;
|
||||
}
|
||||
case MPV_EVENT_START_FILE: {
|
||||
SendEvent("start-file");
|
||||
break;
|
||||
}
|
||||
case MPV_EVENT_FILE_LOADED: {
|
||||
audio_recovery_.SetFileLoaded(true);
|
||||
SendEvent("file-loaded");
|
||||
|
||||
@@ -971,6 +971,11 @@ class MpvPlayerCoreBase: NSObject {
|
||||
case MPV_EVENT_GET_PROPERTY_REPLY:
|
||||
completeGetPropertyRequest(event)
|
||||
|
||||
case MPV_EVENT_START_FILE:
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.onEvent(name: "start-file", data: nil)
|
||||
}
|
||||
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.onEvent(name: "file-loaded", data: nil)
|
||||
|
||||
@@ -545,6 +545,71 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV exposes load-scoped start and terminal failure events', () async {
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
final fileStarted = expectLater(player.streams.fileStarted, emits(isNull));
|
||||
player.handlePlayerEvent('start-file', null);
|
||||
await fileStarted;
|
||||
|
||||
final fileLoadFailed = expectLater(player.streams.fileLoadFailed, emits(isNull));
|
||||
player.handlePlayerEvent('end-file', {'reason': 'error'});
|
||||
await fileLoadFailed;
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV exposes primary media readiness before external subtitles finish', () async {
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
var emissionCount = 0;
|
||||
final subscription = player.streams.primaryMediaReady.listen((_) => emissionCount++);
|
||||
try {
|
||||
player.handlePlayerEvent('start-file', null);
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{'type': 'sub', 'id': '2', 'external': true},
|
||||
]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(emissionCount, 0);
|
||||
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{'type': 'video', 'id': '4', 'external': false, 'image': true, 'albumart': true},
|
||||
{'type': 'sub', 'id': '2', 'external': true},
|
||||
]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(emissionCount, 0);
|
||||
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{'type': 'video', 'id': '1', 'external': false},
|
||||
{'type': 'sub', 'id': '2', 'external': true},
|
||||
]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(emissionCount, 1);
|
||||
|
||||
player.handlePropertyChange('track-list', const [
|
||||
{'type': 'video', 'id': '1', 'external': false},
|
||||
{'type': 'audio', 'id': '3', 'external': false},
|
||||
]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(emissionCount, 1);
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV maps server-offset streams to absolute timeline positions', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@ void main() {
|
||||
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
|
||||
expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue);
|
||||
expect(result.externalSubtitles, hasLength(1));
|
||||
expect(result.subtitleSidecars.single.sourceStreamId, 2);
|
||||
expect(result.externalSubtitles.single.title, 'English');
|
||||
expect(result.externalSubtitles.single.language, 'eng');
|
||||
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
|
||||
@@ -732,13 +733,14 @@ void main() {
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
|
||||
expect(result.externalSubtitles, hasLength(1));
|
||||
expect(result.subtitleSidecars.single.sourceStreamId, 3);
|
||||
expect(result.externalSubtitles.single.title, 'English');
|
||||
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
|
||||
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
|
||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization skips negotiated subtitle delivery for original playback', () async {
|
||||
test('getPlaybackInitialization exposes negotiated subtitle delivery for DirectStream playback', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
@@ -819,7 +821,10 @@ void main() {
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(2));
|
||||
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
|
||||
expect(result.externalSubtitles, isEmpty);
|
||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4]);
|
||||
expect(result.externalSubtitles, hasLength(2));
|
||||
expect(Uri.parse(result.externalSubtitles.first.uri!).path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
|
||||
expect(Uri.parse(result.externalSubtitles.last.uri!).path, '/Videos/item-1/src-1/Subtitles/4/Stream.srt');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/mpv_sidecar_open_guard.dart';
|
||||
|
||||
class _StreamPlayer implements Player {
|
||||
@override
|
||||
final PlayerStreams streams;
|
||||
|
||||
_StreamPlayer({
|
||||
Stream<void> fileStarted = const Stream.empty(),
|
||||
required Stream<void> primaryMediaReady,
|
||||
required Stream<void> fileLoaded,
|
||||
Stream<void> fileLoadFailed = const Stream.empty(),
|
||||
Stream<void> playbackRestart = const Stream.empty(),
|
||||
Stream<void> backendSwitched = const Stream.empty(),
|
||||
Stream<PlayerError> error = const Stream.empty(),
|
||||
}) : streams = PlayerStreams(
|
||||
playing: const Stream.empty(),
|
||||
completed: const Stream.empty(),
|
||||
buffering: const Stream.empty(),
|
||||
position: const Stream.empty(),
|
||||
duration: const Stream.empty(),
|
||||
seekable: const Stream.empty(),
|
||||
buffer: const Stream.empty(),
|
||||
volume: const Stream.empty(),
|
||||
rate: const Stream.empty(),
|
||||
tracks: const Stream.empty(),
|
||||
track: const Stream.empty(),
|
||||
log: const Stream.empty(),
|
||||
error: error,
|
||||
audioDevice: const Stream.empty(),
|
||||
audioDevices: const Stream.empty(),
|
||||
bufferRanges: const Stream.empty(),
|
||||
playbackRestart: playbackRestart,
|
||||
fileStarted: fileStarted,
|
||||
fileLoaded: fileLoaded,
|
||||
fileLoadFailed: fileLoadFailed,
|
||||
primaryMediaReady: primaryMediaReady,
|
||||
backendSwitched: backendSwitched,
|
||||
);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('file-loaded before primary discovery completes normally', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(started.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(fileStarted: started.stream, primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
|
||||
discoveryTimeout: const Duration(milliseconds: 20),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 20),
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
started.add(null);
|
||||
loaded.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.loaded);
|
||||
});
|
||||
|
||||
test('file-loaded after primary discovery completes normally', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(started.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(fileStarted: started.stream, primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
|
||||
discoveryTimeout: const Duration(milliseconds: 50),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 50),
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
started.add(null);
|
||||
primary.add(null);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
loaded.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.loaded);
|
||||
});
|
||||
|
||||
test('missing file-loaded after primary discovery is classified as a sidecar stall', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(started.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(fileStarted: started.stream, primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
|
||||
discoveryTimeout: const Duration(milliseconds: 50),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
started.add(null);
|
||||
primary.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.stalled);
|
||||
});
|
||||
|
||||
test('unrelated player errors do not suppress sidecar-stall recovery', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
final errors = StreamController<PlayerError>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(started.close);
|
||||
addTearDown(errors.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(
|
||||
fileStarted: started.stream,
|
||||
primaryMediaReady: primary.stream,
|
||||
fileLoaded: loaded.stream,
|
||||
error: errors.stream,
|
||||
),
|
||||
discoveryTimeout: const Duration(milliseconds: 50),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
started.add(null);
|
||||
primary.add(null);
|
||||
errors.add(const PlayerError('setVisible failed'));
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.stalled);
|
||||
});
|
||||
|
||||
test('load-scoped end-file error suppresses sidecar-stall recovery', () async {
|
||||
final started = StreamController<void>.broadcast();
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final failed = StreamController<void>.broadcast();
|
||||
addTearDown(started.close);
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(failed.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(
|
||||
fileStarted: started.stream,
|
||||
primaryMediaReady: primary.stream,
|
||||
fileLoaded: loaded.stream,
|
||||
fileLoadFailed: failed.stream,
|
||||
),
|
||||
discoveryTimeout: const Duration(milliseconds: 50),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
started.add(null);
|
||||
primary.add(null);
|
||||
failed.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.inconclusive);
|
||||
});
|
||||
|
||||
test('missing primary discovery times out without claiming a sidecar stall', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
|
||||
discoveryTimeout: const Duration(milliseconds: 10),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
expect(await guard.wait(), MpvSidecarOpenOutcome.inconclusive);
|
||||
});
|
||||
|
||||
test('Android ExoPlayer success completes without waiting for mpv signals', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final restarted = StreamController<void>.broadcast();
|
||||
final switched = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(restarted.close);
|
||||
addTearDown(switched.close);
|
||||
addTearDown(started.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(
|
||||
fileStarted: started.stream,
|
||||
primaryMediaReady: primary.stream,
|
||||
fileLoaded: loaded.stream,
|
||||
playbackRestart: restarted.stream,
|
||||
backendSwitched: switched.stream,
|
||||
),
|
||||
discoveryTimeout: const Duration(milliseconds: 50),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
startsOnAndroidExoPlayer: true,
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
loaded.add(null); // ExoPlayer's media-item transition is not mpv readiness.
|
||||
restarted.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.loaded);
|
||||
});
|
||||
|
||||
test('Android fallback ignores stale Exo terminal events and detects an mpv sidecar stall', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final failed = StreamController<void>.broadcast();
|
||||
final restarted = StreamController<void>.broadcast();
|
||||
final switched = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(failed.close);
|
||||
addTearDown(restarted.close);
|
||||
addTearDown(switched.close);
|
||||
addTearDown(started.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(
|
||||
fileStarted: started.stream,
|
||||
primaryMediaReady: primary.stream,
|
||||
fileLoaded: loaded.stream,
|
||||
fileLoadFailed: failed.stream,
|
||||
playbackRestart: restarted.stream,
|
||||
backendSwitched: switched.stream,
|
||||
),
|
||||
discoveryTimeout: const Duration(milliseconds: 50),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
startsOnAndroidExoPlayer: true,
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
loaded.add(null);
|
||||
failed.add(null);
|
||||
switched.add(null);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
started.add(null);
|
||||
primary.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.stalled);
|
||||
});
|
||||
|
||||
test('Android fallback shares one bounded primary-discovery budget with mpv', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
final restarted = StreamController<void>.broadcast();
|
||||
final switched = StreamController<void>.broadcast();
|
||||
final started = StreamController<void>.broadcast();
|
||||
addTearDown(primary.close);
|
||||
addTearDown(loaded.close);
|
||||
addTearDown(restarted.close);
|
||||
addTearDown(switched.close);
|
||||
addTearDown(started.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(
|
||||
fileStarted: started.stream,
|
||||
primaryMediaReady: primary.stream,
|
||||
fileLoaded: loaded.stream,
|
||||
playbackRestart: restarted.stream,
|
||||
backendSwitched: switched.stream,
|
||||
),
|
||||
discoveryTimeout: const Duration(milliseconds: 100),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 20),
|
||||
startsOnAndroidExoPlayer: true,
|
||||
);
|
||||
|
||||
final clock = Stopwatch()..start();
|
||||
final outcome = guard.wait();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
switched.add(null);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
started.add(null);
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.inconclusive);
|
||||
clock.stop();
|
||||
expect(clock.elapsed, lessThan(const Duration(milliseconds: 145)));
|
||||
});
|
||||
|
||||
test('closing player streams aborts the guard without waiting forever', () async {
|
||||
final primary = StreamController<void>.broadcast();
|
||||
final loaded = StreamController<void>.broadcast();
|
||||
addTearDown(loaded.close);
|
||||
final guard = MpvSidecarOpenGuard.armForTesting(
|
||||
player: _StreamPlayer(primaryMediaReady: primary.stream, fileLoaded: loaded.stream),
|
||||
discoveryTimeout: const Duration(milliseconds: 20),
|
||||
fileLoadedTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
final outcome = guard.wait();
|
||||
await primary.close();
|
||||
|
||||
expect(await outcome, MpvSidecarOpenOutcome.aborted);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/playback_initialization_types.dart';
|
||||
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
MediaSubtitleTrack _sourceSubtitle(
|
||||
int id, {
|
||||
String language = 'eng',
|
||||
bool selected = false,
|
||||
bool external = false,
|
||||
bool usesExternalDelivery = false,
|
||||
}) {
|
||||
return MediaSubtitleTrack(
|
||||
id: id,
|
||||
language: language,
|
||||
languageCode: language,
|
||||
title: 'Subtitle $id',
|
||||
selected: selected,
|
||||
forced: false,
|
||||
external: external,
|
||||
usesExternalDelivery: usesExternalDelivery,
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackSubtitleSidecar _sidecar(int id, {String language = 'eng', bool isDefault = false}) {
|
||||
return PlaybackSubtitleSidecar(
|
||||
sourceStreamId: id,
|
||||
track: SubtitleTrack.uri(
|
||||
'https://example.test/subtitles/$id.srt',
|
||||
title: 'Subtitle $id',
|
||||
language: language,
|
||||
codec: 'srt',
|
||||
isDefault: isDefault,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
MediaSourceInfo _mediaInfo(List<MediaSubtitleTrack> subtitles) {
|
||||
return MediaSourceInfo(videoUrl: '', audioTracks: const [], subtitleTracks: subtitles, chapters: const []);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('direct-play source routing', () {
|
||||
test('matches an embedded source to its loaded native track', () {
|
||||
final source = _sourceSubtitle(2, language: 'eng');
|
||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [native],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: false,
|
||||
),
|
||||
native,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not fuzzy-match a different loaded external sidecar', () {
|
||||
final source = MediaSubtitleTrack(
|
||||
id: 2,
|
||||
codec: 'srt',
|
||||
languageCode: 'eng',
|
||||
key: '/library/streams/2',
|
||||
external: true,
|
||||
selected: false,
|
||||
forced: false,
|
||||
);
|
||||
const other = SubtitleTrack(
|
||||
id: '9',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
uri: 'https://server/library/streams/9.srt',
|
||||
);
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [other],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: true,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('cycles through typed off and every authoritative source id, including zero', () {
|
||||
final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)];
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off()),
|
||||
const PlaybackSourceSubtitleChoice.source(0),
|
||||
);
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0)),
|
||||
const PlaybackSourceSubtitleChoice.source(2),
|
||||
);
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2)),
|
||||
const PlaybackSourceSubtitleChoice.off(),
|
||||
);
|
||||
expect(
|
||||
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 4),
|
||||
const PlaybackSourceSubtitleChoice.source(0),
|
||||
);
|
||||
});
|
||||
|
||||
test('fuzzy-matches Jellyfin external-delivery rows that remain embedded in direct play', () {
|
||||
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
|
||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [native],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: false,
|
||||
),
|
||||
native,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
final metadata = testMediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
|
||||
|
||||
test('attaches only the server-selected sidecar from the full catalog', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([
|
||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
||||
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
||||
]),
|
||||
sidecars: [
|
||||
_sidecar(2),
|
||||
_sidecar(3, language: 'swe'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 2);
|
||||
expect(result.sidecarsAtOpen, hasLength(1));
|
||||
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
|
||||
});
|
||||
|
||||
test('explicit off produces an open with zero sidecars', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
|
||||
sidecars: [_sidecar(2)],
|
||||
preferredSubtitleTrack: SubtitleTrack.off,
|
||||
);
|
||||
|
||||
expect(result.isOff, isTrue);
|
||||
expect(result.sidecarsAtOpen, isEmpty);
|
||||
});
|
||||
|
||||
test('explicit source selection wins over the server default', () {
|
||||
final mediaInfo = _mediaInfo([
|
||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
||||
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
||||
]);
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: mediaInfo,
|
||||
sidecars: [
|
||||
_sidecar(2),
|
||||
_sidecar(3, language: 'swe'),
|
||||
],
|
||||
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 3);
|
||||
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt');
|
||||
});
|
||||
|
||||
test('selected embedded subtitle keeps sidecars out of the open', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true)]),
|
||||
sidecars: const [],
|
||||
);
|
||||
|
||||
expect(result.isOff, isFalse);
|
||||
expect(result.primarySourceStreamId, 2);
|
||||
expect(result.primarySidecar, isNull);
|
||||
expect(result.sidecarsAtOpen, isEmpty);
|
||||
});
|
||||
|
||||
test('preferred secondary subtitle attaches a second distinct sidecar', () {
|
||||
final mediaInfo = _mediaInfo([
|
||||
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
||||
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
||||
]);
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: mediaInfo,
|
||||
sidecars: [
|
||||
_sidecar(2),
|
||||
_sidecar(3, language: 'swe'),
|
||||
],
|
||||
preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
||||
);
|
||||
|
||||
expect(result.primarySourceStreamId, 2);
|
||||
expect(result.secondarySourceStreamId, 3);
|
||||
expect(result.sidecarsAtOpen, hasLength(2));
|
||||
});
|
||||
|
||||
test('legacy sidecar without source metadata still follows default selection', () {
|
||||
final result = PlaybackSubtitleResolver.resolve(
|
||||
metadata: metadata,
|
||||
mediaInfo: null,
|
||||
sidecars: [
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: null,
|
||||
track: SubtitleTrack.uri('file:///tmp/subtitle.srt', title: 'Local', isDefault: true),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.isOff, isFalse);
|
||||
expect(result.primarySourceStreamId, isNull);
|
||||
expect(result.sidecarsAtOpen.single.uri, 'file:///tmp/subtitle.srt');
|
||||
});
|
||||
}
|
||||
@@ -346,6 +346,39 @@ void main() {
|
||||
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
|
||||
});
|
||||
|
||||
test('transcode negotiation honors an explicit source subtitle preference', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
final info = mediaInfoWithSubtitles([
|
||||
MediaSubtitleTrack(id: 401, languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 402, languageCode: 'swe', selected: false, forced: false),
|
||||
]);
|
||||
|
||||
final selected = client.resolveTranscodeSubtitleTrackForTesting(
|
||||
info,
|
||||
const SubtitleTrack(id: 'source:402', language: 'swe'),
|
||||
);
|
||||
|
||||
expect(selected?.id, 402);
|
||||
expect(client.resolveTranscodeSubtitleTrackForTesting(info, SubtitleTrack.off), isNull);
|
||||
});
|
||||
|
||||
test('transcode negotiation falls back to the new source default for a stale source id', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
final info = mediaInfoWithSubtitles([
|
||||
MediaSubtitleTrack(id: 501, languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 502, languageCode: 'swe', selected: false, forced: false),
|
||||
]);
|
||||
|
||||
final selected = client.resolveTranscodeSubtitleTrackForTesting(
|
||||
info,
|
||||
const SubtitleTrack(id: 'source:402', language: 'und'),
|
||||
);
|
||||
|
||||
expect(selected?.id, 501);
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are not attached as external sidecars', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
||||
import 'package:plezy/theme/mono_tokens.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
|
||||
import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart';
|
||||
|
||||
@@ -89,6 +91,143 @@ void main() {
|
||||
|
||||
expect(find.text('Search Subtitles'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('keeps source stream zero distinct from turning subtitles off', (tester) async {
|
||||
final player = _FakeTrackSheetPlayer(
|
||||
tracks: const Tracks(),
|
||||
track: const TrackSelection(subtitle: SubtitleTrack.off),
|
||||
);
|
||||
PlaybackSourceSubtitleChoice? switchedChoice;
|
||||
|
||||
await _pumpTrackSheet(
|
||||
tester,
|
||||
player: player,
|
||||
trackControlsState: TrackControlsState(
|
||||
isTranscoding: true,
|
||||
sourceSubtitleTracks: [MediaSubtitleTrack(id: 0, title: 'Stream zero', selected: false, forced: false)],
|
||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.off(),
|
||||
onSwitchSubtitle: (choice) => switchedChoice = choice,
|
||||
subtitleSearchSupported: false,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Stream zero'));
|
||||
expect(switchedChoice, const PlaybackSourceSubtitleChoice.source(0));
|
||||
});
|
||||
|
||||
testWidgets('direct play keeps native tracks and appends unloaded source sidecars', (tester) async {
|
||||
final player = _FakeTrackSheetPlayer(
|
||||
tracks: const Tracks(
|
||||
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
|
||||
),
|
||||
track: const TrackSelection(
|
||||
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
||||
),
|
||||
);
|
||||
PlaybackSourceSubtitleChoice? switchedSourceChoice;
|
||||
|
||||
await _pumpTrackSheet(
|
||||
tester,
|
||||
player: player,
|
||||
trackControlsState: TrackControlsState(
|
||||
sourceSubtitleTracks: [
|
||||
MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false),
|
||||
MediaSubtitleTrack(
|
||||
id: 2,
|
||||
title: 'Remote sidecar',
|
||||
codec: 'ass',
|
||||
external: true,
|
||||
selected: false,
|
||||
forced: false,
|
||||
),
|
||||
],
|
||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
||||
sourceSubtitleSidecarIds: const {2},
|
||||
onSwitchSubtitle: (choice) => switchedSourceChoice = choice,
|
||||
subtitleSearchSupported: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('English'), findsOneWidget);
|
||||
expect(find.text('Remote sidecar'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Remote sidecar'));
|
||||
expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2));
|
||||
});
|
||||
|
||||
testWidgets('direct-play embedded selection stays on the native player path', (tester) async {
|
||||
final player = _FakeTrackSheetPlayer(
|
||||
tracks: const Tracks(
|
||||
subtitle: [
|
||||
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
||||
SubtitleTrack(id: 's2', language: 'swe', codec: 'srt'),
|
||||
],
|
||||
),
|
||||
track: const TrackSelection(
|
||||
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
||||
),
|
||||
);
|
||||
PlaybackSourceSubtitleChoice? switchedSourceChoice;
|
||||
|
||||
await _pumpTrackSheet(
|
||||
tester,
|
||||
player: player,
|
||||
trackControlsState: TrackControlsState(
|
||||
sourceSubtitleTracks: [
|
||||
MediaSubtitleTrack(id: 1, languageCode: 'eng', selected: true, forced: false),
|
||||
MediaSubtitleTrack(id: 2, languageCode: 'swe', selected: false, forced: false),
|
||||
],
|
||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
||||
onSwitchSubtitle: (choice) => switchedSourceChoice = choice,
|
||||
subtitleSearchSupported: false,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Swedish'));
|
||||
|
||||
expect(player.lastSelectedSubtitle?.id, 's2');
|
||||
expect(switchedSourceChoice, isNull);
|
||||
});
|
||||
|
||||
testWidgets('does not append a source sidecar already loaded as the secondary track', (tester) async {
|
||||
final player = _FakeTrackSheetPlayer(
|
||||
supportsSecondarySubtitles: true,
|
||||
tracks: const Tracks(
|
||||
subtitle: [
|
||||
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
||||
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
|
||||
],
|
||||
),
|
||||
track: const TrackSelection(
|
||||
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
||||
secondarySubtitle: SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpTrackSheet(
|
||||
tester,
|
||||
player: player,
|
||||
trackControlsState: TrackControlsState(
|
||||
sourceSubtitleTracks: [
|
||||
MediaSubtitleTrack(
|
||||
id: 2,
|
||||
title: 'Remote sidecar',
|
||||
codec: 'ass',
|
||||
external: true,
|
||||
selected: false,
|
||||
forced: false,
|
||||
),
|
||||
],
|
||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
||||
selectedSecondarySubtitleStreamId: 2,
|
||||
sourceSubtitleSidecarIds: const {2},
|
||||
onSwitchSubtitle: (_) {},
|
||||
subtitleSearchSupported: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Remote sidecar'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('TrackSheet two-line labels', () {
|
||||
@@ -145,12 +284,32 @@ void main() {
|
||||
TrackControlsState(
|
||||
isTranscoding: true,
|
||||
sourceSubtitleTracks: [sourceSubtitle],
|
||||
onSwitchSubtitleStreamId: (_) {},
|
||||
onSwitchSubtitle: (_) {},
|
||||
).hasSubtitleControls(const Tracks()),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('counts direct-play source sidecars without replacing native tracks', () {
|
||||
final sourceSidecar = MediaSubtitleTrack(id: 1, external: true, selected: false, forced: false);
|
||||
final state = TrackControlsState(
|
||||
sourceSubtitleTracks: [sourceSidecar],
|
||||
sourceSubtitleSidecarIds: {sourceSidecar.id},
|
||||
onSwitchSubtitle: (_) {},
|
||||
);
|
||||
|
||||
expect(state.canUseSourceSubtitles, isFalse);
|
||||
expect(state.directPlaySourceSidecars, [sourceSidecar]);
|
||||
expect(state.hasSubtitleControls(const Tracks()), isTrue);
|
||||
});
|
||||
|
||||
test('does not misclassify a Jellyfin external-delivery embedded row as a sidecar', () {
|
||||
final sourceTrack = MediaSubtitleTrack(id: 1, usesExternalDelivery: true, selected: false, forced: false);
|
||||
final state = TrackControlsState(sourceSubtitleTracks: [sourceTrack], onSwitchSubtitle: (_) {});
|
||||
|
||||
expect(state.directPlaySourceSidecars, isEmpty);
|
||||
});
|
||||
|
||||
test('ignores player subtitle placeholders', () {
|
||||
const state = TrackControlsState(subtitleSearchSupported: false);
|
||||
|
||||
@@ -168,11 +327,13 @@ Future<void> _pumpTrackSheet(
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(extensions: const [_testTokens]),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 700,
|
||||
height: 400,
|
||||
child: TrackSheet(player: player, trackControlsState: trackControlsState),
|
||||
home: OverlaySheetHost(
|
||||
child: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 700,
|
||||
height: 400,
|
||||
child: TrackSheet(player: player, trackControlsState: trackControlsState),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -181,31 +342,38 @@ Future<void> _pumpTrackSheet(
|
||||
}
|
||||
|
||||
class _FakeTrackSheetPlayer implements Player {
|
||||
_FakeTrackSheetPlayer({required Tracks tracks, required TrackSelection track})
|
||||
: _state = PlayerState(tracks: tracks, track: track),
|
||||
_streams = PlayerStreams(
|
||||
playing: const Stream<bool>.empty(),
|
||||
completed: const Stream<bool>.empty(),
|
||||
buffering: const Stream<bool>.empty(),
|
||||
position: const Stream<Duration>.empty(),
|
||||
duration: const Stream<Duration>.empty(),
|
||||
seekable: const Stream<bool>.empty(),
|
||||
buffer: const Stream<Duration>.empty(),
|
||||
volume: const Stream<double>.empty(),
|
||||
rate: const Stream<double>.empty(),
|
||||
tracks: const Stream<Tracks>.empty(),
|
||||
track: const Stream<TrackSelection>.empty(),
|
||||
log: const Stream<PlayerLog>.empty(),
|
||||
error: const Stream<PlayerError>.empty(),
|
||||
audioDevice: const Stream<AudioDevice>.empty(),
|
||||
audioDevices: const Stream<List<AudioDevice>>.empty(),
|
||||
bufferRanges: const Stream<List<BufferRange>>.empty(),
|
||||
playbackRestart: const Stream<void>.empty(),
|
||||
backendSwitched: const Stream<void>.empty(),
|
||||
);
|
||||
_FakeTrackSheetPlayer({
|
||||
required Tracks tracks,
|
||||
required TrackSelection track,
|
||||
this.supportsSecondarySubtitles = false,
|
||||
}) : _state = PlayerState(tracks: tracks, track: track),
|
||||
_streams = PlayerStreams(
|
||||
playing: const Stream<bool>.empty(),
|
||||
completed: const Stream<bool>.empty(),
|
||||
buffering: const Stream<bool>.empty(),
|
||||
position: const Stream<Duration>.empty(),
|
||||
duration: const Stream<Duration>.empty(),
|
||||
seekable: const Stream<bool>.empty(),
|
||||
buffer: const Stream<Duration>.empty(),
|
||||
volume: const Stream<double>.empty(),
|
||||
rate: const Stream<double>.empty(),
|
||||
tracks: const Stream<Tracks>.empty(),
|
||||
track: const Stream<TrackSelection>.empty(),
|
||||
log: const Stream<PlayerLog>.empty(),
|
||||
error: const Stream<PlayerError>.empty(),
|
||||
audioDevice: const Stream<AudioDevice>.empty(),
|
||||
audioDevices: const Stream<List<AudioDevice>>.empty(),
|
||||
bufferRanges: const Stream<List<BufferRange>>.empty(),
|
||||
playbackRestart: const Stream<void>.empty(),
|
||||
backendSwitched: const Stream<void>.empty(),
|
||||
);
|
||||
|
||||
final PlayerState _state;
|
||||
final PlayerStreams _streams;
|
||||
SubtitleTrack? lastSelectedSubtitle;
|
||||
|
||||
@override
|
||||
final bool supportsSecondarySubtitles;
|
||||
|
||||
@override
|
||||
PlayerState get state => _state;
|
||||
@@ -214,7 +382,9 @@ class _FakeTrackSheetPlayer implements Player {
|
||||
PlayerStreams get streams => _streams;
|
||||
|
||||
@override
|
||||
bool get supportsSecondarySubtitles => false;
|
||||
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
||||
lastSelectedSubtitle = track;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user