feat(video): add custom zoom controls

close #1159
This commit is contained in:
edde746
2026-05-29 00:06:47 +02:00
parent 47276e1bac
commit 2b7ea5fe02
52 changed files with 1123 additions and 106 deletions
@@ -122,6 +122,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var surfaceContainer: FrameLayout? = null
private var videoAspectContainer: AspectRatioFrameLayout? = null
private var subtitleView: SubtitleView? = null
private var videoZoomScale: Float = 1.0f
private var assHandler: AssHandler? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var lastVideoSize: VideoSize? = null
@@ -1193,18 +1194,26 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val containerHeight = contentView.height
if (containerWidth == 0 || containerHeight == 0) return
// In cover/stretch modes subtitles stay at container size so they never get
// cropped or distorted. In letterbox mode they follow the video rect so they
// anchor to the bottom of the video (matching MPV's default sub positioning).
// In cover/stretch/zoomed-in modes subtitles stay at container size so they
// never get cropped. In letterbox mode they follow the visible video rect.
val isLetterbox = videoAspectContainer?.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT
val (subWidth, subHeight) = if (isLetterbox) {
val videoAspect = (videoWidth * pixelRatio) / videoHeight
val containerAspect = containerWidth.toFloat() / containerHeight
if (videoAspect > containerAspect) {
val (baseWidth, baseHeight) = if (videoAspect > containerAspect) {
containerWidth to (containerWidth / videoAspect).toInt()
} else {
(containerHeight * videoAspect).toInt() to containerHeight
}
if (videoZoomScale < 0.999f) {
val zoomedWidth = ((baseWidth * videoZoomScale).toInt()).coerceAtLeast(1)
val zoomedHeight = ((baseHeight * videoZoomScale).toInt()).coerceAtLeast(1)
zoomedWidth to zoomedHeight
} else if (videoZoomScale > 1.001f) {
containerWidth to containerHeight
} else {
baseWidth to baseHeight
}
} else {
containerWidth to containerHeight
}
@@ -1235,6 +1244,21 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
fun setVideoZoom(scale: Double) {
if (disposing) return
val clamped = scale.coerceIn(0.5, 2.0).toFloat()
activity.runOnUiThread {
videoZoomScale = clamped
videoAspectContainer?.scaleX = clamped
videoAspectContainer?.scaleY = clamped
lastVideoSize?.let { vs ->
if (vs.width > 0 && vs.height > 0) {
updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
}
}
}
}
private fun emitTrackList() {
val player = exoPlayer ?: return
val tracks = player.currentTracks
@@ -148,6 +148,7 @@ class ExoPlayerPlugin :
}
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
"setVideoZoom" -> handleSetVideoZoom(call, result)
"setDvConversionMode" -> handleSetDvConversionMode(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"setMpvProperty" -> handleSetMpvProperty(call, result)
@@ -550,6 +551,22 @@ class ExoPlayerPlugin :
} ?: result.success(null)
}
private fun handleSetVideoZoom(call: MethodCall, result: MethodChannel.Result) {
val scale = call.argument<Number>("scale")?.toDouble()
if (scale == null) {
result.error("INVALID_ARGS", "Missing 'scale'", null)
return
}
if (usingMpvFallback) {
result.success(null)
return
}
activity?.runOnUiThread {
playerCore?.setVideoZoom(scale)
result.success(null)
} ?: result.success(null)
}
private fun handleSetDvConversionMode(call: MethodCall, result: MethodChannel.Result) {
val mode = call.argument<String>("mode")
if (mode == null) {
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Увеличи скоростта",
"speedDecrease": "Намали скоростта",
"speedReset": "Нулирай скоростта",
"zoomIn": "Увеличи мащаба",
"zoomOut": "Намали мащаба",
"zoomReset": "Нулирай мащаба",
"subSeekNext": "Отиди до следващ субтитър",
"subSeekPrev": "Отиди до предишен субтитър",
"shaderToggle": "Превключи шейдъри",
@@ -444,6 +447,7 @@
"pipActive": "Възпроизвеждане в режим картина в картината",
"pipFailed": "Режимът картина в картината не успя да стартира",
"screenshotSaved": "Екранната снимка е запазена",
"zoomPercent": "Мащаб ${percent}%",
"pipErrors": {
"androidVersion": "Изисква Android 8.0 или по-нова версия",
"iosVersion": "Изисква iOS 15.0 или по-нова версия",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Скорост на възпроизвеждане",
"zoom": "Мащаб",
"sleepTimer": "Таймер за заспиване",
"audioSync": "Синхронизация на аудио",
"subtitleSync": "Синхронизация на субтитри",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Øg hastighed",
"speedDecrease": "Sænk hastighed",
"speedReset": "Nulstil hastighed",
"zoomIn": "Zoom ind",
"zoomOut": "Zoom ud",
"zoomReset": "Nulstil zoom",
"subSeekNext": "Søg til næste undertekst",
"subSeekPrev": "Søg til forrige undertekst",
"shaderToggle": "Skift shadere",
@@ -444,6 +447,7 @@
"pipActive": "Afspiller i billede-i-billede",
"pipFailed": "Billede-i-billede kunne ikke starte",
"screenshotSaved": "Skærmbillede gemt",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Kræver Android 8.0 eller nyere",
"iosVersion": "Kræver iOS 15.0 eller nyere",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Afspilningshastighed",
"zoom": "Zoom",
"sleepTimer": "Sove-timer",
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Geschwindigkeit erhöhen",
"speedDecrease": "Geschwindigkeit verringern",
"speedReset": "Geschwindigkeit zurücksetzen",
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
"zoomReset": "Zoom zurücksetzen",
"subSeekNext": "Zum nächsten Untertitel springen",
"subSeekPrev": "Zum vorherigen Untertitel springen",
"shaderToggle": "Shader umschalten",
@@ -444,6 +447,7 @@
"pipActive": "Wiedergabe im Bild-in-Bild-Modus",
"pipFailed": "Bild-in-Bild konnte nicht gestartet werden",
"screenshotSaved": "Screenshot gespeichert",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Erfordert Android 8.0 oder neuer",
"iosVersion": "Erfordert iOS 15.0 oder neuer",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Wiedergabegeschwindigkeit",
"zoom": "Zoom",
"sleepTimer": "Schlaftimer",
"audioSync": "Audio-Synchronisation",
"subtitleSync": "Untertitel-Synchronisation",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Increase Speed",
"speedDecrease": "Decrease Speed",
"speedReset": "Reset Speed",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"zoomReset": "Reset Zoom",
"subSeekNext": "Seek to Next Subtitle",
"subSeekPrev": "Seek to Previous Subtitle",
"shaderToggle": "Toggle Shaders",
@@ -444,6 +447,7 @@
"pipActive": "Playing in Picture-in-Picture",
"pipFailed": "Picture-in-picture failed to start",
"screenshotSaved": "Screenshot saved",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Requires Android 8.0 or newer",
"iosVersion": "Requires iOS 15.0 or newer",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Playback Speed",
"zoom": "Zoom",
"sleepTimer": "Sleep Timer",
"audioSync": "Audio Sync",
"subtitleSync": "Subtitle Sync",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Aumentar Velocidad",
"speedDecrease": "Disminuir Velocidad",
"speedReset": "Restablecer Velocidad",
"zoomIn": "Acercar",
"zoomOut": "Alejar",
"zoomReset": "Restablecer zoom",
"subSeekNext": "Ir al Siguiente Subtítulo",
"subSeekPrev": "Ir al Anterior Subtítulo",
"shaderToggle": "Alternar Shaders",
@@ -444,6 +447,7 @@
"pipActive": "Reproduciendo en Imagen en Imagen",
"pipFailed": "Error al iniciar Imagen en Imagen",
"screenshotSaved": "Captura de pantalla guardada",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Requiere Android 8.0 o más reciente",
"iosVersion": "Requiere iOS 15.0 o más reciente",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Velocidad de reproducción",
"zoom": "Zoom",
"sleepTimer": "Temporizador de apagado",
"audioSync": "Sincronización de audio",
"subtitleSync": "Sincronización de subtítulos",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Augmenter la vitesse",
"speedDecrease": "Réduire la vitesse",
"speedReset": "Réinitialiser la vitesse",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"zoomReset": "Réinitialiser le zoom",
"subSeekNext": "Rechercher le sous-titre suivant",
"subSeekPrev": "Rechercher le sous-titre précédent",
"shaderToggle": "Activer/désactiver les shaders",
@@ -444,6 +447,7 @@
"pipActive": "Lecture en mode image dans l'image",
"pipFailed": "Échec du démarrage du mode image dans l'image",
"screenshotSaved": "Capture d'écran enregistrée",
"zoomPercent": "Zoom ${percent} %",
"pipErrors": {
"androidVersion": "Nécessite Android 8.0 ou plus récent",
"iosVersion": "Nécessite iOS 15.0 ou plus récent",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Vitesse de lecture",
"zoom": "Zoom",
"sleepTimer": "Minuterie de mise en veille",
"audioSync": "Synchronisation audio",
"subtitleSync": "Synchronisation des sous-titres",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Aumenta velocità",
"speedDecrease": "Diminuisci velocità",
"speedReset": "Ripristina velocità",
"zoomIn": "Aumenta zoom",
"zoomOut": "Riduci zoom",
"zoomReset": "Ripristina zoom",
"subSeekNext": "Vai al sottotitolo successivo",
"subSeekPrev": "Vai al sottotitolo precedente",
"shaderToggle": "Attiva/disattiva shader",
@@ -444,6 +447,7 @@
"pipActive": "Riproduzione in Picture-in-Picture",
"pipFailed": "Impossibile avviare la modalità Picture-in-Picture",
"screenshotSaved": "Schermata salvata",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Richiede Android 8.0 o versioni successive",
"iosVersion": "Richiede iOS 15.0 o versioni successive",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Velocità di riproduzione",
"zoom": "Zoom",
"sleepTimer": "Timer di spegnimento",
"audioSync": "Sincronizzazione audio",
"subtitleSync": "Sincronizzazione sottotitoli",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "速度を上げる",
"speedDecrease": "速度を下げる",
"speedReset": "速度をリセット",
"zoomIn": "ズームイン",
"zoomOut": "ズームアウト",
"zoomReset": "ズームをリセット",
"subSeekNext": "次の字幕にシーク",
"subSeekPrev": "前の字幕にシーク",
"shaderToggle": "シェーダー切替",
@@ -444,6 +447,7 @@
"pipActive": "ピクチャーインピクチャーで再生中",
"pipFailed": "ピクチャーインピクチャーの開始に失敗しました",
"screenshotSaved": "スクリーンショットを保存しました",
"zoomPercent": "ズーム ${percent}%",
"pipErrors": {
"androidVersion": "Android 8.0以降が必要です",
"iosVersion": "iOS 15.0以降が必要です",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "再生速度",
"zoom": "ズーム",
"sleepTimer": "スリープタイマー",
"audioSync": "音声同期",
"subtitleSync": "字幕同期",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "속도 높이기",
"speedDecrease": "속도 낮추기",
"speedReset": "속도 초기화",
"zoomIn": "확대",
"zoomOut": "축소",
"zoomReset": "확대/축소 초기화",
"subSeekNext": "다음 자막으로 이동",
"subSeekPrev": "이전 자막으로 이동",
"shaderToggle": "셰이더 전환",
@@ -444,6 +447,7 @@
"pipActive": "화면 속 화면으로 재생 중",
"pipFailed": "화면 속 화면 모드를 시작할 수 없습니다",
"screenshotSaved": "스크린샷 저장됨",
"zoomPercent": "확대/축소 ${percent}%",
"pipErrors": {
"androidVersion": "Android 8.0 이상이 필요합니다",
"iosVersion": "iOS 15.0 이상이 필요합니다",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "재생 속도",
"zoom": "확대/축소",
"sleepTimer": "취침 타이머",
"audioSync": "오디오 동기화",
"subtitleSync": "자막 동기화",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Øk hastighet",
"speedDecrease": "Reduser hastighet",
"speedReset": "Tilbakestill hastighet",
"zoomIn": "Zoom inn",
"zoomOut": "Zoom ut",
"zoomReset": "Tilbakestill zoom",
"subSeekNext": "Spol til neste undertekst",
"subSeekPrev": "Spol til forrige undertekst",
"shaderToggle": "Veksle shadere",
@@ -444,6 +447,7 @@
"pipActive": "Spiller i bilde-i-bilde",
"pipFailed": "Bilde-i-bilde kunne ikke starte",
"screenshotSaved": "Skjermbilde lagret",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Krever Android 8.0 eller nyere",
"iosVersion": "Krever iOS 15.0 eller nyere",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Avspillingshastighet",
"zoom": "Zoom",
"sleepTimer": "Søvntimer",
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Snelheid verhogen",
"speedDecrease": "Snelheid verlagen",
"speedReset": "Snelheid resetten",
"zoomIn": "Inzoomen",
"zoomOut": "Uitzoomen",
"zoomReset": "Zoom resetten",
"subSeekNext": "Naar volgende ondertitel",
"subSeekPrev": "Naar vorige ondertitel",
"shaderToggle": "Shaders aan/uit",
@@ -444,6 +447,7 @@
"pipActive": "Afspelen in beeld-in-beeld",
"pipFailed": "Beeld-in-beeld kon niet worden gestart",
"screenshotSaved": "Schermafbeelding opgeslagen",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Vereist Android 8.0 of nieuwer",
"iosVersion": "Vereist iOS 15.0 of nieuwer",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Afspeelsnelheid",
"zoom": "Zoom",
"sleepTimer": "Slaaptimer",
"audioSync": "Audio synchronisatie",
"subtitleSync": "Ondertitel synchronisatie",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Zwiększ prędkość",
"speedDecrease": "Zmniejsz prędkość",
"speedReset": "Zresetuj prędkość",
"zoomIn": "Powiększ",
"zoomOut": "Pomniejsz",
"zoomReset": "Zresetuj zoom",
"subSeekNext": "Przewiń do następnego napisu",
"subSeekPrev": "Przewiń do poprzedniego napisu",
"shaderToggle": "Przełącz shadery",
@@ -444,6 +447,7 @@
"pipActive": "Odtwarzanie w trybie obraz w obrazie",
"pipFailed": "Nie udało się uruchomić trybu obraz w obrazie",
"screenshotSaved": "Zrzut ekranu zapisany",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Wymaga Androida 8.0 lub nowszego",
"iosVersion": "Wymaga iOS 15.0 lub nowszego",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Prędkość odtwarzania",
"zoom": "Zoom",
"sleepTimer": "Wyłącznik czasowy",
"audioSync": "Synchronizacja audio",
"subtitleSync": "Synchronizacja napisów",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Aumentar Velocidade",
"speedDecrease": "Diminuir Velocidade",
"speedReset": "Redefinir Velocidade",
"zoomIn": "Aumentar zoom",
"zoomOut": "Diminuir zoom",
"zoomReset": "Redefinir zoom",
"subSeekNext": "Ir para Próxima Legenda",
"subSeekPrev": "Ir para Legenda Anterior",
"shaderToggle": "Alternar Shaders",
@@ -444,6 +447,7 @@
"pipActive": "Reproduzindo em Picture-in-Picture",
"pipFailed": "Falha ao iniciar picture-in-picture",
"screenshotSaved": "Captura de tela salva",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Requer Android 8.0 ou superior",
"iosVersion": "Requer iOS 15.0 ou superior",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Velocidade de Reprodução",
"zoom": "Zoom",
"sleepTimer": "Timer de Sono",
"audioSync": "Sincronia de Áudio",
"subtitleSync": "Sincronia de Legendas",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Увеличить скорость",
"speedDecrease": "Уменьшить скорость",
"speedReset": "Сбросить скорость",
"zoomIn": "Увеличить масштаб",
"zoomOut": "Уменьшить масштаб",
"zoomReset": "Сбросить масштаб",
"subSeekNext": "К следующему субтитру",
"subSeekPrev": "К предыдущему субтитру",
"shaderToggle": "Вкл./выкл. шейдеры",
@@ -444,6 +447,7 @@
"pipActive": "Воспроизводится в режиме «картинка в картинке»",
"pipFailed": "Не удалось запустить режим «картинка в картинке»",
"screenshotSaved": "Снимок экрана сохранён",
"zoomPercent": "Масштаб ${percent}%",
"pipErrors": {
"androidVersion": "Требуется Android 8.0 или новее",
"iosVersion": "Требуется iOS 15.0 или новее",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Скорость воспроизведения",
"zoom": "Масштаб",
"sleepTimer": "Таймер сна",
"audioSync": "Синхронизация аудио",
"subtitleSync": "Синхронизация субтитров",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 18864 (1179 per locale)
/// Strings: 18944 (1184 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsBg extends TranslationsVideoControlsEn {
@override String get pipActive => 'Възпроизвеждане в режим картина в картината';
@override String get pipFailed => 'Режимът картина в картината не успя да стартира';
@override String get screenshotSaved => 'Екранната снимка е запазена';
@override String zoomPercent({required Object percent}) => 'Мащаб ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsBg pipErrors = _TranslationsVideoControlsPipErrorsBg._(_root);
@override String get chapters => 'Глави';
@override String get noChaptersAvailable => 'Няма налични глави';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsBg extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Скорост на възпроизвеждане';
@override String get zoom => 'Мащаб';
@override String get sleepTimer => 'Таймер за заспиване';
@override String get audioSync => 'Синхронизация на аудио';
@override String get subtitleSync => 'Синхронизация на субтитри';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsBg extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Увеличи скоростта';
@override String get speedDecrease => 'Намали скоростта';
@override String get speedReset => 'Нулирай скоростта';
@override String get zoomIn => 'Увеличи мащаба';
@override String get zoomOut => 'Намали мащаба';
@override String get zoomReset => 'Нулирай мащаба';
@override String get subSeekNext => 'Отиди до следващ субтитър';
@override String get subSeekPrev => 'Отиди до предишен субтитър';
@override String get shaderToggle => 'Превключи шейдъри';
@@ -2099,6 +2104,9 @@ extension on TranslationsBg {
'hotkeys.actions.speedIncrease' => 'Увеличи скоростта',
'hotkeys.actions.speedDecrease' => 'Намали скоростта',
'hotkeys.actions.speedReset' => 'Нулирай скоростта',
'hotkeys.actions.zoomIn' => 'Увеличи мащаба',
'hotkeys.actions.zoomOut' => 'Намали мащаба',
'hotkeys.actions.zoomReset' => 'Нулирай мащаба',
'hotkeys.actions.subSeekNext' => 'Отиди до следващ субтитър',
'hotkeys.actions.subSeekPrev' => 'Отиди до предишен субтитър',
'hotkeys.actions.shaderToggle' => 'Превключи шейдъри',
@@ -2224,6 +2232,7 @@ extension on TranslationsBg {
'videoControls.pipActive' => 'Възпроизвеждане в режим картина в картината',
'videoControls.pipFailed' => 'Режимът картина в картината не успя да стартира',
'videoControls.screenshotSaved' => 'Екранната снимка е запазена',
'videoControls.zoomPercent' => ({required Object percent}) => 'Мащаб ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Изисква Android 8.0 или по-нова версия',
'videoControls.pipErrors.iosVersion' => 'Изисква iOS 15.0 или по-нова версия',
'videoControls.pipErrors.permissionDisabled' => 'Режимът картина в картината е изключен. Включете го в системните настройки.',
@@ -2316,12 +2325,12 @@ extension on TranslationsBg {
'profiles.addPlezyProfile' => 'Добави Plezy профил',
'profiles.switchingProfile' => 'Смяна на профил…',
'profiles.deleteThisProfileTitle' => 'Да се изтрие ли този профил?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Премахване на ${displayName}. Връзките не се засягат.',
'profiles.active' => 'Активен',
'profiles.manage' => 'Управление',
'profiles.delete' => 'Изтрий',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Изход',
'profiles.signOutPlexTitle' => 'Изход от Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Премахване на ${displayName} и всички Plex Home потребители? Можете да влезете отново по всяко време.',
@@ -2801,6 +2810,7 @@ extension on TranslationsBg {
'companionRemote.remote.audio' => 'Аудио',
'companionRemote.remote.searchHint' => 'Търсене на настолен компютър...',
'videoSettings.playbackSpeed' => 'Скорост на възпроизвеждане',
'videoSettings.zoom' => 'Мащаб',
'videoSettings.sleepTimer' => 'Таймер за заспиване',
'videoSettings.audioSync' => 'Синхронизация на аудио',
'videoSettings.subtitleSync' => 'Синхронизация на субтитри',
@@ -2829,13 +2839,13 @@ extension on TranslationsBg {
'metadataEdit.basicInfo' => 'Основна информация',
'metadataEdit.artwork' => 'Обложка',
'metadataEdit.advancedSettings' => 'Разширени настройки',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Заглавие',
'metadataEdit.sortTitle' => 'Заглавие за сортиране',
'metadataEdit.originalTitle' => 'Оригинално заглавие',
'metadataEdit.releaseDate' => 'Дата на излизане',
'metadataEdit.contentRating' => 'Възрастов рейтинг',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Студио',
'metadataEdit.tagline' => 'Слоган',
'metadataEdit.summary' => 'Резюме',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsDa extends TranslationsVideoControlsEn {
@override String get pipActive => 'Afspiller i billede-i-billede';
@override String get pipFailed => 'Billede-i-billede kunne ikke starte';
@override String get screenshotSaved => 'Skærmbillede gemt';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsDa pipErrors = _TranslationsVideoControlsPipErrorsDa._(_root);
@override String get chapters => 'Kapitler';
@override String get noChaptersAvailable => 'Ingen kapitler tilgængelige';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsDa extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Afspilningshastighed';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Sove-timer';
@override String get audioSync => 'Lydsynkronisering';
@override String get subtitleSync => 'Undertekstsynkronisering';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsDa extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Øg hastighed';
@override String get speedDecrease => 'Sænk hastighed';
@override String get speedReset => 'Nulstil hastighed';
@override String get zoomIn => 'Zoom ind';
@override String get zoomOut => 'Zoom ud';
@override String get zoomReset => 'Nulstil zoom';
@override String get subSeekNext => 'Søg til næste undertekst';
@override String get subSeekPrev => 'Søg til forrige undertekst';
@override String get shaderToggle => 'Skift shadere';
@@ -2099,6 +2104,9 @@ extension on TranslationsDa {
'hotkeys.actions.speedIncrease' => 'Øg hastighed',
'hotkeys.actions.speedDecrease' => 'Sænk hastighed',
'hotkeys.actions.speedReset' => 'Nulstil hastighed',
'hotkeys.actions.zoomIn' => 'Zoom ind',
'hotkeys.actions.zoomOut' => 'Zoom ud',
'hotkeys.actions.zoomReset' => 'Nulstil zoom',
'hotkeys.actions.subSeekNext' => 'Søg til næste undertekst',
'hotkeys.actions.subSeekPrev' => 'Søg til forrige undertekst',
'hotkeys.actions.shaderToggle' => 'Skift shadere',
@@ -2224,6 +2232,7 @@ extension on TranslationsDa {
'videoControls.pipActive' => 'Afspiller i billede-i-billede',
'videoControls.pipFailed' => 'Billede-i-billede kunne ikke starte',
'videoControls.screenshotSaved' => 'Skærmbillede gemt',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Kræver Android 8.0 eller nyere',
'videoControls.pipErrors.iosVersion' => 'Kræver iOS 15.0 eller nyere',
'videoControls.pipErrors.permissionDisabled' => 'Billede-i-billede er deaktiveret. Slå det til i systemindstillinger.',
@@ -2316,12 +2325,12 @@ extension on TranslationsDa {
'profiles.addPlezyProfile' => 'Tilføj Plezy-profil',
'profiles.switchingProfile' => 'Skifter profil…',
'profiles.deleteThisProfileTitle' => 'Slet denne profil?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Fjern ${displayName}. Forbindelser påvirkes ikke.',
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Administrer',
'profiles.delete' => 'Slet',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Log ud',
'profiles.signOutPlexTitle' => 'Log ud af Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjern ${displayName} og alle Plex Home-brugere? Log ind igen når som helst.',
@@ -2801,6 +2810,7 @@ extension on TranslationsDa {
'companionRemote.remote.audio' => 'Lyd',
'companionRemote.remote.searchHint' => 'Søg på desktop...',
'videoSettings.playbackSpeed' => 'Afspilningshastighed',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Sove-timer',
'videoSettings.audioSync' => 'Lydsynkronisering',
'videoSettings.subtitleSync' => 'Undertekstsynkronisering',
@@ -2829,13 +2839,13 @@ extension on TranslationsDa {
'metadataEdit.basicInfo' => 'Grundlæggende info',
'metadataEdit.artwork' => 'Grafik',
'metadataEdit.advancedSettings' => 'Avancerede indstillinger',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Titel',
'metadataEdit.sortTitle' => 'Sorteringstitel',
'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Udgivelsesdato',
'metadataEdit.contentRating' => 'Aldersgrænse',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studie',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Resumé',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsDe extends TranslationsVideoControlsEn {
@override String get pipActive => 'Wiedergabe im Bild-in-Bild-Modus';
@override String get pipFailed => 'Bild-in-Bild konnte nicht gestartet werden';
@override String get screenshotSaved => 'Screenshot gespeichert';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsDe pipErrors = _TranslationsVideoControlsPipErrorsDe._(_root);
@override String get chapters => 'Kapitel';
@override String get noChaptersAvailable => 'Keine Kapitel verfügbar';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsDe extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Wiedergabegeschwindigkeit';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Schlaftimer';
@override String get audioSync => 'Audio-Synchronisation';
@override String get subtitleSync => 'Untertitel-Synchronisation';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsDe extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Geschwindigkeit erhöhen';
@override String get speedDecrease => 'Geschwindigkeit verringern';
@override String get speedReset => 'Geschwindigkeit zurücksetzen';
@override String get zoomIn => 'Vergrößern';
@override String get zoomOut => 'Verkleinern';
@override String get zoomReset => 'Zoom zurücksetzen';
@override String get subSeekNext => 'Zum nächsten Untertitel springen';
@override String get subSeekPrev => 'Zum vorherigen Untertitel springen';
@override String get shaderToggle => 'Shader umschalten';
@@ -2099,6 +2104,9 @@ extension on TranslationsDe {
'hotkeys.actions.speedIncrease' => 'Geschwindigkeit erhöhen',
'hotkeys.actions.speedDecrease' => 'Geschwindigkeit verringern',
'hotkeys.actions.speedReset' => 'Geschwindigkeit zurücksetzen',
'hotkeys.actions.zoomIn' => 'Vergrößern',
'hotkeys.actions.zoomOut' => 'Verkleinern',
'hotkeys.actions.zoomReset' => 'Zoom zurücksetzen',
'hotkeys.actions.subSeekNext' => 'Zum nächsten Untertitel springen',
'hotkeys.actions.subSeekPrev' => 'Zum vorherigen Untertitel springen',
'hotkeys.actions.shaderToggle' => 'Shader umschalten',
@@ -2224,6 +2232,7 @@ extension on TranslationsDe {
'videoControls.pipActive' => 'Wiedergabe im Bild-in-Bild-Modus',
'videoControls.pipFailed' => 'Bild-in-Bild konnte nicht gestartet werden',
'videoControls.screenshotSaved' => 'Screenshot gespeichert',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Erfordert Android 8.0 oder neuer',
'videoControls.pipErrors.iosVersion' => 'Erfordert iOS 15.0 oder neuer',
'videoControls.pipErrors.permissionDisabled' => 'Bild-in-Bild ist deaktiviert. Aktiviere es in den Systemeinstellungen.',
@@ -2316,12 +2325,12 @@ extension on TranslationsDe {
'profiles.addPlezyProfile' => 'Plezy-Profil hinzufügen',
'profiles.switchingProfile' => 'Profil wird gewechselt…',
'profiles.deleteThisProfileTitle' => 'Dieses Profil löschen?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} entfernen. Verbindungen bleiben unberührt.',
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Verwalten',
'profiles.delete' => 'Löschen',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Abmelden',
'profiles.signOutPlexTitle' => 'Von Plex abmelden?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer entfernen? Du kannst dich jederzeit wieder anmelden.',
@@ -2801,6 +2810,7 @@ extension on TranslationsDe {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Auf Desktop suchen...',
'videoSettings.playbackSpeed' => 'Wiedergabegeschwindigkeit',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Schlaftimer',
'videoSettings.audioSync' => 'Audio-Synchronisation',
'videoSettings.subtitleSync' => 'Untertitel-Synchronisation',
@@ -2829,13 +2839,13 @@ extension on TranslationsDe {
'metadataEdit.basicInfo' => 'Grundlegende Infos',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Erweiterte Einstellungen',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Titel',
'metadataEdit.sortTitle' => 'Sortiertitel',
'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Erscheinungsdatum',
'metadataEdit.contentRating' => 'Altersfreigabe',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Zusammenfassung',
+24 -4
View File
@@ -1396,6 +1396,9 @@ class TranslationsVideoControlsEn {
/// en: 'Screenshot saved'
String get screenshotSaved => 'Screenshot saved';
/// en: 'Zoom ${percent}%'
String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
late final TranslationsVideoControlsPipErrorsEn pipErrors = TranslationsVideoControlsPipErrorsEn.internal(_root);
/// en: 'Chapters'
@@ -3060,6 +3063,9 @@ class TranslationsVideoSettingsEn {
/// en: 'Playback Speed'
String get playbackSpeed => 'Playback Speed';
/// en: 'Zoom'
String get zoom => 'Zoom';
/// en: 'Sleep Timer'
String get sleepTimer => 'Sleep Timer';
@@ -3680,6 +3686,15 @@ class TranslationsHotkeysActionsEn {
/// en: 'Reset Speed'
String get speedReset => 'Reset Speed';
/// en: 'Zoom In'
String get zoomIn => 'Zoom In';
/// en: 'Zoom Out'
String get zoomOut => 'Zoom Out';
/// en: 'Reset Zoom'
String get zoomReset => 'Reset Zoom';
/// en: 'Seek to Next Subtitle'
String get subSeekNext => 'Seek to Next Subtitle';
@@ -4463,6 +4478,9 @@ extension on Translations {
'hotkeys.actions.speedIncrease' => 'Increase Speed',
'hotkeys.actions.speedDecrease' => 'Decrease Speed',
'hotkeys.actions.speedReset' => 'Reset Speed',
'hotkeys.actions.zoomIn' => 'Zoom In',
'hotkeys.actions.zoomOut' => 'Zoom Out',
'hotkeys.actions.zoomReset' => 'Reset Zoom',
'hotkeys.actions.subSeekNext' => 'Seek to Next Subtitle',
'hotkeys.actions.subSeekPrev' => 'Seek to Previous Subtitle',
'hotkeys.actions.shaderToggle' => 'Toggle Shaders',
@@ -4588,6 +4606,7 @@ extension on Translations {
'videoControls.pipActive' => 'Playing in Picture-in-Picture',
'videoControls.pipFailed' => 'Picture-in-picture failed to start',
'videoControls.screenshotSaved' => 'Screenshot saved',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Requires Android 8.0 or newer',
'videoControls.pipErrors.iosVersion' => 'Requires iOS 15.0 or newer',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture is disabled. Enable it in system settings.',
@@ -4680,12 +4699,12 @@ extension on Translations {
'profiles.addPlezyProfile' => 'Add Plezy profile',
'profiles.switchingProfile' => 'Switching profile…',
'profiles.deleteThisProfileTitle' => 'Delete this profile?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Remove ${displayName}. Connections aren\'t affected.',
'profiles.active' => 'Active',
'profiles.manage' => 'Manage',
'profiles.delete' => 'Delete',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Sign out',
'profiles.signOutPlexTitle' => 'Sign out of Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remove ${displayName} and all Plex Home users? Sign back in anytime.',
@@ -5165,6 +5184,7 @@ extension on Translations {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Search on desktop...',
'videoSettings.playbackSpeed' => 'Playback Speed',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Sleep Timer',
'videoSettings.audioSync' => 'Audio Sync',
'videoSettings.subtitleSync' => 'Subtitle Sync',
@@ -5193,13 +5213,13 @@ extension on Translations {
'metadataEdit.basicInfo' => 'Basic Info',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Advanced Settings',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Title',
'metadataEdit.sortTitle' => 'Sort Title',
'metadataEdit.originalTitle' => 'Original Title',
'metadataEdit.releaseDate' => 'Release Date',
'metadataEdit.contentRating' => 'Content Rating',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Summary',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsEs extends TranslationsVideoControlsEn {
@override String get pipActive => 'Reproduciendo en Imagen en Imagen';
@override String get pipFailed => 'Error al iniciar Imagen en Imagen';
@override String get screenshotSaved => 'Captura de pantalla guardada';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsEs pipErrors = _TranslationsVideoControlsPipErrorsEs._(_root);
@override String get chapters => 'Capítulos';
@override String get noChaptersAvailable => 'No hay capítulos disponibles';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsEs extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Velocidad de reproducción';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Temporizador de apagado';
@override String get audioSync => 'Sincronización de audio';
@override String get subtitleSync => 'Sincronización de subtítulos';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsEs extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Aumentar Velocidad';
@override String get speedDecrease => 'Disminuir Velocidad';
@override String get speedReset => 'Restablecer Velocidad';
@override String get zoomIn => 'Acercar';
@override String get zoomOut => 'Alejar';
@override String get zoomReset => 'Restablecer zoom';
@override String get subSeekNext => 'Ir al Siguiente Subtítulo';
@override String get subSeekPrev => 'Ir al Anterior Subtítulo';
@override String get shaderToggle => 'Alternar Shaders';
@@ -2099,6 +2104,9 @@ extension on TranslationsEs {
'hotkeys.actions.speedIncrease' => 'Aumentar Velocidad',
'hotkeys.actions.speedDecrease' => 'Disminuir Velocidad',
'hotkeys.actions.speedReset' => 'Restablecer Velocidad',
'hotkeys.actions.zoomIn' => 'Acercar',
'hotkeys.actions.zoomOut' => 'Alejar',
'hotkeys.actions.zoomReset' => 'Restablecer zoom',
'hotkeys.actions.subSeekNext' => 'Ir al Siguiente Subtítulo',
'hotkeys.actions.subSeekPrev' => 'Ir al Anterior Subtítulo',
'hotkeys.actions.shaderToggle' => 'Alternar Shaders',
@@ -2224,6 +2232,7 @@ extension on TranslationsEs {
'videoControls.pipActive' => 'Reproduciendo en Imagen en Imagen',
'videoControls.pipFailed' => 'Error al iniciar Imagen en Imagen',
'videoControls.screenshotSaved' => 'Captura de pantalla guardada',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Requiere Android 8.0 o más reciente',
'videoControls.pipErrors.iosVersion' => 'Requiere iOS 15.0 o más reciente',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture está desactivado. Actívalo en los ajustes del sistema.',
@@ -2316,12 +2325,12 @@ extension on TranslationsEs {
'profiles.addPlezyProfile' => 'Añadir perfil de Plezy',
'profiles.switchingProfile' => 'Cambiando de perfil…',
'profiles.deleteThisProfileTitle' => '¿Eliminar este perfil?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Eliminar ${displayName}. Las conexiones no se verán afectadas.',
'profiles.active' => 'Activo',
'profiles.manage' => 'Administrar',
'profiles.delete' => 'Eliminar',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Cerrar sesión',
'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.',
@@ -2801,6 +2810,7 @@ extension on TranslationsEs {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Buscar en escritorio...',
'videoSettings.playbackSpeed' => 'Velocidad de reproducción',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Temporizador de apagado',
'videoSettings.audioSync' => 'Sincronización de audio',
'videoSettings.subtitleSync' => 'Sincronización de subtítulos',
@@ -2829,13 +2839,13 @@ extension on TranslationsEs {
'metadataEdit.basicInfo' => 'Información básica',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Ajustes avanzados',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Título',
'metadataEdit.sortTitle' => 'Título de ordenación',
'metadataEdit.originalTitle' => 'Título original',
'metadataEdit.releaseDate' => 'Fecha de estreno',
'metadataEdit.contentRating' => 'Clasificación de contenido',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Estudio',
'metadataEdit.tagline' => 'Eslogan',
'metadataEdit.summary' => 'Resumen',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsFr extends TranslationsVideoControlsEn {
@override String get pipActive => 'Lecture en mode image dans l\'image';
@override String get pipFailed => 'Échec du démarrage du mode image dans l\'image';
@override String get screenshotSaved => 'Capture d\'écran enregistrée';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent} %';
@override late final _TranslationsVideoControlsPipErrorsFr pipErrors = _TranslationsVideoControlsPipErrorsFr._(_root);
@override String get chapters => 'Chapitres';
@override String get noChaptersAvailable => 'Aucun chapitre disponible';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsFr extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Vitesse de lecture';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Minuterie de mise en veille';
@override String get audioSync => 'Synchronisation audio';
@override String get subtitleSync => 'Synchronisation des sous-titres';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsFr extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Augmenter la vitesse';
@override String get speedDecrease => 'Réduire la vitesse';
@override String get speedReset => 'Réinitialiser la vitesse';
@override String get zoomIn => 'Zoom avant';
@override String get zoomOut => 'Zoom arrière';
@override String get zoomReset => 'Réinitialiser le zoom';
@override String get subSeekNext => 'Rechercher le sous-titre suivant';
@override String get subSeekPrev => 'Rechercher le sous-titre précédent';
@override String get shaderToggle => 'Activer/désactiver les shaders';
@@ -2099,6 +2104,9 @@ extension on TranslationsFr {
'hotkeys.actions.speedIncrease' => 'Augmenter la vitesse',
'hotkeys.actions.speedDecrease' => 'Réduire la vitesse',
'hotkeys.actions.speedReset' => 'Réinitialiser la vitesse',
'hotkeys.actions.zoomIn' => 'Zoom avant',
'hotkeys.actions.zoomOut' => 'Zoom arrière',
'hotkeys.actions.zoomReset' => 'Réinitialiser le zoom',
'hotkeys.actions.subSeekNext' => 'Rechercher le sous-titre suivant',
'hotkeys.actions.subSeekPrev' => 'Rechercher le sous-titre précédent',
'hotkeys.actions.shaderToggle' => 'Activer/désactiver les shaders',
@@ -2224,6 +2232,7 @@ extension on TranslationsFr {
'videoControls.pipActive' => 'Lecture en mode image dans l\'image',
'videoControls.pipFailed' => 'Échec du démarrage du mode image dans l\'image',
'videoControls.screenshotSaved' => 'Capture d\'écran enregistrée',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent} %',
'videoControls.pipErrors.androidVersion' => 'Nécessite Android 8.0 ou plus récent',
'videoControls.pipErrors.iosVersion' => 'Nécessite iOS 15.0 ou plus récent',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture est désactivé. Activez-le dans les paramètres système.',
@@ -2316,12 +2325,12 @@ extension on TranslationsFr {
'profiles.addPlezyProfile' => 'Ajouter un profil Plezy',
'profiles.switchingProfile' => 'Changement de profil…',
'profiles.deleteThisProfileTitle' => 'Supprimer ce profil ?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Supprimer ${displayName}. Les connexions ne sont pas affectées.',
'profiles.active' => 'Actif',
'profiles.manage' => 'Gérer',
'profiles.delete' => 'Supprimer',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Se déconnecter',
'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et tous les utilisateurs Plex Home ? Reconnexion possible à tout moment.',
@@ -2801,6 +2810,7 @@ extension on TranslationsFr {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Rechercher sur le bureau...',
'videoSettings.playbackSpeed' => 'Vitesse de lecture',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Minuterie de mise en veille',
'videoSettings.audioSync' => 'Synchronisation audio',
'videoSettings.subtitleSync' => 'Synchronisation des sous-titres',
@@ -2829,13 +2839,13 @@ extension on TranslationsFr {
'metadataEdit.basicInfo' => 'Informations de base',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Paramètres avancés',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Titre',
'metadataEdit.sortTitle' => 'Titre de tri',
'metadataEdit.originalTitle' => 'Titre original',
'metadataEdit.releaseDate' => 'Date de sortie',
'metadataEdit.contentRating' => 'Classification',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slogan',
'metadataEdit.summary' => 'Résumé',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsIt extends TranslationsVideoControlsEn {
@override String get pipActive => 'Riproduzione in Picture-in-Picture';
@override String get pipFailed => 'Impossibile avviare la modalità Picture-in-Picture';
@override String get screenshotSaved => 'Schermata salvata';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsIt pipErrors = _TranslationsVideoControlsPipErrorsIt._(_root);
@override String get chapters => 'Capitoli';
@override String get noChaptersAvailable => 'Nessun capitolo disponibile';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsIt extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Velocità di riproduzione';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Timer di spegnimento';
@override String get audioSync => 'Sincronizzazione audio';
@override String get subtitleSync => 'Sincronizzazione sottotitoli';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsIt extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Aumenta velocità';
@override String get speedDecrease => 'Diminuisci velocità';
@override String get speedReset => 'Ripristina velocità';
@override String get zoomIn => 'Aumenta zoom';
@override String get zoomOut => 'Riduci zoom';
@override String get zoomReset => 'Ripristina zoom';
@override String get subSeekNext => 'Vai al sottotitolo successivo';
@override String get subSeekPrev => 'Vai al sottotitolo precedente';
@override String get shaderToggle => 'Attiva/disattiva shader';
@@ -2099,6 +2104,9 @@ extension on TranslationsIt {
'hotkeys.actions.speedIncrease' => 'Aumenta velocità',
'hotkeys.actions.speedDecrease' => 'Diminuisci velocità',
'hotkeys.actions.speedReset' => 'Ripristina velocità',
'hotkeys.actions.zoomIn' => 'Aumenta zoom',
'hotkeys.actions.zoomOut' => 'Riduci zoom',
'hotkeys.actions.zoomReset' => 'Ripristina zoom',
'hotkeys.actions.subSeekNext' => 'Vai al sottotitolo successivo',
'hotkeys.actions.subSeekPrev' => 'Vai al sottotitolo precedente',
'hotkeys.actions.shaderToggle' => 'Attiva/disattiva shader',
@@ -2224,6 +2232,7 @@ extension on TranslationsIt {
'videoControls.pipActive' => 'Riproduzione in Picture-in-Picture',
'videoControls.pipFailed' => 'Impossibile avviare la modalità Picture-in-Picture',
'videoControls.screenshotSaved' => 'Schermata salvata',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Richiede Android 8.0 o versioni successive',
'videoControls.pipErrors.iosVersion' => 'Richiede iOS 15.0 o versioni successive',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture è disattivato. Attivalo nelle impostazioni di sistema.',
@@ -2316,12 +2325,12 @@ extension on TranslationsIt {
'profiles.addPlezyProfile' => 'Aggiungi profilo Plezy',
'profiles.switchingProfile' => 'Cambio profilo…',
'profiles.deleteThisProfileTitle' => 'Eliminare questo profilo?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Rimuovi ${displayName}. Le connessioni non sono interessate.',
'profiles.active' => 'Attivo',
'profiles.manage' => 'Gestisci',
'profiles.delete' => 'Elimina',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Esci',
'profiles.signOutPlexTitle' => 'Uscire da Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.',
@@ -2801,6 +2810,7 @@ extension on TranslationsIt {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Cerca sul desktop...',
'videoSettings.playbackSpeed' => 'Velocità di riproduzione',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Timer di spegnimento',
'videoSettings.audioSync' => 'Sincronizzazione audio',
'videoSettings.subtitleSync' => 'Sincronizzazione sottotitoli',
@@ -2829,13 +2839,13 @@ extension on TranslationsIt {
'metadataEdit.basicInfo' => 'Informazioni di base',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Impostazioni avanzate',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Titolo',
'metadataEdit.sortTitle' => 'Titolo di ordinamento',
'metadataEdit.originalTitle' => 'Titolo originale',
'metadataEdit.releaseDate' => 'Data di uscita',
'metadataEdit.contentRating' => 'Classificazione',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Trama',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsJa extends TranslationsVideoControlsEn {
@override String get pipActive => 'ピクチャーインピクチャーで再生中';
@override String get pipFailed => 'ピクチャーインピクチャーの開始に失敗しました';
@override String get screenshotSaved => 'スクリーンショットを保存しました';
@override String zoomPercent({required Object percent}) => 'ズーム ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsJa pipErrors = _TranslationsVideoControlsPipErrorsJa._(_root);
@override String get chapters => 'チャプター';
@override String get noChaptersAvailable => 'チャプターがありません';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsJa extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => '再生速度';
@override String get zoom => 'ズーム';
@override String get sleepTimer => 'スリープタイマー';
@override String get audioSync => '音声同期';
@override String get subtitleSync => '字幕同期';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsJa extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => '速度を上げる';
@override String get speedDecrease => '速度を下げる';
@override String get speedReset => '速度をリセット';
@override String get zoomIn => 'ズームイン';
@override String get zoomOut => 'ズームアウト';
@override String get zoomReset => 'ズームをリセット';
@override String get subSeekNext => '次の字幕にシーク';
@override String get subSeekPrev => '前の字幕にシーク';
@override String get shaderToggle => 'シェーダー切替';
@@ -2099,6 +2104,9 @@ extension on TranslationsJa {
'hotkeys.actions.speedIncrease' => '速度を上げる',
'hotkeys.actions.speedDecrease' => '速度を下げる',
'hotkeys.actions.speedReset' => '速度をリセット',
'hotkeys.actions.zoomIn' => 'ズームイン',
'hotkeys.actions.zoomOut' => 'ズームアウト',
'hotkeys.actions.zoomReset' => 'ズームをリセット',
'hotkeys.actions.subSeekNext' => '次の字幕にシーク',
'hotkeys.actions.subSeekPrev' => '前の字幕にシーク',
'hotkeys.actions.shaderToggle' => 'シェーダー切替',
@@ -2224,6 +2232,7 @@ extension on TranslationsJa {
'videoControls.pipActive' => 'ピクチャーインピクチャーで再生中',
'videoControls.pipFailed' => 'ピクチャーインピクチャーの開始に失敗しました',
'videoControls.screenshotSaved' => 'スクリーンショットを保存しました',
'videoControls.zoomPercent' => ({required Object percent}) => 'ズーム ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Android 8.0以降が必要です',
'videoControls.pipErrors.iosVersion' => 'iOS 15.0以降が必要です',
'videoControls.pipErrors.permissionDisabled' => 'ピクチャーインピクチャーが無効です。システム設定で有効にしてください。',
@@ -2316,12 +2325,12 @@ extension on TranslationsJa {
'profiles.addPlezyProfile' => 'Plezyプロファイルを追加',
'profiles.switchingProfile' => 'プロファイルを切り替え中…',
'profiles.deleteThisProfileTitle' => 'このプロファイルを削除しますか?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName}を削除します。接続には影響しません。',
'profiles.active' => 'アクティブ',
'profiles.manage' => '管理',
'profiles.delete' => '削除',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'サインアウト',
'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。',
@@ -2801,6 +2810,7 @@ extension on TranslationsJa {
'companionRemote.remote.audio' => '音声',
'companionRemote.remote.searchHint' => 'デスクトップで検索...',
'videoSettings.playbackSpeed' => '再生速度',
'videoSettings.zoom' => 'ズーム',
'videoSettings.sleepTimer' => 'スリープタイマー',
'videoSettings.audioSync' => '音声同期',
'videoSettings.subtitleSync' => '字幕同期',
@@ -2829,13 +2839,13 @@ extension on TranslationsJa {
'metadataEdit.basicInfo' => '基本情報',
'metadataEdit.artwork' => 'アートワーク',
'metadataEdit.advancedSettings' => '詳細設定',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'タイトル',
'metadataEdit.sortTitle' => 'ソートタイトル',
'metadataEdit.originalTitle' => '原題',
'metadataEdit.releaseDate' => '公開日',
'metadataEdit.contentRating' => 'コンテンツレーティング',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'スタジオ',
'metadataEdit.tagline' => 'タグライン',
'metadataEdit.summary' => 'あらすじ',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsKo extends TranslationsVideoControlsEn {
@override String get pipActive => '화면 속 화면으로 재생 중';
@override String get pipFailed => '화면 속 화면 모드를 시작할 수 없습니다';
@override String get screenshotSaved => '스크린샷 저장됨';
@override String zoomPercent({required Object percent}) => '확대/축소 ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsKo pipErrors = _TranslationsVideoControlsPipErrorsKo._(_root);
@override String get chapters => '챕터';
@override String get noChaptersAvailable => '사용 가능한 챕터가 없습니다';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsKo extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => '재생 속도';
@override String get zoom => '확대/축소';
@override String get sleepTimer => '취침 타이머';
@override String get audioSync => '오디오 동기화';
@override String get subtitleSync => '자막 동기화';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsKo extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => '속도 높이기';
@override String get speedDecrease => '속도 낮추기';
@override String get speedReset => '속도 초기화';
@override String get zoomIn => '확대';
@override String get zoomOut => '축소';
@override String get zoomReset => '확대/축소 초기화';
@override String get subSeekNext => '다음 자막으로 이동';
@override String get subSeekPrev => '이전 자막으로 이동';
@override String get shaderToggle => '셰이더 전환';
@@ -2099,6 +2104,9 @@ extension on TranslationsKo {
'hotkeys.actions.speedIncrease' => '속도 높이기',
'hotkeys.actions.speedDecrease' => '속도 낮추기',
'hotkeys.actions.speedReset' => '속도 초기화',
'hotkeys.actions.zoomIn' => '확대',
'hotkeys.actions.zoomOut' => '축소',
'hotkeys.actions.zoomReset' => '확대/축소 초기화',
'hotkeys.actions.subSeekNext' => '다음 자막으로 이동',
'hotkeys.actions.subSeekPrev' => '이전 자막으로 이동',
'hotkeys.actions.shaderToggle' => '셰이더 전환',
@@ -2224,6 +2232,7 @@ extension on TranslationsKo {
'videoControls.pipActive' => '화면 속 화면으로 재생 중',
'videoControls.pipFailed' => '화면 속 화면 모드를 시작할 수 없습니다',
'videoControls.screenshotSaved' => '스크린샷 저장됨',
'videoControls.zoomPercent' => ({required Object percent}) => '확대/축소 ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Android 8.0 이상이 필요합니다',
'videoControls.pipErrors.iosVersion' => 'iOS 15.0 이상이 필요합니다',
'videoControls.pipErrors.permissionDisabled' => '화면 속 화면이 비활성화되어 있습니다. 시스템 설정에서 활성화하세요.',
@@ -2316,12 +2325,12 @@ extension on TranslationsKo {
'profiles.addPlezyProfile' => 'Plezy 프로필 추가',
'profiles.switchingProfile' => '프로필 전환 중…',
'profiles.deleteThisProfileTitle' => '이 프로필을 삭제하시겠습니까?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName}을(를) 제거합니다. 연결에는 영향이 없습니다.',
'profiles.active' => '활성',
'profiles.manage' => '관리',
'profiles.delete' => '삭제',
_ => null,
} ?? switch (path) {
'profiles.signOut' => '로그아웃',
'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.',
@@ -2801,6 +2810,7 @@ extension on TranslationsKo {
'companionRemote.remote.audio' => '오디오',
'companionRemote.remote.searchHint' => '데스크톱에서 검색...',
'videoSettings.playbackSpeed' => '재생 속도',
'videoSettings.zoom' => '확대/축소',
'videoSettings.sleepTimer' => '취침 타이머',
'videoSettings.audioSync' => '오디오 동기화',
'videoSettings.subtitleSync' => '자막 동기화',
@@ -2829,13 +2839,13 @@ extension on TranslationsKo {
'metadataEdit.basicInfo' => '기본 정보',
'metadataEdit.artwork' => '아트워크',
'metadataEdit.advancedSettings' => '고급 설정',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => '제목',
'metadataEdit.sortTitle' => '정렬 제목',
'metadataEdit.originalTitle' => '원제',
'metadataEdit.releaseDate' => '출시일',
'metadataEdit.contentRating' => '콘텐츠 등급',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => '스튜디오',
'metadataEdit.tagline' => '태그라인',
'metadataEdit.summary' => '줄거리',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsNb extends TranslationsVideoControlsEn {
@override String get pipActive => 'Spiller i bilde-i-bilde';
@override String get pipFailed => 'Bilde-i-bilde kunne ikke starte';
@override String get screenshotSaved => 'Skjermbilde lagret';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsNb pipErrors = _TranslationsVideoControlsPipErrorsNb._(_root);
@override String get chapters => 'Kapitler';
@override String get noChaptersAvailable => 'Ingen kapitler tilgjengelig';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsNb extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Avspillingshastighet';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Søvntimer';
@override String get audioSync => 'Lydsynkronisering';
@override String get subtitleSync => 'Undertekstsynkronisering';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsNb extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Øk hastighet';
@override String get speedDecrease => 'Reduser hastighet';
@override String get speedReset => 'Tilbakestill hastighet';
@override String get zoomIn => 'Zoom inn';
@override String get zoomOut => 'Zoom ut';
@override String get zoomReset => 'Tilbakestill zoom';
@override String get subSeekNext => 'Spol til neste undertekst';
@override String get subSeekPrev => 'Spol til forrige undertekst';
@override String get shaderToggle => 'Veksle shadere';
@@ -2099,6 +2104,9 @@ extension on TranslationsNb {
'hotkeys.actions.speedIncrease' => 'Øk hastighet',
'hotkeys.actions.speedDecrease' => 'Reduser hastighet',
'hotkeys.actions.speedReset' => 'Tilbakestill hastighet',
'hotkeys.actions.zoomIn' => 'Zoom inn',
'hotkeys.actions.zoomOut' => 'Zoom ut',
'hotkeys.actions.zoomReset' => 'Tilbakestill zoom',
'hotkeys.actions.subSeekNext' => 'Spol til neste undertekst',
'hotkeys.actions.subSeekPrev' => 'Spol til forrige undertekst',
'hotkeys.actions.shaderToggle' => 'Veksle shadere',
@@ -2224,6 +2232,7 @@ extension on TranslationsNb {
'videoControls.pipActive' => 'Spiller i bilde-i-bilde',
'videoControls.pipFailed' => 'Bilde-i-bilde kunne ikke starte',
'videoControls.screenshotSaved' => 'Skjermbilde lagret',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Krever Android 8.0 eller nyere',
'videoControls.pipErrors.iosVersion' => 'Krever iOS 15.0 eller nyere',
'videoControls.pipErrors.permissionDisabled' => 'Bilde-i-bilde er deaktivert. Slå det på i systeminnstillinger.',
@@ -2316,12 +2325,12 @@ extension on TranslationsNb {
'profiles.addPlezyProfile' => 'Legg til Plezy-profil',
'profiles.switchingProfile' => 'Bytter profil…',
'profiles.deleteThisProfileTitle' => 'Slett denne profilen?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Fjern ${displayName}. Tilkoblinger påvirkes ikke.',
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Administrer',
'profiles.delete' => 'Slett',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Logg ut',
'profiles.signOutPlexTitle' => 'Logge ut av Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjerne ${displayName} og alle Plex Home-brukere? Du kan logge inn igjen når som helst.',
@@ -2801,6 +2810,7 @@ extension on TranslationsNb {
'companionRemote.remote.audio' => 'Lyd',
'companionRemote.remote.searchHint' => 'Søk på stasjonær...',
'videoSettings.playbackSpeed' => 'Avspillingshastighet',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Søvntimer',
'videoSettings.audioSync' => 'Lydsynkronisering',
'videoSettings.subtitleSync' => 'Undertekstsynkronisering',
@@ -2829,13 +2839,13 @@ extension on TranslationsNb {
'metadataEdit.basicInfo' => 'Grunnleggende info',
'metadataEdit.artwork' => 'Kunstverk',
'metadataEdit.advancedSettings' => 'Avanserte innstillinger',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Tittel',
'metadataEdit.sortTitle' => 'Sorteringsstittel',
'metadataEdit.originalTitle' => 'Originaltittel',
'metadataEdit.releaseDate' => 'Utgivelsesdato',
'metadataEdit.contentRating' => 'Innholdsvurdering',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Slagord',
'metadataEdit.summary' => 'Sammendrag',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsNl extends TranslationsVideoControlsEn {
@override String get pipActive => 'Afspelen in beeld-in-beeld';
@override String get pipFailed => 'Beeld-in-beeld kon niet worden gestart';
@override String get screenshotSaved => 'Schermafbeelding opgeslagen';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsNl pipErrors = _TranslationsVideoControlsPipErrorsNl._(_root);
@override String get chapters => 'Hoofdstukken';
@override String get noChaptersAvailable => 'Geen hoofdstukken beschikbaar';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsNl extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Afspeelsnelheid';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Slaaptimer';
@override String get audioSync => 'Audio synchronisatie';
@override String get subtitleSync => 'Ondertitel synchronisatie';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsNl extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Snelheid verhogen';
@override String get speedDecrease => 'Snelheid verlagen';
@override String get speedReset => 'Snelheid resetten';
@override String get zoomIn => 'Inzoomen';
@override String get zoomOut => 'Uitzoomen';
@override String get zoomReset => 'Zoom resetten';
@override String get subSeekNext => 'Naar volgende ondertitel';
@override String get subSeekPrev => 'Naar vorige ondertitel';
@override String get shaderToggle => 'Shaders aan/uit';
@@ -2099,6 +2104,9 @@ extension on TranslationsNl {
'hotkeys.actions.speedIncrease' => 'Snelheid verhogen',
'hotkeys.actions.speedDecrease' => 'Snelheid verlagen',
'hotkeys.actions.speedReset' => 'Snelheid resetten',
'hotkeys.actions.zoomIn' => 'Inzoomen',
'hotkeys.actions.zoomOut' => 'Uitzoomen',
'hotkeys.actions.zoomReset' => 'Zoom resetten',
'hotkeys.actions.subSeekNext' => 'Naar volgende ondertitel',
'hotkeys.actions.subSeekPrev' => 'Naar vorige ondertitel',
'hotkeys.actions.shaderToggle' => 'Shaders aan/uit',
@@ -2224,6 +2232,7 @@ extension on TranslationsNl {
'videoControls.pipActive' => 'Afspelen in beeld-in-beeld',
'videoControls.pipFailed' => 'Beeld-in-beeld kon niet worden gestart',
'videoControls.screenshotSaved' => 'Schermafbeelding opgeslagen',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Vereist Android 8.0 of nieuwer',
'videoControls.pipErrors.iosVersion' => 'Vereist iOS 15.0 of nieuwer',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture is uitgeschakeld. Schakel het in via systeeminstellingen.',
@@ -2316,12 +2325,12 @@ extension on TranslationsNl {
'profiles.addPlezyProfile' => 'Plezy-profiel toevoegen',
'profiles.switchingProfile' => 'Profiel wisselen…',
'profiles.deleteThisProfileTitle' => 'Dit profiel verwijderen?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Verwijder ${displayName}. Verbindingen blijven ongewijzigd.',
'profiles.active' => 'Actief',
'profiles.manage' => 'Beheren',
'profiles.delete' => 'Verwijderen',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Afmelden',
'profiles.signOutPlexTitle' => 'Afmelden bij Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers verwijderen? Je kunt altijd opnieuw inloggen.',
@@ -2801,6 +2810,7 @@ extension on TranslationsNl {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Zoeken op desktop...',
'videoSettings.playbackSpeed' => 'Afspeelsnelheid',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Slaaptimer',
'videoSettings.audioSync' => 'Audio synchronisatie',
'videoSettings.subtitleSync' => 'Ondertitel synchronisatie',
@@ -2829,13 +2839,13 @@ extension on TranslationsNl {
'metadataEdit.basicInfo' => 'Basisinformatie',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Geavanceerde instellingen',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Titel',
'metadataEdit.sortTitle' => 'Sorteertitel',
'metadataEdit.originalTitle' => 'Oorspronkelijke titel',
'metadataEdit.releaseDate' => 'Releasedatum',
'metadataEdit.contentRating' => 'Leeftijdsclassificatie',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Samenvatting',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsPl extends TranslationsVideoControlsEn {
@override String get pipActive => 'Odtwarzanie w trybie obraz w obrazie';
@override String get pipFailed => 'Nie udało się uruchomić trybu obraz w obrazie';
@override String get screenshotSaved => 'Zrzut ekranu zapisany';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsPl pipErrors = _TranslationsVideoControlsPipErrorsPl._(_root);
@override String get chapters => 'Rozdziały';
@override String get noChaptersAvailable => 'Brak dostępnych rozdziałów';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsPl extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Prędkość odtwarzania';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Wyłącznik czasowy';
@override String get audioSync => 'Synchronizacja audio';
@override String get subtitleSync => 'Synchronizacja napisów';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsPl extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Zwiększ prędkość';
@override String get speedDecrease => 'Zmniejsz prędkość';
@override String get speedReset => 'Zresetuj prędkość';
@override String get zoomIn => 'Powiększ';
@override String get zoomOut => 'Pomniejsz';
@override String get zoomReset => 'Zresetuj zoom';
@override String get subSeekNext => 'Przewiń do następnego napisu';
@override String get subSeekPrev => 'Przewiń do poprzedniego napisu';
@override String get shaderToggle => 'Przełącz shadery';
@@ -2099,6 +2104,9 @@ extension on TranslationsPl {
'hotkeys.actions.speedIncrease' => 'Zwiększ prędkość',
'hotkeys.actions.speedDecrease' => 'Zmniejsz prędkość',
'hotkeys.actions.speedReset' => 'Zresetuj prędkość',
'hotkeys.actions.zoomIn' => 'Powiększ',
'hotkeys.actions.zoomOut' => 'Pomniejsz',
'hotkeys.actions.zoomReset' => 'Zresetuj zoom',
'hotkeys.actions.subSeekNext' => 'Przewiń do następnego napisu',
'hotkeys.actions.subSeekPrev' => 'Przewiń do poprzedniego napisu',
'hotkeys.actions.shaderToggle' => 'Przełącz shadery',
@@ -2224,6 +2232,7 @@ extension on TranslationsPl {
'videoControls.pipActive' => 'Odtwarzanie w trybie obraz w obrazie',
'videoControls.pipFailed' => 'Nie udało się uruchomić trybu obraz w obrazie',
'videoControls.screenshotSaved' => 'Zrzut ekranu zapisany',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Wymaga Androida 8.0 lub nowszego',
'videoControls.pipErrors.iosVersion' => 'Wymaga iOS 15.0 lub nowszego',
'videoControls.pipErrors.permissionDisabled' => 'Obraz w obrazie jest wyłączony. Włącz go w ustawieniach systemu.',
@@ -2316,12 +2325,12 @@ extension on TranslationsPl {
'profiles.addPlezyProfile' => 'Dodaj profil Plezy',
'profiles.switchingProfile' => 'Przełączanie profilu…',
'profiles.deleteThisProfileTitle' => 'Usunąć ten profil?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Usuń ${displayName}. Połączenia nie zostaną zmienione.',
'profiles.active' => 'Aktywny',
'profiles.manage' => 'Zarządzaj',
'profiles.delete' => 'Usuń',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Wyloguj się',
'profiles.signOutPlexTitle' => 'Wylogować się z Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Usunąć ${displayName} i wszystkich użytkowników Plex Home? Możesz zalogować się ponownie w każdej chwili.',
@@ -2801,6 +2810,7 @@ extension on TranslationsPl {
'companionRemote.remote.audio' => 'Audio',
'companionRemote.remote.searchHint' => 'Szukaj na komputerze...',
'videoSettings.playbackSpeed' => 'Prędkość odtwarzania',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Wyłącznik czasowy',
'videoSettings.audioSync' => 'Synchronizacja audio',
'videoSettings.subtitleSync' => 'Synchronizacja napisów',
@@ -2829,13 +2839,13 @@ extension on TranslationsPl {
'metadataEdit.basicInfo' => 'Podstawowe informacje',
'metadataEdit.artwork' => 'Grafika',
'metadataEdit.advancedSettings' => 'Ustawienia zaawansowane',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Tytuł',
'metadataEdit.sortTitle' => 'Tytuł do sortowania',
'metadataEdit.originalTitle' => 'Tytuł oryginalny',
'metadataEdit.releaseDate' => 'Data premiery',
'metadataEdit.contentRating' => 'Klasyfikacja wiekowa',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Opis',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsPt extends TranslationsVideoControlsEn {
@override String get pipActive => 'Reproduzindo em Picture-in-Picture';
@override String get pipFailed => 'Falha ao iniciar picture-in-picture';
@override String get screenshotSaved => 'Captura de tela salva';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsPt pipErrors = _TranslationsVideoControlsPipErrorsPt._(_root);
@override String get chapters => 'Capítulos';
@override String get noChaptersAvailable => 'Nenhum capítulo disponível';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsPt extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Velocidade de Reprodução';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Timer de Sono';
@override String get audioSync => 'Sincronia de Áudio';
@override String get subtitleSync => 'Sincronia de Legendas';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsPt extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Aumentar Velocidade';
@override String get speedDecrease => 'Diminuir Velocidade';
@override String get speedReset => 'Redefinir Velocidade';
@override String get zoomIn => 'Aumentar zoom';
@override String get zoomOut => 'Diminuir zoom';
@override String get zoomReset => 'Redefinir zoom';
@override String get subSeekNext => 'Ir para Próxima Legenda';
@override String get subSeekPrev => 'Ir para Legenda Anterior';
@override String get shaderToggle => 'Alternar Shaders';
@@ -2099,6 +2104,9 @@ extension on TranslationsPt {
'hotkeys.actions.speedIncrease' => 'Aumentar Velocidade',
'hotkeys.actions.speedDecrease' => 'Diminuir Velocidade',
'hotkeys.actions.speedReset' => 'Redefinir Velocidade',
'hotkeys.actions.zoomIn' => 'Aumentar zoom',
'hotkeys.actions.zoomOut' => 'Diminuir zoom',
'hotkeys.actions.zoomReset' => 'Redefinir zoom',
'hotkeys.actions.subSeekNext' => 'Ir para Próxima Legenda',
'hotkeys.actions.subSeekPrev' => 'Ir para Legenda Anterior',
'hotkeys.actions.shaderToggle' => 'Alternar Shaders',
@@ -2224,6 +2232,7 @@ extension on TranslationsPt {
'videoControls.pipActive' => 'Reproduzindo em Picture-in-Picture',
'videoControls.pipFailed' => 'Falha ao iniciar picture-in-picture',
'videoControls.screenshotSaved' => 'Captura de tela salva',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Requer Android 8.0 ou superior',
'videoControls.pipErrors.iosVersion' => 'Requer iOS 15.0 ou superior',
'videoControls.pipErrors.permissionDisabled' => 'Picture-in-picture está desativado. Ative nas configurações do sistema.',
@@ -2316,12 +2325,12 @@ extension on TranslationsPt {
'profiles.addPlezyProfile' => 'Adicionar perfil Plezy',
'profiles.switchingProfile' => 'Mudando perfil…',
'profiles.deleteThisProfileTitle' => 'Excluir este perfil?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Remover ${displayName}. As conexões não serão afetadas.',
'profiles.active' => 'Ativo',
'profiles.manage' => 'Gerenciar',
'profiles.delete' => 'Excluir',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Sair',
'profiles.signOutPlexTitle' => 'Sair do Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remover ${displayName} e todos os usuários Plex Home? Você pode entrar novamente quando quiser.',
@@ -2801,6 +2810,7 @@ extension on TranslationsPt {
'companionRemote.remote.audio' => 'Áudio',
'companionRemote.remote.searchHint' => 'Buscar no desktop...',
'videoSettings.playbackSpeed' => 'Velocidade de Reprodução',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Timer de Sono',
'videoSettings.audioSync' => 'Sincronia de Áudio',
'videoSettings.subtitleSync' => 'Sincronia de Legendas',
@@ -2829,13 +2839,13 @@ extension on TranslationsPt {
'metadataEdit.basicInfo' => 'Informações Básicas',
'metadataEdit.artwork' => 'Arte',
'metadataEdit.advancedSettings' => 'Configurações Avançadas',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Título',
'metadataEdit.sortTitle' => 'Título para Ordenação',
'metadataEdit.originalTitle' => 'Título Original',
'metadataEdit.releaseDate' => 'Data de Lançamento',
'metadataEdit.contentRating' => 'Classificação Indicativa',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Estúdio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Sinopse',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsRu extends TranslationsVideoControlsEn {
@override String get pipActive => 'Воспроизводится в режиме «картинка в картинке»';
@override String get pipFailed => 'Не удалось запустить режим «картинка в картинке»';
@override String get screenshotSaved => 'Снимок экрана сохранён';
@override String zoomPercent({required Object percent}) => 'Масштаб ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsRu pipErrors = _TranslationsVideoControlsPipErrorsRu._(_root);
@override String get chapters => 'Главы';
@override String get noChaptersAvailable => 'Главы недоступны';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsRu extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Скорость воспроизведения';
@override String get zoom => 'Масштаб';
@override String get sleepTimer => 'Таймер сна';
@override String get audioSync => 'Синхронизация аудио';
@override String get subtitleSync => 'Синхронизация субтитров';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsRu extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Увеличить скорость';
@override String get speedDecrease => 'Уменьшить скорость';
@override String get speedReset => 'Сбросить скорость';
@override String get zoomIn => 'Увеличить масштаб';
@override String get zoomOut => 'Уменьшить масштаб';
@override String get zoomReset => 'Сбросить масштаб';
@override String get subSeekNext => 'К следующему субтитру';
@override String get subSeekPrev => 'К предыдущему субтитру';
@override String get shaderToggle => 'Вкл./выкл. шейдеры';
@@ -2099,6 +2104,9 @@ extension on TranslationsRu {
'hotkeys.actions.speedIncrease' => 'Увеличить скорость',
'hotkeys.actions.speedDecrease' => 'Уменьшить скорость',
'hotkeys.actions.speedReset' => 'Сбросить скорость',
'hotkeys.actions.zoomIn' => 'Увеличить масштаб',
'hotkeys.actions.zoomOut' => 'Уменьшить масштаб',
'hotkeys.actions.zoomReset' => 'Сбросить масштаб',
'hotkeys.actions.subSeekNext' => 'К следующему субтитру',
'hotkeys.actions.subSeekPrev' => 'К предыдущему субтитру',
'hotkeys.actions.shaderToggle' => 'Вкл./выкл. шейдеры',
@@ -2224,6 +2232,7 @@ extension on TranslationsRu {
'videoControls.pipActive' => 'Воспроизводится в режиме «картинка в картинке»',
'videoControls.pipFailed' => 'Не удалось запустить режим «картинка в картинке»',
'videoControls.screenshotSaved' => 'Снимок экрана сохранён',
'videoControls.zoomPercent' => ({required Object percent}) => 'Масштаб ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Требуется Android 8.0 или новее',
'videoControls.pipErrors.iosVersion' => 'Требуется iOS 15.0 или новее',
'videoControls.pipErrors.permissionDisabled' => 'Картинка-в-картинке отключена. Включите ее в системных настройках.',
@@ -2316,12 +2325,12 @@ extension on TranslationsRu {
'profiles.addPlezyProfile' => 'Добавить профиль Plezy',
'profiles.switchingProfile' => 'Переключение профиля…',
'profiles.deleteThisProfileTitle' => 'Удалить этот профиль?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Удалить ${displayName}. Подключения не изменятся.',
'profiles.active' => 'Активный',
'profiles.manage' => 'Управление',
'profiles.delete' => 'Удалить',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Выйти',
'profiles.signOutPlexTitle' => 'Выйти из Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.',
@@ -2801,6 +2810,7 @@ extension on TranslationsRu {
'companionRemote.remote.audio' => 'Аудио',
'companionRemote.remote.searchHint' => 'Поиск на десктопе...',
'videoSettings.playbackSpeed' => 'Скорость воспроизведения',
'videoSettings.zoom' => 'Масштаб',
'videoSettings.sleepTimer' => 'Таймер сна',
'videoSettings.audioSync' => 'Синхронизация аудио',
'videoSettings.subtitleSync' => 'Синхронизация субтитров',
@@ -2829,13 +2839,13 @@ extension on TranslationsRu {
'metadataEdit.basicInfo' => 'Основная информация',
'metadataEdit.artwork' => 'Обложка',
'metadataEdit.advancedSettings' => 'Дополнительные настройки',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Название',
'metadataEdit.sortTitle' => 'Название для сортировки',
'metadataEdit.originalTitle' => 'Оригинальное название',
'metadataEdit.releaseDate' => 'Дата выпуска',
'metadataEdit.contentRating' => 'Возрастной рейтинг',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Студия',
'metadataEdit.tagline' => 'Слоган',
'metadataEdit.summary' => 'Описание',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsSv extends TranslationsVideoControlsEn {
@override String get pipActive => 'Spelar i bild-i-bild';
@override String get pipFailed => 'Bild-i-bild kunde inte starta';
@override String get screenshotSaved => 'Skärmbild sparad';
@override String zoomPercent({required Object percent}) => 'Zoom ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsSv pipErrors = _TranslationsVideoControlsPipErrorsSv._(_root);
@override String get chapters => 'Kapitel';
@override String get noChaptersAvailable => 'Inga kapitel tillgängliga';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsSv extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => 'Uppspelningshastighet';
@override String get zoom => 'Zoom';
@override String get sleepTimer => 'Sovtimer';
@override String get audioSync => 'Ljudsynkronisering';
@override String get subtitleSync => 'Undertextsynkronisering';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsSv extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => 'Öka hastighet';
@override String get speedDecrease => 'Minska hastighet';
@override String get speedReset => 'Återställ hastighet';
@override String get zoomIn => 'Zooma in';
@override String get zoomOut => 'Zooma ut';
@override String get zoomReset => 'Återställ zoom';
@override String get subSeekNext => 'Hoppa till nästa undertext';
@override String get subSeekPrev => 'Hoppa till föregående undertext';
@override String get shaderToggle => 'Växla shaders';
@@ -2099,6 +2104,9 @@ extension on TranslationsSv {
'hotkeys.actions.speedIncrease' => 'Öka hastighet',
'hotkeys.actions.speedDecrease' => 'Minska hastighet',
'hotkeys.actions.speedReset' => 'Återställ hastighet',
'hotkeys.actions.zoomIn' => 'Zooma in',
'hotkeys.actions.zoomOut' => 'Zooma ut',
'hotkeys.actions.zoomReset' => 'Återställ zoom',
'hotkeys.actions.subSeekNext' => 'Hoppa till nästa undertext',
'hotkeys.actions.subSeekPrev' => 'Hoppa till föregående undertext',
'hotkeys.actions.shaderToggle' => 'Växla shaders',
@@ -2224,6 +2232,7 @@ extension on TranslationsSv {
'videoControls.pipActive' => 'Spelar i bild-i-bild',
'videoControls.pipFailed' => 'Bild-i-bild kunde inte starta',
'videoControls.screenshotSaved' => 'Skärmbild sparad',
'videoControls.zoomPercent' => ({required Object percent}) => 'Zoom ${percent}%',
'videoControls.pipErrors.androidVersion' => 'Kräver Android 8.0 eller nyare',
'videoControls.pipErrors.iosVersion' => 'Kräver iOS 15.0 eller nyare',
'videoControls.pipErrors.permissionDisabled' => 'Bild-i-bild är inaktiverat. Aktivera det i systeminställningarna.',
@@ -2316,12 +2325,12 @@ extension on TranslationsSv {
'profiles.addPlezyProfile' => 'Lägg till Plezy-profil',
'profiles.switchingProfile' => 'Byter profil…',
'profiles.deleteThisProfileTitle' => 'Ta bort denna profil?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => 'Ta bort ${displayName}. Anslutningar påverkas inte.',
'profiles.active' => 'Aktiv',
'profiles.manage' => 'Hantera',
'profiles.delete' => 'Ta bort',
_ => null,
} ?? switch (path) {
'profiles.signOut' => 'Logga ut',
'profiles.signOutPlexTitle' => 'Logga ut från Plex?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och alla Plex Home-användare? Du kan logga in igen när som helst.',
@@ -2801,6 +2810,7 @@ extension on TranslationsSv {
'companionRemote.remote.audio' => 'Ljud',
'companionRemote.remote.searchHint' => 'Sök på datorn...',
'videoSettings.playbackSpeed' => 'Uppspelningshastighet',
'videoSettings.zoom' => 'Zoom',
'videoSettings.sleepTimer' => 'Sovtimer',
'videoSettings.audioSync' => 'Ljudsynkronisering',
'videoSettings.subtitleSync' => 'Undertextsynkronisering',
@@ -2829,13 +2839,13 @@ extension on TranslationsSv {
'metadataEdit.basicInfo' => 'Grundläggande info',
'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Avancerade inställningar',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => 'Titel',
'metadataEdit.sortTitle' => 'Sorteringstitel',
'metadataEdit.originalTitle' => 'Originaltitel',
'metadataEdit.releaseDate' => 'Utgivningsdatum',
'metadataEdit.contentRating' => 'Åldersgräns',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => 'Studio',
'metadataEdit.tagline' => 'Tagline',
'metadataEdit.summary' => 'Sammanfattning',
+14 -4
View File
@@ -605,6 +605,7 @@ class _TranslationsVideoControlsZh extends TranslationsVideoControlsEn {
@override String get pipActive => '正在画中画模式中播放';
@override String get pipFailed => '画中画启动失败';
@override String get screenshotSaved => '截图已保存';
@override String zoomPercent({required Object percent}) => '缩放 ${percent}%';
@override late final _TranslationsVideoControlsPipErrorsZh pipErrors = _TranslationsVideoControlsPipErrorsZh._(_root);
@override String get chapters => '章节';
@override String get noChaptersAvailable => '没有可用的章节';
@@ -1308,6 +1309,7 @@ class _TranslationsVideoSettingsZh extends TranslationsVideoSettingsEn {
// Translations
@override String get playbackSpeed => '播放速度';
@override String get zoom => '缩放';
@override String get sleepTimer => '睡眠定时器';
@override String get audioSync => '音频同步';
@override String get subtitleSync => '字幕同步';
@@ -1565,6 +1567,9 @@ class _TranslationsHotkeysActionsZh extends TranslationsHotkeysActionsEn {
@override String get speedIncrease => '加速';
@override String get speedDecrease => '减速';
@override String get speedReset => '重置速度';
@override String get zoomIn => '放大';
@override String get zoomOut => '缩小';
@override String get zoomReset => '重置缩放';
@override String get subSeekNext => '跳转到下一字幕';
@override String get subSeekPrev => '跳转到上一字幕';
@override String get shaderToggle => '切换着色器';
@@ -2099,6 +2104,9 @@ extension on TranslationsZh {
'hotkeys.actions.speedIncrease' => '加速',
'hotkeys.actions.speedDecrease' => '减速',
'hotkeys.actions.speedReset' => '重置速度',
'hotkeys.actions.zoomIn' => '放大',
'hotkeys.actions.zoomOut' => '缩小',
'hotkeys.actions.zoomReset' => '重置缩放',
'hotkeys.actions.subSeekNext' => '跳转到下一字幕',
'hotkeys.actions.subSeekPrev' => '跳转到上一字幕',
'hotkeys.actions.shaderToggle' => '切换着色器',
@@ -2224,6 +2232,7 @@ extension on TranslationsZh {
'videoControls.pipActive' => '正在画中画模式中播放',
'videoControls.pipFailed' => '画中画启动失败',
'videoControls.screenshotSaved' => '截图已保存',
'videoControls.zoomPercent' => ({required Object percent}) => '缩放 ${percent}%',
'videoControls.pipErrors.androidVersion' => '需要 Android 8.0 或更高版本',
'videoControls.pipErrors.iosVersion' => '需要 iOS 15.0 或更高版本',
'videoControls.pipErrors.permissionDisabled' => '画中画已禁用。请在系统设置中启用。',
@@ -2316,12 +2325,12 @@ extension on TranslationsZh {
'profiles.addPlezyProfile' => '添加 Plezy 配置文件',
'profiles.switchingProfile' => '切换配置文件中…',
'profiles.deleteThisProfileTitle' => '删除此配置文件?',
_ => null,
} ?? switch (path) {
'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '移除 ${displayName}。连接不会受影响。',
'profiles.active' => '活跃',
'profiles.manage' => '管理',
'profiles.delete' => '删除',
_ => null,
} ?? switch (path) {
'profiles.signOut' => '退出登录',
'profiles.signOutPlexTitle' => '退出 Plex 登录?',
'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。',
@@ -2801,6 +2810,7 @@ extension on TranslationsZh {
'companionRemote.remote.audio' => '音频',
'companionRemote.remote.searchHint' => '在桌面上搜索...',
'videoSettings.playbackSpeed' => '播放速度',
'videoSettings.zoom' => '缩放',
'videoSettings.sleepTimer' => '睡眠定时器',
'videoSettings.audioSync' => '音频同步',
'videoSettings.subtitleSync' => '字幕同步',
@@ -2829,13 +2839,13 @@ extension on TranslationsZh {
'metadataEdit.basicInfo' => '基本信息',
'metadataEdit.artwork' => '封面图片',
'metadataEdit.advancedSettings' => '高级设置',
_ => null,
} ?? switch (path) {
'metadataEdit.title' => '标题',
'metadataEdit.sortTitle' => '排序标题',
'metadataEdit.originalTitle' => '原始标题',
'metadataEdit.releaseDate' => '上映日期',
'metadataEdit.contentRating' => '内容分级',
_ => null,
} ?? switch (path) {
'metadataEdit.studio' => '制片厂',
'metadataEdit.tagline' => '标语',
'metadataEdit.summary' => '简介',
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "Öka hastighet",
"speedDecrease": "Minska hastighet",
"speedReset": "Återställ hastighet",
"zoomIn": "Zooma in",
"zoomOut": "Zooma ut",
"zoomReset": "Återställ zoom",
"subSeekNext": "Hoppa till nästa undertext",
"subSeekPrev": "Hoppa till föregående undertext",
"shaderToggle": "Växla shaders",
@@ -444,6 +447,7 @@
"pipActive": "Spelar i bild-i-bild",
"pipFailed": "Bild-i-bild kunde inte starta",
"screenshotSaved": "Skärmbild sparad",
"zoomPercent": "Zoom ${percent}%",
"pipErrors": {
"androidVersion": "Kräver Android 8.0 eller nyare",
"iosVersion": "Kräver iOS 15.0 eller nyare",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "Uppspelningshastighet",
"zoom": "Zoom",
"sleepTimer": "Sovtimer",
"audioSync": "Ljudsynkronisering",
"subtitleSync": "Undertextsynkronisering",
+5
View File
@@ -306,6 +306,9 @@
"speedIncrease": "加速",
"speedDecrease": "减速",
"speedReset": "重置速度",
"zoomIn": "放大",
"zoomOut": "缩小",
"zoomReset": "重置缩放",
"subSeekNext": "跳转到下一字幕",
"subSeekPrev": "跳转到上一字幕",
"shaderToggle": "切换着色器",
@@ -444,6 +447,7 @@
"pipActive": "正在画中画模式中播放",
"pipFailed": "画中画启动失败",
"screenshotSaved": "截图已保存",
"zoomPercent": "缩放 ${percent}%",
"pipErrors": {
"androidVersion": "需要 Android 8.0 或更高版本",
"iosVersion": "需要 iOS 15.0 或更高版本",
@@ -1083,6 +1087,7 @@
},
"videoSettings": {
"playbackSpeed": "播放速度",
"zoom": "缩放",
"sleepTimer": "睡眠定时器",
"audioSync": "音频同步",
"subtitleSync": "字幕同步",
@@ -372,6 +372,12 @@ class PlayerAndroid extends PlayerBase {
await invoke('setBoxFitMode', {'mode': mode});
}
/// Apply custom zoom to the native ExoPlayer layer.
Future<void> setVideoZoom(double scale) async {
if (disposed || !initialized) return;
await invoke('setVideoZoom', {'scale': scale});
}
@override
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
if (disposed || !initialized) return false;
+42 -9
View File
@@ -2,6 +2,7 @@ part of '../../video_player_screen.dart';
extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
static const double _videoLayoutSizeTolerance = 0.1;
static const double _pinchZoomActivationThreshold = 0.015;
bool _isSameVideoLayoutSize(Size a, Size b) {
return (a.width - b.width).abs() <= _videoLayoutSizeTolerance &&
@@ -138,6 +139,22 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
);
}
void _startMobileZoomGesture() {
final filterManager = _videoFilterManager;
if (filterManager == null || _isPinchZooming) return;
_ambientLightingService?.disable();
_isPinchZooming = true;
_pinchZoomChanged = false;
_pinchStartZoomScale = filterManager.zoomScale;
}
void _clearMobileZoomGesture() {
_isPinchZooming = false;
_pinchZoomChanged = false;
_pinchStartZoomScale = null;
}
Widget _buildVideoPlayer(BuildContext context) {
// Cache platform detection to avoid multiple calls
final isMobile = PlatformDetector.isMobile(context);
@@ -167,22 +184,33 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls
onScaleStart: (details) {
if (!isMobile) return;
if (_videoFilterManager != null) {
_videoFilterManager!.isPinching = false;
}
if (details.pointerCount >= 2) _startMobileZoomGesture();
},
onScaleUpdate: (details) {
if (!isMobile) return;
if (details.pointerCount >= 2 && _videoFilterManager != null) {
_videoFilterManager!.isPinching = true;
}
if (details.pointerCount < 2) return;
if (!_isPinchZooming) _startMobileZoomGesture();
final startZoom = _pinchStartZoomScale;
final filterManager = _videoFilterManager;
if (!_isPinchZooming || startZoom == null || filterManager == null) return;
if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold && !_pinchZoomChanged) return;
_pinchZoomChanged = true;
filterManager.setZoomScale(startZoom * details.scale);
},
onScaleEnd: (details) {
if (!isMobile) return;
if (_videoFilterManager != null && _videoFilterManager!.isPinching) {
_toggleContainCover();
_videoFilterManager!.isPinching = false;
if (!_isPinchZooming) return;
if (!_pinchZoomChanged) {
_clearMobileZoomGesture();
return;
}
final zoomScale = _videoFilterManager?.zoomScale ?? 1.0;
_showZoomToast(zoomScale);
_clearMobileZoomGesture();
_setPlayerState(() {});
},
child: Stack(
children: [
@@ -244,7 +272,12 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
sourcePartId: _currentMediaInfo?.partId,
onTogglePIPMode: _togglePIPMode,
boxFitMode: _videoFilterManager?.boxFitMode ?? 0,
videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0,
onCycleBoxFitMode: _cycleBoxFitMode,
onVideoZoomChanged: _setVideoZoom,
onZoomIn: _zoomVideoIn,
onZoomOut: _zoomVideoOut,
onResetVideoZoom: _resetVideoZoom,
onCycleAudioTrack: _cycleAudioTrack,
onCycleSubtitleTrack: _cycleSubtitleTrack,
onAudioTrackChanged: _onAudioTrackChanged,
+29 -7
View File
@@ -68,6 +68,35 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
});
}
void _showZoomToast(double zoomScale) {
_toastController.show(Symbols.zoom_in_rounded, t.videoControls.zoomPercent(percent: (zoomScale * 100).round()));
}
double _setVideoZoom(double zoomScale, {bool showToast = true}) {
final filterManager = _videoFilterManager;
if (filterManager == null) return 1.0;
_ambientLightingService?.disable();
final next = filterManager.setZoomScale(zoomScale);
if (showToast) _showZoomToast(next);
if (mounted) _setPlayerState(() {});
return next;
}
void _zoomVideoIn() {
final current = _videoFilterManager?.zoomScale ?? 1.0;
_setVideoZoom(current + VideoFilterManager.zoomStep);
}
void _zoomVideoOut() {
final current = _videoFilterManager?.zoomScale ?? 1.0;
_setVideoZoom(current - VideoFilterManager.zoomStep);
}
void _resetVideoZoom() {
_setVideoZoom(1.0);
}
/// Update video-aspect-override when player size changes.
/// The shader adapts automatically via built-in target_size uniform.
void _updateAmbientLightingOnResize(Size newSize) {
@@ -120,11 +149,4 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
if (mounted) _setPlayerState(() {});
}
/// Toggle between contain and cover modes only (for pinch gesture)
void _toggleContainCover() {
_setPlayerState(() {
_videoFilterManager?.toggleContainCover();
});
}
}
+3
View File
@@ -400,6 +400,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Size? _pendingVideoLayoutSize;
Player? _lastVideoLayoutPlayer;
bool _videoLayoutUpdateScheduled = false;
double? _pinchStartZoomScale;
bool _isPinchZooming = false;
bool _pinchZoomChanged = false;
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
WatchTogetherProvider? _watchTogetherProvider;
+33 -2
View File
@@ -12,6 +12,8 @@ import '../utils/platform_detector.dart';
import '../utils/player_utils.dart';
class KeyboardShortcutsService extends ChangeNotifier {
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
static KeyboardShortcutsService? _instance;
late SettingsService _settingsService;
final List<VoidCallback> _settingsDisposers = [];
@@ -183,13 +185,17 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onNextEpisode,
VoidCallback? onPreviousEpisode,
VoidCallback? onScreenshot,
VoidCallback? onZoomIn,
VoidCallback? onZoomOut,
VoidCallback? onZoomReset,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
Future<void> Function(Duration position)? onSeekRequested,
}) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final isRepeat = event is KeyRepeatEvent;
if (event is! KeyDownEvent && !isRepeat) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.escape) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
onBack?.call();
return KeyEventResult.handled;
}
@@ -247,6 +253,10 @@ class KeyboardShortcutsService extends ChangeNotifier {
continue;
}
if (isRepeat && !_repeatableVideoActions.contains(action)) {
return KeyEventResult.handled;
}
_executeAction(
action,
player,
@@ -261,6 +271,9 @@ class KeyboardShortcutsService extends ChangeNotifier {
onNextEpisode: onNextEpisode,
onPreviousEpisode: onPreviousEpisode,
onScreenshot: onScreenshot,
onZoomIn: onZoomIn,
onZoomOut: onZoomOut,
onZoomReset: onZoomReset,
currentPositionEpoch: currentPositionEpoch,
onLiveSeek: onLiveSeek,
onSeekRequested: onSeekRequested,
@@ -286,6 +299,9 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onNextEpisode,
VoidCallback? onPreviousEpisode,
VoidCallback? onScreenshot,
VoidCallback? onZoomIn,
VoidCallback? onZoomOut,
VoidCallback? onZoomReset,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
Future<void> Function(Duration position)? onSeekRequested,
@@ -383,6 +399,15 @@ class KeyboardShortcutsService extends ChangeNotifier {
case 'screenshot':
unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call()));
break;
case 'zoom_in':
onZoomIn?.call();
break;
case 'zoom_out':
onZoomOut?.call();
break;
case 'zoom_reset':
onZoomReset?.call();
break;
}
}
@@ -436,6 +461,12 @@ class KeyboardShortcutsService extends ChangeNotifier {
return t.hotkeys.actions.skipMarker;
case 'screenshot':
return t.hotkeys.actions.screenshot;
case 'zoom_in':
return t.hotkeys.actions.zoomIn;
case 'zoom_out':
return t.hotkeys.actions.zoomOut;
case 'zoom_reset':
return t.hotkeys.actions.zoomReset;
default:
return action;
}
+6
View File
@@ -216,6 +216,9 @@ Map<String, String> _defaultKeyboardShortcuts() => {
'speed_increase': 'Plus',
'speed_decrease': 'Minus',
'speed_reset': 'R',
'zoom_in': 'Alt+Plus',
'zoom_out': 'Alt+Minus',
'zoom_reset': 'Alt+Backspace',
'sub_seek_next': 'Ctrl+Right',
'sub_seek_prev': 'Ctrl+Left',
'screenshot': 'Ctrl+S',
@@ -241,6 +244,9 @@ Map<String, HotKey> _defaultKeyboardHotkeys() => {
'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal),
'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus),
'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR),
'zoom_in': const HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]),
'zoom_out': const HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]),
'zoom_reset': const HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt]),
'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]),
'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]),
'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG),
+53 -12
View File
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:rate_limiter/rate_limiter.dart';
@@ -17,6 +19,10 @@ import 'ambient_lighting_service.dart';
/// - Debounced video filter updates on resize events
/// - Ambient-lighting-friendly reset to contain mode
class VideoFilterManager {
static const double minZoomScale = 0.5;
static const double maxZoomScale = 2.0;
static const double zoomStep = 0.01;
final Player player;
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
@@ -27,14 +33,17 @@ class VideoFilterManager {
/// Store the boxFitMode before entering PiP so it can be restored
int? _prePipBoxFitMode;
/// Store the zoom level before entering PiP so it can be restored
double? _prePipZoomScale;
/// Store whether ambient lighting was active before entering PiP
bool? _prePipAmbientLighting;
/// Ambient lighting service reference - when active, video-aspect-override is managed by ambient lighting
AmbientLightingService? ambientLightingService;
/// Track if a pinch gesture is occurring (public for gesture tracking)
bool isPinching = false;
/// Custom video zoom layered on top of the selected fit mode.
double _zoomScale = 1.0;
/// Current player viewport size
Size? _playerSize;
@@ -63,8 +72,27 @@ class VideoFilterManager {
/// Current BoxFit mode (0=contain, 1=cover, 2=fill)
int get boxFitMode => _boxFitMode;
double get zoomScale => _zoomScale;
Size? get playerSize => _playerSize;
static double videoZoomPropertyForScale(double scale) {
final clamped = scale.clamp(minZoomScale, maxZoomScale).toDouble();
return math.log(clamped) / math.ln2;
}
double setZoomScale(double scale) {
final next = scale.clamp(minZoomScale, maxZoomScale).toDouble();
if ((_zoomScale - next).abs() < 0.0001) return _zoomScale;
_zoomScale = next;
updateVideoFilter();
return _zoomScale;
}
double adjustZoom(double delta) => setZoomScale(_zoomScale + delta);
double resetZoom() => setZoomScale(1.0);
/// Cycle through BoxFit modes: contain → cover → fill → contain (for button)
void cycleBoxFitMode() {
_boxFitMode = (_boxFitMode + 1) % 3;
@@ -74,19 +102,13 @@ class VideoFilterManager {
/// Reset to contain mode (mode 0). Used when enabling ambient lighting.
void resetToContain() {
if (_boxFitMode != 0) {
if (_boxFitMode != 0 || (_zoomScale - 1.0).abs() > 0.0001) {
_boxFitMode = 0;
_zoomScale = 1.0;
updateVideoFilter();
}
}
/// Toggle between contain and cover modes only (for pinch gesture)
void toggleContainCover() {
_boxFitMode = _boxFitMode == 0 ? 1 : 0;
onBoxFitModeChanged?.call(_boxFitMode);
updateVideoFilter();
}
/// Force contain mode for PiP (no cropping/stretching)
void enterPipMode() {
// Disable ambient lighting for PiP — it wastes space on blurred borders
@@ -97,17 +119,30 @@ class VideoFilterManager {
if (_boxFitMode != 0) {
_prePipBoxFitMode = _boxFitMode;
_boxFitMode = 0; // Contain mode
}
if ((_zoomScale - 1.0).abs() > 0.0001) {
_prePipZoomScale = _zoomScale;
_zoomScale = 1.0;
}
if (_prePipBoxFitMode != null || _prePipZoomScale != null) {
updateVideoFilter();
}
}
/// Restore previous mode when exiting PiP
void exitPipMode() {
var shouldUpdate = false;
if (_prePipBoxFitMode != null) {
_boxFitMode = _prePipBoxFitMode!;
_prePipBoxFitMode = null;
updateVideoFilter();
shouldUpdate = true;
}
if (_prePipZoomScale != null) {
_zoomScale = _prePipZoomScale!;
_prePipZoomScale = null;
shouldUpdate = true;
}
if (shouldUpdate) updateVideoFilter();
}
/// Whether ambient lighting was active before entering PiP
@@ -129,13 +164,14 @@ class VideoFilterManager {
/// Update the video scaling and positioning based on current display mode.
/// When ambient lighting is active, video-aspect-override is managed by ambient lighting.
void updateVideoFilter() async {
Future<void> updateVideoFilter() async {
try {
// ExoPlayer handles scaling via AspectRatioFrameLayout. The MPV properties
// below still run — on PlayerAndroid they forward to setMpvProperty, which
// queues them for any future fallback to MPV.
if (player is PlayerAndroid) {
await (player as PlayerAndroid).setBoxFitMode(_boxFitMode);
await (player as PlayerAndroid).setVideoZoom(_zoomScale);
}
if (ambientLightingService?.isEnabled != true) {
@@ -143,6 +179,7 @@ class VideoFilterManager {
}
await player.setProperty('sub-ass-force-margins', 'no');
await player.setProperty('panscan', '0');
await player.setProperty('video-zoom', videoZoomPropertyForScale(_zoomScale).toString());
if (_boxFitMode == 1) {
// Cover mode - use panscan to fill screen while maintaining aspect ratio
@@ -156,6 +193,10 @@ class VideoFilterManager {
appLogger.d('Stretch mode: aspect-override=$playerAspect (player: $_playerSize)');
}
}
if (_zoomScale > 1.0001) {
await player.setProperty('sub-ass-force-margins', 'yes');
}
} catch (e) {
appLogger.w('Failed to update video filter', error: e);
}
@@ -0,0 +1,136 @@
import 'dart:ui' show Offset;
import 'package:flutter/gestures.dart' show kDoubleTapSlop, kDoubleTapTimeout, kDoubleTapTouchSlop;
class TwoFingerDoubleTapTracker {
TwoFingerDoubleTapTracker({
DateTime Function()? now,
this.tapTimeout = kDoubleTapTimeout,
this.doubleTapTimeout = kDoubleTapTimeout,
this.tapSlop = kDoubleTapTouchSlop,
this.doubleTapSlop = kDoubleTapSlop,
}) : _now = now ?? DateTime.now;
final DateTime Function() _now;
final Duration tapTimeout;
final Duration doubleTapTimeout;
final double tapSlop;
final double doubleTapSlop;
final Map<int, _TrackedTouch> _activeTouches = {};
DateTime? _candidateStartTime;
Offset? _candidateFocalPoint;
bool _candidateActive = false;
bool _candidateInvalid = false;
DateTime? _lastTapTime;
Offset? _lastTapFocalPoint;
bool get isChordActive => _activeTouches.length > 1 || _candidateActive;
void pointerDown(int pointer, Offset position) {
_activeTouches[pointer] = _TrackedTouch(start: position, current: position, downTime: _now());
if (_activeTouches.length == 2) {
_candidateActive = true;
_candidateInvalid = false;
_candidateStartTime = _earliestActiveDownTime();
_candidateFocalPoint = _activeFocalPoint();
} else if (_activeTouches.length > 2) {
_candidateInvalid = true;
}
}
void pointerMove(int pointer, Offset position) {
final touch = _activeTouches[pointer];
if (touch == null) return;
touch.current = position;
if ((position - touch.start).distance > tapSlop) {
_candidateInvalid = true;
}
}
bool pointerUp(int pointer, Offset position) {
final touch = _activeTouches[pointer];
if (touch == null) return false;
touch.current = position;
if ((position - touch.start).distance > tapSlop) {
_candidateInvalid = true;
}
_activeTouches.remove(pointer);
if (_activeTouches.isNotEmpty) return false;
final startTime = _candidateStartTime;
final focalPoint = _candidateFocalPoint;
final isTap =
_candidateActive &&
!_candidateInvalid &&
startTime != null &&
focalPoint != null &&
_now().difference(startTime) <= tapTimeout;
_clearCandidate();
if (!isTap) return false;
return _recordTwoFingerTap(focalPoint);
}
void pointerCancel(int pointer) {
_activeTouches.remove(pointer);
_candidateInvalid = true;
if (_activeTouches.isEmpty) _clearCandidate();
}
void resetSeries() {
_lastTapTime = null;
_lastTapFocalPoint = null;
}
DateTime _earliestActiveDownTime() {
return _activeTouches.values.map((touch) => touch.downTime).reduce((a, b) => a.isBefore(b) ? a : b);
}
Offset _activeFocalPoint() {
var sum = Offset.zero;
for (final touch in _activeTouches.values) {
sum += touch.current;
}
return sum / _activeTouches.length.toDouble();
}
bool _recordTwoFingerTap(Offset focalPoint) {
final now = _now();
final lastTapTime = _lastTapTime;
final lastTapFocalPoint = _lastTapFocalPoint;
final isDoubleTap =
lastTapTime != null &&
lastTapFocalPoint != null &&
now.difference(lastTapTime) <= doubleTapTimeout &&
(focalPoint - lastTapFocalPoint).distance <= doubleTapSlop;
if (isDoubleTap) {
resetSeries();
return true;
}
_lastTapTime = now;
_lastTapFocalPoint = focalPoint;
return false;
}
void _clearCandidate() {
_candidateStartTime = null;
_candidateFocalPoint = null;
_candidateActive = false;
_candidateInvalid = false;
}
}
class _TrackedTouch {
_TrackedTouch({required this.start, required this.current, required this.downTime});
final Offset start;
Offset current;
final DateTime downTime;
}
@@ -24,6 +24,7 @@ class TrackControlsState {
/// to show estimated file sizes per preset (bitrate × duration).
final int? sourceDurationMs;
final int boxFitMode;
final double videoZoomScale;
final int audioSyncOffset;
final int subtitleSyncOffset;
final bool isRotationLocked;
@@ -32,6 +33,8 @@ class TrackControlsState {
final bool isAlwaysOnTop;
final VoidCallback? onTogglePIPMode;
final VoidCallback? onCycleBoxFitMode;
final ValueChanged<double>? onVideoZoomChanged;
final VoidCallback? onResetVideoZoom;
final VoidCallback? onToggleRotationLock;
final VoidCallback? onToggleScreenLock;
final VoidCallback? onToggleFullscreen;
@@ -80,6 +83,7 @@ class TrackControlsState {
this.sourcePartId,
this.sourceDurationMs,
this.boxFitMode = 0,
this.videoZoomScale = 1.0,
this.audioSyncOffset = 0,
this.subtitleSyncOffset = 0,
this.isRotationLocked = false,
@@ -88,6 +92,8 @@ class TrackControlsState {
this.isAlwaysOnTop = false,
this.onTogglePIPMode,
this.onCycleBoxFitMode,
this.onVideoZoomChanged,
this.onResetVideoZoom,
this.onToggleRotationLock,
this.onToggleScreenLock,
this.onToggleFullscreen,
@@ -156,6 +156,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
onNextEpisode: widget.onNext,
onPreviousEpisode: widget.onPrevious,
onScreenshot: _showScreenshotToast,
onZoomIn: widget.onZoomIn,
onZoomOut: widget.onZoomOut,
onZoomReset: widget.onResetVideoZoom,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
);
@@ -304,12 +307,14 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
onNextEpisode: widget.onNext,
onPreviousEpisode: widget.onPrevious,
onScreenshot: _showScreenshotToast,
onZoomIn: widget.onZoomIn,
onZoomOut: widget.onZoomOut,
onZoomReset: widget.onResetVideoZoom,
currentPositionEpoch: widget.currentPositionEpoch,
onLiveSeek: widget.onLiveSeek,
onSeekRequested: widget.onSeekRequested,
);
// Let non-navigation keys (volume, etc.) pass through to the OS.
if (!event.logicalKey.isNavigationKey) return KeyEventResult.ignored;
if (!event.logicalKey.isNavigationKey) return result;
// Never return .ignored for navigation keys — prevent leaking to previous routes.
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
}
@@ -1,6 +1,8 @@
part of '../video_controls.dart';
extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
static const Duration _touchTapSuppressionPadding = Duration(milliseconds: 80);
void _onRateChanged(double newRate) {
if (!mounted) return;
if (_isLongPressing) return;
@@ -95,9 +97,52 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
unawaited(_seekToPosition(position));
}
bool get _isTouchTapSuppressed {
final until = _suppressTouchTapUntil;
if (until == null) return false;
if (DateTime.now().isAfter(until)) {
_suppressTouchTapUntil = null;
return false;
}
return true;
}
void _suppressTouchTaps() {
_singleTapTimer?.cancel();
_singleTapTimer = null;
_suppressTouchTapUntil = DateTime.now().add(kDoubleTapTimeout + _touchTapSuppressionPadding);
}
void _handleTouchPointerDown(PointerDownEvent event) {
if (event.kind != PointerDeviceKind.touch) return;
_twoFingerDoubleTapTracker.pointerDown(event.pointer, event.position);
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
}
void _handleTouchPointerMove(PointerMoveEvent event) {
if (event.kind != PointerDeviceKind.touch) return;
_twoFingerDoubleTapTracker.pointerMove(event.pointer, event.position);
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
}
void _handleTouchPointerUp(PointerUpEvent event) {
if (event.kind != PointerDeviceKind.touch) return;
final isResetGesture = _twoFingerDoubleTapTracker.pointerUp(event.pointer, event.position);
if (_isTouchTapSuppressed || isResetGesture) _suppressTouchTaps();
if (isResetGesture) widget.onResetVideoZoom?.call();
}
void _handleTouchPointerCancel(PointerCancelEvent event) {
if (event.kind != PointerDeviceKind.touch) return;
_twoFingerDoubleTapTracker.pointerCancel(event.pointer);
if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps();
}
/// Timing-based double-click detection: avoids `onDoubleTap`'s ~300 ms
/// tap-resolution delay and the arena competition it introduces.
void _handleOuterTap() {
if (PlatformDetector.isMobile(context) && _isTouchTapSuppressed) return;
if (widget.canControl && _clickVideoTogglesPlayback) {
_playOrPause();
} else {
@@ -117,6 +162,8 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
/// Handle tap in skip zone with custom double-tap detection
void _handleTapInSkipZone({required bool isForward}) {
if (_isTouchTapSuppressed) return;
final now = DateTime.now();
// Cancel any pending single-tap action
@@ -253,6 +300,8 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
return;
}
if (_isTouchTapSuppressed) return;
final skipZone = mobileSkipZoneForTap(position: details.localPosition, size: size);
if (skipZone != null) {
_handleTapInSkipZone(isForward: skipZone);
@@ -101,6 +101,7 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
sourceDurationMs: widget.metadata.durationMs,
boxFitMode: widget.boxFitMode,
videoZoomScale: widget.videoZoomScale,
audioSyncOffset: _audioSyncOffset,
subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked,
@@ -109,6 +110,8 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
onCycleBoxFitMode: widget.onCycleBoxFitMode,
onVideoZoomChanged: widget.onVideoZoomChanged,
onResetVideoZoom: widget.onResetVideoZoom,
onToggleRotationLock: _toggleRotationLock,
onToggleScreenLock: _toggleScreenLock,
onToggleFullscreen: _toggleFullscreen,
@@ -20,6 +20,7 @@ import '../../../services/file_picker_service.dart';
import '../../../services/settings_service.dart';
import '../../../services/shader_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../services/video_filter_manager.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../utils/dialogs.dart';
import '../../../utils/formatters.dart';
@@ -35,7 +36,18 @@ import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'version_quality_sheet.dart';
enum _SettingsView { menu, speed, versionQuality, sleep, audioSync, subtitleSync, audioDevice, shader, dvConversion }
enum _SettingsView {
menu,
speed,
zoom,
versionQuality,
sleep,
audioSync,
subtitleSync,
audioDevice,
shader,
dvConversion,
}
class _SettingsMenuItem extends StatelessWidget {
final IconData icon;
@@ -115,6 +127,9 @@ class VideoSettingsSheet extends StatefulWidget {
final Player player;
final int audioSyncOffset;
final int subtitleSyncOffset;
final double videoZoomScale;
final ValueChanged<double>? onVideoZoomChanged;
final VoidCallback? onResetVideoZoom;
/// Whether the user can control playback (false hides speed option in host-only mode).
final bool canControl;
@@ -157,6 +172,9 @@ class VideoSettingsSheet extends StatefulWidget {
required this.player,
required this.audioSyncOffset,
required this.subtitleSyncOffset,
this.videoZoomScale = 1.0,
this.onVideoZoomChanged,
this.onResetVideoZoom,
this.canControl = true,
this.isLive = false,
this.availableVersions = const [],
@@ -183,6 +201,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_SettingsView _currentView = _SettingsView.menu;
late int _audioSyncOffset;
late int _subtitleSyncOffset;
late double _zoomScale;
String _dvConversionMode = 'auto';
bool get _showDebugDvConversionMode {
@@ -196,9 +215,18 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
super.initState();
_audioSyncOffset = widget.audioSyncOffset;
_subtitleSyncOffset = widget.subtitleSyncOffset;
_zoomScale = widget.videoZoomScale;
_loadDebugDvConversionMode();
}
@override
void didUpdateWidget(covariant VideoSettingsSheet oldWidget) {
super.didUpdateWidget(oldWidget);
if ((widget.videoZoomScale - oldWidget.videoZoomScale).abs() > 0.0001) {
_zoomScale = widget.videoZoomScale;
}
}
Future<void> _loadDebugDvConversionMode() async {
if (!_showDebugDvConversionMode) return;
final dvConversionMode = await widget.player.getProperty('dv-conversion-mode');
@@ -292,6 +320,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return t.videoControls.settingsButton;
case _SettingsView.speed:
return t.videoSettings.playbackSpeed;
case _SettingsView.zoom:
return t.videoSettings.zoom;
case _SettingsView.versionQuality:
return _versionQualityTitle();
case _SettingsView.sleep:
@@ -315,6 +345,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return Symbols.tune_rounded;
case _SettingsView.speed:
return Symbols.speed_rounded;
case _SettingsView.zoom:
return Symbols.zoom_in_rounded;
case _SettingsView.versionQuality:
return Symbols.art_track;
case _SettingsView.sleep:
@@ -357,6 +389,28 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return 'Active (${formatDurationWithSeconds(remaining)})';
}
String _formatZoomScale(double scale) => '${(scale * 100).round()}%';
void _setZoomScale(double scale) {
final next = scale.clamp(VideoFilterManager.minZoomScale, VideoFilterManager.maxZoomScale).toDouble();
setState(() {
_zoomScale = next;
});
widget.onVideoZoomChanged?.call(next);
}
void _resetZoomScale() {
setState(() {
_zoomScale = 1.0;
});
final reset = widget.onResetVideoZoom;
if (reset != null) {
reset();
} else {
widget.onVideoZoomChanged?.call(1.0);
}
}
bool get _hasVersionQuality {
return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) &&
(widget.onVersionSelected != null || widget.onQualitySelected != null);
@@ -406,6 +460,15 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
},
),
if (widget.onVideoZoomChanged != null || widget.onResetVideoZoom != null)
_SettingsMenuItem(
icon: Symbols.zoom_in_rounded,
title: t.videoSettings.zoom,
valueText: _formatZoomScale(_zoomScale),
isHighlighted: (_zoomScale - 1.0).abs() > 0.0001,
onTap: () => _navigateTo(_SettingsView.zoom),
),
if (_hasVersionQuality)
_SettingsMenuItem(
icon: Symbols.art_track,
@@ -633,6 +696,32 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
Widget _buildZoomView() {
const zoomPresets = [0.5, 0.75, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.75, 2.0];
final primary = Theme.of(context).colorScheme.primary;
return ListView(
children: [
FocusableListTile(
leading: AppIcon(Symbols.restart_alt_rounded, fill: 1, color: tokens(context).textMuted),
title: Text(t.common.reset),
onTap: _resetZoomScale,
),
for (final scale in zoomPresets)
FocusableListTile(
title: Text(
_formatZoomScale(scale),
style: TextStyle(color: (_zoomScale - scale).abs() < 0.005 ? primary : null),
),
trailing: (_zoomScale - scale).abs() < 0.005
? AppIcon(Symbols.check_rounded, fill: 1, color: primary)
: null,
onTap: () => _setZoomScale(scale),
),
],
);
}
Widget _buildSleepView() {
final sleepTimer = SleepTimerService();
@@ -894,9 +983,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
Widget build(BuildContext context) {
final sleepTimer = SleepTimerService();
final isShaderActive = widget.shaderService != null && widget.shaderService!.currentPreset.isEnabled;
final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001;
final isIconActive =
_currentView == _SettingsView.menu &&
(sleepTimer.isActive || _audioSyncOffset != 0 || _subtitleSyncOffset != 0 || isShaderActive);
(sleepTimer.isActive || _audioSyncOffset != 0 || _subtitleSyncOffset != 0 || isShaderActive || isZoomActive);
return BaseVideoControlSheet(
title: _getTitle(),
@@ -913,6 +1003,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return _buildMenuView();
case _SettingsView.speed:
return _buildSpeedView();
case _SettingsView.zoom:
return _buildZoomView();
case _SettingsView.versionQuality:
return _buildVersionQualityView();
case _SettingsView.sleep:
+27 -1
View File
@@ -1,7 +1,16 @@
import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform;
import 'package:flutter/gestures.dart' show PointerSignalEvent, PointerScrollEvent;
import 'package:flutter/gestures.dart'
show
PointerCancelEvent,
PointerDeviceKind,
PointerDownEvent,
PointerMoveEvent,
PointerScrollEvent,
PointerSignalEvent,
PointerUpEvent,
kDoubleTapTimeout;
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -54,6 +63,7 @@ import '../../i18n/strings.g.dart';
import '../../focus/input_mode_tracker.dart';
import 'models/track_controls_state.dart';
import 'widgets/double_tap_feedback.dart';
import 'helpers/two_finger_double_tap_tracker.dart';
import 'widgets/linux_keep_alive.dart';
import 'widgets/mobile_skip_zones.dart';
import 'widgets/skip_marker_button.dart';
@@ -150,8 +160,13 @@ class PlexVideoControls extends StatefulWidget {
final int? selectedSubtitleStreamId;
final int? sourcePartId;
final int boxFitMode;
final double videoZoomScale;
final VoidCallback? onTogglePIPMode;
final VoidCallback? onCycleBoxFitMode;
final ValueChanged<double>? onVideoZoomChanged;
final VoidCallback? onZoomIn;
final VoidCallback? onZoomOut;
final VoidCallback? onResetVideoZoom;
final VoidCallback? onCycleAudioTrack;
final VoidCallback? onCycleSubtitleTrack;
final Function(AudioTrack)? onAudioTrackChanged;
@@ -247,8 +262,13 @@ class PlexVideoControls extends StatefulWidget {
this.selectedSubtitleStreamId,
this.sourcePartId,
this.boxFitMode = 0,
this.videoZoomScale = 1.0,
this.onTogglePIPMode,
this.onCycleBoxFitMode,
this.onVideoZoomChanged,
this.onZoomIn,
this.onZoomOut,
this.onResetVideoZoom,
this.onCycleAudioTrack,
this.onCycleSubtitleTrack,
this.onAudioTrackChanged,
@@ -324,6 +344,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
bool _lastSkipTapWasForward = true;
DateTime? _lastSkipActionTime; // Debounce: prevents double-tap counting as 2 skips
Timer? _singleTapTimer; // Timer for delayed single-tap action (toggle controls)
final TwoFingerDoubleTapTracker _twoFingerDoubleTapTracker = TwoFingerDoubleTapTracker();
DateTime? _suppressTouchTapUntil;
// Seek throttle
late final Throttle _seekThrottle;
// Current marker state
@@ -553,6 +575,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
onKeyEvent: (node, event) => _handleControlsKeyEvent(event, isMobile),
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: isMobile ? _handleTouchPointerDown : null,
onPointerMove: isMobile ? _handleTouchPointerMove : null,
onPointerUp: isMobile ? _handleTouchPointerUp : null,
onPointerCancel: isMobile ? _handleTouchPointerCancel : null,
onPointerSignal: _handlePointerSignal,
child: MouseRegion(
cursor: (_showControls || _forceShowControls) ? SystemMouseCursors.basic : SystemMouseCursors.none,
@@ -70,6 +70,7 @@ class TrackChapterControls extends StatelessWidget {
bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding;
ValueChanged<TranscodeQualityPreset>? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset;
int get boxFitMode => trackControlsState.boxFitMode;
double get videoZoomScale => trackControlsState.videoZoomScale;
int get audioSyncOffset => trackControlsState.audioSyncOffset;
int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset;
bool get isRotationLocked => trackControlsState.isRotationLocked;
@@ -78,6 +79,8 @@ class TrackChapterControls extends StatelessWidget {
bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop;
VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode;
VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode;
ValueChanged<double>? get onVideoZoomChanged => trackControlsState.onVideoZoomChanged;
VoidCallback? get onResetVideoZoom => trackControlsState.onResetVideoZoom;
VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock;
VoidCallback? get onToggleScreenLock => trackControlsState.onToggleScreenLock;
VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen;
@@ -196,7 +199,13 @@ class TrackChapterControls extends StatelessWidget {
final sleepTimer = SleepTimerService();
final isShaderActive =
shaderService != null && shaderService!.isSupported && shaderService!.currentPreset.isEnabled;
final isActive = sleepTimer.isActive || audioSyncOffset != 0 || subtitleSyncOffset != 0 || isShaderActive;
final isZoomActive = (videoZoomScale - 1.0).abs() > 0.0001;
final isActive =
sleepTimer.isActive ||
audioSyncOffset != 0 ||
subtitleSyncOffset != 0 ||
isShaderActive ||
isZoomActive;
return _buildTrackButton(
buttonIndex: 0,
icon: Symbols.tune_rounded,
@@ -213,6 +222,9 @@ class TrackChapterControls extends StatelessWidget {
player: player,
audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset,
videoZoomScale: videoZoomScale,
onVideoZoomChanged: onVideoZoomChanged,
onResetVideoZoom: onResetVideoZoom,
canControl: canControl,
isLive: isLive,
availableVersions: availableVersions,
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/keyboard_shortcuts_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_filter_manager.dart';
import '../test_helpers/prefs.dart';
@@ -13,7 +14,7 @@ void main() {
SettingsService.resetForTesting();
});
testWidgets('Ctrl+S takes a screenshot and shows feedback', (tester) async {
testWidgets('Ctrl+S takes a screenshot once while held', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
@@ -35,15 +36,197 @@ void main() {
null,
onScreenshot: () => feedbackCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
const KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.keyS,
logicalKey: LogicalKeyboardKey.keyS,
timeStamp: Duration(milliseconds: 30),
),
player,
null,
null,
null,
null,
null,
null,
onScreenshot: () => feedbackCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(result, KeyEventResult.handled);
expect(repeatResult, KeyEventResult.handled);
expect(player.commands, [
['screenshot', 'subtitles'],
]);
expect(feedbackCount, 1);
});
testWidgets('Alt+Plus triggers zoom in callback', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
var zoomInCount = 0;
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
final result = service.handleVideoPlayerKeyEvent(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.equal,
logicalKey: LogicalKeyboardKey.equal,
timeStamp: Duration.zero,
),
player,
null,
null,
null,
null,
null,
null,
onZoomIn: () => zoomInCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
expect(result, KeyEventResult.handled);
expect(zoomInCount, 1);
});
testWidgets('Alt+Plus repeats zoom in callback while held', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
var zoomInCount = 0;
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
final downResult = service.handleVideoPlayerKeyEvent(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.equal,
logicalKey: LogicalKeyboardKey.equal,
timeStamp: Duration.zero,
),
player,
null,
null,
null,
null,
null,
null,
onZoomIn: () => zoomInCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
const KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.equal,
logicalKey: LogicalKeyboardKey.equal,
timeStamp: Duration(milliseconds: 30),
),
player,
null,
null,
null,
null,
null,
null,
onZoomIn: () => zoomInCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
expect(downResult, KeyEventResult.handled);
expect(repeatResult, KeyEventResult.handled);
expect(zoomInCount, 2);
});
testWidgets('Alt+Minus repeats zoom out callback while held', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
var zoomOutCount = 0;
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
final downResult = service.handleVideoPlayerKeyEvent(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.minus,
logicalKey: LogicalKeyboardKey.minus,
timeStamp: Duration.zero,
),
player,
null,
null,
null,
null,
null,
null,
onZoomOut: () => zoomOutCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
const KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.minus,
logicalKey: LogicalKeyboardKey.minus,
timeStamp: Duration(milliseconds: 30),
),
player,
null,
null,
null,
null,
null,
null,
onZoomOut: () => zoomOutCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
expect(downResult, KeyEventResult.handled);
expect(repeatResult, KeyEventResult.handled);
expect(zoomOutCount, 2);
});
testWidgets('Alt+Backspace reset does not repeat while held', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
final player = _FakePlayer();
var resetCount = 0;
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
final downResult = service.handleVideoPlayerKeyEvent(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.backspace,
logicalKey: LogicalKeyboardKey.backspace,
timeStamp: Duration.zero,
),
player,
null,
null,
null,
null,
null,
null,
onZoomReset: () => resetCount++,
);
final repeatResult = service.handleVideoPlayerKeyEvent(
const KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.backspace,
logicalKey: LogicalKeyboardKey.backspace,
timeStamp: Duration(milliseconds: 30),
),
player,
null,
null,
null,
null,
null,
null,
onZoomReset: () => resetCount++,
);
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
expect(downResult, KeyEventResult.handled);
expect(repeatResult, KeyEventResult.handled);
expect(resetCount, 1);
});
test('video zoom scale maps to mpv logarithmic property', () {
expect(VideoFilterManager.videoZoomPropertyForScale(1.0), closeTo(0.0, 0.0001));
expect(VideoFilterManager.videoZoomPropertyForScale(2.0), closeTo(1.0, 0.0001));
expect(VideoFilterManager.videoZoomPropertyForScale(0.5), closeTo(-1.0, 0.0001));
});
}
class _FakePlayer implements Player {
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart';
void main() {
late DateTime now;
late TwoFingerDoubleTapTracker tracker;
void advance(Duration duration) {
now = now.add(duration);
}
setUp(() {
now = DateTime(2026);
tracker = TwoFingerDoubleTapTracker(now: () => now);
});
test('detects two two-finger taps as double tap', () {
tracker.pointerDown(1, const Offset(100, 100));
tracker.pointerDown(2, const Offset(140, 100));
expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse);
expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse);
advance(const Duration(milliseconds: 120));
tracker.pointerDown(3, const Offset(102, 102));
tracker.pointerDown(4, const Offset(142, 102));
expect(tracker.pointerUp(3, const Offset(102, 102)), isFalse);
expect(tracker.pointerUp(4, const Offset(142, 102)), isTrue);
});
test('does not detect one-finger double tap', () {
tracker.pointerDown(1, const Offset(100, 100));
expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse);
advance(const Duration(milliseconds: 120));
tracker.pointerDown(2, const Offset(100, 100));
expect(tracker.pointerUp(2, const Offset(100, 100)), isFalse);
});
test('movement invalidates a candidate two-finger tap', () {
tracker.pointerDown(1, const Offset(100, 100));
tracker.pointerDown(2, const Offset(140, 100));
tracker.pointerMove(1, const Offset(140, 140));
expect(tracker.pointerUp(1, const Offset(140, 140)), isFalse);
expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse);
advance(const Duration(milliseconds: 120));
tracker.pointerDown(3, const Offset(100, 100));
tracker.pointerDown(4, const Offset(140, 100));
expect(tracker.pointerUp(3, const Offset(100, 100)), isFalse);
expect(tracker.pointerUp(4, const Offset(140, 100)), isFalse);
});
test('third finger invalidates a candidate two-finger tap', () {
tracker.pointerDown(1, const Offset(100, 100));
tracker.pointerDown(2, const Offset(140, 100));
tracker.pointerDown(3, const Offset(180, 100));
expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse);
expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse);
expect(tracker.pointerUp(3, const Offset(180, 100)), isFalse);
advance(const Duration(milliseconds: 120));
tracker.pointerDown(4, const Offset(100, 100));
tracker.pointerDown(5, const Offset(140, 100));
expect(tracker.pointerUp(4, const Offset(100, 100)), isFalse);
expect(tracker.pointerUp(5, const Offset(140, 100)), isFalse);
});
}