diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index be80e149..366faa9a 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index deaf700c..71bb6985 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 2f24ef0a..f0fdf423 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 4badfbe0..a62a529a 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 2dbfe60d..e163fb5d 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 2551ff88..0ff40029 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 168542d5..e1f89319 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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": "アンビエントライティング", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index f4f52d62..fdec85d6 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "주변 조명", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 6eb93cc2..eb525a45 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 9e7688e4..596245db 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 13031015..6aa53a53 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 27ef5d19..f579a4b5 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 5b3ade7d..cda480cc 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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": "Фоновая подсветка", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index e69ebcd8..b99a45ce 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 19b833a3..e488ab5c 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 5cf2e888..7ec3b9dd 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index b47ce980..a664f58c 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 72fe34aa..52a7f0af 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index d7e50d64..64d65a50 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 6bd146b7..b5ae5950 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 9780d9b8..78446f3b 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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' => 'プレイリスト', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index ea1077de..9331634a 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '재생 목록', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index fca9f839..3f663cac 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 14b9e773..11c3ef23 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 2d6734c7..adbc82e9 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index ba72affb..6a4b96f7 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 23c6eb22..ea35d924 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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' => 'Плейлисты', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 788765cb..56169f93 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 6c6576a7..5bf1eab7 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '播放列表', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index af5e4bff..0bc387c2 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 98992399..539c6046 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "氛围灯光", diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index d05f0f6f..c17bd6cd 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -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 { diff --git a/lib/models/transcode_quality_preset.dart b/lib/models/transcode_quality_preset.dart new file mode 100644 index 00000000..3735236e --- /dev/null +++ b/lib/models/transcode_quality_preset.dart @@ -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 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, +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 8fdd8e05..d5bed61d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -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? _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 _updateSetting({ required T current, @@ -192,4 +197,11 @@ class SettingsProvider extends ChangeNotifier { setLocal: (v) => _autoHidePerformanceOverlay = v, persist: _settingsService!.setAutoHidePerformanceOverlay, ); + + Future setDefaultQualityPreset(TranscodeQualityPreset preset) => _updateSetting( + current: _defaultQualityPreset, + value: preset, + setLocal: (v) => _defaultQualityPreset = v, + persist: (v) => _settingsService!.setDefaultQualityPreset(v.storageKey), + ); } diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index afbb349a..966430ff 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -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 { bool _useExoPlayer = true; bool _useExternalPlayer = false; String _selectedExternalPlayerName = ''; + TranscodeQualityPreset _defaultQualityPreset = TranscodeQualityPreset.original; @override void initState() { @@ -95,6 +98,7 @@ class _PlaybackSettingsScreenState extends State { _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 { _buildDisplaySwitchDelay(), if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(), _buildBufferSizeSelector(), + _buildDefaultQualityTile(), // --- Subtitles & Config --- SettingsSectionHeader(t.settings.subtitlesAndConfig), @@ -300,6 +305,31 @@ class _PlaybackSettingsScreenState extends State { ); } + 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( + 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), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7a2ecb6c..1a890603 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 with WidgetsBindin bool _isPhone = false; List _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? _errorSubscription; StreamSubscription? _playingSubscription; StreamSubscription? _completedSubscription; @@ -284,6 +312,15 @@ class VideoPlayerScreenState extends State 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 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(); + _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 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 _navigateToEpisode(PlexMetadata episodeMetadata) async { @@ -3420,6 +3504,11 @@ class VideoPlayerScreenState extends State 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, diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 8941b0ab..8543c02b 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -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 diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 60cb09a8..f6c1b5f1 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -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 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 _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 = []; + 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 _buildExternalSubtitles(PlexMediaInfo? mediaInfo) { final externalSubtitles = []; @@ -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 availableVersions; @@ -217,12 +325,25 @@ class PlaybackInitializationResult { final List 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, }); } diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 0e3ec0e4..60952ffa 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -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 findBestWorkingConnection({String? preferredUri, String? clientIdentifier}) async* { + Stream 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); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index fc54a438..f963666f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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? _serverTranscoderPending; + /// Libraries parsed from /media/providers (includes individually shared items) late final List _providerLibraries; @@ -157,6 +171,7 @@ class PlexClient { List? prioritizedEndpoints, Future 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 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 _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 = []; + 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 = { + '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() ?? ''; + 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.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? container; + if (data is Map && data['MediaContainer'] is Map) { + container = Map.from(data['MediaContainer'] as Map); + } else if (data is Map) { + 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> getLiveTvSessions() { return _wrapListApiCall( diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index c05b38ae..32248fbf 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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 setDefaultQualityPreset(String presetName) async { + await prefs.setString(_keyDefaultQualityPreset, presetName); + } + + String getDefaultQualityPreset() { + return prefs.getString(_keyDefaultQualityPreset) ?? 'original'; + } + // Buffer Size (in MB) Future setBufferSize(int sizeInMB) async { await prefs.setInt(_keyBufferSize, sizeInMB); diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index e9b272bf..7fcd9cef 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -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 showOptionPickerDialog( BuildContext context, { required String title, - required List<({IconData icon, String label, T value})> options, + required List<({IconData? icon, String label, T value})> options, Future Function(T value)? onBeforeClose, }) { final focusFirstItem = InputModeTracker.isKeyboardMode(context); @@ -343,7 +345,7 @@ Future showOptionPickerDialog( class _OptionPickerDialog 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 Function(T value)? onBeforeClose; @@ -383,9 +385,10 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { 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 { diff --git a/lib/utils/quality_preset_labels.dart b/lib/utils/quality_preset_labels.dart new file mode 100644 index 00000000..61280f8b --- /dev/null +++ b/lib/utils/quality_preset_labels.dart @@ -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 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( + context, + title: title ?? t.videoControls.qualityColumnHeader, + options: TranscodeQualityPreset.displayOrder.map((p) => (icon: null, label: labelFor(p), value: p)).toList(), + ); +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index fdec4c1a..d7a0d2df 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -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 navigateToVideoPlayer( SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, + TranscodeQualityPreset? selectedQualityPreset, bool usePushReplacement = false, bool isOffline = false, PlexVideoPlaybackData? playbackData, @@ -130,6 +132,7 @@ Future navigateToVideoPlayer( preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: mediaIndex, + selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, playbackData: effectivePlaybackData, ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 7c514e12..9ead75ad 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { ); } - // 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 { } } - /// Handle play version selection Future _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 diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 5672d256..2161f8e5 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -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 availableVersions; final int selectedMediaIndex; + final TranscodeQualityPreset selectedQualityPreset; + final bool serverSupportsTranscoding; + final bool isTranscoding; + final List 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? onSwitchQualityPreset; + final ValueChanged? 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, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 4193dabd..64ea54d1 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -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 sourceAudioTracks; + final int? selectedAudioStreamId; + final ValueChanged? 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(tracks, (t) => t?.audio ?? []); + final playerAudioTracks = TrackFilterHelper.extractAndFilterTracks(tracks, (t) => t?.audio ?? []); final subtitleTracks = TrackFilterHelper.extractAndFilterTracks( 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 tracks; + final int? selectedStreamId; + final ValueChanged 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( + 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 tracks; final TrackSelection selection; diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart new file mode 100644 index 00000000..e073c1d0 --- /dev/null +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -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 availableVersions; + final int selectedMediaIndex; + final TranscodeQualityPreset selectedQualityPreset; + final bool serverSupportsTranscoding; + final int? sourceDurationMs; + final ValueChanged onVersionSelected; + final ValueChanged 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 versions; + final int selectedIndex; + final ValueChanged 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 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), + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart deleted file mode 100644 index c447b3d2..00000000 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ /dev/null @@ -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 availableVersions; - final int selectedMediaIndex; - final Function(int) onVersionSelected; - - const VersionSheet({ - super.key, - required this.availableVersions, - required this.selectedMediaIndex, - required this.onVersionSelected, - }); - - @override - State createState() => _VersionSheetState(); -} - -class _VersionSheetState extends State { - @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); - }, - ); - }, - ), - ); - } -} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 84821cc7..cfa7ecec 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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? availableVersions, int? selectedMediaIndex, + TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original, + bool serverSupportsTranscoding = false, + bool isTranscoding = false, + List 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 availableVersions; final int selectedMediaIndex; + final TranscodeQualityPreset selectedQualityPreset; + final bool serverSupportsTranscoding; + final bool isTranscoding; + final List 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 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 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 with WindowListen } } - Future _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 _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 with WindowListen // Get state reference before async operations final videoPlayerState = context.findAncestorStateOfType(); - // 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 with WindowListen PageRouteBuilder( 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, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index b13f1857..b6c86492 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -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 get availableVersions => trackControlsState.availableVersions; int get selectedMediaIndex => trackControlsState.selectedMediaIndex; + TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset; + bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding; + ValueChanged? 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)