From 2b7ea5fe02fb66852b92b85918f3d3f3d39f890b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 28 May 2026 23:32:20 +0200 Subject: [PATCH] feat(video): add custom zoom controls close #1159 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 32 ++- .../plezy/exoplayer/ExoPlayerPlugin.kt | 17 ++ lib/i18n/bg.i18n.json | 5 + lib/i18n/da.i18n.json | 5 + lib/i18n/de.i18n.json | 5 + lib/i18n/en.i18n.json | 5 + lib/i18n/es.i18n.json | 5 + lib/i18n/fr.i18n.json | 5 + lib/i18n/it.i18n.json | 5 + lib/i18n/ja.i18n.json | 5 + lib/i18n/ko.i18n.json | 5 + lib/i18n/nb.i18n.json | 5 + lib/i18n/nl.i18n.json | 5 + lib/i18n/pl.i18n.json | 5 + lib/i18n/pt.i18n.json | 5 + lib/i18n/ru.i18n.json | 5 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 18 +- lib/i18n/strings_da.g.dart | 18 +- lib/i18n/strings_de.g.dart | 18 +- lib/i18n/strings_en.g.dart | 28 ++- lib/i18n/strings_es.g.dart | 18 +- lib/i18n/strings_fr.g.dart | 18 +- lib/i18n/strings_it.g.dart | 18 +- lib/i18n/strings_ja.g.dart | 18 +- lib/i18n/strings_ko.g.dart | 18 +- lib/i18n/strings_nb.g.dart | 18 +- lib/i18n/strings_nl.g.dart | 18 +- lib/i18n/strings_pl.g.dart | 18 +- lib/i18n/strings_pt.g.dart | 18 +- lib/i18n/strings_ru.g.dart | 18 +- lib/i18n/strings_sv.g.dart | 18 +- lib/i18n/strings_zh.g.dart | 18 +- lib/i18n/sv.i18n.json | 5 + lib/i18n/zh.i18n.json | 5 + lib/mpv/player/platform/player_android.dart | 6 + lib/screens/video_player/parts/build.dart | 51 ++++- lib/screens/video_player/parts/shader.dart | 36 +++- lib/screens/video_player_screen.dart | 3 + lib/services/keyboard_shortcuts_service.dart | 35 +++- lib/services/settings_service.dart | 6 + lib/services/video_filter_manager.dart | 65 ++++-- .../two_finger_double_tap_tracker.dart | 136 +++++++++++++ .../models/track_controls_state.dart | 6 + .../video_controls/parts/key_events.dart | 9 +- .../video_controls/parts/playback_input.dart | 49 +++++ .../video_controls/parts/track_controls.dart | 3 + .../sheets/video_settings_sheet.dart | 96 ++++++++- .../video_controls/video_controls.dart | 28 ++- .../widgets/track_chapter_controls.dart | 14 +- .../keyboard_shortcuts_service_test.dart | 185 +++++++++++++++++- ...ls_two_finger_double_tap_tracker_test.dart | 72 +++++++ 52 files changed, 1123 insertions(+), 106 deletions(-) create mode 100644 lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart create mode 100644 test/widgets/video_controls_two_finger_double_tap_tracker_test.dart diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index e8c7520e..73f6b9ca 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 96589f94..55ad5649 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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("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("mode") if (mode == null) { diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index e90d6263..9389d4a2 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -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": "Синхронизация на субтитри", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index b053a16c..119763df 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 2b6f4d10..5dc51b1a 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index c8d7e49a..677f8fd5 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 23ebcf01..32ed58b0 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4f815422..2e0c47a0 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index a07a4158..ec4e175a 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 9c9878f2..00e19346 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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": "字幕同期", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 947cd1c9..bd014f3b 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "자막 동기화", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 452f8a79..02836e9a 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 86c93759..c845b2a7 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 56196b34..5c8afe08 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index fc684349..08fe02d0 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 71222002..d4e17c33 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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": "Синхронизация субтитров", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 66ff8329..0794f733 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 47412bca..1bf00e71 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -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' => 'Резюме', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 5641d442..37d004c6 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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é', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 9f76fa30..8f161fac 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 5c0edb90..d2617551 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 73ea4964..dc223df9 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 7a08db8d..163d202c 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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é', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index a31fde13..ca8cd653 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 872ea816..471fa7be 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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' => 'あらすじ', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 7bb93104..49e5fea6 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '줄거리', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 1f0be9b5..4ca62f9f 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index d9107c6c..d5fab26c 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index e4867339..3c1c5af1 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 67b1ec8b..e2680208 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 247374ac..1184797c 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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' => 'Описание', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index f4520f33..9951324d 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 7cb320a4..c73def89 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '简介', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 87be6b4d..2db408b4 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index d6cbbf83..60c5f09b 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "字幕同步", diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 80120504..df5ae565 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -372,6 +372,12 @@ class PlayerAndroid extends PlayerBase { await invoke('setBoxFitMode', {'mode': mode}); } + /// Apply custom zoom to the native ExoPlayer layer. + Future setVideoZoom(double scale) async { + if (disposed || !initialized) return; + await invoke('setVideoZoom', {'scale': scale}); + } + @override Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async { if (disposed || !initialized) return false; diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 2bb2920e..cc7e4128 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -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, diff --git a/lib/screens/video_player/parts/shader.dart b/lib/screens/video_player/parts/shader.dart index ac9fdea0..4d09d9de 100644 --- a/lib/screens/video_player/parts/shader.dart +++ b/lib/screens/video_player/parts/shader.dart @@ -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(); - }); - } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 07035c48..a2b5b38b 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -400,6 +400,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin Size? _pendingVideoLayoutSize; Player? _lastVideoLayoutPlayer; bool _videoLayoutUpdateScheduled = false; + double? _pinchStartZoomScale; + bool _isPinchZooming = false; + bool _pinchZoomChanged = false; final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService(); WatchTogetherProvider? _watchTogetherProvider; diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 7af22655..abdcedae 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -12,6 +12,8 @@ import '../utils/platform_detector.dart'; import '../utils/player_utils.dart'; class KeyboardShortcutsService extends ChangeNotifier { + static const Set _repeatableVideoActions = {'zoom_in', 'zoom_out'}; + static KeyboardShortcutsService? _instance; late SettingsService _settingsService; final List _settingsDisposers = []; @@ -183,13 +185,17 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onNextEpisode, VoidCallback? onPreviousEpisode, VoidCallback? onScreenshot, + VoidCallback? onZoomIn, + VoidCallback? onZoomOut, + VoidCallback? onZoomReset, int? currentPositionEpoch, ValueChanged? onLiveSeek, Future 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? onLiveSeek, Future 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; } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 59616fe4..c23f8d30 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -216,6 +216,9 @@ Map _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 _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), diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 9cac9608..6f40d822 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -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 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 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); } diff --git a/lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart b/lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart new file mode 100644 index 00000000..9e71db89 --- /dev/null +++ b/lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart @@ -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 _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; +} diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 1a8ca658..303bccd1 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -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? 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, diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 297e0d73..be637b47 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -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; } diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index a461a7d0..6be92c21 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -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); diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index cccb8d0d..b024c31c 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -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, diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 2f54428e..48f11eaa 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -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? 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 { _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 { 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 _loadDebugDvConversionMode() async { if (!_showDebugDvConversionMode) return; final dvConversionMode = await widget.player.getProperty('dv-conversion-mode'); @@ -292,6 +320,8 @@ class _VideoSettingsSheetState extends State { 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 { 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 { 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 { }, ), + 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 { ); } + 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 { 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 { return _buildMenuView(); case _SettingsView.speed: return _buildSpeedView(); + case _SettingsView.zoom: + return _buildZoomView(); case _SettingsView.versionQuality: return _buildVersionQualityView(); case _SettingsView.sleep: diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 41a030de..f01ac5ac 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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? 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 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 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, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 9fae18e9..d682a304 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -70,6 +70,7 @@ class TrackChapterControls extends StatelessWidget { bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding; ValueChanged? 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? 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, diff --git a/test/services/keyboard_shortcuts_service_test.dart b/test/services/keyboard_shortcuts_service_test.dart index cf99c112..fe36d279 100644 --- a/test/services/keyboard_shortcuts_service_test.dart +++ b/test/services/keyboard_shortcuts_service_test.dart @@ -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 { diff --git a/test/widgets/video_controls_two_finger_double_tap_tracker_test.dart b/test/widgets/video_controls_two_finger_double_tap_tracker_test.dart new file mode 100644 index 00000000..ba695281 --- /dev/null +++ b/test/widgets/video_controls_two_finger_double_tap_tracker_test.dart @@ -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); + }); +}