feat: transcoding

This commit is contained in:
edde746
2026-04-22 21:07:12 +02:00
parent 05aeee1124
commit c10cf55f2d
51 changed files with 1712 additions and 145 deletions
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Anbefalet)",
"bufferSizeWarning": "Din enhed har ${heap}MB hukommelse. En buffer på ${size}MB kan forårsage afspilningsproblemer.",
"defaultQualityTitle": "Standardkvalitet",
"defaultQualityDescription": "Bruges ved start af afspilning. Lavere værdier reducerer båndbredden.",
"subtitleStyling": "Undertekststil",
"subtitleStylingDescription": "Tilpas underteksters udseende",
"smallSkipDuration": "Kort spring-varighed",
@@ -372,6 +374,13 @@
"tracksButton": "Lyd og undertekster",
"chaptersButton": "Kapitler",
"versionsButton": "Videoversioner",
"versionQualityButton": "Version og kvalitet",
"versionColumnHeader": "Version",
"qualityColumnHeader": "Kvalitet",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodning utilgængelig — afspiller original kvalitet",
"pipButton": "Billede-i-billede-tilstand",
"aspectRatioButton": "Billedformat",
"ambientLighting": "Omgivelsesbelysning",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Empfohlen)",
"bufferSizeWarning": "Ihr Gerät hat ${heap}MB Speicher. Ein ${size}MB-Puffer kann Wiedergabeprobleme verursachen.",
"defaultQualityTitle": "Standardqualität",
"defaultQualityDescription": "Wird beim Starten der Wiedergabe verwendet. Niedrigere Werte reduzieren die Bandbreite.",
"subtitleStyling": "Untertitel-Stil",
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
"smallSkipDuration": "Kleine Sprungdauer",
@@ -372,6 +374,13 @@
"tracksButton": "Audio und Untertitel",
"chaptersButton": "Kapitel",
"versionsButton": "Videoversionen",
"versionQualityButton": "Version & Qualität",
"versionColumnHeader": "Version",
"qualityColumnHeader": "Qualität",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodierung nicht verfügbar — Wiedergabe in Originalqualität",
"pipButton": "Bild-in-Bild Modus",
"aspectRatioButton": "Seitenverhältnis",
"ambientLighting": "Umgebungsbeleuchtung",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Recommended)",
"bufferSizeWarning": "Your device has ${heap}MB of memory. A ${size}MB buffer may cause playback issues.",
"defaultQualityTitle": "Default Quality",
"defaultQualityDescription": "Used when starting playback. Lower values reduce bandwidth.",
"subtitleStyling": "Subtitle Styling",
"subtitleStylingDescription": "Customize subtitle appearance",
"smallSkipDuration": "Small Skip Duration",
@@ -372,6 +374,13 @@
"tracksButton": "Audio & Subtitles",
"chaptersButton": "Chapters",
"versionsButton": "Video versions",
"versionQualityButton": "Version & Quality",
"versionColumnHeader": "Version",
"qualityColumnHeader": "Quality",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcoding unavailable — playing original quality",
"pipButton": "Picture-in-Picture mode",
"aspectRatioButton": "Aspect ratio",
"ambientLighting": "Ambient lighting",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Recomendado)",
"bufferSizeWarning": "Tu dispositivo tiene ${heap}MB de memoria. Un búfer de ${size}MB puede causar problemas de reproducción.",
"defaultQualityTitle": "Calidad predeterminada",
"defaultQualityDescription": "Se utiliza al iniciar la reproducción. Los valores más bajos reducen el ancho de banda.",
"subtitleStyling": "Estilo de Subtítulos",
"subtitleStylingDescription": "Personalizar la apariencia de los subtítulos",
"smallSkipDuration": "Salto pequeño",
@@ -372,6 +374,13 @@
"tracksButton": "Audio y subtítulos",
"chaptersButton": "Capítulos",
"versionsButton": "Versiones de video",
"versionQualityButton": "Versión y calidad",
"versionColumnHeader": "Versión",
"qualityColumnHeader": "Calidad",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodificación no disponible — reproduciendo calidad original",
"pipButton": "Modo PiP (Imagen en Imagen)",
"aspectRatioButton": "Relación de aspecto",
"ambientLighting": "Iluminación ambiental",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Recommandé)",
"bufferSizeWarning": "Votre appareil dispose de ${heap}MB de mémoire. Un tampon de ${size}MB peut causer des problèmes de lecture.",
"defaultQualityTitle": "Qualité par défaut",
"defaultQualityDescription": "Utilisé au démarrage de la lecture. Les valeurs plus basses réduisent la bande passante.",
"subtitleStyling": "Stylisation des sous-titres",
"subtitleStylingDescription": "Personnaliser l'apparence des sous-titres",
"smallSkipDuration": "Durée du petit saut",
@@ -372,6 +374,13 @@
"tracksButton": "Audio et sous-titres",
"chaptersButton": "Chapitres",
"versionsButton": "Versions vidéo",
"versionQualityButton": "Version et qualité",
"versionColumnHeader": "Version",
"qualityColumnHeader": "Qualité",
"qualityOriginal": "Originale",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodage indisponible — lecture en qualité originale",
"pipButton": "Mode PiP (Picture-in-Picture)",
"aspectRatioButton": "Format d'image",
"ambientLighting": "Éclairage ambiant",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Consigliato)",
"bufferSizeWarning": "Il tuo dispositivo ha ${heap}MB di memoria. Un buffer di ${size}MB potrebbe causare problemi di riproduzione.",
"defaultQualityTitle": "Qualità predefinita",
"defaultQualityDescription": "Utilizzato all'avvio della riproduzione. Valori più bassi riducono la larghezza di banda.",
"subtitleStyling": "Stile sottotitoli",
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
"smallSkipDuration": "Durata skip breve",
@@ -372,6 +374,13 @@
"tracksButton": "Audio e sottotitoli",
"chaptersButton": "Capitoli",
"versionsButton": "Versioni video",
"versionQualityButton": "Versione e qualità",
"versionColumnHeader": "Versione",
"qualityColumnHeader": "Qualità",
"qualityOriginal": "Originale",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodifica non disponibile — riproduzione in qualità originale",
"pipButton": "Modalità Picture-in-Picture",
"aspectRatioButton": "Proporzioni",
"ambientLighting": "Illuminazione ambientale",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "自動(推奨)",
"bufferSizeWarning": "デバイスのメモリは${heap}MBです。${size}MBのバッファは再生の問題を引き起こす可能性があります。",
"defaultQualityTitle": "デフォルト画質",
"defaultQualityDescription": "再生開始時に使用。低い値ほど帯域幅が削減されます。",
"subtitleStyling": "字幕スタイル",
"subtitleStylingDescription": "字幕の外観をカスタマイズ",
"smallSkipDuration": "短いスキップ時間",
@@ -372,6 +374,13 @@
"tracksButton": "音声と字幕",
"chaptersButton": "チャプター",
"versionsButton": "動画バージョン",
"versionQualityButton": "バージョンと画質",
"versionColumnHeader": "バージョン",
"qualityColumnHeader": "画質",
"qualityOriginal": "オリジナル",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "トランスコードは利用できません — オリジナル画質で再生中",
"pipButton": "ピクチャーインピクチャーモード",
"aspectRatioButton": "アスペクト比",
"ambientLighting": "アンビエントライティング",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "자동 (권장)",
"bufferSizeWarning": "기기 메모리가 ${heap}MB입니다. ${size}MB 버퍼는 재생 문제를 일으킬 수 있습니다.",
"defaultQualityTitle": "기본 화질",
"defaultQualityDescription": "재생 시작 시 사용됩니다. 낮은 값은 대역폭을 줄입니다.",
"subtitleStyling": "자막 스타일",
"subtitleStylingDescription": "자막의 외형을 사용자 설정",
"smallSkipDuration": "짧은 건너뛰기 시간",
@@ -372,6 +374,13 @@
"tracksButton": "오디오 및 자막",
"chaptersButton": "챕터",
"versionsButton": "동영상 버전",
"versionQualityButton": "버전 및 화질",
"versionColumnHeader": "버전",
"qualityColumnHeader": "화질",
"qualityOriginal": "원본",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "트랜스코딩을 사용할 수 없음 — 원본 화질로 재생",
"pipButton": "픽처 인 픽처 모드",
"aspectRatioButton": "화면비율",
"ambientLighting": "주변 조명",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Anbefalt)",
"bufferSizeWarning": "Enheten din har ${heap}MB minne. En ${size}MB buffer kan forårsake avspillingsproblemer.",
"defaultQualityTitle": "Standardkvalitet",
"defaultQualityDescription": "Brukes ved start av avspilling. Lavere verdier reduserer båndbredden.",
"subtitleStyling": "Undertekststil",
"subtitleStylingDescription": "Tilpass utseendet på undertekster",
"smallSkipDuration": "Kort hoppvarighet",
@@ -372,6 +374,13 @@
"tracksButton": "Lyd og undertekster",
"chaptersButton": "Kapitler",
"versionsButton": "Videoversjoner",
"versionQualityButton": "Versjon og kvalitet",
"versionColumnHeader": "Versjon",
"qualityColumnHeader": "Kvalitet",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkoding utilgjengelig — spiller av i original kvalitet",
"pipButton": "Bilde-i-bilde-modus",
"aspectRatioButton": "Sideforhold",
"ambientLighting": "Omgivelseslys",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Aanbevolen)",
"bufferSizeWarning": "Je apparaat heeft ${heap}MB geheugen. Een buffer van ${size}MB kan afspeelproblemen veroorzaken.",
"defaultQualityTitle": "Standaardkwaliteit",
"defaultQualityDescription": "Wordt gebruikt bij het starten van de weergave. Lagere waarden verminderen de bandbreedte.",
"subtitleStyling": "Ondertitel opmaak",
"subtitleStylingDescription": "Pas ondertitel uiterlijk aan",
"smallSkipDuration": "Korte skip duur",
@@ -372,6 +374,13 @@
"tracksButton": "Audio en ondertitels",
"chaptersButton": "Hoofdstukken",
"versionsButton": "Videoversies",
"versionQualityButton": "Versie en kwaliteit",
"versionColumnHeader": "Versie",
"qualityColumnHeader": "Kwaliteit",
"qualityOriginal": "Origineel",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcoderen niet beschikbaar — originele kwaliteit wordt afgespeeld",
"pipButton": "Beeld-in-beeld modus",
"aspectRatioButton": "Beeldverhouding",
"ambientLighting": "Omgevingsverlichting",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automatyczny (Zalecany)",
"bufferSizeWarning": "Twoje urządzenie ma ${heap}MB pamięci. Bufor ${size}MB może powodować problemy z odtwarzaniem.",
"defaultQualityTitle": "Domyślna jakość",
"defaultQualityDescription": "Używane podczas rozpoczynania odtwarzania. Niższe wartości zmniejszają przepustowość.",
"subtitleStyling": "Styl napisów",
"subtitleStylingDescription": "Dostosuj wygląd napisów",
"smallSkipDuration": "Krótki skok",
@@ -372,6 +374,13 @@
"tracksButton": "Audio i napisy",
"chaptersButton": "Rozdziały",
"versionsButton": "Wersje wideo",
"versionQualityButton": "Wersja i jakość",
"versionColumnHeader": "Wersja",
"qualityColumnHeader": "Jakość",
"qualityOriginal": "Oryginalna",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodowanie niedostępne — odtwarzanie w oryginalnej jakości",
"pipButton": "Tryb obraz w obrazie",
"aspectRatioButton": "Proporcje",
"ambientLighting": "Oświetlenie otoczenia",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Automático (Recomendado)",
"bufferSizeWarning": "Seu dispositivo tem ${heap}MB de memória. Um buffer de ${size}MB pode causar problemas de reprodução.",
"defaultQualityTitle": "Qualidade padrão",
"defaultQualityDescription": "Usado ao iniciar a reprodução. Valores menores reduzem a largura de banda.",
"subtitleStyling": "Estilo de Legendas",
"subtitleStylingDescription": "Personalizar aparência das legendas",
"smallSkipDuration": "Duração do Avanço Curto",
@@ -372,6 +374,13 @@
"tracksButton": "Áudio e Legendas",
"chaptersButton": "Capítulos",
"versionsButton": "Versões do vídeo",
"versionQualityButton": "Versão e qualidade",
"versionColumnHeader": "Versão",
"qualityColumnHeader": "Qualidade",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transcodificação indisponível — reproduzindo qualidade original",
"pipButton": "Modo Picture-in-Picture",
"aspectRatioButton": "Proporção",
"ambientLighting": "Iluminação ambiente",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}МБ",
"bufferSizeAuto": "Авто (Рекомендуется)",
"bufferSizeWarning": "У вашего устройства ${heap}МБ памяти. Буфер ${size}МБ может вызвать проблемы с воспроизведением.",
"defaultQualityTitle": "Качество по умолчанию",
"defaultQualityDescription": "Используется при запуске воспроизведения. Более низкие значения снижают пропускную способность.",
"subtitleStyling": "Стиль субтитров",
"subtitleStylingDescription": "Настроить внешний вид субтитров",
"smallSkipDuration": "Малая перемотка",
@@ -372,6 +374,13 @@
"tracksButton": "Аудио и субтитры",
"chaptersButton": "Главы",
"versionsButton": "Версии видео",
"versionQualityButton": "Версия и качество",
"versionColumnHeader": "Версия",
"qualityColumnHeader": "Качество",
"qualityOriginal": "Оригинал",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Транскодирование недоступно — воспроизведение в оригинальном качестве",
"pipButton": "Режим «картинка в картинке»",
"aspectRatioButton": "Соотношение сторон",
"ambientLighting": "Фоновая подсветка",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 13530 (902 per locale)
/// Strings: 13665 (911 per locale)
///
/// Built on 2026-04-21 at 15:33 UTC
/// Built on 2026-04-22 at 19:06 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Anbefalet)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Din enhed har ${heap}MB hukommelse. En buffer på ${size}MB kan forårsage afspilningsproblemer.';
@override String get defaultQualityTitle => 'Standardkvalitet';
@override String get defaultQualityDescription => 'Bruges ved start af afspilning. Lavere værdier reducerer båndbredden.';
@override String get subtitleStyling => 'Undertekststil';
@override String get subtitleStylingDescription => 'Tilpas underteksters udseende';
@override String get smallSkipDuration => 'Kort spring-varighed';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsDa implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Lyd og undertekster';
@override String get chaptersButton => 'Kapitler';
@override String get versionsButton => 'Videoversioner';
@override String get versionQualityButton => 'Version og kvalitet';
@override String get versionColumnHeader => 'Version';
@override String get qualityColumnHeader => 'Kvalitet';
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodning utilgængelig — afspiller original kvalitet';
@override String get pipButton => 'Billede-i-billede-tilstand';
@override String get aspectRatioButton => 'Billedformat';
@override String get ambientLighting => 'Omgivelsesbelysning';
@@ -1520,6 +1529,8 @@ extension on TranslationsDa {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Anbefalet)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Din enhed har ${heap}MB hukommelse. En buffer på ${size}MB kan forårsage afspilningsproblemer.',
'settings.defaultQualityTitle' => 'Standardkvalitet',
'settings.defaultQualityDescription' => 'Bruges ved start af afspilning. Lavere værdier reducerer båndbredden.',
'settings.subtitleStyling' => 'Undertekststil',
'settings.subtitleStylingDescription' => 'Tilpas underteksters udseende',
'settings.smallSkipDuration' => 'Kort spring-varighed',
@@ -1755,6 +1766,13 @@ extension on TranslationsDa {
'videoControls.tracksButton' => 'Lyd og undertekster',
'videoControls.chaptersButton' => 'Kapitler',
'videoControls.versionsButton' => 'Videoversioner',
'videoControls.versionQualityButton' => 'Version og kvalitet',
'videoControls.versionColumnHeader' => 'Version',
'videoControls.qualityColumnHeader' => 'Kvalitet',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodning utilgængelig — afspiller original kvalitet',
'videoControls.pipButton' => 'Billede-i-billede-tilstand',
'videoControls.aspectRatioButton' => 'Billedformat',
'videoControls.ambientLighting' => 'Omgivelsesbelysning',
@@ -1912,6 +1930,8 @@ extension on TranslationsDa {
'libraries.confirmActionMessage' => 'Er du sikker på, at du vil udføre denne handling?',
'libraries.showLibrary' => 'Vis bibliotek',
'libraries.hideLibrary' => 'Skjul bibliotek',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Biblioteksindstillinger',
'libraries.content' => 'biblioteksindhold',
'libraries.selectLibrary' => 'Vælg bibliotek',
@@ -1921,8 +1941,6 @@ extension on TranslationsDa {
'libraries.noFoldersFound' => 'Ingen mapper fundet',
'libraries.folders' => 'mapper',
'libraries.tabs.recommended' => 'Anbefalet',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Gennemse',
'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Playlister',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Empfohlen)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Ihr Gerät hat ${heap}MB Speicher. Ein ${size}MB-Puffer kann Wiedergabeprobleme verursachen.';
@override String get defaultQualityTitle => 'Standardqualität';
@override String get defaultQualityDescription => 'Wird beim Starten der Wiedergabe verwendet. Niedrigere Werte reduzieren die Bandbreite.';
@override String get subtitleStyling => 'Untertitel-Stil';
@override String get subtitleStylingDescription => 'Aussehen von Untertiteln anpassen';
@override String get smallSkipDuration => 'Kleine Sprungdauer';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Audio und Untertitel';
@override String get chaptersButton => 'Kapitel';
@override String get versionsButton => 'Videoversionen';
@override String get versionQualityButton => 'Version & Qualität';
@override String get versionColumnHeader => 'Version';
@override String get qualityColumnHeader => 'Qualität';
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodierung nicht verfügbar — Wiedergabe in Originalqualität';
@override String get pipButton => 'Bild-in-Bild Modus';
@override String get aspectRatioButton => 'Seitenverhältnis';
@override String get ambientLighting => 'Umgebungsbeleuchtung';
@@ -1520,6 +1529,8 @@ extension on TranslationsDe {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Empfohlen)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Ihr Gerät hat ${heap}MB Speicher. Ein ${size}MB-Puffer kann Wiedergabeprobleme verursachen.',
'settings.defaultQualityTitle' => 'Standardqualität',
'settings.defaultQualityDescription' => 'Wird beim Starten der Wiedergabe verwendet. Niedrigere Werte reduzieren die Bandbreite.',
'settings.subtitleStyling' => 'Untertitel-Stil',
'settings.subtitleStylingDescription' => 'Aussehen von Untertiteln anpassen',
'settings.smallSkipDuration' => 'Kleine Sprungdauer',
@@ -1755,6 +1766,13 @@ extension on TranslationsDe {
'videoControls.tracksButton' => 'Audio und Untertitel',
'videoControls.chaptersButton' => 'Kapitel',
'videoControls.versionsButton' => 'Videoversionen',
'videoControls.versionQualityButton' => 'Version & Qualität',
'videoControls.versionColumnHeader' => 'Version',
'videoControls.qualityColumnHeader' => 'Qualität',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodierung nicht verfügbar — Wiedergabe in Originalqualität',
'videoControls.pipButton' => 'Bild-in-Bild Modus',
'videoControls.aspectRatioButton' => 'Seitenverhältnis',
'videoControls.ambientLighting' => 'Umgebungsbeleuchtung',
@@ -1912,6 +1930,8 @@ extension on TranslationsDe {
'libraries.confirmActionMessage' => 'Aktion wirklich durchführen?',
'libraries.showLibrary' => 'Mediathek anzeigen',
'libraries.hideLibrary' => 'Mediathek ausblenden',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Mediatheksoptionen',
'libraries.content' => 'Bibliotheksinhalt',
'libraries.selectLibrary' => 'Bibliothek auswählen',
@@ -1921,8 +1941,6 @@ extension on TranslationsDe {
'libraries.noFoldersFound' => 'Keine Ordner gefunden',
'libraries.folders' => 'Ordner',
'libraries.tabs.recommended' => 'Empfohlen',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Durchsuchen',
'libraries.tabs.collections' => 'Sammlungen',
'libraries.tabs.playlists' => 'Wiedergabelisten',
+38 -2
View File
@@ -467,6 +467,12 @@ class TranslationsSettingsEn {
/// en: 'Your device has ${heap}MB of memory. A ${size}MB buffer may cause playback issues.'
String bufferSizeWarning({required Object heap, required Object size}) => 'Your device has ${heap}MB of memory. A ${size}MB buffer may cause playback issues.';
/// en: 'Default Quality'
String get defaultQualityTitle => 'Default Quality';
/// en: 'Used when starting playback. Lower values reduce bandwidth.'
String get defaultQualityDescription => 'Used when starting playback. Lower values reduce bandwidth.';
/// en: 'Subtitle Styling'
String get subtitleStyling => 'Subtitle Styling';
@@ -1174,6 +1180,27 @@ class TranslationsVideoControlsEn {
/// en: 'Video versions'
String get versionsButton => 'Video versions';
/// en: 'Version & Quality'
String get versionQualityButton => 'Version & Quality';
/// en: 'Version'
String get versionColumnHeader => 'Version';
/// en: 'Quality'
String get qualityColumnHeader => 'Quality';
/// en: 'Original'
String get qualityOriginal => 'Original';
/// en: '${resolution}p ${bitrate} Mbps'
String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
/// en: '~${bitrate} Mbps'
String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
/// en: 'Transcoding unavailable — playing original quality'
String get transcodeUnavailableFallback => 'Transcoding unavailable — playing original quality';
/// en: 'Picture-in-Picture mode'
String get pipButton => 'Picture-in-Picture mode';
@@ -3331,6 +3358,8 @@ extension on Translations {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Recommended)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Your device has ${heap}MB of memory. A ${size}MB buffer may cause playback issues.',
'settings.defaultQualityTitle' => 'Default Quality',
'settings.defaultQualityDescription' => 'Used when starting playback. Lower values reduce bandwidth.',
'settings.subtitleStyling' => 'Subtitle Styling',
'settings.subtitleStylingDescription' => 'Customize subtitle appearance',
'settings.smallSkipDuration' => 'Small Skip Duration',
@@ -3566,6 +3595,13 @@ extension on Translations {
'videoControls.tracksButton' => 'Audio & Subtitles',
'videoControls.chaptersButton' => 'Chapters',
'videoControls.versionsButton' => 'Video versions',
'videoControls.versionQualityButton' => 'Version & Quality',
'videoControls.versionColumnHeader' => 'Version',
'videoControls.qualityColumnHeader' => 'Quality',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcoding unavailable — playing original quality',
'videoControls.pipButton' => 'Picture-in-Picture mode',
'videoControls.aspectRatioButton' => 'Aspect ratio',
'videoControls.ambientLighting' => 'Ambient lighting',
@@ -3723,6 +3759,8 @@ extension on Translations {
'libraries.confirmActionMessage' => 'Are you sure you want to perform this action?',
'libraries.showLibrary' => 'Show library',
'libraries.hideLibrary' => 'Hide library',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Library options',
'libraries.content' => 'library content',
'libraries.selectLibrary' => 'Select library',
@@ -3732,8 +3770,6 @@ extension on Translations {
'libraries.noFoldersFound' => 'No folders found',
'libraries.folders' => 'folders',
'libraries.tabs.recommended' => 'Recommended',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Recomendado)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Tu dispositivo tiene ${heap}MB de memoria. Un búfer de ${size}MB puede causar problemas de reproducción.';
@override String get defaultQualityTitle => 'Calidad predeterminada';
@override String get defaultQualityDescription => 'Se utiliza al iniciar la reproducción. Los valores más bajos reducen el ancho de banda.';
@override String get subtitleStyling => 'Estilo de Subtítulos';
@override String get subtitleStylingDescription => 'Personalizar la apariencia de los subtítulos';
@override String get smallSkipDuration => 'Salto pequeño';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Audio y subtítulos';
@override String get chaptersButton => 'Capítulos';
@override String get versionsButton => 'Versiones de video';
@override String get versionQualityButton => 'Versión y calidad';
@override String get versionColumnHeader => 'Versión';
@override String get qualityColumnHeader => 'Calidad';
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodificación no disponible — reproduciendo calidad original';
@override String get pipButton => 'Modo PiP (Imagen en Imagen)';
@override String get aspectRatioButton => 'Relación de aspecto';
@override String get ambientLighting => 'Iluminación ambiental';
@@ -1520,6 +1529,8 @@ extension on TranslationsEs {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Recomendado)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Tu dispositivo tiene ${heap}MB de memoria. Un búfer de ${size}MB puede causar problemas de reproducción.',
'settings.defaultQualityTitle' => 'Calidad predeterminada',
'settings.defaultQualityDescription' => 'Se utiliza al iniciar la reproducción. Los valores más bajos reducen el ancho de banda.',
'settings.subtitleStyling' => 'Estilo de Subtítulos',
'settings.subtitleStylingDescription' => 'Personalizar la apariencia de los subtítulos',
'settings.smallSkipDuration' => 'Salto pequeño',
@@ -1755,6 +1766,13 @@ extension on TranslationsEs {
'videoControls.tracksButton' => 'Audio y subtítulos',
'videoControls.chaptersButton' => 'Capítulos',
'videoControls.versionsButton' => 'Versiones de video',
'videoControls.versionQualityButton' => 'Versión y calidad',
'videoControls.versionColumnHeader' => 'Versión',
'videoControls.qualityColumnHeader' => 'Calidad',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodificación no disponible — reproduciendo calidad original',
'videoControls.pipButton' => 'Modo PiP (Imagen en Imagen)',
'videoControls.aspectRatioButton' => 'Relación de aspecto',
'videoControls.ambientLighting' => 'Iluminación ambiental',
@@ -1912,6 +1930,8 @@ extension on TranslationsEs {
'libraries.confirmActionMessage' => '¿Estás seguro de que quieres realizar esta acción?',
'libraries.showLibrary' => 'Mostrar biblioteca',
'libraries.hideLibrary' => 'Ocultar biblioteca',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Opciones de biblioteca',
'libraries.content' => 'contenido de la biblioteca',
'libraries.selectLibrary' => 'Seleccionar biblioteca',
@@ -1921,8 +1941,6 @@ extension on TranslationsEs {
'libraries.noFoldersFound' => 'No se encontraron carpetas',
'libraries.folders' => 'carpetas',
'libraries.tabs.recommended' => 'Recomendado',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Explorar',
'libraries.tabs.collections' => 'Colecciones',
'libraries.tabs.playlists' => 'Listas',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Recommandé)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Votre appareil dispose de ${heap}MB de mémoire. Un tampon de ${size}MB peut causer des problèmes de lecture.';
@override String get defaultQualityTitle => 'Qualité par défaut';
@override String get defaultQualityDescription => 'Utilisé au démarrage de la lecture. Les valeurs plus basses réduisent la bande passante.';
@override String get subtitleStyling => 'Stylisation des sous-titres';
@override String get subtitleStylingDescription => 'Personnaliser l\'apparence des sous-titres';
@override String get smallSkipDuration => 'Durée du petit saut';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Audio et sous-titres';
@override String get chaptersButton => 'Chapitres';
@override String get versionsButton => 'Versions vidéo';
@override String get versionQualityButton => 'Version et qualité';
@override String get versionColumnHeader => 'Version';
@override String get qualityColumnHeader => 'Qualité';
@override String get qualityOriginal => 'Originale';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodage indisponible — lecture en qualité originale';
@override String get pipButton => 'Mode PiP (Picture-in-Picture)';
@override String get aspectRatioButton => 'Format d\'image';
@override String get ambientLighting => 'Éclairage ambiant';
@@ -1520,6 +1529,8 @@ extension on TranslationsFr {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Recommandé)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Votre appareil dispose de ${heap}MB de mémoire. Un tampon de ${size}MB peut causer des problèmes de lecture.',
'settings.defaultQualityTitle' => 'Qualité par défaut',
'settings.defaultQualityDescription' => 'Utilisé au démarrage de la lecture. Les valeurs plus basses réduisent la bande passante.',
'settings.subtitleStyling' => 'Stylisation des sous-titres',
'settings.subtitleStylingDescription' => 'Personnaliser l\'apparence des sous-titres',
'settings.smallSkipDuration' => 'Durée du petit saut',
@@ -1755,6 +1766,13 @@ extension on TranslationsFr {
'videoControls.tracksButton' => 'Audio et sous-titres',
'videoControls.chaptersButton' => 'Chapitres',
'videoControls.versionsButton' => 'Versions vidéo',
'videoControls.versionQualityButton' => 'Version et qualité',
'videoControls.versionColumnHeader' => 'Version',
'videoControls.qualityColumnHeader' => 'Qualité',
'videoControls.qualityOriginal' => 'Originale',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodage indisponible — lecture en qualité originale',
'videoControls.pipButton' => 'Mode PiP (Picture-in-Picture)',
'videoControls.aspectRatioButton' => 'Format d\'image',
'videoControls.ambientLighting' => 'Éclairage ambiant',
@@ -1912,6 +1930,8 @@ extension on TranslationsFr {
'libraries.confirmActionMessage' => 'Êtes-vous sûr de vouloir effectuer cette action ?',
'libraries.showLibrary' => 'Afficher la bibliothèque',
'libraries.hideLibrary' => 'Masquer la bibliothèque',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Options de bibliothèque',
'libraries.content' => 'contenu de la bibliothèque',
'libraries.selectLibrary' => 'Sélectionner la bibliothèque',
@@ -1921,8 +1941,6 @@ extension on TranslationsFr {
'libraries.noFoldersFound' => 'Aucun dossier trouvé',
'libraries.folders' => 'dossiers',
'libraries.tabs.recommended' => 'Recommandé',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Parcourir',
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Consigliato)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Il tuo dispositivo ha ${heap}MB di memoria. Un buffer di ${size}MB potrebbe causare problemi di riproduzione.';
@override String get defaultQualityTitle => 'Qualità predefinita';
@override String get defaultQualityDescription => 'Utilizzato all\'avvio della riproduzione. Valori più bassi riducono la larghezza di banda.';
@override String get subtitleStyling => 'Stile sottotitoli';
@override String get subtitleStylingDescription => 'Personalizza l\'aspetto dei sottotitoli';
@override String get smallSkipDuration => 'Durata skip breve';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Audio e sottotitoli';
@override String get chaptersButton => 'Capitoli';
@override String get versionsButton => 'Versioni video';
@override String get versionQualityButton => 'Versione e qualità';
@override String get versionColumnHeader => 'Versione';
@override String get qualityColumnHeader => 'Qualità';
@override String get qualityOriginal => 'Originale';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodifica non disponibile — riproduzione in qualità originale';
@override String get pipButton => 'Modalità Picture-in-Picture';
@override String get aspectRatioButton => 'Proporzioni';
@override String get ambientLighting => 'Illuminazione ambientale';
@@ -1520,6 +1529,8 @@ extension on TranslationsIt {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Consigliato)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Il tuo dispositivo ha ${heap}MB di memoria. Un buffer di ${size}MB potrebbe causare problemi di riproduzione.',
'settings.defaultQualityTitle' => 'Qualità predefinita',
'settings.defaultQualityDescription' => 'Utilizzato all\'avvio della riproduzione. Valori più bassi riducono la larghezza di banda.',
'settings.subtitleStyling' => 'Stile sottotitoli',
'settings.subtitleStylingDescription' => 'Personalizza l\'aspetto dei sottotitoli',
'settings.smallSkipDuration' => 'Durata skip breve',
@@ -1755,6 +1766,13 @@ extension on TranslationsIt {
'videoControls.tracksButton' => 'Audio e sottotitoli',
'videoControls.chaptersButton' => 'Capitoli',
'videoControls.versionsButton' => 'Versioni video',
'videoControls.versionQualityButton' => 'Versione e qualità',
'videoControls.versionColumnHeader' => 'Versione',
'videoControls.qualityColumnHeader' => 'Qualità',
'videoControls.qualityOriginal' => 'Originale',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodifica non disponibile — riproduzione in qualità originale',
'videoControls.pipButton' => 'Modalità Picture-in-Picture',
'videoControls.aspectRatioButton' => 'Proporzioni',
'videoControls.ambientLighting' => 'Illuminazione ambientale',
@@ -1912,6 +1930,8 @@ extension on TranslationsIt {
'libraries.confirmActionMessage' => 'Sei sicuro di voler eseguire questa azione?',
'libraries.showLibrary' => 'Mostra libreria',
'libraries.hideLibrary' => 'Nascondi libreria',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Opzioni libreria',
'libraries.content' => 'contenuto della libreria',
'libraries.selectLibrary' => 'Seleziona libreria',
@@ -1921,8 +1941,6 @@ extension on TranslationsIt {
'libraries.noFoldersFound' => 'Nessuna cartella trovata',
'libraries.folders' => 'cartelle',
'libraries.tabs.recommended' => 'Consigliati',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Esplora',
'libraries.tabs.collections' => 'Raccolte',
'libraries.tabs.playlists' => 'Playlist',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => '自動(推奨)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'デバイスのメモリは${heap}MBです。${size}MBのバッファは再生の問題を引き起こす可能性があります。';
@override String get defaultQualityTitle => 'デフォルト画質';
@override String get defaultQualityDescription => '再生開始時に使用。低い値ほど帯域幅が削減されます。';
@override String get subtitleStyling => '字幕スタイル';
@override String get subtitleStylingDescription => '字幕の外観をカスタマイズ';
@override String get smallSkipDuration => '短いスキップ時間';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsJa implements TranslationsVideoControlsEn {
@override String get tracksButton => '音声と字幕';
@override String get chaptersButton => 'チャプター';
@override String get versionsButton => '動画バージョン';
@override String get versionQualityButton => 'バージョンと画質';
@override String get versionColumnHeader => 'バージョン';
@override String get qualityColumnHeader => '画質';
@override String get qualityOriginal => 'オリジナル';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'トランスコードは利用できません — オリジナル画質で再生中';
@override String get pipButton => 'ピクチャーインピクチャーモード';
@override String get aspectRatioButton => 'アスペクト比';
@override String get ambientLighting => 'アンビエントライティング';
@@ -1520,6 +1529,8 @@ extension on TranslationsJa {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => '自動(推奨)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'デバイスのメモリは${heap}MBです。${size}MBのバッファは再生の問題を引き起こす可能性があります。',
'settings.defaultQualityTitle' => 'デフォルト画質',
'settings.defaultQualityDescription' => '再生開始時に使用。低い値ほど帯域幅が削減されます。',
'settings.subtitleStyling' => '字幕スタイル',
'settings.subtitleStylingDescription' => '字幕の外観をカスタマイズ',
'settings.smallSkipDuration' => '短いスキップ時間',
@@ -1755,6 +1766,13 @@ extension on TranslationsJa {
'videoControls.tracksButton' => '音声と字幕',
'videoControls.chaptersButton' => 'チャプター',
'videoControls.versionsButton' => '動画バージョン',
'videoControls.versionQualityButton' => 'バージョンと画質',
'videoControls.versionColumnHeader' => 'バージョン',
'videoControls.qualityColumnHeader' => '画質',
'videoControls.qualityOriginal' => 'オリジナル',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'トランスコードは利用できません — オリジナル画質で再生中',
'videoControls.pipButton' => 'ピクチャーインピクチャーモード',
'videoControls.aspectRatioButton' => 'アスペクト比',
'videoControls.ambientLighting' => 'アンビエントライティング',
@@ -1912,6 +1930,8 @@ extension on TranslationsJa {
'libraries.confirmActionMessage' => 'この操作を実行してもよろしいですか?',
'libraries.showLibrary' => 'ライブラリを表示',
'libraries.hideLibrary' => 'ライブラリを非表示',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'ライブラリオプション',
'libraries.content' => 'ライブラリコンテンツ',
'libraries.selectLibrary' => 'ライブラリを選択',
@@ -1921,8 +1941,6 @@ extension on TranslationsJa {
'libraries.noFoldersFound' => 'フォルダが見つかりません',
'libraries.folders' => 'フォルダ',
'libraries.tabs.recommended' => 'おすすめ',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'ブラウズ',
'libraries.tabs.collections' => 'コレクション',
'libraries.tabs.playlists' => 'プレイリスト',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => '자동 (권장)';
@override String bufferSizeWarning({required Object heap, required Object size}) => '기기 메모리가 ${heap}MB입니다. ${size}MB 버퍼는 재생 문제를 일으킬 수 있습니다.';
@override String get defaultQualityTitle => '기본 화질';
@override String get defaultQualityDescription => '재생 시작 시 사용됩니다. 낮은 값은 대역폭을 줄입니다.';
@override String get subtitleStyling => '자막 스타일';
@override String get subtitleStylingDescription => '자막의 외형을 사용자 설정';
@override String get smallSkipDuration => '짧은 건너뛰기 시간';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get tracksButton => '오디오 및 자막';
@override String get chaptersButton => '챕터';
@override String get versionsButton => '동영상 버전';
@override String get versionQualityButton => '버전 및 화질';
@override String get versionColumnHeader => '버전';
@override String get qualityColumnHeader => '화질';
@override String get qualityOriginal => '원본';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => '트랜스코딩을 사용할 수 없음 — 원본 화질로 재생';
@override String get pipButton => '픽처 인 픽처 모드';
@override String get aspectRatioButton => '화면비율';
@override String get ambientLighting => '주변 조명';
@@ -1520,6 +1529,8 @@ extension on TranslationsKo {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => '자동 (권장)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => '기기 메모리가 ${heap}MB입니다. ${size}MB 버퍼는 재생 문제를 일으킬 수 있습니다.',
'settings.defaultQualityTitle' => '기본 화질',
'settings.defaultQualityDescription' => '재생 시작 시 사용됩니다. 낮은 값은 대역폭을 줄입니다.',
'settings.subtitleStyling' => '자막 스타일',
'settings.subtitleStylingDescription' => '자막의 외형을 사용자 설정',
'settings.smallSkipDuration' => '짧은 건너뛰기 시간',
@@ -1755,6 +1766,13 @@ extension on TranslationsKo {
'videoControls.tracksButton' => '오디오 및 자막',
'videoControls.chaptersButton' => '챕터',
'videoControls.versionsButton' => '동영상 버전',
'videoControls.versionQualityButton' => '버전 및 화질',
'videoControls.versionColumnHeader' => '버전',
'videoControls.qualityColumnHeader' => '화질',
'videoControls.qualityOriginal' => '원본',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => '트랜스코딩을 사용할 수 없음 — 원본 화질로 재생',
'videoControls.pipButton' => '픽처 인 픽처 모드',
'videoControls.aspectRatioButton' => '화면비율',
'videoControls.ambientLighting' => '주변 조명',
@@ -1912,6 +1930,8 @@ extension on TranslationsKo {
'libraries.confirmActionMessage' => '이 작업을 실행 하시겠습니까?',
'libraries.showLibrary' => '미디어 라이브러리 표시',
'libraries.hideLibrary' => '미디어 라이브러리 숨기기',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => '미디어 라이브러리 옵션',
'libraries.content' => '미디어 라이브러리 콘텐츠',
'libraries.selectLibrary' => '미디어 라이브러리 선택',
@@ -1921,8 +1941,6 @@ extension on TranslationsKo {
'libraries.noFoldersFound' => '폴더를 찾을 수 없습니다',
'libraries.folders' => '폴더',
'libraries.tabs.recommended' => '추천',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => '찾아보기',
'libraries.tabs.collections' => '컬렉션',
'libraries.tabs.playlists' => '재생 목록',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Anbefalt)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Enheten din har ${heap}MB minne. En ${size}MB buffer kan forårsake avspillingsproblemer.';
@override String get defaultQualityTitle => 'Standardkvalitet';
@override String get defaultQualityDescription => 'Brukes ved start av avspilling. Lavere verdier reduserer båndbredden.';
@override String get subtitleStyling => 'Undertekststil';
@override String get subtitleStylingDescription => 'Tilpass utseendet på undertekster';
@override String get smallSkipDuration => 'Kort hoppvarighet';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsNb implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Lyd og undertekster';
@override String get chaptersButton => 'Kapitler';
@override String get versionsButton => 'Videoversjoner';
@override String get versionQualityButton => 'Versjon og kvalitet';
@override String get versionColumnHeader => 'Versjon';
@override String get qualityColumnHeader => 'Kvalitet';
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkoding utilgjengelig — spiller av i original kvalitet';
@override String get pipButton => 'Bilde-i-bilde-modus';
@override String get aspectRatioButton => 'Sideforhold';
@override String get ambientLighting => 'Omgivelseslys';
@@ -1520,6 +1529,8 @@ extension on TranslationsNb {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Anbefalt)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Enheten din har ${heap}MB minne. En ${size}MB buffer kan forårsake avspillingsproblemer.',
'settings.defaultQualityTitle' => 'Standardkvalitet',
'settings.defaultQualityDescription' => 'Brukes ved start av avspilling. Lavere verdier reduserer båndbredden.',
'settings.subtitleStyling' => 'Undertekststil',
'settings.subtitleStylingDescription' => 'Tilpass utseendet på undertekster',
'settings.smallSkipDuration' => 'Kort hoppvarighet',
@@ -1755,6 +1766,13 @@ extension on TranslationsNb {
'videoControls.tracksButton' => 'Lyd og undertekster',
'videoControls.chaptersButton' => 'Kapitler',
'videoControls.versionsButton' => 'Videoversjoner',
'videoControls.versionQualityButton' => 'Versjon og kvalitet',
'videoControls.versionColumnHeader' => 'Versjon',
'videoControls.qualityColumnHeader' => 'Kvalitet',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkoding utilgjengelig — spiller av i original kvalitet',
'videoControls.pipButton' => 'Bilde-i-bilde-modus',
'videoControls.aspectRatioButton' => 'Sideforhold',
'videoControls.ambientLighting' => 'Omgivelseslys',
@@ -1912,6 +1930,8 @@ extension on TranslationsNb {
'libraries.confirmActionMessage' => 'Er du sikker på at du vil utføre denne handlingen?',
'libraries.showLibrary' => 'Vis bibliotek',
'libraries.hideLibrary' => 'Skjul bibliotek',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Bibliotekalternativer',
'libraries.content' => 'bibliotekinnhold',
'libraries.selectLibrary' => 'Velg bibliotek',
@@ -1921,8 +1941,6 @@ extension on TranslationsNb {
'libraries.noFoldersFound' => 'Ingen mapper funnet',
'libraries.folders' => 'mapper',
'libraries.tabs.recommended' => 'Anbefalt',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Bla gjennom',
'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Spillelister',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Aanbevolen)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Je apparaat heeft ${heap}MB geheugen. Een buffer van ${size}MB kan afspeelproblemen veroorzaken.';
@override String get defaultQualityTitle => 'Standaardkwaliteit';
@override String get defaultQualityDescription => 'Wordt gebruikt bij het starten van de weergave. Lagere waarden verminderen de bandbreedte.';
@override String get subtitleStyling => 'Ondertitel opmaak';
@override String get subtitleStylingDescription => 'Pas ondertitel uiterlijk aan';
@override String get smallSkipDuration => 'Korte skip duur';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Audio en ondertitels';
@override String get chaptersButton => 'Hoofdstukken';
@override String get versionsButton => 'Videoversies';
@override String get versionQualityButton => 'Versie en kwaliteit';
@override String get versionColumnHeader => 'Versie';
@override String get qualityColumnHeader => 'Kwaliteit';
@override String get qualityOriginal => 'Origineel';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcoderen niet beschikbaar — originele kwaliteit wordt afgespeeld';
@override String get pipButton => 'Beeld-in-beeld modus';
@override String get aspectRatioButton => 'Beeldverhouding';
@override String get ambientLighting => 'Omgevingsverlichting';
@@ -1520,6 +1529,8 @@ extension on TranslationsNl {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Aanbevolen)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Je apparaat heeft ${heap}MB geheugen. Een buffer van ${size}MB kan afspeelproblemen veroorzaken.',
'settings.defaultQualityTitle' => 'Standaardkwaliteit',
'settings.defaultQualityDescription' => 'Wordt gebruikt bij het starten van de weergave. Lagere waarden verminderen de bandbreedte.',
'settings.subtitleStyling' => 'Ondertitel opmaak',
'settings.subtitleStylingDescription' => 'Pas ondertitel uiterlijk aan',
'settings.smallSkipDuration' => 'Korte skip duur',
@@ -1755,6 +1766,13 @@ extension on TranslationsNl {
'videoControls.tracksButton' => 'Audio en ondertitels',
'videoControls.chaptersButton' => 'Hoofdstukken',
'videoControls.versionsButton' => 'Videoversies',
'videoControls.versionQualityButton' => 'Versie en kwaliteit',
'videoControls.versionColumnHeader' => 'Versie',
'videoControls.qualityColumnHeader' => 'Kwaliteit',
'videoControls.qualityOriginal' => 'Origineel',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcoderen niet beschikbaar — originele kwaliteit wordt afgespeeld',
'videoControls.pipButton' => 'Beeld-in-beeld modus',
'videoControls.aspectRatioButton' => 'Beeldverhouding',
'videoControls.ambientLighting' => 'Omgevingsverlichting',
@@ -1912,6 +1930,8 @@ extension on TranslationsNl {
'libraries.confirmActionMessage' => 'Weet je zeker dat je deze actie wilt uitvoeren?',
'libraries.showLibrary' => 'Toon bibliotheek',
'libraries.hideLibrary' => 'Verberg bibliotheek',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Bibliotheek opties',
'libraries.content' => 'bibliotheekinhoud',
'libraries.selectLibrary' => 'Bibliotheek kiezen',
@@ -1921,8 +1941,6 @@ extension on TranslationsNl {
'libraries.noFoldersFound' => 'Geen mappen gevonden',
'libraries.folders' => 'mappen',
'libraries.tabs.recommended' => 'Aanbevolen',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Bladeren',
'libraries.tabs.collections' => 'Collecties',
'libraries.tabs.playlists' => 'Afspeellijsten',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Automatyczny (Zalecany)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Twoje urządzenie ma ${heap}MB pamięci. Bufor ${size}MB może powodować problemy z odtwarzaniem.';
@override String get defaultQualityTitle => 'Domyślna jakość';
@override String get defaultQualityDescription => 'Używane podczas rozpoczynania odtwarzania. Niższe wartości zmniejszają przepustowość.';
@override String get subtitleStyling => 'Styl napisów';
@override String get subtitleStylingDescription => 'Dostosuj wygląd napisów';
@override String get smallSkipDuration => 'Krótki skok';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsPl implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Audio i napisy';
@override String get chaptersButton => 'Rozdziały';
@override String get versionsButton => 'Wersje wideo';
@override String get versionQualityButton => 'Wersja i jakość';
@override String get versionColumnHeader => 'Wersja';
@override String get qualityColumnHeader => 'Jakość';
@override String get qualityOriginal => 'Oryginalna';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodowanie niedostępne — odtwarzanie w oryginalnej jakości';
@override String get pipButton => 'Tryb obraz w obrazie';
@override String get aspectRatioButton => 'Proporcje';
@override String get ambientLighting => 'Oświetlenie otoczenia';
@@ -1520,6 +1529,8 @@ extension on TranslationsPl {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Automatyczny (Zalecany)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Twoje urządzenie ma ${heap}MB pamięci. Bufor ${size}MB może powodować problemy z odtwarzaniem.',
'settings.defaultQualityTitle' => 'Domyślna jakość',
'settings.defaultQualityDescription' => 'Używane podczas rozpoczynania odtwarzania. Niższe wartości zmniejszają przepustowość.',
'settings.subtitleStyling' => 'Styl napisów',
'settings.subtitleStylingDescription' => 'Dostosuj wygląd napisów',
'settings.smallSkipDuration' => 'Krótki skok',
@@ -1755,6 +1766,13 @@ extension on TranslationsPl {
'videoControls.tracksButton' => 'Audio i napisy',
'videoControls.chaptersButton' => 'Rozdziały',
'videoControls.versionsButton' => 'Wersje wideo',
'videoControls.versionQualityButton' => 'Wersja i jakość',
'videoControls.versionColumnHeader' => 'Wersja',
'videoControls.qualityColumnHeader' => 'Jakość',
'videoControls.qualityOriginal' => 'Oryginalna',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodowanie niedostępne — odtwarzanie w oryginalnej jakości',
'videoControls.pipButton' => 'Tryb obraz w obrazie',
'videoControls.aspectRatioButton' => 'Proporcje',
'videoControls.ambientLighting' => 'Oświetlenie otoczenia',
@@ -1912,6 +1930,8 @@ extension on TranslationsPl {
'libraries.confirmActionMessage' => 'Czy na pewno chcesz wykonać tę operację?',
'libraries.showLibrary' => 'Pokaż bibliotekę',
'libraries.hideLibrary' => 'Ukryj bibliotekę',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Opcje biblioteki',
'libraries.content' => 'zawartość biblioteki',
'libraries.selectLibrary' => 'Wybierz bibliotekę',
@@ -1921,8 +1941,6 @@ extension on TranslationsPl {
'libraries.noFoldersFound' => 'Nie znaleziono folderów',
'libraries.folders' => 'foldery',
'libraries.tabs.recommended' => 'Rekomendowane',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Przeglądaj',
'libraries.tabs.collections' => 'Kolekcje',
'libraries.tabs.playlists' => 'Playlisty',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Automático (Recomendado)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Seu dispositivo tem ${heap}MB de memória. Um buffer de ${size}MB pode causar problemas de reprodução.';
@override String get defaultQualityTitle => 'Qualidade padrão';
@override String get defaultQualityDescription => 'Usado ao iniciar a reprodução. Valores menores reduzem a largura de banda.';
@override String get subtitleStyling => 'Estilo de Legendas';
@override String get subtitleStylingDescription => 'Personalizar aparência das legendas';
@override String get smallSkipDuration => 'Duração do Avanço Curto';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsPt implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Áudio e Legendas';
@override String get chaptersButton => 'Capítulos';
@override String get versionsButton => 'Versões do vídeo';
@override String get versionQualityButton => 'Versão e qualidade';
@override String get versionColumnHeader => 'Versão';
@override String get qualityColumnHeader => 'Qualidade';
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transcodificação indisponível — reproduzindo qualidade original';
@override String get pipButton => 'Modo Picture-in-Picture';
@override String get aspectRatioButton => 'Proporção';
@override String get ambientLighting => 'Iluminação ambiente';
@@ -1520,6 +1529,8 @@ extension on TranslationsPt {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Automático (Recomendado)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Seu dispositivo tem ${heap}MB de memória. Um buffer de ${size}MB pode causar problemas de reprodução.',
'settings.defaultQualityTitle' => 'Qualidade padrão',
'settings.defaultQualityDescription' => 'Usado ao iniciar a reprodução. Valores menores reduzem a largura de banda.',
'settings.subtitleStyling' => 'Estilo de Legendas',
'settings.subtitleStylingDescription' => 'Personalizar aparência das legendas',
'settings.smallSkipDuration' => 'Duração do Avanço Curto',
@@ -1755,6 +1766,13 @@ extension on TranslationsPt {
'videoControls.tracksButton' => 'Áudio e Legendas',
'videoControls.chaptersButton' => 'Capítulos',
'videoControls.versionsButton' => 'Versões do vídeo',
'videoControls.versionQualityButton' => 'Versão e qualidade',
'videoControls.versionColumnHeader' => 'Versão',
'videoControls.qualityColumnHeader' => 'Qualidade',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transcodificação indisponível — reproduzindo qualidade original',
'videoControls.pipButton' => 'Modo Picture-in-Picture',
'videoControls.aspectRatioButton' => 'Proporção',
'videoControls.ambientLighting' => 'Iluminação ambiente',
@@ -1912,6 +1930,8 @@ extension on TranslationsPt {
'libraries.confirmActionMessage' => 'Tem certeza que deseja realizar esta ação?',
'libraries.showLibrary' => 'Mostrar biblioteca',
'libraries.hideLibrary' => 'Ocultar biblioteca',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Opções da biblioteca',
'libraries.content' => 'conteúdo da biblioteca',
'libraries.selectLibrary' => 'Selecionar biblioteca',
@@ -1921,8 +1941,6 @@ extension on TranslationsPt {
'libraries.noFoldersFound' => 'Nenhuma pasta encontrada',
'libraries.folders' => 'pastas',
'libraries.tabs.recommended' => 'Recomendados',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Navegar',
'libraries.tabs.collections' => 'Coleções',
'libraries.tabs.playlists' => 'Playlists',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}МБ';
@override String get bufferSizeAuto => 'Авто (Рекомендуется)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'У вашего устройства ${heap}МБ памяти. Буфер ${size}МБ может вызвать проблемы с воспроизведением.';
@override String get defaultQualityTitle => 'Качество по умолчанию';
@override String get defaultQualityDescription => 'Используется при запуске воспроизведения. Более низкие значения снижают пропускную способность.';
@override String get subtitleStyling => 'Стиль субтитров';
@override String get subtitleStylingDescription => 'Настроить внешний вид субтитров';
@override String get smallSkipDuration => 'Малая перемотка';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsRu implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Аудио и субтитры';
@override String get chaptersButton => 'Главы';
@override String get versionsButton => 'Версии видео';
@override String get versionQualityButton => 'Версия и качество';
@override String get versionColumnHeader => 'Версия';
@override String get qualityColumnHeader => 'Качество';
@override String get qualityOriginal => 'Оригинал';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Транскодирование недоступно — воспроизведение в оригинальном качестве';
@override String get pipButton => 'Режим «картинка в картинке»';
@override String get aspectRatioButton => 'Соотношение сторон';
@override String get ambientLighting => 'Фоновая подсветка';
@@ -1520,6 +1529,8 @@ extension on TranslationsRu {
'settings.bufferSizeMB' => ({required Object size}) => '${size}МБ',
'settings.bufferSizeAuto' => 'Авто (Рекомендуется)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'У вашего устройства ${heap}МБ памяти. Буфер ${size}МБ может вызвать проблемы с воспроизведением.',
'settings.defaultQualityTitle' => 'Качество по умолчанию',
'settings.defaultQualityDescription' => 'Используется при запуске воспроизведения. Более низкие значения снижают пропускную способность.',
'settings.subtitleStyling' => 'Стиль субтитров',
'settings.subtitleStylingDescription' => 'Настроить внешний вид субтитров',
'settings.smallSkipDuration' => 'Малая перемотка',
@@ -1755,6 +1766,13 @@ extension on TranslationsRu {
'videoControls.tracksButton' => 'Аудио и субтитры',
'videoControls.chaptersButton' => 'Главы',
'videoControls.versionsButton' => 'Версии видео',
'videoControls.versionQualityButton' => 'Версия и качество',
'videoControls.versionColumnHeader' => 'Версия',
'videoControls.qualityColumnHeader' => 'Качество',
'videoControls.qualityOriginal' => 'Оригинал',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Транскодирование недоступно — воспроизведение в оригинальном качестве',
'videoControls.pipButton' => 'Режим «картинка в картинке»',
'videoControls.aspectRatioButton' => 'Соотношение сторон',
'videoControls.ambientLighting' => 'Фоновая подсветка',
@@ -1912,6 +1930,8 @@ extension on TranslationsRu {
'libraries.confirmActionMessage' => 'Вы уверены, что хотите выполнить это действие?',
'libraries.showLibrary' => 'Показать библиотеку',
'libraries.hideLibrary' => 'Скрыть библиотеку',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Параметры библиотеки',
'libraries.content' => 'содержимое библиотеки',
'libraries.selectLibrary' => 'Выбрать библиотеку',
@@ -1921,8 +1941,6 @@ extension on TranslationsRu {
'libraries.noFoldersFound' => 'Папки не найдены',
'libraries.folders' => 'папки',
'libraries.tabs.recommended' => 'Рекомендуемые',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Обзор',
'libraries.tabs.collections' => 'Коллекции',
'libraries.tabs.playlists' => 'Плейлисты',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => 'Auto (Rekommenderat)';
@override String bufferSizeWarning({required Object heap, required Object size}) => 'Din enhet har ${heap}MB minne. En buffert på ${size}MB kan orsaka uppspelningsproblem.';
@override String get defaultQualityTitle => 'Standardkvalitet';
@override String get defaultQualityDescription => 'Används vid start av uppspelning. Lägre värden minskar bandbredden.';
@override String get subtitleStyling => 'Undertext-styling';
@override String get subtitleStylingDescription => 'Anpassa undertextutseende';
@override String get smallSkipDuration => 'Kort hoppvaraktighet';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get tracksButton => 'Ljud och undertexter';
@override String get chaptersButton => 'Kapitel';
@override String get versionsButton => 'Videoversioner';
@override String get versionQualityButton => 'Version och kvalitet';
@override String get versionColumnHeader => 'Version';
@override String get qualityColumnHeader => 'Kvalitet';
@override String get qualityOriginal => 'Original';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => 'Transkodning otillgänglig — spelar upp i originalkvalitet';
@override String get pipButton => 'Bild-i-bild läge';
@override String get aspectRatioButton => 'Bildförhållande';
@override String get ambientLighting => 'Ambientbelysning';
@@ -1520,6 +1529,8 @@ extension on TranslationsSv {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => 'Auto (Rekommenderat)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => 'Din enhet har ${heap}MB minne. En buffert på ${size}MB kan orsaka uppspelningsproblem.',
'settings.defaultQualityTitle' => 'Standardkvalitet',
'settings.defaultQualityDescription' => 'Används vid start av uppspelning. Lägre värden minskar bandbredden.',
'settings.subtitleStyling' => 'Undertext-styling',
'settings.subtitleStylingDescription' => 'Anpassa undertextutseende',
'settings.smallSkipDuration' => 'Kort hoppvaraktighet',
@@ -1755,6 +1766,13 @@ extension on TranslationsSv {
'videoControls.tracksButton' => 'Ljud och undertexter',
'videoControls.chaptersButton' => 'Kapitel',
'videoControls.versionsButton' => 'Videoversioner',
'videoControls.versionQualityButton' => 'Version och kvalitet',
'videoControls.versionColumnHeader' => 'Version',
'videoControls.qualityColumnHeader' => 'Kvalitet',
'videoControls.qualityOriginal' => 'Original',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => 'Transkodning otillgänglig — spelar upp i originalkvalitet',
'videoControls.pipButton' => 'Bild-i-bild läge',
'videoControls.aspectRatioButton' => 'Bildförhållande',
'videoControls.ambientLighting' => 'Ambientbelysning',
@@ -1912,6 +1930,8 @@ extension on TranslationsSv {
'libraries.confirmActionMessage' => 'Är du säker på att du vill utföra denna åtgärd?',
'libraries.showLibrary' => 'Visa bibliotek',
'libraries.hideLibrary' => 'Dölj bibliotek',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => 'Biblioteksalternativ',
'libraries.content' => 'bibliotekets innehåll',
'libraries.selectLibrary' => 'Välj bibliotek',
@@ -1921,8 +1941,6 @@ extension on TranslationsSv {
'libraries.noFoldersFound' => 'Inga mappar hittades',
'libraries.folders' => 'mappar',
'libraries.tabs.recommended' => 'Rekommenderat',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Bläddra',
'libraries.tabs.collections' => 'Samlingar',
'libraries.tabs.playlists' => 'Spellistor',
+20 -2
View File
@@ -241,6 +241,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
@override String bufferSizeMB({required Object size}) => '${size}MB';
@override String get bufferSizeAuto => '自动(推荐)';
@override String bufferSizeWarning({required Object heap, required Object size}) => '您的设备有 ${heap}MB 内存。${size}MB 的缓冲区可能导致播放问题。';
@override String get defaultQualityTitle => '默认画质';
@override String get defaultQualityDescription => '开始播放时使用。较低的值可减少带宽。';
@override String get subtitleStyling => '字幕样式';
@override String get subtitleStylingDescription => '调整字幕外观';
@override String get smallSkipDuration => '短跳过时长';
@@ -519,6 +521,13 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get tracksButton => '音频和字幕';
@override String get chaptersButton => '章节';
@override String get versionsButton => '视频版本';
@override String get versionQualityButton => '版本与画质';
@override String get versionColumnHeader => '版本';
@override String get qualityColumnHeader => '画质';
@override String get qualityOriginal => '原始';
@override String qualityPresetLabel({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps';
@override String qualityBandwidthEstimate({required Object bitrate}) => '~${bitrate} Mbps';
@override String get transcodeUnavailableFallback => '转码不可用 — 以原始画质播放';
@override String get pipButton => '画中画模式';
@override String get aspectRatioButton => '宽高比';
@override String get ambientLighting => '氛围灯光';
@@ -1520,6 +1529,8 @@ extension on TranslationsZh {
'settings.bufferSizeMB' => ({required Object size}) => '${size}MB',
'settings.bufferSizeAuto' => '自动(推荐)',
'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => '您的设备有 ${heap}MB 内存。${size}MB 的缓冲区可能导致播放问题。',
'settings.defaultQualityTitle' => '默认画质',
'settings.defaultQualityDescription' => '开始播放时使用。较低的值可减少带宽。',
'settings.subtitleStyling' => '字幕样式',
'settings.subtitleStylingDescription' => '调整字幕外观',
'settings.smallSkipDuration' => '短跳过时长',
@@ -1755,6 +1766,13 @@ extension on TranslationsZh {
'videoControls.tracksButton' => '音频和字幕',
'videoControls.chaptersButton' => '章节',
'videoControls.versionsButton' => '视频版本',
'videoControls.versionQualityButton' => '版本与画质',
'videoControls.versionColumnHeader' => '版本',
'videoControls.qualityColumnHeader' => '画质',
'videoControls.qualityOriginal' => '原始',
'videoControls.qualityPresetLabel' => ({required Object resolution, required Object bitrate}) => '${resolution}p ${bitrate} Mbps',
'videoControls.qualityBandwidthEstimate' => ({required Object bitrate}) => '~${bitrate} Mbps',
'videoControls.transcodeUnavailableFallback' => '转码不可用 — 以原始画质播放',
'videoControls.pipButton' => '画中画模式',
'videoControls.aspectRatioButton' => '宽高比',
'videoControls.ambientLighting' => '氛围灯光',
@@ -1912,6 +1930,8 @@ extension on TranslationsZh {
'libraries.confirmActionMessage' => '确定要执行此操作吗?',
'libraries.showLibrary' => '显示媒体库',
'libraries.hideLibrary' => '隐藏媒体库',
_ => null,
} ?? switch (path) {
'libraries.libraryOptions' => '媒体库选项',
'libraries.content' => '媒体库内容',
'libraries.selectLibrary' => '选择媒体库',
@@ -1921,8 +1941,6 @@ extension on TranslationsZh {
'libraries.noFoldersFound' => '未找到文件夹',
'libraries.folders' => '文件夹',
'libraries.tabs.recommended' => '推荐',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => '浏览',
'libraries.tabs.collections' => '合集',
'libraries.tabs.playlists' => '播放列表',
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "Auto (Rekommenderat)",
"bufferSizeWarning": "Din enhet har ${heap}MB minne. En buffert på ${size}MB kan orsaka uppspelningsproblem.",
"defaultQualityTitle": "Standardkvalitet",
"defaultQualityDescription": "Används vid start av uppspelning. Lägre värden minskar bandbredden.",
"subtitleStyling": "Undertext-styling",
"subtitleStylingDescription": "Anpassa undertextutseende",
"smallSkipDuration": "Kort hoppvaraktighet",
@@ -372,6 +374,13 @@
"tracksButton": "Ljud och undertexter",
"chaptersButton": "Kapitel",
"versionsButton": "Videoversioner",
"versionQualityButton": "Version och kvalitet",
"versionColumnHeader": "Version",
"qualityColumnHeader": "Kvalitet",
"qualityOriginal": "Original",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "Transkodning otillgänglig — spelar upp i originalkvalitet",
"pipButton": "Bild-i-bild läge",
"aspectRatioButton": "Bildförhållande",
"ambientLighting": "Ambientbelysning",
+9
View File
@@ -121,6 +121,8 @@
"bufferSizeMB": "${size}MB",
"bufferSizeAuto": "自动(推荐)",
"bufferSizeWarning": "您的设备有 ${heap}MB 内存。${size}MB 的缓冲区可能导致播放问题。",
"defaultQualityTitle": "默认画质",
"defaultQualityDescription": "开始播放时使用。较低的值可减少带宽。",
"subtitleStyling": "字幕样式",
"subtitleStylingDescription": "调整字幕外观",
"smallSkipDuration": "短跳过时长",
@@ -372,6 +374,13 @@
"tracksButton": "音频和字幕",
"chaptersButton": "章节",
"versionsButton": "视频版本",
"versionQualityButton": "版本与画质",
"versionColumnHeader": "版本",
"qualityColumnHeader": "画质",
"qualityOriginal": "原始",
"qualityPresetLabel": "${resolution}p ${bitrate} Mbps",
"qualityBandwidthEstimate": "~${bitrate} Mbps",
"transcodeUnavailableFallback": "转码不可用 — 以原始画质播放",
"pipButton": "画中画模式",
"aspectRatioButton": "宽高比",
"ambientLighting": "氛围灯光",
+10 -4
View File
@@ -186,13 +186,19 @@ class PlexSubtitleTrack with _TrackLabelMixin {
/// Returns null if this is not an external subtitle
String? getSubtitleUrl(String baseUrl, String token) {
if (!isExternal) return null;
// Determine file extension based on codec
final ext = CodecUtils.getSubtitleExtension(codec);
// Construct URL with authentication token
return '$baseUrl$key.$ext?encoding=utf-8&X-Plex-Token=$token';
}
/// Constructs a sidecar URL for any subtitle track (internal or external),
/// used in transcode mode where embedded subtitle streams are stripped.
/// Falls back to the standard `/library/streams/{id}.{ext}` path when
/// [key] is missing.
String getTranscodeSidecarUrl(String baseUrl, String token) {
final ext = CodecUtils.getSubtitleExtension(codec);
final path = (key != null && key!.isNotEmpty) ? key! : '/library/streams/$id';
return '$baseUrl$path.$ext?encoding=utf-8&X-Plex-Token=$token';
}
}
class PlexChapter {
+65
View File
@@ -0,0 +1,65 @@
/// Video transcode quality presets modeled on Plex Web's custom-quality table.
///
/// When a non-[original] preset is selected, playback goes through Plex's
/// `/video/:/transcode/universal/decision` + `start` flow. [original] bypasses
/// transcoding entirely and uses the direct-play URL.
enum TranscodeQualityPreset {
original(null, null, null),
p240_320(320, '420x240', 30),
p320_720(720, '576x320', 40),
p480_1_5mbps(1500, '720x480', 60),
p720_2mbps(2000, '1280x720', 60),
p720_3mbps(3000, '1280x720', 75),
p720_4mbps(4000, '1280x720', 100),
p1080_8mbps(8000, '1920x1080', 60),
p1080_10mbps(10000, '1920x1080', 75),
p1080_12mbps(12000, '1920x1080', 90),
p1080_20mbps(20000, '1920x1080', 100);
const TranscodeQualityPreset(this.videoBitrateKbps, this.videoResolution, this.videoQuality);
final int? videoBitrateKbps;
final String? videoResolution;
final int? videoQuality;
bool get isOriginal => this == TranscodeQualityPreset.original;
String get storageKey => name;
static TranscodeQualityPreset fromStorage(String? stored) {
if (stored == null) return TranscodeQualityPreset.original;
for (final v in TranscodeQualityPreset.values) {
if (v.name == stored) return v;
}
return TranscodeQualityPreset.original;
}
/// Resolution height (e.g. 720, 1080) parsed from [videoResolution]. Null for original.
int? get resolutionHeight {
final r = videoResolution;
if (r == null) return null;
final parts = r.split('x');
if (parts.length != 2) return null;
return int.tryParse(parts[1]);
}
/// Order shared by every picker surface so they can't drift apart:
/// [original] pinned first, then transcode presets highest-bitrate first.
static final List<TranscodeQualityPreset> displayOrder = List.unmodifiable([
original,
...values.where((p) => !p.isOriginal).toList().reversed,
]);
}
/// Outcome of a transcode decision call.
enum TranscodeDecisionOutcome {
/// Decision indicates transcode is available (`transcodeDecisionCode == 1001`).
transcodeOk,
/// Decision indicates only direct play is available (`transcodeDecisionCode == 1000`).
/// Caller should fall back to the direct-play URL.
directPlayOnly,
/// Decision failed (HTTP error, code >= 2000, or parse error).
failed,
}
+12
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../models/transcode_quality_preset.dart';
import '../services/settings_service.dart';
class SettingsProvider extends ChangeNotifier {
@@ -15,6 +16,7 @@ class SettingsProvider extends ChangeNotifier {
bool _showNavBarLabels = true;
bool _liveTvDefaultFavorites = false;
bool _autoHidePerformanceOverlay = true;
TranscodeQualityPreset _defaultQualityPreset = TranscodeQualityPreset.original;
bool _isInitialized = false;
Future<void>? _initFuture;
@@ -56,6 +58,7 @@ class SettingsProvider extends ChangeNotifier {
_showNavBarLabels = s.getShowNavBarLabels();
_liveTvDefaultFavorites = s.getLiveTvDefaultFavorites();
_autoHidePerformanceOverlay = s.getAutoHidePerformanceOverlay();
_defaultQualityPreset = TranscodeQualityPreset.fromStorage(s.getDefaultQualityPreset());
}
/// Re-read all settings from SharedPreferences. Used after imports or resets
@@ -94,6 +97,8 @@ class SettingsProvider extends ChangeNotifier {
bool get autoHidePerformanceOverlay => _autoHidePerformanceOverlay;
TranscodeQualityPreset get defaultQualityPreset => _defaultQualityPreset;
/// Helper to update a setting: ensures init, deduplicates, persists, notifies.
Future<void> _updateSetting<T>({
required T current,
@@ -192,4 +197,11 @@ class SettingsProvider extends ChangeNotifier {
setLocal: (v) => _autoHidePerformanceOverlay = v,
persist: _settingsService!.setAutoHidePerformanceOverlay,
);
Future<void> setDefaultQualityPreset(TranscodeQualityPreset preset) => _updateSetting(
current: _defaultQualityPreset,
value: preset,
setLocal: (v) => _defaultQualityPreset = v,
persist: (v) => _settingsService!.setDefaultQualityPreset(v.storageKey),
);
}
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../i18n/strings.g.dart';
import '../../models/transcode_quality_preset.dart';
import '../../mpv/player/platform/player_android.dart';
import '../../utils/quality_preset_labels.dart';
import '../../services/discord_rpc_service.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../services/settings_service.dart' as settings;
@@ -55,6 +57,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
bool _useExoPlayer = true;
bool _useExternalPlayer = false;
String _selectedExternalPlayerName = '';
TranscodeQualityPreset _defaultQualityPreset = TranscodeQualityPreset.original;
@override
void initState() {
@@ -95,6 +98,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
_useExoPlayer = _settingsService.getUseExoPlayer();
_useExternalPlayer = _settingsService.getUseExternalPlayer();
_selectedExternalPlayerName = _settingsService.getSelectedExternalPlayer().name;
_defaultQualityPreset = TranscodeQualityPreset.fromStorage(_settingsService.getDefaultQualityPreset());
_isLoading = false;
});
}
@@ -129,6 +133,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
_buildDisplaySwitchDelay(),
if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(),
_buildBufferSizeSelector(),
_buildDefaultQualityTile(),
// --- Subtitles & Config ---
SettingsSectionHeader(t.settings.subtitlesAndConfig),
@@ -300,6 +305,31 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
);
}
Widget _buildDefaultQualityTile() {
return ListTile(
leading: const AppIcon(Symbols.high_quality_rounded, fill: 1),
title: Text(t.settings.defaultQualityTitle),
subtitle: Text(qualityPresetLabel(_defaultQualityPreset)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () async {
final value = await showSelectionDialog<TranscodeQualityPreset>(
context: context,
title: t.settings.defaultQualityTitle,
options: TranscodeQualityPreset.displayOrder
.map((p) => DialogOption(value: p, title: qualityPresetLabel(p)))
.toList(),
currentValue: _defaultQualityPreset,
);
if (value != null) {
setState(() {
_defaultQualityPreset = value;
_settingsService.setDefaultQualityPreset(value.storageKey);
});
}
},
);
}
Widget _buildBufferSizeSelector() {
return ListTile(
leading: const AppIcon(Symbols.memory_rounded, fill: 1),
+90 -1
View File
@@ -25,6 +25,7 @@ import '../services/plex_api_cache.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/content_utils.dart';
import '../utils/plex_cache_parser.dart';
import '../models/plex_media_info.dart';
@@ -44,6 +45,7 @@ import '../services/playback_progress_tracker.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/display_mode_service.dart';
import '../services/settings_service.dart';
import '../providers/settings_provider.dart';
import '../services/sleep_timer_service.dart';
import '../services/track_manager.dart';
import '../services/ambient_lighting_service.dart';
@@ -100,6 +102,20 @@ class VideoPlayerScreen extends StatefulWidget {
final bool isOffline;
final PlexVideoPlaybackData? playbackData;
/// Quality preset override for this playback. When `null`, the screen uses
/// the user's default from [SettingsProvider].
final TranscodeQualityPreset? selectedQualityPreset;
/// Audio stream ID to pass to the transcoder when [selectedQualityPreset]
/// is non-original. When `null`, the playback service picks the `selected`
/// Plex audio track (fallback: first).
final int? selectedAudioStreamId;
/// Session identifiers forwarded across quality/version/audio switches so
/// the server-side transcode session is preserved.
final String? reusedSessionIdentifier;
final String? reusedTranscodeSessionId;
// Live TV fields
final bool isLive;
final String? liveChannelName;
@@ -120,6 +136,10 @@ class VideoPlayerScreen extends StatefulWidget {
this.selectedMediaIndex = 0,
this.isOffline = false,
this.playbackData,
this.selectedQualityPreset,
this.selectedAudioStreamId,
this.reusedSessionIdentifier,
this.reusedTranscodeSessionId,
this.isLive = false,
this.liveChannelName,
this.liveStreamUrl,
@@ -157,6 +177,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isPhone = false;
List<PlexMediaVersion> _availableVersions = [];
PlexMediaInfo? _currentMediaInfo;
// Transcode / quality state
late TranscodeQualityPreset _selectedQualityPreset;
int? _selectedAudioStreamId;
bool _isTranscoding = false;
bool _serverSupportsTranscoding = false;
late final String _playbackSessionIdentifier;
late final String _playbackTranscodeSessionId;
StreamSubscription<PlayerError>? _errorSubscription;
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<bool>? _completedSubscription;
@@ -284,6 +312,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_activeRatingKey = widget.metadata.ratingKey;
_activeMediaIndex = widget.selectedMediaIndex;
// Transcode session identifiers — reused across quality/version/audio
// switches so the server-side transcode session is preserved.
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? PlexClient.generateSessionIdentifier();
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? PlexClient.generateSessionIdentifier();
_selectedAudioStreamId = widget.selectedAudioStreamId;
// Quality preset is resolved later when the SettingsProvider is available;
// see _resolveQualityPreset() called from _initializePlayer.
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
// Initialize live TV channel tracking
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
_liveChannelName = widget.liveChannelName;
@@ -1390,13 +1427,54 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Online mode: use server-specific client
final client = _getClientForMetadata(context);
plexHeaders = client.config.headers;
// Resolve quality preset from widget → SettingsProvider default.
if (widget.selectedQualityPreset == null && mounted) {
try {
final settingsProvider = context.read<SettingsProvider>();
_selectedQualityPreset = settingsProvider.defaultQualityPreset;
} catch (_) {
_selectedQualityPreset = TranscodeQualityPreset.original;
}
} else {
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
}
// Capability flag comes from the warm cache populated at connection
// time. If the warm-up hasn't landed yet (rare — playback within a
// few seconds of connect), the sync getter returns `true` and the
// transcode decision's own fallback handles a "not actually supported"
// server without blocking the hot path.
_serverSupportsTranscoding = client.serverSupportsVideoTranscodingCached;
final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database);
// Only prefer a downloaded local file when the user wants Original
// quality. Any non-Original preset implies an explicit request to
// transcode, which can only be satisfied by hitting the server — the
// local file is always source quality.
result = await playbackService.getPlaybackData(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
preferOffline: true, // Use downloaded file if available
preferOffline: _selectedQualityPreset.isOriginal,
playbackData: widget.playbackData,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
_isTranscoding = result.isTranscoding;
if (result.activeAudioStreamId != null) {
_selectedAudioStreamId = result.activeAudioStreamId;
}
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
if (mounted) {
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
}
// Reset the preset so the UI reflects what's actually playing.
_selectedQualityPreset = TranscodeQualityPreset.original;
}
}
// Primary refresh-rate path: when Plex metadata provides an fps and the
@@ -2980,6 +3058,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_isReplacingWithVideo = true;
}
/// Session identifiers owned by this screen, forwarded to a replacement
/// [VideoPlayerScreen] during quality/version/audio switches so the Plex
/// transcode session is continued rather than restarted.
String get playbackSessionIdentifier => _playbackSessionIdentifier;
String get playbackTranscodeSessionId => _playbackTranscodeSessionId;
/// Navigates to a new episode, preserving playback state and track selections.
/// When PiP is active, swaps the media source in-place to keep the PiP window alive.
Future<void> _navigateToEpisode(PlexMetadata episodeMetadata) async {
@@ -3420,6 +3504,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
onPrevious: onPrevious,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedQualityPreset: _selectedQualityPreset,
serverSupportsTranscoding: _serverSupportsTranscoding,
isTranscoding: _isTranscoding,
sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [],
selectedAudioStreamId: _selectedAudioStreamId,
onTogglePIPMode: _togglePIPMode,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
onCycleBoxFitMode: _cycleBoxFitMode,
+11 -1
View File
@@ -94,9 +94,18 @@ class MultiServerManager {
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(serverId);
// The connection race already hits `/` on the winning endpoint — capture
// `transcoderVideo` from that response so PlexClient.create can skip the
// redundant warm-up probe.
bool? observedTranscoderVideo;
// Find best working connection, passing cached endpoint for fast-path
final streamIterator = StreamIterator(
server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: clientIdentifier),
server.findBestWorkingConnection(
preferredUri: cachedEndpoint,
clientIdentifier: clientIdentifier,
onTranscoderCapability: (b) => observedTranscoderVideo = b,
),
);
if (!await streamIterator.moveNext()) {
@@ -124,6 +133,7 @@ class MultiServerManager {
appLogger.i('Updated endpoint for ${server.name} after failover: $newUrl');
},
onAllEndpointsExhausted: () => _onServerEndpointsExhausted(serverId),
seedTranscoderVideoSupport: observedTranscoderVideo,
);
// Save the initial endpoint
@@ -3,8 +3,10 @@ import '../models/plex_media_info.dart';
import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart';
import '../models/download_models.dart';
import '../models/transcode_quality_preset.dart';
import '../mpv/mpv.dart';
import '../utils/app_logger.dart';
import '../utils/plex_url_helper.dart';
import '../i18n/strings.g.dart';
import '../database/app_database.dart';
import 'download_storage_service.dart';
@@ -86,11 +88,18 @@ class PlaybackInitializationService {
/// Returns a PlaybackInitializationResult with video URL and available versions
/// If [preferOffline] is true and offline content is available, uses local file
/// If [playbackData] is provided, skips the network call to fetch it again.
/// When [qualityPreset] is non-original and online, the video URL is built
/// against Plex's transcode start endpoint and all subtitle tracks are
/// sidecar-attached since the transcoded stream carries none.
Future<PlaybackInitializationResult> getPlaybackData({
required PlexMetadata metadata,
required int selectedMediaIndex,
bool preferOffline = false,
PlexVideoPlaybackData? playbackData,
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
int? selectedAudioStreamId,
String? sessionIdentifier,
String? transcodeSessionId,
}) async {
try {
// Check for offline content first if preferOffline is enabled
@@ -145,6 +154,54 @@ class PlaybackInitializationService {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
final wantTranscode = !qualityPreset.isOriginal;
if (wantTranscode && sessionIdentifier != null && transcodeSessionId != null) {
final resolvedAudioId = _resolveAudioStreamId(selectedAudioStreamId, data.mediaInfo);
// Note: no `offsetMs` — seeking is handled by the player via the HLS
// manifest, matching Plex Web's behavior. Baking `offset=` into the URL
// makes the server pre-position the transcoder, but the resulting
// segments and mpv's native HLS positioning fight each other, leaving
// the player clock at 0 and desyncing sidecar subtitles.
final result = await client.buildTranscodeStartPath(
ratingKey: metadata.ratingKey,
mediaIndex: selectedMediaIndex,
preset: qualityPreset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
audioStreamId: resolvedAudioId,
);
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
final transcodeUrl = '${client.config.baseUrl}${result.startPath}'.withPlexToken(client.config.token);
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: transcodeUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: sidecarSubs,
isOffline: false,
isTranscoding: true,
activeAudioStreamId: resolvedAudioId,
);
}
// Decision failed or said direct-play only — fall through to direct-play path
// and surface the fallback reason so the UI can notify the user.
final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly
? TranscodeFallbackReason.directPlayOnly
: TranscodeFallbackReason.decisionFailed;
appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: data.videoUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
isTranscoding: false,
fallbackReason: fallbackReason,
);
}
// Build list of external subtitle tracks
final externalSubtitles = _buildExternalSubtitles(data.mediaInfo);
@@ -164,6 +221,48 @@ class PlaybackInitializationService {
}
}
/// Pick the audio stream ID to send to the transcoder. Preference order:
/// explicit [explicit] → audio track with `selected == true` → first → null.
int? _resolveAudioStreamId(int? explicit, PlexMediaInfo? info) {
if (explicit != null) return explicit;
if (info == null) return null;
final tracks = info.audioTracks;
if (tracks.isEmpty) return null;
for (final track in tracks) {
if (track.selected) return track.id;
}
return tracks.first.id;
}
/// Build sidecar SubtitleTracks for ALL source subtitle streams (internal +
/// external) so the player can hot-swap between them when the main stream
/// is transcoded and has no embedded subs.
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(PlexMediaInfo? mediaInfo) {
if (mediaInfo == null) return const [];
final token = client.config.token;
if (token == null) {
appLogger.w('No auth token available for transcode sidecar subtitles');
return const [];
}
final tracks = <SubtitleTrack>[];
for (final sub in mediaInfo.subtitleTracks) {
try {
final url = sub.getTranscodeSidecarUrl(client.config.baseUrl, token);
tracks.add(
SubtitleTrack.uri(
url,
title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}',
language: sub.languageCode,
),
);
} catch (e) {
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
}
}
return tracks;
}
/// Build list of external subtitle tracks from media info
List<SubtitleTrack> _buildExternalSubtitles(PlexMediaInfo? mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
@@ -209,6 +308,15 @@ class PlaybackInitializationService {
}
}
/// Reason the transcode branch fell back to direct play.
enum TranscodeFallbackReason {
/// Plex decision said only direct-play is available.
directPlayOnly,
/// The decision endpoint errored (HTTP error, code >= 2000, parse failure).
decisionFailed,
}
/// Result of playback initialization
class PlaybackInitializationResult {
final List<dynamic> availableVersions;
@@ -217,12 +325,25 @@ class PlaybackInitializationResult {
final List<SubtitleTrack> externalSubtitles;
final bool isOffline;
/// `true` when [videoUrl] is a Plex transcode start URL.
final bool isTranscoding;
/// Non-null when a non-original preset was requested but fallback kicked in.
final TranscodeFallbackReason? fallbackReason;
/// The Plex audio stream ID actually passed to the transcoder (`null` when
/// not transcoding or when no audio stream was selectable).
final int? activeAudioStreamId;
PlaybackInitializationResult({
required this.availableVersions,
this.videoUrl,
this.mediaInfo,
this.externalSubtitles = const [],
this.isOffline = false,
this.isTranscoding = false,
this.fallbackReason,
this.activeAudioStreamId,
});
}
+7 -1
View File
@@ -374,7 +374,11 @@ class PlexServer {
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
/// Tests both plex.direct URI and direct IP for each connection
/// HTTPS connections are tested first, with HTTP as fallback
Stream<PlexConnection> findBestWorkingConnection({String? preferredUri, String? clientIdentifier}) async* {
Stream<PlexConnection> findBestWorkingConnection({
String? preferredUri,
String? clientIdentifier,
void Function(bool)? onTranscoderCapability,
}) async* {
if (connections.isEmpty) {
appLogger.w('No connections available for server discovery');
return;
@@ -426,6 +430,7 @@ class PlexServer {
if (result.success) {
appLogger.i('Cached endpoint succeeded, using immediately', error: {'uri': preferredUri});
firstCandidate = cachedCandidate;
if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
} else {
appLogger.w('Cached endpoint failed, falling back to candidate race', error: {'uri': preferredUri});
}
@@ -462,6 +467,7 @@ class PlexServer {
}
if (result.success && !completer.isCompleted) {
if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
completer.complete(candidate);
}
+269 -1
View File
@@ -27,6 +27,7 @@ import '../utils/content_utils.dart';
import '../models/plex_playlist.dart';
import '../models/plex_sort.dart';
import '../models/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/endpoint_failover_interceptor.dart';
import '../utils/app_logger.dart';
import '../utils/connection_constants.dart';
@@ -98,7 +99,12 @@ class ConnectionTestResult {
final int latencyMs;
final String? error;
ConnectionTestResult({required this.success, required this.latencyMs, this.error});
/// `transcoderVideo` from the `/` MediaContainer, captured on successful
/// probes so the connection race doubles as a capability probe. `null`
/// when the probe didn't succeed or the field was absent.
final bool? transcoderVideo;
ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo});
}
class PlexClient {
@@ -120,6 +126,14 @@ class PlexClient {
/// Whether to operate in offline mode (use cache only)
bool _offlineMode = false;
/// Cached result of [serverSupportsVideoTranscoding]. `null` = not yet fetched.
bool? _serverTranscoderCached;
/// In-flight probe for [serverSupportsVideoTranscoding], used to dedupe
/// concurrent callers (e.g. the post-connect warm-up racing the first
/// playback).
Future<bool>? _serverTranscoderPending;
/// Libraries parsed from /media/providers (includes individually shared items)
late final List<PlexLibrary> _providerLibraries;
@@ -157,6 +171,7 @@ class PlexClient {
List<String>? prioritizedEndpoints,
Future<void> Function(String newBaseUrl)? onEndpointChanged,
VoidCallback? onAllEndpointsExhausted,
bool? seedTranscoderVideoSupport,
}) async {
final client = PlexClient._(
config,
@@ -166,7 +181,16 @@ class PlexClient {
onEndpointChanged: onEndpointChanged,
onAllEndpointsExhausted: onAllEndpointsExhausted,
);
if (seedTranscoderVideoSupport != null) {
client._serverTranscoderCached = seedTranscoderVideoSupport;
}
await client._initMediaProviders();
// If the connection race didn't seed the capability, warm the cache in
// the background so the first playback doesn't pay the probe cost on its
// hot path.
if (seedTranscoderVideoSupport == null) {
unawaited(client.serverSupportsVideoTranscoding());
}
return client;
}
@@ -415,10 +439,16 @@ class PlexClient {
stopwatch.stop();
final success = response.statusCode == 200;
bool? transcoderVideo;
if (success && response.data is Map && response.data['MediaContainer'] is Map) {
transcoderVideo = flexibleBool((response.data['MediaContainer'] as Map)['transcoderVideo']);
}
return ConnectionTestResult(
success: success,
latencyMs: stopwatch.elapsedMilliseconds,
error: success ? null : 'HTTP ${response.statusCode}',
transcoderVideo: transcoderVideo,
);
} on PlexHttpException catch (e) {
stopwatch.stop();
@@ -2902,6 +2932,244 @@ class PlexClient {
}
}
/// Checks whether the server has video transcoding enabled.
///
/// Reads `transcoderVideo` from the root MediaContainer. Result is cached
/// for the lifetime of this [PlexClient]. Returns `true` on error (fail-open)
/// — the transcode decision call itself will fail gracefully if transcoding
/// really is unavailable.
Future<bool> serverSupportsVideoTranscoding() {
final cached = _serverTranscoderCached;
if (cached != null) return Future.value(cached);
return _serverTranscoderPending ??= _fetchTranscoderCapability();
}
/// Synchronous view of the probe — returns the cached value, or `true`
/// (assume supported) if the post-connect warm-up hasn't landed yet. The
/// transcode decision call has its own fallback path, so guessing wrong
/// here just routes through that fallback instead of blocking playback.
bool get serverSupportsVideoTranscodingCached => _serverTranscoderCached ?? true;
Future<bool> _fetchTranscoderCapability() async {
try {
// Tight timeout: `/` returns a tiny MediaContainer — any responsive
// server answers in well under a second. Inheriting the default 120 s
// receive timeout would keep a hung server from ever resolving.
final response = await _http.get('/', timeout: const Duration(seconds: 5));
final container = _getMediaContainer(response);
final value = container?['transcoderVideo'];
final supported = flexibleBool(value);
_serverTranscoderCached = supported;
return supported;
} catch (e) {
appLogger.w('Failed to query server transcoder capability', error: e);
_serverTranscoderCached = true;
return true;
}
}
/// Build a VOD transcode stream URL (decision + start path).
///
/// Mirrors [buildLiveStreamPath] but for on-demand video with a quality
/// preset, selected audio stream, and HLS protocol. Plex returns a single
/// audio track and no subtitle tracks in the transcoded stream — callers
/// are expected to sidecar additional subtitles separately.
///
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
/// seeks + quality/version/audio switches within one playback so the
/// server-side transcode session is preserved.
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
required String ratingKey,
required int mediaIndex,
required TranscodeQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
int? audioStreamId,
int? offsetMs,
}) async {
try {
final isOriginal = preset.isOriginal;
final metadataPath = '/library/metadata/$ratingKey';
// Build the client profile from scratch via X-Plex-Client-Profile-Extra.
// We use the `Generic` base platform (see [_transcodePlatformName]) which
// has no pre-installed transcode targets, so we must `add-transcode-target`
// rather than `append-transcode-target-codec` (which only edits existing
// targets — empty on Generic, hence Plex returned decision code 2000
// "neither direct play nor conversion is available").
//
// For non-original presets we also add a bitrate limitation that caps
// the video codec; with `replace=true` it overrides any default limit.
//
// See openapi.md §"Profile Augmentations" for the DSL reference.
final profileExtraClauses = <String>[];
if (!isOriginal && preset.videoBitrateKbps != null) {
profileExtraClauses.add(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
);
}
// Declare both h264 and hevc as allowed transcode targets. In practice
// Plex's decision engine strongly prefers h264 for HLS output, so hevc
// only gets chosen in edge cases (e.g. HDR content where the server
// wants to preserve dynamic range). The codec-list comma is pre-encoded
// as `%2C` — see the profile-extra encoding note above.
profileExtraClauses.add(
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc&audioCodec=aac)',
);
final clientProfileExtra = profileExtraClauses.join('+');
// HLS protocol: seekable via manifest segments. We started with `dash`
// (what Plex Web on Chrome uses) but Plex's server only has DASH
// transcode profiles for Chrome/Firefox/Safari/Opera — mobile/desktop
// platforms fall through with "No conversion profile found for
// protocol dash". HLS profiles exist for every Plex-accepted platform.
final allParams = <String, String>{
'hasMDE': '1',
'path': metadataPath,
'mediaIndex': mediaIndex.toString(),
'partIndex': '0',
'protocol': 'hls',
'fastSeek': '1',
'directPlay': isOriginal ? '1' : '0',
'directStream': isOriginal ? '1' : '0',
'subtitleSize': '100',
'audioBoost': '100',
'location': 'lan',
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
'addDebugOverlay': '0',
'autoAdjustQuality': '0',
'directStreamAudio': '0',
'mediaBufferSize': '102400',
'session': transcodeSessionId,
// Subtitles are delivered as client-side sidecars (see
// [PlaybackInitializationService._buildTranscodeSidecarSubtitles]).
// `subtitles=none` makes the server set the subtitle decision to
// `ignore`, so nothing is embedded or burned into the video stream.
'subtitles': 'none',
// Preserve source timestamps in the transcoded segments. Without it,
// Plex resets segment PTS to 0 — so mpv shows 0:00 and sidecar
// subtitles desync even though the server is transcoding from the
// `offset` position. With copyts=1 the first segment's PTS equals
// the source offset and the player's clock lines up with source time.
'copyts': '1',
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Client-Profile-Extra': clientProfileExtra,
'X-Plex-Incomplete-Segments': '1',
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'standalone',
'X-Plex-Language': 'en',
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
// Plex's server rejects unknown platform names with HTTP 400 and maps
// known names to codec/bitrate base profiles. Our usual "Flutter"
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
// Plex-recognized name just for transcode requests. See
// [_transcodePlatformName] for the mapping.
'X-Plex-Platform': _transcodePlatformName(),
if (config.device != null) 'X-Plex-Device': config.device!,
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
final decisionClient = PlexHttpClient(
connectTimeout: ConnectionTimeouts.connect,
receiveTimeout: ConnectionTimeouts.receive,
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
);
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
final decisionResponse = await decisionClient.get(decisionUrl);
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
appLogger.i(
'Transcode decision [${decisionResponse.statusCode}] body: '
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}' : decisionBody}',
);
if (decisionResponse.statusCode != 200) {
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
}
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
if (outcome == TranscodeDecisionOutcome.failed) {
return (startPath: null, outcome: outcome);
}
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
// `.m3u8` tells the server to return an HLS manifest.
return (startPath: '/video/:/transcode/universal/start.m3u8?$startQuery', outcome: outcome);
} catch (e, st) {
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
}
}
/// Platform name Plex Media Server accepts on the transcode decision
/// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
/// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
/// `Generic` is accepted and comes with no preset transcode targets — we
/// build the profile ourselves via `X-Plex-Client-Profile-Extra` with
/// `add-transcode-target`.
static String _transcodePlatformName() => 'Generic';
/// Strict percent-encoder matching Plex Web's URL encoder — escapes the
/// extra characters `(`, `)`, `*`, `'`, `!` that Dart's [Uri.encodeComponent]
/// leaves literal. Required for `X-Plex-Client-Profile-Extra` whose parens
/// and asterisks must appear as `%28`, `%29`, `%2A` on the wire.
static String _plexEncode(String value) {
return Uri.encodeComponent(value)
.replaceAll('(', '%28')
.replaceAll(')', '%29')
.replaceAll('*', '%2A')
.replaceAll("'", '%27')
.replaceAll('!', '%21');
}
/// Parse decision response for outcome. Any decision code >= 2000 = error
/// (matching Plex Web's error detector).
TranscodeDecisionOutcome _parseTranscodeDecisionOutcome(dynamic data, {required bool isOriginal}) {
try {
Map<String, dynamic>? container;
if (data is Map && data['MediaContainer'] is Map) {
container = Map<String, dynamic>.from(data['MediaContainer'] as Map);
} else if (data is Map<String, dynamic>) {
container = data;
}
if (container == null) return TranscodeDecisionOutcome.failed;
final general = flexibleInt(container['generalDecisionCode']);
final transcode = flexibleInt(container['transcodeDecisionCode']);
final mde = flexibleInt(container['mdeDecisionCode']);
bool isError(int? code) => code != null && code >= 2000;
if (isError(general) || isError(transcode) || isError(mde)) {
appLogger.w('Transcode decision error codes: general=$general transcode=$transcode mde=$mde');
return TranscodeDecisionOutcome.failed;
}
if (isOriginal) return TranscodeDecisionOutcome.transcodeOk;
if (transcode == 1000) return TranscodeDecisionOutcome.directPlayOnly;
if (transcode == 1001) return TranscodeDecisionOutcome.transcodeOk;
if (general == 1001) return TranscodeDecisionOutcome.transcodeOk;
if (general == 1000) return TranscodeDecisionOutcome.directPlayOnly;
return TranscodeDecisionOutcome.transcodeOk;
} catch (e) {
appLogger.w('Failed to parse transcode decision', error: e);
return TranscodeDecisionOutcome.failed;
}
}
/// Get active live TV sessions
Future<List<PlexMetadata>> getLiveTvSessions() {
return _wrapListApiCall<PlexMetadata>(
+10
View File
@@ -100,6 +100,7 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyTunneledPlayback = 'tunneled_playback';
static const String _keyDefaultPlaybackSpeed = 'default_playback_speed';
static const String _keyDefaultBoxFitMode = 'default_box_fit_mode';
static const String _keyDefaultQualityPreset = 'default_quality_preset';
static const String _keyAutoPlayNextEpisode = 'auto_play_next_episode';
static const String _keyUseExoPlayer = 'use_exoplayer';
static const String _keyAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
@@ -177,6 +178,15 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyCrashReporting) ?? true; // Default enabled
}
// Default video quality preset (persisted as enum name)
Future<void> setDefaultQualityPreset(String presetName) async {
await prefs.setString(_keyDefaultQualityPreset, presetName);
}
String getDefaultQualityPreset() {
return prefs.getString(_keyDefaultQualityPreset) ?? 'original';
}
// Buffer Size (in MB)
Future<void> setBufferSize(int sizeInMB) async {
await prefs.setInt(_keyBufferSize, sizeInMB);
+7 -4
View File
@@ -322,11 +322,13 @@ class _TextInputDialogState extends State<_TextInputDialog> with _TextInputDialo
}
/// Shows a simple option picker dialog with focusable items for TV/keyboard navigation.
/// Returns the selected value, or null if cancelled.
/// Returns the selected value, or null if cancelled. Each option's [icon] may
/// be `null` to render a label-only row (useful when the choices are variants
/// of the same thing and a repeated icon would just be noise).
Future<T?> showOptionPickerDialog<T>(
BuildContext context, {
required String title,
required List<({IconData icon, String label, T value})> options,
required List<({IconData? icon, String label, T value})> options,
Future<T?> Function(T value)? onBeforeClose,
}) {
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
@@ -343,7 +345,7 @@ Future<T?> showOptionPickerDialog<T>(
class _OptionPickerDialog<T> extends StatefulWidget {
final String title;
final List<({IconData icon, String label, T value})> options;
final List<({IconData? icon, String label, T value})> options;
final bool focusFirstItem;
final Future<T?> Function(T value)? onBeforeClose;
@@ -383,9 +385,10 @@ class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: List.generate(widget.options.length, (index) {
final option = widget.options[index];
final icon = option.icon;
return FocusableListTile(
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
leading: AppIcon(option.icon, fill: 1, size: 24),
leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null,
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
onTap: () async {
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/widgets.dart';
import '../i18n/strings.g.dart';
import '../models/transcode_quality_preset.dart';
import 'dialogs.dart';
import 'formatters.dart';
const int _audioBitrateEstimateKbps = 192;
/// User-facing label for a quality preset.
///
/// Examples:
/// - [TranscodeQualityPreset.original] → "Original"
/// - [TranscodeQualityPreset.p720_2mbps] → "720p 2 Mbps"
/// - [TranscodeQualityPreset.p480_1_5mbps] → "480p 1.5 Mbps"
String qualityPresetLabel(TranscodeQualityPreset preset) {
if (preset.isOriginal) return t.videoControls.qualityOriginal;
final height = preset.resolutionHeight?.toString() ?? '';
final bitrate = _formatBitrate(preset.videoBitrateKbps!);
return t.videoControls.qualityPresetLabel(resolution: height, bitrate: bitrate);
}
String _formatBitrate(int kbps) {
final mbps = kbps / 1000.0;
if (mbps >= 10) return mbps.toStringAsFixed(0);
if (mbps == mbps.roundToDouble()) return mbps.toStringAsFixed(0);
return mbps.toStringAsFixed(1);
}
/// File-size hint for a quality row, e.g. `3.6 GB (45%)`. Transcode presets
/// append the ratio vs. source so the user can compare at a glance; Original
/// returns just the raw source size. Returns `null` when inputs are missing.
String? qualityPresetSizeEstimate({
required TranscodeQualityPreset preset,
required int? sourceBitrateKbps,
required int? sourceDurationMs,
}) {
if (sourceDurationMs == null || sourceDurationMs <= 0) return null;
if (preset.isOriginal) {
if (sourceBitrateKbps == null || sourceBitrateKbps <= 0) return null;
return ByteFormatter.formatBytes(sourceBitrateKbps * sourceDurationMs ~/ 8);
}
final videoKbps = preset.videoBitrateKbps;
if (videoKbps == null) return null;
final totalKbps = videoKbps + _audioBitrateEstimateKbps;
final size = ByteFormatter.formatBytes(totalKbps * sourceDurationMs ~/ 8);
if (sourceBitrateKbps != null && sourceBitrateKbps > 0) {
final pct = (totalKbps * 100 / sourceBitrateKbps).round();
return '$size ($pct%)';
}
return size;
}
/// Quality-preset picker dialog — shares [TranscodeQualityPreset.displayOrder]
/// with the in-player sheet. Returns the selected preset, or `null` if dismissed.
Future<TranscodeQualityPreset?> showQualityPickerDialog(
BuildContext context, {
String? title,
int? sourceBitrateKbps,
int? sourceDurationMs,
}) {
String labelFor(TranscodeQualityPreset p) {
final base = qualityPresetLabel(p);
final size = qualityPresetSizeEstimate(
preset: p,
sourceBitrateKbps: sourceBitrateKbps,
sourceDurationMs: sourceDurationMs,
);
return size == null ? base : toBulletedString([base, size]);
}
return showOptionPickerDialog<TranscodeQualityPreset>(
context,
title: title ?? t.videoControls.qualityColumnHeader,
options: TranscodeQualityPreset.displayOrder.map((p) => (icon: null, label: labelFor(p), value: p)).toList(),
);
}
+3
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../mpv/mpv.dart';
import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../screens/video_player_screen.dart';
@@ -52,6 +53,7 @@ Future<bool?> navigateToVideoPlayer(
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
int? selectedMediaIndex,
TranscodeQualityPreset? selectedQualityPreset,
bool usePushReplacement = false,
bool isOffline = false,
PlexVideoPlaybackData? playbackData,
@@ -130,6 +132,7 @@ Future<bool?> navigateToVideoPlayer(
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: mediaIndex,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
playbackData: effectivePlaybackData,
),
+28 -12
View File
@@ -10,6 +10,7 @@ import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../utils/download_version_utils.dart';
import '../utils/download_utils.dart';
import '../utils/quality_preset_labels.dart';
import '../utils/content_utils.dart';
import '../utils/global_key_utils.dart';
import '../providers/download_provider.dart';
@@ -283,10 +284,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
);
}
// Play Version (for episodes and movies with multiple versions)
if ((mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) &&
metadata.mediaVersions != null &&
metadata.mediaVersions!.length > 1) {
// Play Version (for episodes and movies). Always shown — even for
// single-version items — so the user can still pick a streaming
// quality. The handler skips the version picker if only one version
// is present, jumping straight to the quality picker.
if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) {
menuActions.add(
_MenuAction(value: 'play_version', icon: Symbols.video_file_rounded, label: t.mediaMenu.playVersion),
);
@@ -693,18 +695,32 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
}
/// Handle play version selection
Future<bool> _handlePlayVersion(BuildContext context) async {
final metadata = widget.item as PlexMetadata;
final versions = metadata.mediaVersions!;
final versions = metadata.mediaVersions ?? const [];
final selectedIndex = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
if (selectedIndex != null && context.mounted) {
await navigateToVideoPlayer(context, metadata: metadata, selectedMediaIndex: selectedIndex);
return true;
int selectedVersionIndex = 0;
if (versions.length > 1) {
final picked = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
if (picked == null || !context.mounted) return false;
selectedVersionIndex = picked;
}
return false;
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
final selectedQuality = await showQualityPickerDialog(
context,
sourceBitrateKbps: selectedVersion?.bitrate,
sourceDurationMs: metadata.duration,
);
if (selectedQuality == null || !context.mounted) return false;
await navigateToVideoPlayer(
context,
metadata: metadata,
selectedMediaIndex: selectedVersionIndex,
selectedQualityPreset: selectedQuality,
);
return true;
}
/// Handle shuffle play using play queues
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import '../../../models/plex_media_info.dart';
import '../../../models/plex_media_version.dart';
import '../../../models/plex_metadata.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../mpv/mpv.dart';
import '../../../services/shader_service.dart';
@@ -9,6 +11,15 @@ import '../../../services/shader_service.dart';
class TrackControlsState {
final List<PlexMediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final bool isTranscoding;
final List<PlexAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
/// Total media duration in milliseconds. Used by the version/quality sheet
/// to show estimated file sizes per preset (bitrate × duration).
final int? sourceDurationMs;
final int boxFitMode;
final int audioSyncOffset;
final int subtitleSyncOffset;
@@ -23,6 +34,8 @@ class TrackControlsState {
final VoidCallback? onToggleFullscreen;
final VoidCallback? onToggleAlwaysOnTop;
final Function(int)? onSwitchVersion;
final ValueChanged<TranscodeQualityPreset>? onSwitchQualityPreset;
final ValueChanged<int>? onSwitchAudioStreamId;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
@@ -47,6 +60,12 @@ class TrackControlsState {
const TrackControlsState({
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.isTranscoding = false,
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.sourceDurationMs,
this.boxFitMode = 0,
this.audioSyncOffset = 0,
this.subtitleSyncOffset = 0,
@@ -61,6 +80,8 @@ class TrackControlsState {
this.onToggleFullscreen,
this.onToggleAlwaysOnTop,
this.onSwitchVersion,
this.onSwitchQualityPreset,
this.onSwitchAudioStreamId,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../models/plex_media_info.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/scroll_utils.dart';
@@ -24,6 +25,15 @@ class TrackSheet extends StatelessWidget {
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
/// When true, the audio column renders the Plex [sourceAudioTracks] list
/// and taps are routed to [onSwitchAudioStreamId] instead of using the
/// player's in-stream audio selection (the transcoded stream only has one
/// audio track).
final bool isTranscoding;
final List<PlexAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final ValueChanged<int>? onSwitchAudioStreamId;
const TrackSheet({
super.key,
required this.player,
@@ -34,6 +44,10 @@ class TrackSheet extends StatelessWidget {
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.isTranscoding = false,
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.onSwitchAudioStreamId,
});
@override
@@ -43,13 +57,14 @@ class TrackSheet extends StatelessWidget {
initialData: player.state.tracks,
builder: (context, tracksSnapshot) {
final tracks = tracksSnapshot.data;
final audioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>(tracks, (t) => t?.audio ?? []);
final playerAudioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>(tracks, (t) => t?.audio ?? []);
final subtitleTracks = TrackFilterHelper.extractAndFilterTracks<SubtitleTrack>(
tracks,
(t) => t?.subtitle ?? [],
);
final showAudio = audioTracks.length > 1;
final useSourceAudio = isTranscoding && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null;
final showAudio = useSourceAudio || playerAudioTracks.length > 1;
final showSubtitles = subtitleTracks.isNotEmpty;
// Determine title/icon based on what's shown
@@ -77,21 +92,29 @@ class TrackSheet extends StatelessWidget {
final supportsSecondary = player.supportsSecondarySubtitles;
Widget audioColumnFor(TrackSelection sel, bool showHeader) {
if (useSourceAudio) {
return _SourceAudioColumn(
tracks: sourceAudioTracks,
selectedStreamId: selectedAudioStreamId,
onSelected: onSwitchAudioStreamId!,
showHeader: showHeader,
);
}
return _AudioColumn(
tracks: playerAudioTracks,
selection: sel,
player: player,
onTrackChanged: onAudioTrackChanged,
showHeader: showHeader,
);
}
if (showAudio && showSubtitles) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: FocusTraversalGroup(
child: _AudioColumn(
tracks: audioTracks,
selection: selection,
player: player,
onTrackChanged: onAudioTrackChanged,
showHeader: true,
),
),
),
Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))),
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
Expanded(
child: FocusTraversalGroup(
@@ -115,13 +138,7 @@ class TrackSheet extends StatelessWidget {
}
if (showAudio) {
return _AudioColumn(
tracks: audioTracks,
selection: selection,
player: player,
onTrackChanged: onAudioTrackChanged,
showHeader: false,
);
return audioColumnFor(selection, false);
}
return _SubtitleColumn(
@@ -145,6 +162,73 @@ class TrackSheet extends StatelessWidget {
}
}
class _SourceAudioColumn extends StatefulWidget {
final List<PlexAudioTrack> tracks;
final int? selectedStreamId;
final ValueChanged<int> onSelected;
final bool showHeader;
const _SourceAudioColumn({
required this.tracks,
required this.selectedStreamId,
required this.onSelected,
required this.showHeader,
});
@override
State<_SourceAudioColumn> createState() => _SourceAudioColumnState();
}
class _SourceAudioColumnState extends State<_SourceAudioColumn> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final selectedId = widget.selectedStreamId;
if (!_didInitialScroll && selectedId != null) {
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
if (selectedIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
}
}
return Column(
children: [
if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: widget.tracks.length,
itemBuilder: (context, index) {
final track = widget.tracks[index];
final isSelected = track.id == selectedId;
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
context: context,
key: index == 0 ? _firstItemKey : null,
label: track.label,
isSelected: isSelected,
onTap: () {
OverlaySheetController.of(context).close();
widget.onSelected(track.id);
},
);
},
),
),
],
);
}
}
class _AudioColumn extends StatefulWidget {
final List<AudioTrack> tracks;
final TrackSelection selection;
@@ -0,0 +1,298 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/widgets/app_icon.dart';
import '../../../i18n/strings.g.dart';
import '../../../models/plex_media_version.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../utils/quality_preset_labels.dart';
import '../../../utils/scroll_utils.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
/// Combined sheet for selecting the media [version] (left) and transcode
/// [quality] preset (right). The version column is hidden when there is only
/// one version so the quality list gets the full width. If the server doesn't
/// support video transcoding, only [TranscodeQualityPreset.original] is
/// enabled in the quality column.
class VersionQualitySheet extends StatelessWidget {
final List<PlexMediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final int? sourceDurationMs;
final ValueChanged<int> onVersionSelected;
final ValueChanged<TranscodeQualityPreset> onQualitySelected;
const VersionQualitySheet({
super.key,
required this.availableVersions,
required this.selectedMediaIndex,
required this.selectedQualityPreset,
required this.serverSupportsTranscoding,
required this.onVersionSelected,
required this.onQualitySelected,
this.sourceDurationMs,
});
@override
Widget build(BuildContext context) {
final showVersions = availableVersions.length > 1;
final title = showVersions ? t.videoControls.versionQualityButton : t.videoControls.qualityColumnHeader;
final qualityColumn = FocusTraversalGroup(
child: _QualityColumn(
selected: selectedQualityPreset,
enabledForTranscoding: serverSupportsTranscoding,
sourceBitrateKbps: _sourceBitrateKbps(),
sourceDurationMs: sourceDurationMs,
onSelected: (preset) {
OverlaySheetController.of(context).close();
onQualitySelected(preset);
},
showHeader: showVersions,
),
);
final Widget body;
if (showVersions) {
body = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: FocusTraversalGroup(
child: _VersionColumn(
versions: availableVersions,
selectedIndex: selectedMediaIndex,
onSelected: (index) {
OverlaySheetController.of(context).close();
onVersionSelected(index);
},
showHeader: true,
),
),
),
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
Expanded(child: qualityColumn),
],
);
} else {
body = qualityColumn;
}
return BaseVideoControlSheet(title: title, icon: Symbols.high_quality_rounded, child: body);
}
int? _sourceBitrateKbps() {
if (selectedMediaIndex < 0 || selectedMediaIndex >= availableVersions.length) {
return null;
}
final b = availableVersions[selectedMediaIndex].bitrate;
if (b == null || b <= 0) return null;
return b;
}
}
class _VersionColumn extends StatefulWidget {
final List<PlexMediaVersion> versions;
final int selectedIndex;
final ValueChanged<int> onSelected;
final bool showHeader;
const _VersionColumn({
required this.versions,
required this.selectedIndex,
required this.onSelected,
required this.showHeader,
});
@override
State<_VersionColumn> createState() => _VersionColumnState();
}
class _VersionColumnState extends State<_VersionColumn> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_didInitialScroll && widget.selectedIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, widget.selectedIndex);
}
return Column(
children: [
if (widget.showHeader) _ColumnHeader(label: t.videoControls.versionColumnHeader),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: widget.versions.length,
itemBuilder: (context, index) {
final version = widget.versions[index];
final isSelected = index == widget.selectedIndex;
return _SelectionTile(
key: index == 0 ? _firstItemKey : null,
label: version.displayLabel,
isSelected: isSelected,
onTap: () => widget.onSelected(index),
);
},
),
),
],
);
}
}
class _QualityColumn extends StatefulWidget {
final TranscodeQualityPreset selected;
final bool enabledForTranscoding;
final int? sourceBitrateKbps;
final int? sourceDurationMs;
final ValueChanged<TranscodeQualityPreset> onSelected;
final bool showHeader;
const _QualityColumn({
required this.selected,
required this.enabledForTranscoding,
required this.sourceBitrateKbps,
required this.sourceDurationMs,
required this.onSelected,
required this.showHeader,
});
@override
State<_QualityColumn> createState() => _QualityColumnState();
}
class _QualityColumnState extends State<_QualityColumn> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final presets = TranscodeQualityPreset.displayOrder;
final selectedIndex = presets.indexOf(widget.selected);
if (!_didInitialScroll && selectedIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
}
return Column(
children: [
if (widget.showHeader) _ColumnHeader(label: t.videoControls.qualityColumnHeader),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: presets.length,
itemBuilder: (context, index) {
final preset = presets[index];
final isSelected = preset == widget.selected;
final isOriginal = preset.isOriginal;
final enabled = isOriginal || widget.enabledForTranscoding;
final trailing = qualityPresetSizeEstimate(
preset: preset,
sourceBitrateKbps: widget.sourceBitrateKbps,
sourceDurationMs: widget.sourceDurationMs,
);
return _SelectionTile(
key: index == 0 ? _firstItemKey : null,
label: qualityPresetLabel(preset),
trailingText: trailing,
isSelected: isSelected,
enabled: enabled,
onTap: enabled ? () => widget.onSelected(preset) : null,
);
},
),
),
],
);
}
}
class _SelectionTile extends StatelessWidget {
final String label;
final String? trailingText;
final bool isSelected;
final bool enabled;
final VoidCallback? onTap;
const _SelectionTile({
super.key,
required this.label,
this.trailingText,
required this.isSelected,
this.enabled = true,
this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
final disabledColor = scheme.onSurface.withValues(alpha: 0.38);
final titleColor = !enabled ? disabledColor : (isSelected ? primary : null);
final trailingColor = !enabled ? disabledColor : scheme.onSurfaceVariant;
final hasText = trailingText != null && trailingText!.isNotEmpty;
final trailing = (hasText || isSelected)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasText) Text(trailingText!, style: TextStyle(color: trailingColor)),
if (hasText && isSelected) const SizedBox(width: 8),
if (isSelected) AppIcon(Symbols.check_rounded, fill: 1, color: primary),
],
)
: null;
return FocusableListTile(
title: Text(label, style: TextStyle(color: titleColor)),
trailing: trailing,
enabled: enabled,
onTap: onTap,
);
}
}
class _ColumnHeader extends StatelessWidget {
final String label;
const _ColumnHeader({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
);
}
}
@@ -1,50 +0,0 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../models/plex_media_version.dart';
import '../../../widgets/overlay_sheet.dart';
import '../helpers/track_selection_helper.dart';
import 'base_video_control_sheet.dart';
/// Bottom sheet for selecting video version
class VersionSheet extends StatefulWidget {
final List<PlexMediaVersion> availableVersions;
final int selectedMediaIndex;
final Function(int) onVersionSelected;
const VersionSheet({
super.key,
required this.availableVersions,
required this.selectedMediaIndex,
required this.onVersionSelected,
});
@override
State<VersionSheet> createState() => _VersionSheetState();
}
class _VersionSheetState extends State<VersionSheet> {
@override
Widget build(BuildContext context) {
return BaseVideoControlSheet(
title: 'Video Version',
icon: Symbols.video_file_rounded,
child: ListView.builder(
itemCount: widget.availableVersions.length,
itemBuilder: (context, index) {
final version = widget.availableVersions[index];
final isSelected = index == widget.selectedMediaIndex;
return TrackSelectionHelper.buildTrackTile(
context: context,
label: version.displayLabel,
isSelected: isSelected,
onTap: () {
OverlaySheetController.of(context).close();
widget.onVersionSelected(index);
},
);
},
),
);
}
}
+64 -10
View File
@@ -31,6 +31,7 @@ import '../../models/livetv_capture_buffer.dart';
import '../../services/plex_client.dart';
import '../../services/plex_api_cache.dart';
import '../../models/plex_media_info.dart';
import '../../models/transcode_quality_preset.dart';
import '../../models/plex_media_version.dart';
import '../../models/plex_metadata.dart';
import '../../screens/video_player_screen.dart';
@@ -69,6 +70,11 @@ Widget plexVideoControlsBuilder(
VoidCallback? onPrevious,
List<PlexMediaVersion>? availableVersions,
int? selectedMediaIndex,
TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original,
bool serverSupportsTranscoding = false,
bool isTranscoding = false,
List<PlexAudioTrack> sourceAudioTracks = const [],
int? selectedAudioStreamId,
VoidCallback? onTogglePIPMode,
int boxFitMode = 0,
VoidCallback? onCycleBoxFitMode,
@@ -107,6 +113,11 @@ Widget plexVideoControlsBuilder(
onPrevious: onPrevious,
availableVersions: availableVersions ?? [],
selectedMediaIndex: selectedMediaIndex ?? 0,
selectedQualityPreset: selectedQualityPreset,
serverSupportsTranscoding: serverSupportsTranscoding,
isTranscoding: isTranscoding,
sourceAudioTracks: sourceAudioTracks,
selectedAudioStreamId: selectedAudioStreamId,
boxFitMode: boxFitMode,
onTogglePIPMode: onTogglePIPMode,
onCycleBoxFitMode: onCycleBoxFitMode,
@@ -145,6 +156,11 @@ class PlexVideoControls extends StatefulWidget {
final VoidCallback? onPrevious;
final List<PlexMediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final bool isTranscoding;
final List<PlexAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final int boxFitMode;
final VoidCallback? onTogglePIPMode;
final VoidCallback? onCycleBoxFitMode;
@@ -228,6 +244,11 @@ class PlexVideoControls extends StatefulWidget {
this.onPrevious,
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.isTranscoding = false,
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.boxFitMode = 0,
this.onTogglePIPMode,
this.onCycleBoxFitMode,
@@ -1149,6 +1170,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
return TrackControlsState(
availableVersions: widget.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedQualityPreset: widget.selectedQualityPreset,
serverSupportsTranscoding: widget.serverSupportsTranscoding,
isTranscoding: widget.isTranscoding,
sourceAudioTracks: widget.sourceAudioTracks,
selectedAudioStreamId: widget.selectedAudioStreamId,
sourceDurationMs: widget.metadata.duration,
boxFitMode: widget.boxFitMode,
audioSyncOffset: _audioSyncOffset,
subtitleSyncOffset: _subtitleSyncOffset,
@@ -1162,7 +1189,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onToggleScreenLock: _toggleScreenLock,
onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: onToggleAlwaysOnTop,
onSwitchVersion: _switchMediaVersion,
onSwitchVersion: (i) => _switchVersionAndQuality(newMediaIndex: i),
onSwitchQualityPreset: (p) => _switchVersionAndQuality(newPreset: p),
onSwitchAudioStreamId: (id) => _switchVersionAndQuality(newAudioStreamId: id),
onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
@@ -2541,9 +2570,24 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
Future<void> _switchMediaVersion(int newMediaIndex) async {
if (newMediaIndex == widget.selectedMediaIndex) {
return; // Already using this version
/// Switch version, quality preset, or audio stream ID. Any combination may
/// change in one invocation; unspecified values retain their current value.
/// Always routes through pushReplacement, preserving playback position and
/// the transcode session identifiers.
Future<void> _switchVersionAndQuality({
int? newMediaIndex,
TranscodeQualityPreset? newPreset,
int? newAudioStreamId,
}) async {
final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex;
final effectivePreset = newPreset ?? widget.selectedQualityPreset;
final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId;
final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex;
final isPresetChange = effectivePreset != widget.selectedQualityPreset;
final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId;
if (!isVersionChange && !isPresetChange && !isAudioChange) {
return;
}
try {
@@ -2553,17 +2597,23 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Get state reference before async operations
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
// Save the preference
final settingsService = await SettingsService.getInstance();
final seriesKey = widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey;
await settingsService.setMediaVersionPreference(seriesKey, newMediaIndex);
if (isVersionChange) {
final settingsService = await SettingsService.getInstance();
final seriesKey = widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey;
await settingsService.setMediaVersionPreference(seriesKey, effectiveMediaIndex);
}
// Preserve session identifiers across the reload so Plex reuses the
// transcode session rather than spinning up a new one.
final sessionId = videoPlayerState?.playbackSessionIdentifier;
final transcodeSessionId = videoPlayerState?.playbackTranscodeSessionId;
// Set flag on parent VideoPlayerScreen to skip orientation restoration
videoPlayerState?.setReplacingWithVideo();
// Dispose the existing player before spinning up the replacement to avoid race conditions
await videoPlayerState?.disposePlayerForNavigation();
// Navigate to new player screen with the selected version
// Navigate to new player screen with the updated selection
// Use PageRouteBuilder with zero-duration transitions to prevent orientation reset
if (mounted) {
Navigator.pushReplacement(
@@ -2571,7 +2621,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
PageRouteBuilder<bool>(
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: widget.metadata.copyWith(viewOffset: currentPosition.inMilliseconds),
selectedMediaIndex: newMediaIndex,
selectedMediaIndex: effectiveMediaIndex,
selectedQualityPreset: effectivePreset,
selectedAudioStreamId: effectiveAudioStreamId,
reusedSessionIdentifier: sessionId,
reusedTranscodeSessionId: transcodeSessionId,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
@@ -14,10 +14,11 @@ import '../../../i18n/strings.g.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../models/plex_metadata.dart';
import '../models/track_controls_state.dart';
import '../../../models/transcode_quality_preset.dart';
import '../sheets/chapter_sheet.dart';
import '../sheets/queue_sheet.dart';
import '../sheets/track_sheet.dart';
import '../sheets/version_sheet.dart';
import '../sheets/version_quality_sheet.dart';
import '../sheets/video_settings_sheet.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
@@ -66,6 +67,9 @@ class TrackChapterControls extends StatelessWidget {
List<PlexMediaVersion> get availableVersions => trackControlsState.availableVersions;
int get selectedMediaIndex => trackControlsState.selectedMediaIndex;
TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset;
bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding;
ValueChanged<TranscodeQualityPreset>? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset;
int get boxFitMode => trackControlsState.boxFitMode;
int get audioSyncOffset => trackControlsState.audioSyncOffset;
int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset;
@@ -267,6 +271,10 @@ class TrackChapterControls extends StatelessWidget {
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
isTranscoding: trackControlsState.isTranscoding,
sourceAudioTracks: trackControlsState.sourceAudioTracks,
selectedAudioStreamId: trackControlsState.selectedAudioStreamId,
onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId,
),
)
.whenComplete(() => onStartAutoHide?.call());
@@ -330,15 +338,18 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++;
}
// Versions button
if (availableVersions.length > 1 && onSwitchVersion != null) {
// Version & Quality button
final showVersionQuality =
(availableVersions.length > 1 || serverSupportsTranscoding) &&
(onSwitchVersion != null || onSwitchQualityPreset != null);
if (showVersionQuality) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.video_file_rounded,
tooltip: t.videoControls.versionsButton,
semanticLabel: t.videoControls.versionsButton,
icon: Symbols.high_quality_rounded,
tooltip: t.videoControls.versionQualityButton,
semanticLabel: t.videoControls.versionQualityButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
@@ -346,10 +357,14 @@ class TrackChapterControls extends StatelessWidget {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => VersionSheet(
builder: (_) => VersionQualitySheet(
availableVersions: availableVersions,
selectedMediaIndex: selectedMediaIndex,
onVersionSelected: onSwitchVersion!,
selectedQualityPreset: selectedQualityPreset,
serverSupportsTranscoding: serverSupportsTranscoding,
sourceDurationMs: trackControlsState.sourceDurationMs,
onVersionSelected: (i) => onSwitchVersion?.call(i),
onQualitySelected: (p) => onSwitchQualityPreset?.call(p),
),
)
.whenComplete(() => onStartAutoHide?.call());
@@ -480,7 +495,10 @@ class TrackChapterControls extends StatelessWidget {
count++; // Audio & subtitles button always shown
if (chapters.isNotEmpty && !hideChaptersAndQueue) count++;
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++;
if (availableVersions.length > 1 && onSwitchVersion != null) count++;
if ((availableVersions.length > 1 || serverSupportsTranscoding) &&
(onSwitchVersion != null || onSwitchQualityPreset != null)) {
count++;
}
if (onTogglePIPMode != null) count++;
if (onCycleBoxFitMode != null) count++;
if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV)