feat: transcoding
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アンビエントライティング",
|
||||
|
||||
@@ -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": "주변 조명",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Фоновая подсветка",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'プレイリスト',
|
||||
|
||||
@@ -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' => '재생 목록',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Плейлисты',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '播放列表',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "氛围灯光",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Video transcode quality presets modeled on Plex Web's custom-quality table.
|
||||
///
|
||||
/// When a non-[original] preset is selected, playback goes through Plex's
|
||||
/// `/video/:/transcode/universal/decision` + `start` flow. [original] bypasses
|
||||
/// transcoding entirely and uses the direct-play URL.
|
||||
enum TranscodeQualityPreset {
|
||||
original(null, null, null),
|
||||
p240_320(320, '420x240', 30),
|
||||
p320_720(720, '576x320', 40),
|
||||
p480_1_5mbps(1500, '720x480', 60),
|
||||
p720_2mbps(2000, '1280x720', 60),
|
||||
p720_3mbps(3000, '1280x720', 75),
|
||||
p720_4mbps(4000, '1280x720', 100),
|
||||
p1080_8mbps(8000, '1920x1080', 60),
|
||||
p1080_10mbps(10000, '1920x1080', 75),
|
||||
p1080_12mbps(12000, '1920x1080', 90),
|
||||
p1080_20mbps(20000, '1920x1080', 100);
|
||||
|
||||
const TranscodeQualityPreset(this.videoBitrateKbps, this.videoResolution, this.videoQuality);
|
||||
|
||||
final int? videoBitrateKbps;
|
||||
final String? videoResolution;
|
||||
final int? videoQuality;
|
||||
|
||||
bool get isOriginal => this == TranscodeQualityPreset.original;
|
||||
|
||||
String get storageKey => name;
|
||||
|
||||
static TranscodeQualityPreset fromStorage(String? stored) {
|
||||
if (stored == null) return TranscodeQualityPreset.original;
|
||||
for (final v in TranscodeQualityPreset.values) {
|
||||
if (v.name == stored) return v;
|
||||
}
|
||||
return TranscodeQualityPreset.original;
|
||||
}
|
||||
|
||||
/// Resolution height (e.g. 720, 1080) parsed from [videoResolution]. Null for original.
|
||||
int? get resolutionHeight {
|
||||
final r = videoResolution;
|
||||
if (r == null) return null;
|
||||
final parts = r.split('x');
|
||||
if (parts.length != 2) return null;
|
||||
return int.tryParse(parts[1]);
|
||||
}
|
||||
|
||||
/// Order shared by every picker surface so they can't drift apart:
|
||||
/// [original] pinned first, then transcode presets highest-bitrate first.
|
||||
static final List<TranscodeQualityPreset> displayOrder = List.unmodifiable([
|
||||
original,
|
||||
...values.where((p) => !p.isOriginal).toList().reversed,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Outcome of a transcode decision call.
|
||||
enum TranscodeDecisionOutcome {
|
||||
/// Decision indicates transcode is available (`transcodeDecisionCode == 1001`).
|
||||
transcodeOk,
|
||||
|
||||
/// Decision indicates only direct play is available (`transcodeDecisionCode == 1000`).
|
||||
/// Caller should fall back to the direct-play URL.
|
||||
directPlayOnly,
|
||||
|
||||
/// Decision failed (HTTP error, code >= 2000, or parse error).
|
||||
failed,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
@@ -15,6 +16,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
bool _showNavBarLabels = true;
|
||||
bool _liveTvDefaultFavorites = false;
|
||||
bool _autoHidePerformanceOverlay = true;
|
||||
TranscodeQualityPreset _defaultQualityPreset = TranscodeQualityPreset.original;
|
||||
bool _isInitialized = false;
|
||||
Future<void>? _initFuture;
|
||||
|
||||
@@ -56,6 +58,7 @@ class SettingsProvider extends ChangeNotifier {
|
||||
_showNavBarLabels = s.getShowNavBarLabels();
|
||||
_liveTvDefaultFavorites = s.getLiveTvDefaultFavorites();
|
||||
_autoHidePerformanceOverlay = s.getAutoHidePerformanceOverlay();
|
||||
_defaultQualityPreset = TranscodeQualityPreset.fromStorage(s.getDefaultQualityPreset());
|
||||
}
|
||||
|
||||
/// Re-read all settings from SharedPreferences. Used after imports or resets
|
||||
@@ -94,6 +97,8 @@ class SettingsProvider extends ChangeNotifier {
|
||||
|
||||
bool get autoHidePerformanceOverlay => _autoHidePerformanceOverlay;
|
||||
|
||||
TranscodeQualityPreset get defaultQualityPreset => _defaultQualityPreset;
|
||||
|
||||
/// Helper to update a setting: ensures init, deduplicates, persists, notifies.
|
||||
Future<void> _updateSetting<T>({
|
||||
required T current,
|
||||
@@ -192,4 +197,11 @@ class SettingsProvider extends ChangeNotifier {
|
||||
setLocal: (v) => _autoHidePerformanceOverlay = v,
|
||||
persist: _settingsService!.setAutoHidePerformanceOverlay,
|
||||
);
|
||||
|
||||
Future<void> setDefaultQualityPreset(TranscodeQualityPreset preset) => _updateSetting(
|
||||
current: _defaultQualityPreset,
|
||||
value: preset,
|
||||
setLocal: (v) => _defaultQualityPreset = v,
|
||||
persist: (v) => _settingsService!.setDefaultQualityPreset(v.storageKey),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/transcode_quality_preset.dart';
|
||||
import '../../mpv/player/platform/player_android.dart';
|
||||
import '../../utils/quality_preset_labels.dart';
|
||||
import '../../services/discord_rpc_service.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart' as settings;
|
||||
@@ -55,6 +57,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
bool _useExoPlayer = true;
|
||||
bool _useExternalPlayer = false;
|
||||
String _selectedExternalPlayerName = '';
|
||||
TranscodeQualityPreset _defaultQualityPreset = TranscodeQualityPreset.original;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -95,6 +98,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
_useExoPlayer = _settingsService.getUseExoPlayer();
|
||||
_useExternalPlayer = _settingsService.getUseExternalPlayer();
|
||||
_selectedExternalPlayerName = _settingsService.getSelectedExternalPlayer().name;
|
||||
_defaultQualityPreset = TranscodeQualityPreset.fromStorage(_settingsService.getDefaultQualityPreset());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -129,6 +133,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
_buildDisplaySwitchDelay(),
|
||||
if (Platform.isAndroid && _useExoPlayer) _buildTunneledPlayback(),
|
||||
_buildBufferSizeSelector(),
|
||||
_buildDefaultQualityTile(),
|
||||
|
||||
// --- Subtitles & Config ---
|
||||
SettingsSectionHeader(t.settings.subtitlesAndConfig),
|
||||
@@ -300,6 +305,31 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultQualityTile() {
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.high_quality_rounded, fill: 1),
|
||||
title: Text(t.settings.defaultQualityTitle),
|
||||
subtitle: Text(qualityPresetLabel(_defaultQualityPreset)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () async {
|
||||
final value = await showSelectionDialog<TranscodeQualityPreset>(
|
||||
context: context,
|
||||
title: t.settings.defaultQualityTitle,
|
||||
options: TranscodeQualityPreset.displayOrder
|
||||
.map((p) => DialogOption(value: p, title: qualityPresetLabel(p)))
|
||||
.toList(),
|
||||
currentValue: _defaultQualityPreset,
|
||||
);
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_defaultQualityPreset = value;
|
||||
_settingsService.setDefaultQualityPreset(value.storageKey);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBufferSizeSelector() {
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.memory_rounded, fill: 1),
|
||||
|
||||
@@ -25,6 +25,7 @@ import '../services/plex_api_cache.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/plex_cache_parser.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
@@ -44,6 +45,7 @@ import '../services/playback_progress_tracker.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/display_mode_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/sleep_timer_service.dart';
|
||||
import '../services/track_manager.dart';
|
||||
import '../services/ambient_lighting_service.dart';
|
||||
@@ -100,6 +102,20 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
final bool isOffline;
|
||||
final PlexVideoPlaybackData? playbackData;
|
||||
|
||||
/// Quality preset override for this playback. When `null`, the screen uses
|
||||
/// the user's default from [SettingsProvider].
|
||||
final TranscodeQualityPreset? selectedQualityPreset;
|
||||
|
||||
/// Audio stream ID to pass to the transcoder when [selectedQualityPreset]
|
||||
/// is non-original. When `null`, the playback service picks the `selected`
|
||||
/// Plex audio track (fallback: first).
|
||||
final int? selectedAudioStreamId;
|
||||
|
||||
/// Session identifiers forwarded across quality/version/audio switches so
|
||||
/// the server-side transcode session is preserved.
|
||||
final String? reusedSessionIdentifier;
|
||||
final String? reusedTranscodeSessionId;
|
||||
|
||||
// Live TV fields
|
||||
final bool isLive;
|
||||
final String? liveChannelName;
|
||||
@@ -120,6 +136,10 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
this.selectedMediaIndex = 0,
|
||||
this.isOffline = false,
|
||||
this.playbackData,
|
||||
this.selectedQualityPreset,
|
||||
this.selectedAudioStreamId,
|
||||
this.reusedSessionIdentifier,
|
||||
this.reusedTranscodeSessionId,
|
||||
this.isLive = false,
|
||||
this.liveChannelName,
|
||||
this.liveStreamUrl,
|
||||
@@ -157,6 +177,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _isPhone = false;
|
||||
List<PlexMediaVersion> _availableVersions = [];
|
||||
PlexMediaInfo? _currentMediaInfo;
|
||||
|
||||
// Transcode / quality state
|
||||
late TranscodeQualityPreset _selectedQualityPreset;
|
||||
int? _selectedAudioStreamId;
|
||||
bool _isTranscoding = false;
|
||||
bool _serverSupportsTranscoding = false;
|
||||
late final String _playbackSessionIdentifier;
|
||||
late final String _playbackTranscodeSessionId;
|
||||
StreamSubscription<PlayerError>? _errorSubscription;
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<bool>? _completedSubscription;
|
||||
@@ -284,6 +312,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_activeRatingKey = widget.metadata.ratingKey;
|
||||
_activeMediaIndex = widget.selectedMediaIndex;
|
||||
|
||||
// Transcode session identifiers — reused across quality/version/audio
|
||||
// switches so the server-side transcode session is preserved.
|
||||
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? PlexClient.generateSessionIdentifier();
|
||||
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? PlexClient.generateSessionIdentifier();
|
||||
_selectedAudioStreamId = widget.selectedAudioStreamId;
|
||||
// Quality preset is resolved later when the SettingsProvider is available;
|
||||
// see _resolveQualityPreset() called from _initializePlayer.
|
||||
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
||||
|
||||
// Initialize live TV channel tracking
|
||||
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
|
||||
_liveChannelName = widget.liveChannelName;
|
||||
@@ -1390,13 +1427,54 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Online mode: use server-specific client
|
||||
final client = _getClientForMetadata(context);
|
||||
plexHeaders = client.config.headers;
|
||||
|
||||
// Resolve quality preset from widget → SettingsProvider default.
|
||||
if (widget.selectedQualityPreset == null && mounted) {
|
||||
try {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
_selectedQualityPreset = settingsProvider.defaultQualityPreset;
|
||||
} catch (_) {
|
||||
_selectedQualityPreset = TranscodeQualityPreset.original;
|
||||
}
|
||||
} else {
|
||||
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
||||
}
|
||||
|
||||
// Capability flag comes from the warm cache populated at connection
|
||||
// time. If the warm-up hasn't landed yet (rare — playback within a
|
||||
// few seconds of connect), the sync getter returns `true` and the
|
||||
// transcode decision's own fallback handles a "not actually supported"
|
||||
// server without blocking the hot path.
|
||||
_serverSupportsTranscoding = client.serverSupportsVideoTranscodingCached;
|
||||
|
||||
final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database);
|
||||
// Only prefer a downloaded local file when the user wants Original
|
||||
// quality. Any non-Original preset implies an explicit request to
|
||||
// transcode, which can only be satisfied by hitting the server — the
|
||||
// local file is always source quality.
|
||||
result = await playbackService.getPlaybackData(
|
||||
metadata: _currentMetadata,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
preferOffline: true, // Use downloaded file if available
|
||||
preferOffline: _selectedQualityPreset.isOriginal,
|
||||
playbackData: widget.playbackData,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
);
|
||||
|
||||
_isTranscoding = result.isTranscoding;
|
||||
if (result.activeAudioStreamId != null) {
|
||||
_selectedAudioStreamId = result.activeAudioStreamId;
|
||||
}
|
||||
|
||||
if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback);
|
||||
}
|
||||
// Reset the preset so the UI reflects what's actually playing.
|
||||
_selectedQualityPreset = TranscodeQualityPreset.original;
|
||||
}
|
||||
}
|
||||
|
||||
// Primary refresh-rate path: when Plex metadata provides an fps and the
|
||||
@@ -2980,6 +3058,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_isReplacingWithVideo = true;
|
||||
}
|
||||
|
||||
/// Session identifiers owned by this screen, forwarded to a replacement
|
||||
/// [VideoPlayerScreen] during quality/version/audio switches so the Plex
|
||||
/// transcode session is continued rather than restarted.
|
||||
String get playbackSessionIdentifier => _playbackSessionIdentifier;
|
||||
String get playbackTranscodeSessionId => _playbackTranscodeSessionId;
|
||||
|
||||
/// Navigates to a new episode, preserving playback state and track selections.
|
||||
/// When PiP is active, swaps the media source in-place to keep the PiP window alive.
|
||||
Future<void> _navigateToEpisode(PlexMetadata episodeMetadata) async {
|
||||
@@ -3420,6 +3504,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedQualityPreset: _selectedQualityPreset,
|
||||
serverSupportsTranscoding: _serverSupportsTranscoding,
|
||||
isTranscoding: _isTranscoding,
|
||||
sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [],
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
onTogglePIPMode: _togglePIPMode,
|
||||
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
|
||||
onCycleBoxFitMode: _cycleBoxFitMode,
|
||||
|
||||
@@ -94,9 +94,18 @@ class MultiServerManager {
|
||||
final storage = await StorageService.getInstance();
|
||||
final cachedEndpoint = storage.getServerEndpoint(serverId);
|
||||
|
||||
// The connection race already hits `/` on the winning endpoint — capture
|
||||
// `transcoderVideo` from that response so PlexClient.create can skip the
|
||||
// redundant warm-up probe.
|
||||
bool? observedTranscoderVideo;
|
||||
|
||||
// Find best working connection, passing cached endpoint for fast-path
|
||||
final streamIterator = StreamIterator(
|
||||
server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: clientIdentifier),
|
||||
server.findBestWorkingConnection(
|
||||
preferredUri: cachedEndpoint,
|
||||
clientIdentifier: clientIdentifier,
|
||||
onTranscoderCapability: (b) => observedTranscoderVideo = b,
|
||||
),
|
||||
);
|
||||
|
||||
if (!await streamIterator.moveNext()) {
|
||||
@@ -124,6 +133,7 @@ class MultiServerManager {
|
||||
appLogger.i('Updated endpoint for ${server.name} after failover: $newUrl');
|
||||
},
|
||||
onAllEndpointsExhausted: () => _onServerEndpointsExhausted(serverId),
|
||||
seedTranscoderVideoSupport: observedTranscoderVideo,
|
||||
);
|
||||
|
||||
// Save the initial endpoint
|
||||
|
||||
@@ -3,8 +3,10 @@ import '../models/plex_media_info.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/plex_url_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../database/app_database.dart';
|
||||
import 'download_storage_service.dart';
|
||||
@@ -86,11 +88,18 @@ class PlaybackInitializationService {
|
||||
/// Returns a PlaybackInitializationResult with video URL and available versions
|
||||
/// If [preferOffline] is true and offline content is available, uses local file
|
||||
/// If [playbackData] is provided, skips the network call to fetch it again.
|
||||
/// When [qualityPreset] is non-original and online, the video URL is built
|
||||
/// against Plex's transcode start endpoint and all subtitle tracks are
|
||||
/// sidecar-attached since the transcoded stream carries none.
|
||||
Future<PlaybackInitializationResult> getPlaybackData({
|
||||
required PlexMetadata metadata,
|
||||
required int selectedMediaIndex,
|
||||
bool preferOffline = false,
|
||||
PlexVideoPlaybackData? playbackData,
|
||||
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
|
||||
int? selectedAudioStreamId,
|
||||
String? sessionIdentifier,
|
||||
String? transcodeSessionId,
|
||||
}) async {
|
||||
try {
|
||||
// Check for offline content first if preferOffline is enabled
|
||||
@@ -145,6 +154,54 @@ class PlaybackInitializationService {
|
||||
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||
}
|
||||
|
||||
final wantTranscode = !qualityPreset.isOriginal;
|
||||
if (wantTranscode && sessionIdentifier != null && transcodeSessionId != null) {
|
||||
final resolvedAudioId = _resolveAudioStreamId(selectedAudioStreamId, data.mediaInfo);
|
||||
// Note: no `offsetMs` — seeking is handled by the player via the HLS
|
||||
// manifest, matching Plex Web's behavior. Baking `offset=` into the URL
|
||||
// makes the server pre-position the transcoder, but the resulting
|
||||
// segments and mpv's native HLS positioning fight each other, leaving
|
||||
// the player clock at 0 and desyncing sidecar subtitles.
|
||||
final result = await client.buildTranscodeStartPath(
|
||||
ratingKey: metadata.ratingKey,
|
||||
mediaIndex: selectedMediaIndex,
|
||||
preset: qualityPreset,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
audioStreamId: resolvedAudioId,
|
||||
);
|
||||
|
||||
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
||||
final transcodeUrl = '${client.config.baseUrl}${result.startPath}'.withPlexToken(client.config.token);
|
||||
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: transcodeUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: sidecarSubs,
|
||||
isOffline: false,
|
||||
isTranscoding: true,
|
||||
activeAudioStreamId: resolvedAudioId,
|
||||
);
|
||||
}
|
||||
|
||||
// Decision failed or said direct-play only — fall through to direct-play path
|
||||
// and surface the fallback reason so the UI can notify the user.
|
||||
final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly
|
||||
? TranscodeFallbackReason.directPlayOnly
|
||||
: TranscodeFallbackReason.decisionFailed;
|
||||
appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: data.videoUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
||||
isOffline: false,
|
||||
isTranscoding: false,
|
||||
fallbackReason: fallbackReason,
|
||||
);
|
||||
}
|
||||
|
||||
// Build list of external subtitle tracks
|
||||
final externalSubtitles = _buildExternalSubtitles(data.mediaInfo);
|
||||
|
||||
@@ -164,6 +221,48 @@ class PlaybackInitializationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the audio stream ID to send to the transcoder. Preference order:
|
||||
/// explicit [explicit] → audio track with `selected == true` → first → null.
|
||||
int? _resolveAudioStreamId(int? explicit, PlexMediaInfo? info) {
|
||||
if (explicit != null) return explicit;
|
||||
if (info == null) return null;
|
||||
final tracks = info.audioTracks;
|
||||
if (tracks.isEmpty) return null;
|
||||
for (final track in tracks) {
|
||||
if (track.selected) return track.id;
|
||||
}
|
||||
return tracks.first.id;
|
||||
}
|
||||
|
||||
/// Build sidecar SubtitleTracks for ALL source subtitle streams (internal +
|
||||
/// external) so the player can hot-swap between them when the main stream
|
||||
/// is transcoded and has no embedded subs.
|
||||
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(PlexMediaInfo? mediaInfo) {
|
||||
if (mediaInfo == null) return const [];
|
||||
final token = client.config.token;
|
||||
if (token == null) {
|
||||
appLogger.w('No auth token available for transcode sidecar subtitles');
|
||||
return const [];
|
||||
}
|
||||
|
||||
final tracks = <SubtitleTrack>[];
|
||||
for (final sub in mediaInfo.subtitleTracks) {
|
||||
try {
|
||||
final url = sub.getTranscodeSidecarUrl(client.config.baseUrl, token);
|
||||
tracks.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}',
|
||||
language: sub.languageCode,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
||||
}
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/// Build list of external subtitle tracks from media info
|
||||
List<SubtitleTrack> _buildExternalSubtitles(PlexMediaInfo? mediaInfo) {
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
@@ -209,6 +308,15 @@ class PlaybackInitializationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reason the transcode branch fell back to direct play.
|
||||
enum TranscodeFallbackReason {
|
||||
/// Plex decision said only direct-play is available.
|
||||
directPlayOnly,
|
||||
|
||||
/// The decision endpoint errored (HTTP error, code >= 2000, parse failure).
|
||||
decisionFailed,
|
||||
}
|
||||
|
||||
/// Result of playback initialization
|
||||
class PlaybackInitializationResult {
|
||||
final List<dynamic> availableVersions;
|
||||
@@ -217,12 +325,25 @@ class PlaybackInitializationResult {
|
||||
final List<SubtitleTrack> externalSubtitles;
|
||||
final bool isOffline;
|
||||
|
||||
/// `true` when [videoUrl] is a Plex transcode start URL.
|
||||
final bool isTranscoding;
|
||||
|
||||
/// Non-null when a non-original preset was requested but fallback kicked in.
|
||||
final TranscodeFallbackReason? fallbackReason;
|
||||
|
||||
/// The Plex audio stream ID actually passed to the transcoder (`null` when
|
||||
/// not transcoding or when no audio stream was selectable).
|
||||
final int? activeAudioStreamId;
|
||||
|
||||
PlaybackInitializationResult({
|
||||
required this.availableVersions,
|
||||
this.videoUrl,
|
||||
this.mediaInfo,
|
||||
this.externalSubtitles = const [],
|
||||
this.isOffline = false,
|
||||
this.isTranscoding = false,
|
||||
this.fallbackReason,
|
||||
this.activeAudioStreamId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +374,11 @@ class PlexServer {
|
||||
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
|
||||
/// Tests both plex.direct URI and direct IP for each connection
|
||||
/// HTTPS connections are tested first, with HTTP as fallback
|
||||
Stream<PlexConnection> findBestWorkingConnection({String? preferredUri, String? clientIdentifier}) async* {
|
||||
Stream<PlexConnection> findBestWorkingConnection({
|
||||
String? preferredUri,
|
||||
String? clientIdentifier,
|
||||
void Function(bool)? onTranscoderCapability,
|
||||
}) async* {
|
||||
if (connections.isEmpty) {
|
||||
appLogger.w('No connections available for server discovery');
|
||||
return;
|
||||
@@ -426,6 +430,7 @@ class PlexServer {
|
||||
if (result.success) {
|
||||
appLogger.i('Cached endpoint succeeded, using immediately', error: {'uri': preferredUri});
|
||||
firstCandidate = cachedCandidate;
|
||||
if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
|
||||
} else {
|
||||
appLogger.w('Cached endpoint failed, falling back to candidate race', error: {'uri': preferredUri});
|
||||
}
|
||||
@@ -462,6 +467,7 @@ class PlexServer {
|
||||
}
|
||||
|
||||
if (result.success && !completer.isCompleted) {
|
||||
if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
|
||||
completer.complete(candidate);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../utils/content_utils.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../utils/endpoint_failover_interceptor.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/connection_constants.dart';
|
||||
@@ -98,7 +99,12 @@ class ConnectionTestResult {
|
||||
final int latencyMs;
|
||||
final String? error;
|
||||
|
||||
ConnectionTestResult({required this.success, required this.latencyMs, this.error});
|
||||
/// `transcoderVideo` from the `/` MediaContainer, captured on successful
|
||||
/// probes so the connection race doubles as a capability probe. `null`
|
||||
/// when the probe didn't succeed or the field was absent.
|
||||
final bool? transcoderVideo;
|
||||
|
||||
ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo});
|
||||
}
|
||||
|
||||
class PlexClient {
|
||||
@@ -120,6 +126,14 @@ class PlexClient {
|
||||
/// Whether to operate in offline mode (use cache only)
|
||||
bool _offlineMode = false;
|
||||
|
||||
/// Cached result of [serverSupportsVideoTranscoding]. `null` = not yet fetched.
|
||||
bool? _serverTranscoderCached;
|
||||
|
||||
/// In-flight probe for [serverSupportsVideoTranscoding], used to dedupe
|
||||
/// concurrent callers (e.g. the post-connect warm-up racing the first
|
||||
/// playback).
|
||||
Future<bool>? _serverTranscoderPending;
|
||||
|
||||
/// Libraries parsed from /media/providers (includes individually shared items)
|
||||
late final List<PlexLibrary> _providerLibraries;
|
||||
|
||||
@@ -157,6 +171,7 @@ class PlexClient {
|
||||
List<String>? prioritizedEndpoints,
|
||||
Future<void> Function(String newBaseUrl)? onEndpointChanged,
|
||||
VoidCallback? onAllEndpointsExhausted,
|
||||
bool? seedTranscoderVideoSupport,
|
||||
}) async {
|
||||
final client = PlexClient._(
|
||||
config,
|
||||
@@ -166,7 +181,16 @@ class PlexClient {
|
||||
onEndpointChanged: onEndpointChanged,
|
||||
onAllEndpointsExhausted: onAllEndpointsExhausted,
|
||||
);
|
||||
if (seedTranscoderVideoSupport != null) {
|
||||
client._serverTranscoderCached = seedTranscoderVideoSupport;
|
||||
}
|
||||
await client._initMediaProviders();
|
||||
// If the connection race didn't seed the capability, warm the cache in
|
||||
// the background so the first playback doesn't pay the probe cost on its
|
||||
// hot path.
|
||||
if (seedTranscoderVideoSupport == null) {
|
||||
unawaited(client.serverSupportsVideoTranscoding());
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -415,10 +439,16 @@ class PlexClient {
|
||||
stopwatch.stop();
|
||||
final success = response.statusCode == 200;
|
||||
|
||||
bool? transcoderVideo;
|
||||
if (success && response.data is Map && response.data['MediaContainer'] is Map) {
|
||||
transcoderVideo = flexibleBool((response.data['MediaContainer'] as Map)['transcoderVideo']);
|
||||
}
|
||||
|
||||
return ConnectionTestResult(
|
||||
success: success,
|
||||
latencyMs: stopwatch.elapsedMilliseconds,
|
||||
error: success ? null : 'HTTP ${response.statusCode}',
|
||||
transcoderVideo: transcoderVideo,
|
||||
);
|
||||
} on PlexHttpException catch (e) {
|
||||
stopwatch.stop();
|
||||
@@ -2902,6 +2932,244 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether the server has video transcoding enabled.
|
||||
///
|
||||
/// Reads `transcoderVideo` from the root MediaContainer. Result is cached
|
||||
/// for the lifetime of this [PlexClient]. Returns `true` on error (fail-open)
|
||||
/// — the transcode decision call itself will fail gracefully if transcoding
|
||||
/// really is unavailable.
|
||||
Future<bool> serverSupportsVideoTranscoding() {
|
||||
final cached = _serverTranscoderCached;
|
||||
if (cached != null) return Future.value(cached);
|
||||
return _serverTranscoderPending ??= _fetchTranscoderCapability();
|
||||
}
|
||||
|
||||
/// Synchronous view of the probe — returns the cached value, or `true`
|
||||
/// (assume supported) if the post-connect warm-up hasn't landed yet. The
|
||||
/// transcode decision call has its own fallback path, so guessing wrong
|
||||
/// here just routes through that fallback instead of blocking playback.
|
||||
bool get serverSupportsVideoTranscodingCached => _serverTranscoderCached ?? true;
|
||||
|
||||
Future<bool> _fetchTranscoderCapability() async {
|
||||
try {
|
||||
// Tight timeout: `/` returns a tiny MediaContainer — any responsive
|
||||
// server answers in well under a second. Inheriting the default 120 s
|
||||
// receive timeout would keep a hung server from ever resolving.
|
||||
final response = await _http.get('/', timeout: const Duration(seconds: 5));
|
||||
final container = _getMediaContainer(response);
|
||||
final value = container?['transcoderVideo'];
|
||||
final supported = flexibleBool(value);
|
||||
_serverTranscoderCached = supported;
|
||||
return supported;
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to query server transcoder capability', error: e);
|
||||
_serverTranscoderCached = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a VOD transcode stream URL (decision + start path).
|
||||
///
|
||||
/// Mirrors [buildLiveStreamPath] but for on-demand video with a quality
|
||||
/// preset, selected audio stream, and HLS protocol. Plex returns a single
|
||||
/// audio track and no subtitle tracks in the transcoded stream — callers
|
||||
/// are expected to sidecar additional subtitles separately.
|
||||
///
|
||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||
/// seeks + quality/version/audio switches within one playback so the
|
||||
/// server-side transcode session is preserved.
|
||||
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
required TranscodeQualityPreset preset,
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
int? offsetMs,
|
||||
}) async {
|
||||
try {
|
||||
final isOriginal = preset.isOriginal;
|
||||
final metadataPath = '/library/metadata/$ratingKey';
|
||||
|
||||
// Build the client profile from scratch via X-Plex-Client-Profile-Extra.
|
||||
// We use the `Generic` base platform (see [_transcodePlatformName]) which
|
||||
// has no pre-installed transcode targets, so we must `add-transcode-target`
|
||||
// rather than `append-transcode-target-codec` (which only edits existing
|
||||
// targets — empty on Generic, hence Plex returned decision code 2000
|
||||
// "neither direct play nor conversion is available").
|
||||
//
|
||||
// For non-original presets we also add a bitrate limitation that caps
|
||||
// the video codec; with `replace=true` it overrides any default limit.
|
||||
//
|
||||
// See openapi.md §"Profile Augmentations" for the DSL reference.
|
||||
final profileExtraClauses = <String>[];
|
||||
if (!isOriginal && preset.videoBitrateKbps != null) {
|
||||
profileExtraClauses.add(
|
||||
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
|
||||
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
|
||||
);
|
||||
}
|
||||
// Declare both h264 and hevc as allowed transcode targets. In practice
|
||||
// Plex's decision engine strongly prefers h264 for HLS output, so hevc
|
||||
// only gets chosen in edge cases (e.g. HDR content where the server
|
||||
// wants to preserve dynamic range). The codec-list comma is pre-encoded
|
||||
// as `%2C` — see the profile-extra encoding note above.
|
||||
profileExtraClauses.add(
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc&audioCodec=aac)',
|
||||
);
|
||||
final clientProfileExtra = profileExtraClauses.join('+');
|
||||
|
||||
// HLS protocol: seekable via manifest segments. We started with `dash`
|
||||
// (what Plex Web on Chrome uses) but Plex's server only has DASH
|
||||
// transcode profiles for Chrome/Firefox/Safari/Opera — mobile/desktop
|
||||
// platforms fall through with "No conversion profile found for
|
||||
// protocol dash". HLS profiles exist for every Plex-accepted platform.
|
||||
final allParams = <String, String>{
|
||||
'hasMDE': '1',
|
||||
'path': metadataPath,
|
||||
'mediaIndex': mediaIndex.toString(),
|
||||
'partIndex': '0',
|
||||
'protocol': 'hls',
|
||||
'fastSeek': '1',
|
||||
'directPlay': isOriginal ? '1' : '0',
|
||||
'directStream': isOriginal ? '1' : '0',
|
||||
'subtitleSize': '100',
|
||||
'audioBoost': '100',
|
||||
'location': 'lan',
|
||||
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
|
||||
'addDebugOverlay': '0',
|
||||
'autoAdjustQuality': '0',
|
||||
'directStreamAudio': '0',
|
||||
'mediaBufferSize': '102400',
|
||||
'session': transcodeSessionId,
|
||||
// Subtitles are delivered as client-side sidecars (see
|
||||
// [PlaybackInitializationService._buildTranscodeSidecarSubtitles]).
|
||||
// `subtitles=none` makes the server set the subtitle decision to
|
||||
// `ignore`, so nothing is embedded or burned into the video stream.
|
||||
'subtitles': 'none',
|
||||
// Preserve source timestamps in the transcoded segments. Without it,
|
||||
// Plex resets segment PTS to 0 — so mpv shows 0:00 and sidecar
|
||||
// subtitles desync even though the server is transcoding from the
|
||||
// `offset` position. With copyts=1 the first segment's PTS equals
|
||||
// the source offset and the player's clock lines up with source time.
|
||||
'copyts': '1',
|
||||
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
||||
'Accept-Language': 'en',
|
||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||
'X-Plex-Client-Profile-Extra': clientProfileExtra,
|
||||
'X-Plex-Incomplete-Segments': '1',
|
||||
'X-Plex-Features': 'external-media,indirect-media',
|
||||
'X-Plex-Model': 'standalone',
|
||||
'X-Plex-Language': 'en',
|
||||
'X-Plex-Product': config.product,
|
||||
'X-Plex-Version': config.version,
|
||||
'X-Plex-Client-Identifier': config.clientIdentifier,
|
||||
// Plex's server rejects unknown platform names with HTTP 400 and maps
|
||||
// known names to codec/bitrate base profiles. Our usual "Flutter"
|
||||
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
|
||||
// Plex-recognized name just for transcode requests. See
|
||||
// [_transcodePlatformName] for the mapping.
|
||||
'X-Plex-Platform': _transcodePlatformName(),
|
||||
if (config.device != null) 'X-Plex-Device': config.device!,
|
||||
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
|
||||
if (config.token != null) 'X-Plex-Token': config.token!,
|
||||
};
|
||||
|
||||
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
|
||||
final decisionClient = PlexHttpClient(
|
||||
connectTimeout: ConnectionTimeouts.connect,
|
||||
receiveTimeout: ConnectionTimeouts.receive,
|
||||
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
|
||||
);
|
||||
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
|
||||
final decisionResponse = await decisionClient.get(decisionUrl);
|
||||
|
||||
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
|
||||
appLogger.i(
|
||||
'Transcode decision [${decisionResponse.statusCode}] body: '
|
||||
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
|
||||
);
|
||||
|
||||
if (decisionResponse.statusCode != 200) {
|
||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
|
||||
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
|
||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
||||
return (startPath: null, outcome: outcome);
|
||||
}
|
||||
|
||||
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
|
||||
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
|
||||
// `.m3u8` tells the server to return an HLS manifest.
|
||||
return (startPath: '/video/:/transcode/universal/start.m3u8?$startQuery', outcome: outcome);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform name Plex Media Server accepts on the transcode decision
|
||||
/// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
|
||||
/// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
|
||||
/// `Generic` is accepted and comes with no preset transcode targets — we
|
||||
/// build the profile ourselves via `X-Plex-Client-Profile-Extra` with
|
||||
/// `add-transcode-target`.
|
||||
static String _transcodePlatformName() => 'Generic';
|
||||
|
||||
/// Strict percent-encoder matching Plex Web's URL encoder — escapes the
|
||||
/// extra characters `(`, `)`, `*`, `'`, `!` that Dart's [Uri.encodeComponent]
|
||||
/// leaves literal. Required for `X-Plex-Client-Profile-Extra` whose parens
|
||||
/// and asterisks must appear as `%28`, `%29`, `%2A` on the wire.
|
||||
static String _plexEncode(String value) {
|
||||
return Uri.encodeComponent(value)
|
||||
.replaceAll('(', '%28')
|
||||
.replaceAll(')', '%29')
|
||||
.replaceAll('*', '%2A')
|
||||
.replaceAll("'", '%27')
|
||||
.replaceAll('!', '%21');
|
||||
}
|
||||
|
||||
/// Parse decision response for outcome. Any decision code >= 2000 = error
|
||||
/// (matching Plex Web's error detector).
|
||||
TranscodeDecisionOutcome _parseTranscodeDecisionOutcome(dynamic data, {required bool isOriginal}) {
|
||||
try {
|
||||
Map<String, dynamic>? container;
|
||||
if (data is Map && data['MediaContainer'] is Map) {
|
||||
container = Map<String, dynamic>.from(data['MediaContainer'] as Map);
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
container = data;
|
||||
}
|
||||
if (container == null) return TranscodeDecisionOutcome.failed;
|
||||
|
||||
final general = flexibleInt(container['generalDecisionCode']);
|
||||
final transcode = flexibleInt(container['transcodeDecisionCode']);
|
||||
final mde = flexibleInt(container['mdeDecisionCode']);
|
||||
|
||||
bool isError(int? code) => code != null && code >= 2000;
|
||||
if (isError(general) || isError(transcode) || isError(mde)) {
|
||||
appLogger.w('Transcode decision error codes: general=$general transcode=$transcode mde=$mde');
|
||||
return TranscodeDecisionOutcome.failed;
|
||||
}
|
||||
|
||||
if (isOriginal) return TranscodeDecisionOutcome.transcodeOk;
|
||||
|
||||
if (transcode == 1000) return TranscodeDecisionOutcome.directPlayOnly;
|
||||
if (transcode == 1001) return TranscodeDecisionOutcome.transcodeOk;
|
||||
if (general == 1001) return TranscodeDecisionOutcome.transcodeOk;
|
||||
if (general == 1000) return TranscodeDecisionOutcome.directPlayOnly;
|
||||
|
||||
return TranscodeDecisionOutcome.transcodeOk;
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to parse transcode decision', error: e);
|
||||
return TranscodeDecisionOutcome.failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get active live TV sessions
|
||||
Future<List<PlexMetadata>> getLiveTvSessions() {
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
|
||||
@@ -100,6 +100,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const String _keyTunneledPlayback = 'tunneled_playback';
|
||||
static const String _keyDefaultPlaybackSpeed = 'default_playback_speed';
|
||||
static const String _keyDefaultBoxFitMode = 'default_box_fit_mode';
|
||||
static const String _keyDefaultQualityPreset = 'default_quality_preset';
|
||||
static const String _keyAutoPlayNextEpisode = 'auto_play_next_episode';
|
||||
static const String _keyUseExoPlayer = 'use_exoplayer';
|
||||
static const String _keyAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
|
||||
@@ -177,6 +178,15 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
return prefs.getBool(_keyCrashReporting) ?? true; // Default enabled
|
||||
}
|
||||
|
||||
// Default video quality preset (persisted as enum name)
|
||||
Future<void> setDefaultQualityPreset(String presetName) async {
|
||||
await prefs.setString(_keyDefaultQualityPreset, presetName);
|
||||
}
|
||||
|
||||
String getDefaultQualityPreset() {
|
||||
return prefs.getString(_keyDefaultQualityPreset) ?? 'original';
|
||||
}
|
||||
|
||||
// Buffer Size (in MB)
|
||||
Future<void> setBufferSize(int sizeInMB) async {
|
||||
await prefs.setInt(_keyBufferSize, sizeInMB);
|
||||
|
||||
@@ -322,11 +322,13 @@ class _TextInputDialogState extends State<_TextInputDialog> with _TextInputDialo
|
||||
}
|
||||
|
||||
/// Shows a simple option picker dialog with focusable items for TV/keyboard navigation.
|
||||
/// Returns the selected value, or null if cancelled.
|
||||
/// Returns the selected value, or null if cancelled. Each option's [icon] may
|
||||
/// be `null` to render a label-only row (useful when the choices are variants
|
||||
/// of the same thing and a repeated icon would just be noise).
|
||||
Future<T?> showOptionPickerDialog<T>(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<({IconData icon, String label, T value})> options,
|
||||
required List<({IconData? icon, String label, T value})> options,
|
||||
Future<T?> Function(T value)? onBeforeClose,
|
||||
}) {
|
||||
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
|
||||
@@ -343,7 +345,7 @@ Future<T?> showOptionPickerDialog<T>(
|
||||
|
||||
class _OptionPickerDialog<T> extends StatefulWidget {
|
||||
final String title;
|
||||
final List<({IconData icon, String label, T value})> options;
|
||||
final List<({IconData? icon, String label, T value})> options;
|
||||
final bool focusFirstItem;
|
||||
final Future<T?> Function(T value)? onBeforeClose;
|
||||
|
||||
@@ -383,9 +385,10 @@ class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: List.generate(widget.options.length, (index) {
|
||||
final option = widget.options[index];
|
||||
final icon = option.icon;
|
||||
return FocusableListTile(
|
||||
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
|
||||
leading: AppIcon(option.icon, fill: 1, size: 24),
|
||||
leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null,
|
||||
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
onTap: () async {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import 'dialogs.dart';
|
||||
import 'formatters.dart';
|
||||
|
||||
const int _audioBitrateEstimateKbps = 192;
|
||||
|
||||
/// User-facing label for a quality preset.
|
||||
///
|
||||
/// Examples:
|
||||
/// - [TranscodeQualityPreset.original] → "Original"
|
||||
/// - [TranscodeQualityPreset.p720_2mbps] → "720p 2 Mbps"
|
||||
/// - [TranscodeQualityPreset.p480_1_5mbps] → "480p 1.5 Mbps"
|
||||
String qualityPresetLabel(TranscodeQualityPreset preset) {
|
||||
if (preset.isOriginal) return t.videoControls.qualityOriginal;
|
||||
final height = preset.resolutionHeight?.toString() ?? '';
|
||||
final bitrate = _formatBitrate(preset.videoBitrateKbps!);
|
||||
return t.videoControls.qualityPresetLabel(resolution: height, bitrate: bitrate);
|
||||
}
|
||||
|
||||
String _formatBitrate(int kbps) {
|
||||
final mbps = kbps / 1000.0;
|
||||
if (mbps >= 10) return mbps.toStringAsFixed(0);
|
||||
if (mbps == mbps.roundToDouble()) return mbps.toStringAsFixed(0);
|
||||
return mbps.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
/// File-size hint for a quality row, e.g. `3.6 GB (45%)`. Transcode presets
|
||||
/// append the ratio vs. source so the user can compare at a glance; Original
|
||||
/// returns just the raw source size. Returns `null` when inputs are missing.
|
||||
String? qualityPresetSizeEstimate({
|
||||
required TranscodeQualityPreset preset,
|
||||
required int? sourceBitrateKbps,
|
||||
required int? sourceDurationMs,
|
||||
}) {
|
||||
if (sourceDurationMs == null || sourceDurationMs <= 0) return null;
|
||||
|
||||
if (preset.isOriginal) {
|
||||
if (sourceBitrateKbps == null || sourceBitrateKbps <= 0) return null;
|
||||
return ByteFormatter.formatBytes(sourceBitrateKbps * sourceDurationMs ~/ 8);
|
||||
}
|
||||
|
||||
final videoKbps = preset.videoBitrateKbps;
|
||||
if (videoKbps == null) return null;
|
||||
final totalKbps = videoKbps + _audioBitrateEstimateKbps;
|
||||
final size = ByteFormatter.formatBytes(totalKbps * sourceDurationMs ~/ 8);
|
||||
|
||||
if (sourceBitrateKbps != null && sourceBitrateKbps > 0) {
|
||||
final pct = (totalKbps * 100 / sourceBitrateKbps).round();
|
||||
return '$size ($pct%)';
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
/// Quality-preset picker dialog — shares [TranscodeQualityPreset.displayOrder]
|
||||
/// with the in-player sheet. Returns the selected preset, or `null` if dismissed.
|
||||
Future<TranscodeQualityPreset?> showQualityPickerDialog(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
int? sourceBitrateKbps,
|
||||
int? sourceDurationMs,
|
||||
}) {
|
||||
String labelFor(TranscodeQualityPreset p) {
|
||||
final base = qualityPresetLabel(p);
|
||||
final size = qualityPresetSizeEstimate(
|
||||
preset: p,
|
||||
sourceBitrateKbps: sourceBitrateKbps,
|
||||
sourceDurationMs: sourceDurationMs,
|
||||
);
|
||||
return size == null ? base : toBulletedString([base, size]);
|
||||
}
|
||||
|
||||
return showOptionPickerDialog<TranscodeQualityPreset>(
|
||||
context,
|
||||
title: title ?? t.videoControls.qualityColumnHeader,
|
||||
options: TranscodeQualityPreset.displayOrder.map((p) => (icon: null, label: labelFor(p), value: p)).toList(),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../screens/video_player_screen.dart';
|
||||
@@ -52,6 +53,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
int? selectedMediaIndex,
|
||||
TranscodeQualityPreset? selectedQualityPreset,
|
||||
bool usePushReplacement = false,
|
||||
bool isOffline = false,
|
||||
PlexVideoPlaybackData? playbackData,
|
||||
@@ -130,6 +132,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
selectedMediaIndex: mediaIndex,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
isOffline: isOffline,
|
||||
playbackData: effectivePlaybackData,
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../utils/download_utils.dart';
|
||||
import '../utils/quality_preset_labels.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
@@ -283,10 +284,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
// Play Version (for episodes and movies with multiple versions)
|
||||
if ((mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) &&
|
||||
metadata.mediaVersions != null &&
|
||||
metadata.mediaVersions!.length > 1) {
|
||||
// Play Version (for episodes and movies). Always shown — even for
|
||||
// single-version items — so the user can still pick a streaming
|
||||
// quality. The handler skips the version picker if only one version
|
||||
// is present, jumping straight to the quality picker.
|
||||
if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'play_version', icon: Symbols.video_file_rounded, label: t.mediaMenu.playVersion),
|
||||
);
|
||||
@@ -693,18 +695,32 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle play version selection
|
||||
Future<bool> _handlePlayVersion(BuildContext context) async {
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final versions = metadata.mediaVersions!;
|
||||
final versions = metadata.mediaVersions ?? const [];
|
||||
|
||||
final selectedIndex = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
|
||||
|
||||
if (selectedIndex != null && context.mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: metadata, selectedMediaIndex: selectedIndex);
|
||||
return true;
|
||||
int selectedVersionIndex = 0;
|
||||
if (versions.length > 1) {
|
||||
final picked = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
|
||||
if (picked == null || !context.mounted) return false;
|
||||
selectedVersionIndex = picked;
|
||||
}
|
||||
return false;
|
||||
|
||||
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
|
||||
final selectedQuality = await showQualityPickerDialog(
|
||||
context,
|
||||
sourceBitrateKbps: selectedVersion?.bitrate,
|
||||
sourceDurationMs: metadata.duration,
|
||||
);
|
||||
if (selectedQuality == null || !context.mounted) return false;
|
||||
|
||||
await navigateToVideoPlayer(
|
||||
context,
|
||||
metadata: metadata,
|
||||
selectedMediaIndex: selectedVersionIndex,
|
||||
selectedQualityPreset: selectedQuality,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Handle shuffle play using play queues
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/plex_media_info.dart';
|
||||
import '../../../models/plex_media_version.dart';
|
||||
import '../../../models/plex_metadata.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/shader_service.dart';
|
||||
|
||||
@@ -9,6 +11,15 @@ import '../../../services/shader_service.dart';
|
||||
class TrackControlsState {
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final TranscodeQualityPreset selectedQualityPreset;
|
||||
final bool serverSupportsTranscoding;
|
||||
final bool isTranscoding;
|
||||
final List<PlexAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
|
||||
/// Total media duration in milliseconds. Used by the version/quality sheet
|
||||
/// to show estimated file sizes per preset (bitrate × duration).
|
||||
final int? sourceDurationMs;
|
||||
final int boxFitMode;
|
||||
final int audioSyncOffset;
|
||||
final int subtitleSyncOffset;
|
||||
@@ -23,6 +34,8 @@ class TrackControlsState {
|
||||
final VoidCallback? onToggleFullscreen;
|
||||
final VoidCallback? onToggleAlwaysOnTop;
|
||||
final Function(int)? onSwitchVersion;
|
||||
final ValueChanged<TranscodeQualityPreset>? onSwitchQualityPreset;
|
||||
final ValueChanged<int>? onSwitchAudioStreamId;
|
||||
final Function(AudioTrack)? onAudioTrackChanged;
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
|
||||
@@ -47,6 +60,12 @@ class TrackControlsState {
|
||||
const TrackControlsState({
|
||||
this.availableVersions = const [],
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedQualityPreset = TranscodeQualityPreset.original,
|
||||
this.serverSupportsTranscoding = false,
|
||||
this.isTranscoding = false,
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.sourceDurationMs,
|
||||
this.boxFitMode = 0,
|
||||
this.audioSyncOffset = 0,
|
||||
this.subtitleSyncOffset = 0,
|
||||
@@ -61,6 +80,8 @@ class TrackControlsState {
|
||||
this.onToggleFullscreen,
|
||||
this.onToggleAlwaysOnTop,
|
||||
this.onSwitchVersion,
|
||||
this.onSwitchQualityPreset,
|
||||
this.onSwitchAudioStreamId,
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
this.onSecondarySubtitleTrackChanged,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../models/plex_media_info.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
@@ -24,6 +25,15 @@ class TrackSheet extends StatelessWidget {
|
||||
final Function(SubtitleTrack)? onSubtitleTrackChanged;
|
||||
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
|
||||
|
||||
/// When true, the audio column renders the Plex [sourceAudioTracks] list
|
||||
/// and taps are routed to [onSwitchAudioStreamId] instead of using the
|
||||
/// player's in-stream audio selection (the transcoded stream only has one
|
||||
/// audio track).
|
||||
final bool isTranscoding;
|
||||
final List<PlexAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final ValueChanged<int>? onSwitchAudioStreamId;
|
||||
|
||||
const TrackSheet({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -34,6 +44,10 @@ class TrackSheet extends StatelessWidget {
|
||||
this.onAudioTrackChanged,
|
||||
this.onSubtitleTrackChanged,
|
||||
this.onSecondarySubtitleTrackChanged,
|
||||
this.isTranscoding = false,
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.onSwitchAudioStreamId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -43,13 +57,14 @@ class TrackSheet extends StatelessWidget {
|
||||
initialData: player.state.tracks,
|
||||
builder: (context, tracksSnapshot) {
|
||||
final tracks = tracksSnapshot.data;
|
||||
final audioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>(tracks, (t) => t?.audio ?? []);
|
||||
final playerAudioTracks = TrackFilterHelper.extractAndFilterTracks<AudioTrack>(tracks, (t) => t?.audio ?? []);
|
||||
final subtitleTracks = TrackFilterHelper.extractAndFilterTracks<SubtitleTrack>(
|
||||
tracks,
|
||||
(t) => t?.subtitle ?? [],
|
||||
);
|
||||
|
||||
final showAudio = audioTracks.length > 1;
|
||||
final useSourceAudio = isTranscoding && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null;
|
||||
final showAudio = useSourceAudio || playerAudioTracks.length > 1;
|
||||
final showSubtitles = subtitleTracks.isNotEmpty;
|
||||
|
||||
// Determine title/icon based on what's shown
|
||||
@@ -77,21 +92,29 @@ class TrackSheet extends StatelessWidget {
|
||||
|
||||
final supportsSecondary = player.supportsSecondarySubtitles;
|
||||
|
||||
Widget audioColumnFor(TrackSelection sel, bool showHeader) {
|
||||
if (useSourceAudio) {
|
||||
return _SourceAudioColumn(
|
||||
tracks: sourceAudioTracks,
|
||||
selectedStreamId: selectedAudioStreamId,
|
||||
onSelected: onSwitchAudioStreamId!,
|
||||
showHeader: showHeader,
|
||||
);
|
||||
}
|
||||
return _AudioColumn(
|
||||
tracks: playerAudioTracks,
|
||||
selection: sel,
|
||||
player: player,
|
||||
onTrackChanged: onAudioTrackChanged,
|
||||
showHeader: showHeader,
|
||||
);
|
||||
}
|
||||
|
||||
if (showAudio && showSubtitles) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusTraversalGroup(
|
||||
child: _AudioColumn(
|
||||
tracks: audioTracks,
|
||||
selection: selection,
|
||||
player: player,
|
||||
onTrackChanged: onAudioTrackChanged,
|
||||
showHeader: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))),
|
||||
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(
|
||||
child: FocusTraversalGroup(
|
||||
@@ -115,13 +138,7 @@ class TrackSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
if (showAudio) {
|
||||
return _AudioColumn(
|
||||
tracks: audioTracks,
|
||||
selection: selection,
|
||||
player: player,
|
||||
onTrackChanged: onAudioTrackChanged,
|
||||
showHeader: false,
|
||||
);
|
||||
return audioColumnFor(selection, false);
|
||||
}
|
||||
|
||||
return _SubtitleColumn(
|
||||
@@ -145,6 +162,73 @@ class TrackSheet extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceAudioColumn extends StatefulWidget {
|
||||
final List<PlexAudioTrack> tracks;
|
||||
final int? selectedStreamId;
|
||||
final ValueChanged<int> onSelected;
|
||||
final bool showHeader;
|
||||
|
||||
const _SourceAudioColumn({
|
||||
required this.tracks,
|
||||
required this.selectedStreamId,
|
||||
required this.onSelected,
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SourceAudioColumn> createState() => _SourceAudioColumnState();
|
||||
}
|
||||
|
||||
class _SourceAudioColumnState extends State<_SourceAudioColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = widget.selectedStreamId;
|
||||
if (!_didInitialScroll && selectedId != null) {
|
||||
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
if (selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: widget.tracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = widget.tracks[index];
|
||||
final isSelected = track.id == selectedId;
|
||||
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
|
||||
context: context,
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
label: track.label,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.onSelected(track.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioColumn extends StatefulWidget {
|
||||
final List<AudioTrack> tracks;
|
||||
final TrackSelection selection;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../models/plex_media_version.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../../../utils/quality_preset_labels.dart';
|
||||
import '../../../utils/scroll_utils.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Combined sheet for selecting the media [version] (left) and transcode
|
||||
/// [quality] preset (right). The version column is hidden when there is only
|
||||
/// one version so the quality list gets the full width. If the server doesn't
|
||||
/// support video transcoding, only [TranscodeQualityPreset.original] is
|
||||
/// enabled in the quality column.
|
||||
class VersionQualitySheet extends StatelessWidget {
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final TranscodeQualityPreset selectedQualityPreset;
|
||||
final bool serverSupportsTranscoding;
|
||||
final int? sourceDurationMs;
|
||||
final ValueChanged<int> onVersionSelected;
|
||||
final ValueChanged<TranscodeQualityPreset> onQualitySelected;
|
||||
|
||||
const VersionQualitySheet({
|
||||
super.key,
|
||||
required this.availableVersions,
|
||||
required this.selectedMediaIndex,
|
||||
required this.selectedQualityPreset,
|
||||
required this.serverSupportsTranscoding,
|
||||
required this.onVersionSelected,
|
||||
required this.onQualitySelected,
|
||||
this.sourceDurationMs,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showVersions = availableVersions.length > 1;
|
||||
final title = showVersions ? t.videoControls.versionQualityButton : t.videoControls.qualityColumnHeader;
|
||||
|
||||
final qualityColumn = FocusTraversalGroup(
|
||||
child: _QualityColumn(
|
||||
selected: selectedQualityPreset,
|
||||
enabledForTranscoding: serverSupportsTranscoding,
|
||||
sourceBitrateKbps: _sourceBitrateKbps(),
|
||||
sourceDurationMs: sourceDurationMs,
|
||||
onSelected: (preset) {
|
||||
OverlaySheetController.of(context).close();
|
||||
onQualitySelected(preset);
|
||||
},
|
||||
showHeader: showVersions,
|
||||
),
|
||||
);
|
||||
|
||||
final Widget body;
|
||||
if (showVersions) {
|
||||
body = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusTraversalGroup(
|
||||
child: _VersionColumn(
|
||||
versions: availableVersions,
|
||||
selectedIndex: selectedMediaIndex,
|
||||
onSelected: (index) {
|
||||
OverlaySheetController.of(context).close();
|
||||
onVersionSelected(index);
|
||||
},
|
||||
showHeader: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(child: qualityColumn),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
body = qualityColumn;
|
||||
}
|
||||
|
||||
return BaseVideoControlSheet(title: title, icon: Symbols.high_quality_rounded, child: body);
|
||||
}
|
||||
|
||||
int? _sourceBitrateKbps() {
|
||||
if (selectedMediaIndex < 0 || selectedMediaIndex >= availableVersions.length) {
|
||||
return null;
|
||||
}
|
||||
final b = availableVersions[selectedMediaIndex].bitrate;
|
||||
if (b == null || b <= 0) return null;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
class _VersionColumn extends StatefulWidget {
|
||||
final List<PlexMediaVersion> versions;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final bool showHeader;
|
||||
|
||||
const _VersionColumn({
|
||||
required this.versions,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_VersionColumn> createState() => _VersionColumnState();
|
||||
}
|
||||
|
||||
class _VersionColumnState extends State<_VersionColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_didInitialScroll && widget.selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, widget.selectedIndex);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.versionColumnHeader),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: widget.versions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = widget.versions[index];
|
||||
final isSelected = index == widget.selectedIndex;
|
||||
return _SelectionTile(
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
label: version.displayLabel,
|
||||
isSelected: isSelected,
|
||||
onTap: () => widget.onSelected(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityColumn extends StatefulWidget {
|
||||
final TranscodeQualityPreset selected;
|
||||
final bool enabledForTranscoding;
|
||||
final int? sourceBitrateKbps;
|
||||
final int? sourceDurationMs;
|
||||
final ValueChanged<TranscodeQualityPreset> onSelected;
|
||||
final bool showHeader;
|
||||
|
||||
const _QualityColumn({
|
||||
required this.selected,
|
||||
required this.enabledForTranscoding,
|
||||
required this.sourceBitrateKbps,
|
||||
required this.sourceDurationMs,
|
||||
required this.onSelected,
|
||||
required this.showHeader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QualityColumn> createState() => _QualityColumnState();
|
||||
}
|
||||
|
||||
class _QualityColumnState extends State<_QualityColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final presets = TranscodeQualityPreset.displayOrder;
|
||||
final selectedIndex = presets.indexOf(widget.selected);
|
||||
|
||||
if (!_didInitialScroll && selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.qualityColumnHeader),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: presets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preset = presets[index];
|
||||
final isSelected = preset == widget.selected;
|
||||
final isOriginal = preset.isOriginal;
|
||||
final enabled = isOriginal || widget.enabledForTranscoding;
|
||||
|
||||
final trailing = qualityPresetSizeEstimate(
|
||||
preset: preset,
|
||||
sourceBitrateKbps: widget.sourceBitrateKbps,
|
||||
sourceDurationMs: widget.sourceDurationMs,
|
||||
);
|
||||
|
||||
return _SelectionTile(
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
label: qualityPresetLabel(preset),
|
||||
trailingText: trailing,
|
||||
isSelected: isSelected,
|
||||
enabled: enabled,
|
||||
onTap: enabled ? () => widget.onSelected(preset) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionTile extends StatelessWidget {
|
||||
final String label;
|
||||
final String? trailingText;
|
||||
final bool isSelected;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SelectionTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.trailingText,
|
||||
required this.isSelected,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = scheme.primary;
|
||||
final disabledColor = scheme.onSurface.withValues(alpha: 0.38);
|
||||
final titleColor = !enabled ? disabledColor : (isSelected ? primary : null);
|
||||
final trailingColor = !enabled ? disabledColor : scheme.onSurfaceVariant;
|
||||
|
||||
final hasText = trailingText != null && trailingText!.isNotEmpty;
|
||||
final trailing = (hasText || isSelected)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasText) Text(trailingText!, style: TextStyle(color: trailingColor)),
|
||||
if (hasText && isSelected) const SizedBox(width: 8),
|
||||
if (isSelected) AppIcon(Symbols.check_rounded, fill: 1, color: primary),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
|
||||
return FocusableListTile(
|
||||
title: Text(label, style: TextStyle(color: titleColor)),
|
||||
trailing: trailing,
|
||||
enabled: enabled,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColumnHeader extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _ColumnHeader({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../models/plex_media_version.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import '../helpers/track_selection_helper.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting video version
|
||||
class VersionSheet extends StatefulWidget {
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final Function(int) onVersionSelected;
|
||||
|
||||
const VersionSheet({
|
||||
super.key,
|
||||
required this.availableVersions,
|
||||
required this.selectedMediaIndex,
|
||||
required this.onVersionSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VersionSheet> createState() => _VersionSheetState();
|
||||
}
|
||||
|
||||
class _VersionSheetState extends State<VersionSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseVideoControlSheet(
|
||||
title: 'Video Version',
|
||||
icon: Symbols.video_file_rounded,
|
||||
child: ListView.builder(
|
||||
itemCount: widget.availableVersions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = widget.availableVersions[index];
|
||||
final isSelected = index == widget.selectedMediaIndex;
|
||||
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
label: version.displayLabel,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.onVersionSelected(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import '../../models/livetv_capture_buffer.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../services/plex_api_cache.dart';
|
||||
import '../../models/plex_media_info.dart';
|
||||
import '../../models/transcode_quality_preset.dart';
|
||||
import '../../models/plex_media_version.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
@@ -69,6 +70,11 @@ Widget plexVideoControlsBuilder(
|
||||
VoidCallback? onPrevious,
|
||||
List<PlexMediaVersion>? availableVersions,
|
||||
int? selectedMediaIndex,
|
||||
TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original,
|
||||
bool serverSupportsTranscoding = false,
|
||||
bool isTranscoding = false,
|
||||
List<PlexAudioTrack> sourceAudioTracks = const [],
|
||||
int? selectedAudioStreamId,
|
||||
VoidCallback? onTogglePIPMode,
|
||||
int boxFitMode = 0,
|
||||
VoidCallback? onCycleBoxFitMode,
|
||||
@@ -107,6 +113,11 @@ Widget plexVideoControlsBuilder(
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: availableVersions ?? [],
|
||||
selectedMediaIndex: selectedMediaIndex ?? 0,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
serverSupportsTranscoding: serverSupportsTranscoding,
|
||||
isTranscoding: isTranscoding,
|
||||
sourceAudioTracks: sourceAudioTracks,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
boxFitMode: boxFitMode,
|
||||
onTogglePIPMode: onTogglePIPMode,
|
||||
onCycleBoxFitMode: onCycleBoxFitMode,
|
||||
@@ -145,6 +156,11 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final VoidCallback? onPrevious;
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final TranscodeQualityPreset selectedQualityPreset;
|
||||
final bool serverSupportsTranscoding;
|
||||
final bool isTranscoding;
|
||||
final List<PlexAudioTrack> sourceAudioTracks;
|
||||
final int? selectedAudioStreamId;
|
||||
final int boxFitMode;
|
||||
final VoidCallback? onTogglePIPMode;
|
||||
final VoidCallback? onCycleBoxFitMode;
|
||||
@@ -228,6 +244,11 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.onPrevious,
|
||||
this.availableVersions = const [],
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedQualityPreset = TranscodeQualityPreset.original,
|
||||
this.serverSupportsTranscoding = false,
|
||||
this.isTranscoding = false,
|
||||
this.sourceAudioTracks = const [],
|
||||
this.selectedAudioStreamId,
|
||||
this.boxFitMode = 0,
|
||||
this.onTogglePIPMode,
|
||||
this.onCycleBoxFitMode,
|
||||
@@ -1149,6 +1170,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
return TrackControlsState(
|
||||
availableVersions: widget.availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedQualityPreset: widget.selectedQualityPreset,
|
||||
serverSupportsTranscoding: widget.serverSupportsTranscoding,
|
||||
isTranscoding: widget.isTranscoding,
|
||||
sourceAudioTracks: widget.sourceAudioTracks,
|
||||
selectedAudioStreamId: widget.selectedAudioStreamId,
|
||||
sourceDurationMs: widget.metadata.duration,
|
||||
boxFitMode: widget.boxFitMode,
|
||||
audioSyncOffset: _audioSyncOffset,
|
||||
subtitleSyncOffset: _subtitleSyncOffset,
|
||||
@@ -1162,7 +1189,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
onToggleScreenLock: _toggleScreenLock,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onToggleAlwaysOnTop: onToggleAlwaysOnTop,
|
||||
onSwitchVersion: _switchMediaVersion,
|
||||
onSwitchVersion: (i) => _switchVersionAndQuality(newMediaIndex: i),
|
||||
onSwitchQualityPreset: (p) => _switchVersionAndQuality(newPreset: p),
|
||||
onSwitchAudioStreamId: (id) => _switchVersionAndQuality(newAudioStreamId: id),
|
||||
onAudioTrackChanged: widget.onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: _onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
|
||||
@@ -2541,9 +2570,24 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _switchMediaVersion(int newMediaIndex) async {
|
||||
if (newMediaIndex == widget.selectedMediaIndex) {
|
||||
return; // Already using this version
|
||||
/// Switch version, quality preset, or audio stream ID. Any combination may
|
||||
/// change in one invocation; unspecified values retain their current value.
|
||||
/// Always routes through pushReplacement, preserving playback position and
|
||||
/// the transcode session identifiers.
|
||||
Future<void> _switchVersionAndQuality({
|
||||
int? newMediaIndex,
|
||||
TranscodeQualityPreset? newPreset,
|
||||
int? newAudioStreamId,
|
||||
}) async {
|
||||
final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex;
|
||||
final effectivePreset = newPreset ?? widget.selectedQualityPreset;
|
||||
final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId;
|
||||
|
||||
final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex;
|
||||
final isPresetChange = effectivePreset != widget.selectedQualityPreset;
|
||||
final isAudioChange = effectiveAudioStreamId != widget.selectedAudioStreamId;
|
||||
if (!isVersionChange && !isPresetChange && !isAudioChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2553,17 +2597,23 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// Get state reference before async operations
|
||||
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
|
||||
// Save the preference
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final seriesKey = widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey;
|
||||
await settingsService.setMediaVersionPreference(seriesKey, newMediaIndex);
|
||||
if (isVersionChange) {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final seriesKey = widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey;
|
||||
await settingsService.setMediaVersionPreference(seriesKey, effectiveMediaIndex);
|
||||
}
|
||||
|
||||
// Preserve session identifiers across the reload so Plex reuses the
|
||||
// transcode session rather than spinning up a new one.
|
||||
final sessionId = videoPlayerState?.playbackSessionIdentifier;
|
||||
final transcodeSessionId = videoPlayerState?.playbackTranscodeSessionId;
|
||||
|
||||
// Set flag on parent VideoPlayerScreen to skip orientation restoration
|
||||
videoPlayerState?.setReplacingWithVideo();
|
||||
// Dispose the existing player before spinning up the replacement to avoid race conditions
|
||||
await videoPlayerState?.disposePlayerForNavigation();
|
||||
|
||||
// Navigate to new player screen with the selected version
|
||||
// Navigate to new player screen with the updated selection
|
||||
// Use PageRouteBuilder with zero-duration transitions to prevent orientation reset
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
@@ -2571,7 +2621,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
PageRouteBuilder<bool>(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||
metadata: widget.metadata.copyWith(viewOffset: currentPosition.inMilliseconds),
|
||||
selectedMediaIndex: newMediaIndex,
|
||||
selectedMediaIndex: effectiveMediaIndex,
|
||||
selectedQualityPreset: effectivePreset,
|
||||
selectedAudioStreamId: effectiveAudioStreamId,
|
||||
reusedSessionIdentifier: sessionId,
|
||||
reusedTranscodeSessionId: transcodeSessionId,
|
||||
),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
|
||||
@@ -14,10 +14,11 @@ import '../../../i18n/strings.g.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import '../../../models/plex_metadata.dart';
|
||||
import '../models/track_controls_state.dart';
|
||||
import '../../../models/transcode_quality_preset.dart';
|
||||
import '../sheets/chapter_sheet.dart';
|
||||
import '../sheets/queue_sheet.dart';
|
||||
import '../sheets/track_sheet.dart';
|
||||
import '../sheets/version_sheet.dart';
|
||||
import '../sheets/version_quality_sheet.dart';
|
||||
import '../sheets/video_settings_sheet.dart';
|
||||
import '../../../services/shader_service.dart';
|
||||
import '../helpers/track_filter_helper.dart';
|
||||
@@ -66,6 +67,9 @@ class TrackChapterControls extends StatelessWidget {
|
||||
|
||||
List<PlexMediaVersion> get availableVersions => trackControlsState.availableVersions;
|
||||
int get selectedMediaIndex => trackControlsState.selectedMediaIndex;
|
||||
TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset;
|
||||
bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding;
|
||||
ValueChanged<TranscodeQualityPreset>? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset;
|
||||
int get boxFitMode => trackControlsState.boxFitMode;
|
||||
int get audioSyncOffset => trackControlsState.audioSyncOffset;
|
||||
int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset;
|
||||
@@ -267,6 +271,10 @@ class TrackChapterControls extends StatelessWidget {
|
||||
onAudioTrackChanged: onAudioTrackChanged,
|
||||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||||
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
|
||||
isTranscoding: trackControlsState.isTranscoding,
|
||||
sourceAudioTracks: trackControlsState.sourceAudioTracks,
|
||||
selectedAudioStreamId: trackControlsState.selectedAudioStreamId,
|
||||
onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId,
|
||||
),
|
||||
)
|
||||
.whenComplete(() => onStartAutoHide?.call());
|
||||
@@ -330,15 +338,18 @@ class TrackChapterControls extends StatelessWidget {
|
||||
buttonIndex++;
|
||||
}
|
||||
|
||||
// Versions button
|
||||
if (availableVersions.length > 1 && onSwitchVersion != null) {
|
||||
// Version & Quality button
|
||||
final showVersionQuality =
|
||||
(availableVersions.length > 1 || serverSupportsTranscoding) &&
|
||||
(onSwitchVersion != null || onSwitchQualityPreset != null);
|
||||
if (showVersionQuality) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
_buildTrackButton(
|
||||
buttonIndex: currentIndex,
|
||||
icon: Symbols.video_file_rounded,
|
||||
tooltip: t.videoControls.versionsButton,
|
||||
semanticLabel: t.videoControls.versionsButton,
|
||||
icon: Symbols.high_quality_rounded,
|
||||
tooltip: t.videoControls.versionQualityButton,
|
||||
semanticLabel: t.videoControls.versionQualityButton,
|
||||
tracks: tracks,
|
||||
isMobile: isMobile,
|
||||
isDesktop: isDesktop,
|
||||
@@ -346,10 +357,14 @@ class TrackChapterControls extends StatelessWidget {
|
||||
onCancelAutoHide?.call();
|
||||
OverlaySheetController.of(context)
|
||||
.show(
|
||||
builder: (_) => VersionSheet(
|
||||
builder: (_) => VersionQualitySheet(
|
||||
availableVersions: availableVersions,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
onVersionSelected: onSwitchVersion!,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
serverSupportsTranscoding: serverSupportsTranscoding,
|
||||
sourceDurationMs: trackControlsState.sourceDurationMs,
|
||||
onVersionSelected: (i) => onSwitchVersion?.call(i),
|
||||
onQualitySelected: (p) => onSwitchQualityPreset?.call(p),
|
||||
),
|
||||
)
|
||||
.whenComplete(() => onStartAutoHide?.call());
|
||||
@@ -480,7 +495,10 @@ class TrackChapterControls extends StatelessWidget {
|
||||
count++; // Audio & subtitles button always shown
|
||||
if (chapters.isNotEmpty && !hideChaptersAndQueue) count++;
|
||||
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++;
|
||||
if (availableVersions.length > 1 && onSwitchVersion != null) count++;
|
||||
if ((availableVersions.length > 1 || serverSupportsTranscoding) &&
|
||||
(onSwitchVersion != null || onSwitchQualityPreset != null)) {
|
||||
count++;
|
||||
}
|
||||
if (onTogglePIPMode != null) count++;
|
||||
if (onCycleBoxFitMode != null) count++;
|
||||
if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV)
|
||||
|
||||
Reference in New Issue
Block a user