diff --git a/AGENTS.md b/AGENTS.md index 1db1a1fa6..a8069f79e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,28 @@ Key files: double-click ownership. Diagnostic interaction gating and owned-fullscreen exit match the other web players. The preference-off path keeps the legacy ArtPlayer skin, source behavior, and series navigation unchanged. +- Shared web picture-in-picture stays inside that default-off rollout. + `PlayerController` exposes capability `pictureInPicture`, state + `pictureInPictureActive`/`canPictureInPicture`, and command + `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard + element PiP from the adapter's attached video; shared ArtPlayer keeps vendor + `pip: false`, while preference-off native/vendor paths remain unchanged. The + capability-gated button sits before fullscreen and uses active enter/exit + semantics; entry is disabled until metadata, and the action is disabled while + an operation is pending. Embedded MPV reports capability/state false with a + no-op command and has no popup/mini-window. +- `WebVideoControlsAdapter` supplies its current video and binding generation to + `WebVideoPictureInPictureController`; the controller reads the video's + `ownerDocument`, while browser enter/leave events remain authoritative. + Exact-owner exit stays available if request support changes. Request/exit + invocation remains synchronous for user activation, one operation is + serialized, and binding generation plus exact video identity protects + replacement and teardown from stale completion. Video.js Tech reset and + ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a + retained target preserve PiP. + Standard PiP shows the browser/OS video surface without Angular control + chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP + keyboard shortcut, and Embedded MPV popup/native support are out of scope. - Canonical docs: `docs/architecture/player-controls-contract.md` and `docs/architecture/embedded-mpv-native.md` diff --git a/CLAUDE.md b/CLAUDE.md index 1e649c494..dfad56813 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -619,6 +619,28 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. - Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine"). - Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. +- Shared web picture-in-picture stays inside that default-off rollout. + `PlayerController` exposes capability `pictureInPicture`, state + `pictureInPictureActive`/`canPictureInPicture`, and command + `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard + element PiP from the adapter's attached video; shared ArtPlayer keeps vendor + `pip: false`, while preference-off native/vendor paths remain unchanged. The + capability-gated button sits before fullscreen and uses active enter/exit + semantics; entry is disabled until metadata, and the action is disabled while + an operation is pending. Embedded MPV reports capability/state false with a + no-op command and has no popup/mini-window. +- `WebVideoControlsAdapter` supplies its current video and binding generation to + `WebVideoPictureInPictureController`; the controller reads the video's + `ownerDocument`, while browser enter/leave events remain authoritative. + Exact-owner exit stays available if request support changes. Request/exit + invocation remains synchronous for user activation, one operation is + serialized, and binding generation plus exact video identity protects + replacement and teardown from stale completion. Video.js Tech reset and + ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a + retained target preserve PiP. + Standard PiP shows the browser/OS video surface without Angular control + chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP + keyboard shortcut, and Embedded MPV popup/native support are out of scope. **VOD/Series Detail Pages (two-state layout)**: diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 308e22664..03ccc34b3 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -263,6 +263,13 @@ test.describe('Electron Settings', () => { await goToDashboard(app.mainWindow); await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); + await app.mainWindow.route( + 'https://example.channels/path-to-file/1.m3u8', + () => { + // Keep the synthetic stream pending so its network failure + // cannot replace the shared controls with a diagnostic. + } + ); const firstChannel = channelItemByTitle( app.mainWindow, @@ -271,14 +278,129 @@ test.describe('Electron Settings', () => { await expect(firstChannel).toBeVisible({ timeout: 20000 }); await firstChannel.click(); - await expect( - app.mainWindow.locator( - 'app-html-video-player app-player-controls' - ) - ).toBeVisible(); + const video = app.mainWindow.locator( + 'app-html-video-player video' + ); + await expect(video).toBeAttached(); + await video.evaluate((video) => { + const ownerDocument = video.ownerDocument; + let activePictureInPictureElement: Element | null = null; + video.dataset['pictureInPictureRequestCount'] = '0'; + video.dataset['pictureInPictureExitCount'] = '0'; + + Object.defineProperty( + ownerDocument, + 'pictureInPictureEnabled', + { + configurable: true, + value: true, + } + ); + Object.defineProperty( + ownerDocument, + 'pictureInPictureElement', + { + configurable: true, + get: () => activePictureInPictureElement, + } + ); + Object.defineProperty(ownerDocument, 'exitPictureInPicture', { + configurable: true, + value: async (): Promise => { + video.dataset['pictureInPictureExitCount'] = String( + Number( + video.dataset['pictureInPictureExitCount'] ?? + '0' + ) + 1 + ); + const previousOwner = activePictureInPictureElement; + activePictureInPictureElement = null; + previousOwner?.dispatchEvent( + new Event('leavepictureinpicture') + ); + }, + }); + Object.defineProperty(video, 'requestPictureInPicture', { + configurable: true, + value: async (): Promise => { + video.dataset['pictureInPictureRequestCount'] = String( + Number( + video.dataset['pictureInPictureRequestCount'] ?? + '0' + ) + 1 + ); + activePictureInPictureElement = video; + video.dispatchEvent(new Event('enterpictureinpicture')); + + const pictureInPictureWindow: PictureInPictureWindow = + Object.assign(new EventTarget(), { + height: video.videoHeight, + onresize: null, + width: video.videoWidth, + }); + return pictureInPictureWindow; + }, + }); + Object.defineProperty(video, 'disablePictureInPicture', { + configurable: true, + value: false, + }); + Object.defineProperty(video, 'readyState', { + configurable: true, + value: HTMLMediaElement.HAVE_METADATA, + }); + video.dispatchEvent(new Event('loadedmetadata')); + }); + + const playerControls = app.mainWindow.locator( + 'app-html-video-player app-player-controls' + ); + await expect(playerControls).toBeVisible(); await expect( app.mainWindow.locator('app-html-video-player video[controls]') ).toHaveCount(0); + const enterPictureInPicture = playerControls.getByRole('button', { + name: 'Enter picture-in-picture', + }); + + await expect(enterPictureInPicture).toBeVisible(); + await expect(enterPictureInPicture).toBeEnabled(); + await expect(enterPictureInPicture).toHaveAttribute( + 'aria-pressed', + 'false' + ); + await expect(video).toHaveAttribute( + 'data-picture-in-picture-request-count', + '0' + ); + await enterPictureInPicture.click(); + await expect(video).toHaveAttribute( + 'data-picture-in-picture-request-count', + '1' + ); + + const exitPictureInPicture = playerControls.getByRole('button', { + name: 'Exit picture-in-picture', + }); + await expect(exitPictureInPicture).toBeVisible(); + await expect(exitPictureInPicture).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(video).toHaveAttribute( + 'data-picture-in-picture-exit-count', + '0' + ); + await exitPictureInPicture.click(); + await expect(video).toHaveAttribute( + 'data-picture-in-picture-exit-count', + '1' + ); + await expect(enterPictureInPicture).toBeVisible(); + await expect(enterPictureInPicture).toHaveAttribute( + 'aria-pressed', + 'false' + ); } finally { await closeElectronApp(app); } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 81cb8bfec..d1ff0c9ba 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "فشل بدء التسجيل.", "RECORDING_FAILED_TO_STOP": "فشل إيقاف التسجيل.", "SAVED_TO": "تم الحفظ في {{path}}", + "ENTER_PICTURE_IN_PICTURE": "تشغيل صورة داخل صورة", + "EXIT_PICTURE_IN_PICTURE": "إنهاء صورة داخل صورة", "ENTER_FULLSCREEN": "ملء الشاشة", "EXIT_FULLSCREEN": "الخروج من ملء الشاشة" } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 8615f456b..ccd51471c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "التسجيل ما قدرش يبدا.", "RECORDING_FAILED_TO_STOP": "التسجيل ما قدرش يتوقف.", "SAVED_TO": "تحفظ في {{path}}", + "ENTER_PICTURE_IN_PICTURE": "شغّل صورة داخل صورة", + "EXIT_PICTURE_IN_PICTURE": "خرج من صورة داخل صورة", "ENTER_FULLSCREEN": "دخل لملء الشاشة", "EXIT_FULLSCREEN": "خرج من ملء الشاشة" } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 3c3f62f7f..3c8433486 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Не ўдалося пачаць запіс.", "RECORDING_FAILED_TO_STOP": "Не ўдалося спыніць запіс.", "SAVED_TO": "Захавана ў {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Уключыць рэжым «выява ў выяве»", + "EXIT_PICTURE_IN_PICTURE": "Выйсці з рэжыму «выява ў выяве»", "ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым", "EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму" } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4b311c69f..313309af8 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Aufnahme konnte nicht gestartet werden.", "RECORDING_FAILED_TO_STOP": "Aufnahme konnte nicht gestoppt werden.", "SAVED_TO": "Gespeichert unter {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Bild-in-Bild starten", + "EXIT_PICTURE_IN_PICTURE": "Bild-in-Bild beenden", "ENTER_FULLSCREEN": "Vollbild aktivieren", "EXIT_FULLSCREEN": "Vollbild beenden" } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index c65264fa3..afc38a1f4 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Η έναρξη της εγγραφής απέτυχε.", "RECORDING_FAILED_TO_STOP": "Η διακοπή της εγγραφής απέτυχε.", "SAVED_TO": "Αποθηκεύτηκε στο {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Έναρξη εικόνας εντός εικόνας", + "EXIT_PICTURE_IN_PICTURE": "Έξοδος από εικόνα εντός εικόνας", "ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη", "EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη" } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index dd305961d..5f33f3935 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Recording failed to start.", "RECORDING_FAILED_TO_STOP": "Recording failed to stop.", "SAVED_TO": "Saved to {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Enter picture-in-picture", + "EXIT_PICTURE_IN_PICTURE": "Exit picture-in-picture", "ENTER_FULLSCREEN": "Enter fullscreen", "EXIT_FULLSCREEN": "Exit fullscreen" } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 12395cd45..c05d3ffb7 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "No se pudo iniciar la grabación.", "RECORDING_FAILED_TO_STOP": "No se pudo detener la grabación.", "SAVED_TO": "Guardada en {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Activar imagen en imagen", + "EXIT_PICTURE_IN_PICTURE": "Salir de imagen en imagen", "ENTER_FULLSCREEN": "Entrar en pantalla completa", "EXIT_FULLSCREEN": "Salir de pantalla completa" } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 769382a0c..f98222115 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "L'enregistrement n'a pas pu démarrer.", "RECORDING_FAILED_TO_STOP": "L'enregistrement n'a pas pu s'arrêter.", "SAVED_TO": "Enregistré dans {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Activer le mode image dans l’image", + "EXIT_PICTURE_IN_PICTURE": "Quitter le mode image dans l’image", "ENTER_FULLSCREEN": "Passer en plein écran", "EXIT_FULLSCREEN": "Quitter le plein écran" } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index becf1856a..13f846f00 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Impossibile avviare la registrazione.", "RECORDING_FAILED_TO_STOP": "Impossibile interrompere la registrazione.", "SAVED_TO": "Salvato in {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Attiva Picture-in-Picture", + "EXIT_PICTURE_IN_PICTURE": "Esci da Picture-in-Picture", "ENTER_FULLSCREEN": "Attiva schermo intero", "EXIT_FULLSCREEN": "Esci dallo schermo intero" } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 30277f87f..aa8e59c32 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "録画を開始できませんでした。", "RECORDING_FAILED_TO_STOP": "録画を停止できませんでした。", "SAVED_TO": "{{path}}に保存しました", + "ENTER_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを開始", + "EXIT_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを終了", "ENTER_FULLSCREEN": "全画面表示", "EXIT_FULLSCREEN": "全画面表示を終了" } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 27e08f9fe..fb8a6a661 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "녹화를 시작하지 못했습니다.", "RECORDING_FAILED_TO_STOP": "녹화를 중지하지 못했습니다.", "SAVED_TO": "{{path}}에 저장되었습니다", + "ENTER_PICTURE_IN_PICTURE": "PIP 모드 시작", + "EXIT_PICTURE_IN_PICTURE": "PIP 모드 종료", "ENTER_FULLSCREEN": "전체 화면", "EXIT_FULLSCREEN": "전체 화면 종료" } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index ccf3ec726..153a6e6ae 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Opname kon niet worden gestart.", "RECORDING_FAILED_TO_STOP": "Opname kon niet worden gestopt.", "SAVED_TO": "Opgeslagen in {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Beeld-in-beeld starten", + "EXIT_PICTURE_IN_PICTURE": "Beeld-in-beeld sluiten", "ENTER_FULLSCREEN": "Volledig scherm openen", "EXIT_FULLSCREEN": "Volledig scherm sluiten" } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 0be167258..32d8608d6 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Nie udało się rozpocząć nagrywania.", "RECORDING_FAILED_TO_STOP": "Nie udało się zatrzymać nagrywania.", "SAVED_TO": "Zapisano w {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Włącz obraz w obrazie", + "EXIT_PICTURE_IN_PICTURE": "Wyłącz obraz w obrazie", "ENTER_FULLSCREEN": "Włącz pełny ekran", "EXIT_FULLSCREEN": "Wyłącz pełny ekran" } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 51ecc82f8..861f772e9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Falha ao iniciar a gravação.", "RECORDING_FAILED_TO_STOP": "Falha ao parar a gravação.", "SAVED_TO": "Salvo em {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Ativar imagem em imagem", + "EXIT_PICTURE_IN_PICTURE": "Sair de imagem em imagem", "ENTER_FULLSCREEN": "Entrar em tela cheia", "EXIT_FULLSCREEN": "Sair da tela cheia" } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1fa420759..d84865bde 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Не удалось начать запись.", "RECORDING_FAILED_TO_STOP": "Не удалось остановить запись.", "SAVED_TO": "Сохранено в {{path}}", + "ENTER_PICTURE_IN_PICTURE": "Включить режим «картинка в картинке»", + "EXIT_PICTURE_IN_PICTURE": "Выйти из режима «картинка в картинке»", "ENTER_FULLSCREEN": "Во весь экран", "EXIT_FULLSCREEN": "Выйти из полноэкранного режима" } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 74a629255..6f0d14338 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "Kayıt başlatılamadı.", "RECORDING_FAILED_TO_STOP": "Kayıt durdurulamadı.", "SAVED_TO": "{{path}} konumuna kaydedildi", + "ENTER_PICTURE_IN_PICTURE": "Resim içinde resim modunu aç", + "EXIT_PICTURE_IN_PICTURE": "Resim içinde resim modundan çık", "ENTER_FULLSCREEN": "Tam ekrana geç", "EXIT_FULLSCREEN": "Tam ekrandan çık" } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 03fa17ac3..06f0bda98 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "录制启动失败。", "RECORDING_FAILED_TO_STOP": "录制停止失败。", "SAVED_TO": "已保存至 {{path}}", + "ENTER_PICTURE_IN_PICTURE": "进入画中画", + "EXIT_PICTURE_IN_PICTURE": "退出画中画", "ENTER_FULLSCREEN": "进入全屏", "EXIT_FULLSCREEN": "退出全屏" } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 97c7004c9..7d7653315 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -489,6 +489,8 @@ "RECORDING_FAILED_TO_START": "無法開始錄製。", "RECORDING_FAILED_TO_STOP": "無法停止錄製。", "SAVED_TO": "已儲存至 {{path}}", + "ENTER_PICTURE_IN_PICTURE": "進入子母畫面", + "EXIT_PICTURE_IN_PICTURE": "退出子母畫面", "ENTER_FULLSCREEN": "進入全螢幕", "EXIT_FULLSCREEN": "離開全螢幕" } diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 3ce8f7207..d6e1e52ea 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -14,6 +14,8 @@ consumers and includes: - the standalone `app-player-controls` presentation component and its transient-state collaborators; - a generic `WebVideoControlsAdapter` plus small host helpers; +- standard element picture-in-picture through that adapter for the guarded web + consumers; - a persisted, default-off web-player preference resolved through an immutable per-host rollout token; - the component-scoped `EmbeddedMpvControlsAdapter`; @@ -60,6 +62,12 @@ remove exact listeners and engines, and destroyed sessions ignore stale delayed `customType` callbacks. When the host token resolves to false, the existing ArtPlayer skin, source behavior, and legacy series navigation remain unchanged. +With shared controls enabled, HTML5, Video.js, and ArtPlayer expose standard +element picture-in-picture through the adapter's attached `