From beb62db3141a9dbacb9e6a7deb3fb1689b36e1ac Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:38:06 +0200 Subject: [PATCH] feat(settings): add shared web player controls toggle (#1198) * docs(playback): design shared controls setting * docs(playback): plan shared controls setting * feat(settings): persist shared web controls preference * test(settings): harden shared controls normalization coverage * feat(settings): expose shared web controls toggle * fix(settings): label shared controls toggle * feat(playback): resolve shared controls from settings * test(playback): cover shared controls setting * docs(playback): document shared controls preference * fix(playback): await settings before host creation * fix(settings): normalize shared controls updates --- AGENTS.md | 34 +- CLAUDE.md | 2 +- README.md | 1 + apps/electron-backend-e2e/src/settings.e2e.ts | 53 ++ apps/web-e2e/src/settings.e2e.ts | 20 + apps/web/src/app/app.routes.spec.ts | 61 +- apps/web/src/app/app.routes.ts | 7 +- .../src/app/settings/settings-form.utils.ts | 2 + .../settings-playback-section.component.html | 28 + ...ettings-playback-section.component.spec.ts | 158 ++- .../settings-playback-section.component.ts | 9 + .../app/settings/settings.component.spec.ts | 33 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/player-controls-contract.md | 76 +- ...7-17-web-player-shared-controls-setting.md | 898 ++++++++++++++++++ ...b-player-shared-controls-setting-design.md | 210 ++++ .../src/lib/settings-store.service.spec.ts | 117 ++- .../src/lib/settings-store.service.ts | 353 +++---- .../interfaces/src/lib/settings.interface.ts | 5 + ...yer-view.component.shared-controls.spec.ts | 41 +- .../web-player-view.component.ts | 19 +- 38 files changed, 1863 insertions(+), 300 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-17-web-player-shared-controls-setting.md create mode 100644 docs/superpowers/specs/2026-07-17-web-player-shared-controls-setting-design.md diff --git a/AGENTS.md b/AGENTS.md index c4f6cfcb9..1db1a1fa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,13 +132,21 @@ Key files: - `libs/ui/playback/src/lib/player-controls/` contains the additive, engine-neutral `PlayerController` contract, standalone - `app-player-controls`, generic web-video adapter/helper, and default-off web - rollout token. -- Embedded MPV frame-copy is the first runtime consumer. Its component-scoped - `EmbeddedMpvControlsAdapter` maps the active session into - `app-player-controls`; the native-view engine retains the legacy - compositor-safe dock. The host must render exactly one controls system for - the reported engine. + `app-player-controls`, 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 + `WEB_PLAYER_SHARED_CONTROLS` 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 its component-scoped `EmbeddedMpvControlsAdapter`, while + native-view retains the legacy compositor-safe dock and external MPV/VLC + retain their own UI. The host must render exactly one controls system for the + reported Embedded MPV engine. - Frame-copy shared controls own DOM surface interactions, shortcuts, fullscreen, and recording feedback. `showControls=false` detaches the shared surface, modal overlays gate playback shortcuts, fullscreen still triggers @@ -158,8 +166,8 @@ Key files: `WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD metadata, while a visible playback diagnostic disables both shared surface interaction and shortcuts and exits the HTML5 shell's own fullscreen so the - diagnostic actions remain visible. The flag-off path keeps native controls - and legacy series navigation unchanged. + diagnostic actions remain visible. The preference-off path keeps native + controls and legacy series navigation unchanged. - Video.js is the third guarded consumer. `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs, @@ -167,10 +175,10 @@ Key files: duration from Video.js. 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. - The flag-on path disables native controls, Video.js + The shared-controls path disables native controls, Video.js click/double-click/hotkey actions, and spatial navigation; - diagnostic gating and owned-fullscreen exit match HTML5. The flag-off path - keeps the existing Video.js skin and legacy series navigation unchanged. + diagnostic gating and owned-fullscreen exit match HTML5. The preference-off + path keeps the existing Video.js skin and legacy series navigation unchanged. - 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 @@ -181,7 +189,7 @@ Key files: 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. Diagnostic interaction gating and owned-fullscreen - exit match the other web players. The default-off flag keeps the legacy + exit match the other web players. The preference-off path keeps the legacy ArtPlayer skin, source behavior, and series navigation unchanged. - Canonical docs: `docs/architecture/player-controls-contract.md` and `docs/architecture/embedded-mpv-native.md` diff --git a/CLAUDE.md b/CLAUDE.md index 2a107bbf5..1e649c494 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -618,7 +618,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - External players: MPV, VLC (via IPC to Electron backend) - 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 a default-off web rollout token. Embedded MPV frame-copy consumes it through `EmbeddedMpvControlsAdapter`; the host selects exactly one UI, so native-view retains its compositor-safe dock. `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. With the flag on, 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. The rollout flag remains default-off, and all three web players retain their existing controls, source behavior, and legacy series navigation on that path. Contract: `docs/architecture/player-controls-contract.md`. +- 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`. **VOD/Series Detail Pages (two-state layout)**: diff --git a/README.md b/README.md index 084cd51bf..d06f5ae1a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The application is a cross-platform, open-source project built with Electron and **Playback** - Built-in HTML5 player (HLS.js or Video.js) with a resizable, resumable inline view +- Optional unified IPTVnator controls for HTML5, Video.js, and ArtPlayer, enabled in **Settings → Playback** _(experimental)_ - External players — MPV, VLC, and IINA on macOS (`mpv.app` / `VLC.app` bundle paths supported) _(desktop)_ - Embedded MPV — native mpv rendered inside the app window on macOS, Windows & Linux 🖥️ _(experimental · desktop)_ - Dedicated radio player for `radio="true"` streams 📻 diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 532d62d38..308e22664 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -169,6 +169,11 @@ test.describe('Electron Settings', () => { 'select-video-player', 'html5' ); + const sharedControlsCheckbox = firstLaunch.mainWindow + .getByTestId('web-player-shared-controls-setting') + .locator('input[type="checkbox"]'); + await expect(sharedControlsCheckbox).toBeVisible(); + await sharedControlsCheckbox.check(); await selectSettingsOption( firstLaunch.mainWindow, 'select-stream-format', @@ -206,6 +211,11 @@ test.describe('Electron Settings', () => { await expect( secondLaunch.mainWindow.getByTestId('select-video-player') ).toContainText(/HTML5/i); + await expect( + secondLaunch.mainWindow + .getByTestId('web-player-shared-controls-setting') + .locator('input[type="checkbox"]') + ).toBeChecked(); await expect( secondLaunch.mainWindow.getByTestId('select-stream-format') ).toContainText('ts'); @@ -231,6 +241,49 @@ test.describe('Electron Settings', () => { } }); + test('@settings @playback @electron applies shared controls to the next HTML5 session', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await selectSettingsOption( + app.mainWindow, + 'select-video-player', + 'html5' + ); + const sharedControlsCheckbox = app.mainWindow + .getByTestId('web-player-shared-controls-setting') + .locator('input[type="checkbox"]'); + await expect(sharedControlsCheckbox).toBeVisible(); + await sharedControlsCheckbox.check(); + await saveSettings(app.mainWindow); + + await goToDashboard(app.mainWindow); + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); + + const firstChannel = channelItemByTitle( + app.mainWindow, + 'Channel 1' + ).first(); + + await expect(firstChannel).toBeVisible({ timeout: 20000 }); + await firstChannel.click(); + await expect( + app.mainWindow.locator( + 'app-html-video-player app-player-controls' + ) + ).toBeVisible(); + await expect( + app.mainWindow.locator('app-html-video-player video[controls]') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('@settings @persistence @electron persists the EPG view mode across app restart', async ({ dataDir, }) => { diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 2b687b91b..113fe8278 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -48,6 +48,26 @@ test.describe('Settings', () => { ); }); + test('@settings @web Enable shared web player controls', async ({ + page, + }) => { + await openSettings(page); + + const setting = page.locator( + '[data-test-id="web-player-shared-controls-setting"]' + ); + const checkbox = setting.locator('input[type="checkbox"]'); + + await expect(setting).toBeVisible(); + await expect(checkbox).not.toBeChecked(); + await checkbox.check(); + await saveSettings(page); + await page.reload(); + await openSettings(page); + + await expect(checkbox).toBeChecked(); + }); + test('@settings @web Change app theme', async ({ page }) => { await openSettings(page); // v0.22 compact theme picker exposes the segmented control as a diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index a53309567..a3626638a 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -1,4 +1,8 @@ +import { TestBed } from '@angular/core/testing'; +import { SettingsStore } from '@iptvnator/services'; + describe('app routes', () => { + let workspaceRoute: import('@angular/router').Route | undefined; let workspaceChildren: Array<{ canActivate?: unknown[]; data?: Record; @@ -13,7 +17,6 @@ describe('app routes', () => { ) => unknown; beforeAll(async () => { - jest.resetModules(); jest.unstable_mockModule( '@iptvnator/playlist/m3u/feature-player', () => ({ @@ -48,14 +51,64 @@ describe('app routes', () => { const appRoutes = await import('./app.routes'); const { routes } = appRoutes; - const workspaceRoute = routes.find( - (route) => route.path === 'workspace' - ); + workspaceRoute = routes.find((route) => route.path === 'workspace'); workspaceChildren = workspaceRoute?.children ?? []; resolveElectronOnlyGlobalSearchRoute = appRoutes.resolveElectronOnlyGlobalSearchRoute; }); + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('waits for settings before activating workspace children', async () => { + let releaseSettings!: () => void; + const settingsPending = new Promise((resolve) => { + releaseSettings = resolve; + }); + const loadSettings = jest.fn(() => settingsPending); + TestBed.configureTestingModule({ + providers: [ + { + provide: SettingsStore, + useValue: { loadSettings }, + }, + ], + }); + const settingsReadyResolver = + workspaceRoute?.resolve?.['settingsReady']; + + expect(typeof settingsReadyResolver).toBe('function'); + if (typeof settingsReadyResolver !== 'function') { + return; + } + + const resolution = TestBed.runInInjectionContext(() => + Promise.resolve( + ( + settingsReadyResolver as import('@angular/router').ResolveFn + )( + {} as import('@angular/router').ActivatedRouteSnapshot, + {} as import('@angular/router').RouterStateSnapshot + ) + ) + ); + let resolved = false; + void resolution.then(() => { + resolved = true; + }); + + await Promise.resolve(); + + expect(loadSettings).toHaveBeenCalledTimes(1); + expect(resolved).toBe(false); + + releaseSettings(); + + await expect(resolution).resolves.toBeUndefined(); + expect(resolved).toBe(true); + }); + it('lazy-loads M3U workspace routes through the feature boundary', async () => { const playlistRoute = workspaceChildren.find( (route) => route.path === 'playlists/:id' diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 5993f4622..73361f1fc 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -1,8 +1,10 @@ import { inject } from '@angular/core'; import { Router, Routes } from '@angular/router'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; +const settingsReadyResolver = () => inject(SettingsStore).loadSettings(); + const workspaceEntryRedirect = async () => inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath(); @@ -41,6 +43,9 @@ export const routes: Routes = [ data: { layout: 'workspace', }, + resolve: { + settingsReady: settingsReadyResolver, + }, loadComponent: () => import('@iptvnator/workspace/shell/feature').then( (c) => c.WorkspaceShellComponent diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index da7d20cef..f09c4aa6e 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -31,6 +31,7 @@ export function createSettingsForm( ) { return formBuilder.group({ player: [VideoPlayer.VideoJs], + webPlayerSharedControls: false, ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), @@ -114,6 +115,7 @@ export function createSettingsFromFormValue( return { player: value.player ?? VideoPlayer.VideoJs, + webPlayerSharedControls: value.webPlayerSharedControls ?? false, streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, language: value.language ?? Language.ENGLISH, diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index defb115c5..75551c14a 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -69,6 +69,34 @@ + @if (isWebPlayerSelected()) { +
+
+

+ {{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate }} +

+

+ {{ + 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
{ let fixture: ComponentFixture; + const queryByTestId = (testId: string): Element | null => + fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ @@ -36,6 +41,8 @@ describe('SettingsPlaybackSectionComponent', () => { MPV_PLAYER_PATH_DESCRIPTION: MPV_PATH_DESCRIPTION, MPV_COMPATIBLE_PLAYER_TIP: MPV_COMPATIBLE_PLAYER_TIP, VLC_PLAYER_PATH_DESCRIPTION: VLC_PATH_DESCRIPTION, + WEB_PLAYER_SHARED_CONTROLS: + WEB_PLAYER_SHARED_CONTROLS_LABEL, }, }, true @@ -65,9 +72,7 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '[data-test-id="external-player-double-click-setting"]' - ) + queryByTestId('external-player-double-click-setting') ).toBeNull(); expect(fixture.nativeElement.textContent).not.toContain( 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' @@ -80,9 +85,7 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '[data-test-id="external-player-double-click-setting"]' - ) + queryByTestId('external-player-double-click-setting') ).toBeNull(); }); @@ -98,9 +101,7 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '[data-test-id="external-player-double-click-setting"]' - ) + queryByTestId('external-player-double-click-setting') ).not.toBeNull(); expect(fixture.nativeElement.textContent).toContain( 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' @@ -116,18 +117,14 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '[data-test-id="external-player-double-click-setting"]' - ) + queryByTestId('external-player-double-click-setting') ).toBeNull(); form.controls['player'].setValue(VideoPlayer.MPV); fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '[data-test-id="external-player-double-click-setting"]' - ) + queryByTestId('external-player-double-click-setting') ).not.toBeNull(); }); @@ -146,6 +143,56 @@ describe('SettingsPlaybackSectionComponent', () => { expect(option?.textContent).toContain('auto'); }); + it.each([ + VideoPlayer.VideoJs, + VideoPlayer.Html5Player, + VideoPlayer.ArtPlayer, + ])('shows shared web controls for %s', (player) => { + fixture.componentRef.setInput('form', createForm(player)); + fixture.detectChanges(); + + expect( + queryByTestId('web-player-shared-controls-setting') + ).not.toBeNull(); + }); + + it.each([VideoPlayer.EmbeddedMpv, VideoPlayer.MPV, VideoPlayer.VLC])( + 'hides shared web controls for %s', + (player) => { + fixture.componentRef.setInput('form', createForm(player)); + fixture.detectChanges(); + + expect( + queryByTestId('web-player-shared-controls-setting') + ).toBeNull(); + } + ); + + it('binds the shared web controls checkbox to the settings form', () => { + const form = createForm(); + form.controls['webPlayerSharedControls'].setValue(true); + fixture.componentRef.setInput('form', form); + fixture.detectChanges(); + + const checkbox = fixture.nativeElement.querySelector( + '[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]' + ); + + expect(checkbox?.checked).toBe(true); + }); + + it('labels the rendered native shared web controls checkbox', () => { + fixture.detectChanges(); + + const checkbox = fixture.nativeElement.querySelector( + '[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]' + ); + + expect(checkbox?.getAttribute('aria-label')).toBe( + WEB_PLAYER_SHARED_CONTROLS_LABEL + ); + }); + it('keeps the double-click option visible when path settings are unavailable', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV)); fixture.componentRef.setInput('isDesktop', true); @@ -157,18 +204,12 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '[data-test-id="external-player-double-click-setting"]' - ) + queryByTestId('external-player-double-click-setting') ).not.toBeNull(); expect(fixture.nativeElement.textContent).not.toContain( MPV_PATH_DESCRIPTION ); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-compatible-player-tip"]' - ) - ).toBeNull(); + expect(queryByTestId('mpv-compatible-player-tip')).toBeNull(); }); it.each<[string, boolean, boolean, boolean, boolean]>([ @@ -188,9 +229,7 @@ describe('SettingsPlaybackSectionComponent', () => { ); fixture.detectChanges(); - const setting = fixture.nativeElement.querySelector( - '[data-test-id="embedded-mpv-frame-copy-setting"]' - ); + const setting = queryByTestId('embedded-mpv-frame-copy-setting'); expect(Boolean(setting)).toBe(expected); } ); @@ -199,20 +238,12 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.componentRef.setInput('isDesktop', true); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="recording-folder-setting"]' - ) - ).not.toBeNull(); + expect(queryByTestId('recording-folder-setting')).not.toBeNull(); fixture.componentRef.setInput('isDesktop', false); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="recording-folder-setting"]' - ) - ).toBeNull(); + expect(queryByTestId('recording-folder-setting')).toBeNull(); }); it('shows MPV bundle guidance and the IINA executable tip for desktop MPV playback', () => { @@ -227,11 +258,7 @@ describe('SettingsPlaybackSectionComponent', () => { expect(fixture.nativeElement.textContent).toContain( MPV_PATH_DESCRIPTION ); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-compatible-player-tip"]' - ) - ).not.toBeNull(); + expect(queryByTestId('mpv-compatible-player-tip')).not.toBeNull(); expect(fixture.nativeElement.textContent).toContain( MPV_COMPATIBLE_PLAYER_TIP ); @@ -253,11 +280,7 @@ describe('SettingsPlaybackSectionComponent', () => { expect(fixture.nativeElement.textContent).not.toContain( MPV_COMPATIBLE_PLAYER_TIP ); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-compatible-player-tip"]' - ) - ).toBeNull(); + expect(queryByTestId('mpv-compatible-player-tip')).toBeNull(); }); it('shows VLC bundle guidance without the IINA tip for desktop VLC playback', () => { @@ -272,11 +295,7 @@ describe('SettingsPlaybackSectionComponent', () => { expect(fixture.nativeElement.textContent).toContain( VLC_PATH_DESCRIPTION ); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-compatible-player-tip"]' - ) - ).toBeNull(); + expect(queryByTestId('mpv-compatible-player-tip')).toBeNull(); expect(fixture.nativeElement.textContent).not.toContain( MPV_COMPATIBLE_PLAYER_TIP ); @@ -295,11 +314,7 @@ describe('SettingsPlaybackSectionComponent', () => { expect(fixture.nativeElement.textContent).not.toContain( VLC_PATH_DESCRIPTION ); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-compatible-player-tip"]' - ) - ).toBeNull(); + expect(queryByTestId('mpv-compatible-player-tip')).toBeNull(); }); it('does not show external-player path guidance for embedded players', () => { @@ -316,11 +331,7 @@ describe('SettingsPlaybackSectionComponent', () => { expect(fixture.nativeElement.textContent).not.toContain( VLC_PATH_DESCRIPTION ); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-compatible-player-tip"]' - ) - ).toBeNull(); + expect(queryByTestId('mpv-compatible-player-tip')).toBeNull(); }); it('shows MPV command-line arguments only when MPV is selected', () => { @@ -332,16 +343,8 @@ describe('SettingsPlaybackSectionComponent', () => { ); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-player-arguments-setting"]' - ) - ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="vlc-player-arguments-setting"]' - ) - ).toBeNull(); + expect(queryByTestId('mpv-player-arguments-setting')).not.toBeNull(); + expect(queryByTestId('vlc-player-arguments-setting')).toBeNull(); expect(fixture.nativeElement.textContent).toContain( 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL' ); @@ -361,16 +364,8 @@ describe('SettingsPlaybackSectionComponent', () => { ); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="vlc-player-arguments-setting"]' - ) - ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector( - '[data-test-id="mpv-player-arguments-setting"]' - ) - ).toBeNull(); + expect(queryByTestId('vlc-player-arguments-setting')).not.toBeNull(); + expect(queryByTestId('mpv-player-arguments-setting')).toBeNull(); expect(fixture.nativeElement.textContent).toContain( 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL' ); @@ -385,6 +380,7 @@ describe('SettingsPlaybackSectionComponent', () => { function createForm(player = VideoPlayer.VideoJs): FormGroup { return new FormGroup({ player: new FormControl(player), + webPlayerSharedControls: new FormControl(false), streamFormat: new FormControl(StreamFormat.AutoStreamFormat), openStreamOnDoubleClick: new FormControl(false), showExternalPlaybackBar: new FormControl(true), diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5e8f645b7..722faca52 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -53,6 +53,15 @@ export class SettingsPlaybackSectionComponent { readonly supportsExternalPlayerPathSettings = input(false); readonly selectRecordingFolder = output(); + isWebPlayerSelected(): boolean { + const player = this.form().value.player; + return ( + player === VideoPlayer.VideoJs || + player === VideoPlayer.Html5Player || + player === VideoPlayer.ArtPlayer + ); + } + isExternalPlayerSelected(): boolean { const player = this.form().value.player; return player === VideoPlayer.MPV || player === VideoPlayer.VLC; diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 95ffca533..d4ac9ac93 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -84,6 +84,7 @@ export class MockRouter { const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, + webPlayerSharedControls: false, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, @@ -661,6 +662,19 @@ describe('SettingsComponent', () => { }); }); + it('hydrates the shared web controls setting from the settings store', () => { + const mockStore = settingsStore as unknown as MockSettingsStore; + mockStore._setSettings({ + webPlayerSharedControls: true, + }); + + component.setSettings(); + + expect( + component.settingsForm.get('webPlayerSharedControls')?.value + ).toBe(true); + }); + it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => { window.electron = { ...window.electron, @@ -1301,9 +1315,7 @@ describe('SettingsComponent', () => { it('updates the EPG view mode through the epg section output', () => { const mockStore = settingsStore as unknown as MockSettingsStore; - const listButton = ( - fixture.nativeElement as HTMLElement - ).querySelector( + const listButton = (fixture.nativeElement as HTMLElement).querySelector( '[data-test-id="epg-view-mode-list"]' ) as HTMLButtonElement; @@ -1398,6 +1410,21 @@ describe('SettingsComponent', () => { }); }); + it('saves the shared web controls setting on submit', async () => { + const mockStore = settingsStore as unknown as MockSettingsStore; + mockStore.updateSettings.mockResolvedValue(undefined); + component.settingsForm.get('webPlayerSharedControls')?.setValue(true); + + component.onSubmit(); + await fixture.whenStable(); + + expect(mockStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + webPlayerSharedControls: true, + }) + ); + }); + it('clears external player paths in Electron when saved as empty', async () => { const mockStore = settingsStore as unknown as MockSettingsStore; mockStore.updateSettings.mockResolvedValue(undefined); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c2b746dc0..81cb8bfec 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "جارٍ المسح…", "EPG_URL_ERROR": "القيمة ليست رابطًا صالحًا", "VIDEO_PLAYER_DESCRIPTION": "اختر مشغل الفيديو الافتراضي", + "WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحّدة لمشغلات الويب (تجريبي)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)", "LANGUAGE_DESCRIPTION": "اختر لغة التطبيق", "THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index d4c8f9aed..8615f456b 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "جاري المسح…", "EPG_URL_ERROR": "القيمة ماشي رابط صحيح", "VIDEO_PLAYER_DESCRIPTION": "اختار مشغل الفيديو الافتراضي", + "WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحدة لمشغلات الويب (تجريبية)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)", "LANGUAGE_DESCRIPTION": "اختار لغة التطبيق", "THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ce6157a0c..3c3f62f7f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Ачышчаецца...", "EPG_URL_ERROR": "Уведзенае значэнне не з'яўляецца URL-адрасам.", "VIDEO_PLAYER_DESCRIPTION": "Выбраць стандартны відэапрайгравальнік", + "WEB_PLAYER_SHARED_CONTROLS": "Адзіныя элементы кіравання для вэб-прайгравальнікаў (эксперыментальна)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)", "LANGUAGE_DESCRIPTION": "Выбраць мову праграмы", "THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 9b81484d6..4b311c69f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Wird gelöscht…", "EPG_URL_ERROR": "Der Wert ist keine gültige URL", "VIDEO_PLAYER_DESCRIPTION": "Wählen Sie den Standard-Videoplayer aus", + "WEB_PLAYER_SHARED_CONTROLS": "Einheitliche Steuerung für Web-Player (experimentell)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)", "LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus", "THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 9ab7b28cc..c65264fa3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Διαγραφή…", "EPG_URL_ERROR": "Η τιμή δεν είναι έγκυρη διεύθυνση URL", "VIDEO_PLAYER_DESCRIPTION": "Επιλογή προεπιλεγμένου προγράμματος αναπαραγωγής βίντεο", + "WEB_PLAYER_SHARED_CONTROLS": "Ενοποιημένα στοιχεία ελέγχου για web players (πειραματικό)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)", "LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής", "THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 0ae94e68d..dd305961d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Clearing…", "EPG_URL_ERROR": "The value is not a valid URL", "VIDEO_PLAYER_DESCRIPTION": "Select default video player", + "WEB_PLAYER_SHARED_CONTROLS": "Unified controls for web players (experimental)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", "LANGUAGE_DESCRIPTION": "Select language of the application", "THEME_DESCRIPTION": "Select visual theme of the application", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 695aa7304..12395cd45 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Borrando…", "EPG_URL_ERROR": "El valor no es una URL válida.", "VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado", + "WEB_PLAYER_SHARED_CONTROLS": "Controles unificados para reproductores web (experimental)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)", "LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación", "THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 328efd3c3..769382a0c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Effacement…", "EPG_URL_ERROR": "La valeur n'est pas une URL valide", "VIDEO_PLAYER_DESCRIPTION": "Sélectionnez le lecteur vidéo par défaut", + "WEB_PLAYER_SHARED_CONTROLS": "Commandes unifiées pour les lecteurs web (expérimental)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)", "LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application", "THEME_DESCRIPTION": "Sélectionnez le thème visuel de l'application", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index fe89e649a..becf1856a 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Cancellazione…", "EPG_URL_ERROR": "Il valore non è una URL valida", "VIDEO_PLAYER_DESCRIPTION": "Seleziona lettore video predefinito", + "WEB_PLAYER_SHARED_CONTROLS": "Controlli unificati per i player web (sperimentale)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)", "LANGUAGE_DESCRIPTION": "Seleziona la lingua dell'applicazione", "THEME_DESCRIPTION": "Seleziona il tema visivo dell'applicazione", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 526e50d78..30277f87f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "クリア中…", "EPG_URL_ERROR": "有効なURLではありません", "VIDEO_PLAYER_DESCRIPTION": "デフォルトのビデオプレーヤーを選択", + "WEB_PLAYER_SHARED_CONTROLS": "Webプレーヤーの統一コントロール(試験的)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。", "STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)", "LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択", "THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 9c4896445..27e08f9fe 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "지우는 중…", "EPG_URL_ERROR": "값이 유효한 URL이 아닙니다.", "VIDEO_PLAYER_DESCRIPTION": "기본 비디오 플레이어 선택", + "WEB_PLAYER_SHARED_CONTROLS": "웹 플레이어 통합 컨트롤(실험적)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.", "STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)", "LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택", "THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 6f59ef527..ccf3ec726 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Wissen…", "EPG_URL_ERROR": "De opgegeven waarde is geen geldige URL.", "VIDEO_PLAYER_DESCRIPTION": "Selecteer de standaard videospeler", + "WEB_PLAYER_SHARED_CONTROLS": "Uniforme bediening voor webspelers (experimenteel)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Selecteer het standaard streamformaat (xtream)", "LANGUAGE_DESCRIPTION": "Selecteer de taal van de applicatie", "THEME_DESCRIPTION": "Kies het visuele thema van de applicatie", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index fc3a6f2dd..0be167258 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Czyszczenie…", "EPG_URL_ERROR": "Wartość nie jest prawidłowym URL", "VIDEO_PLAYER_DESCRIPTION": "Wybierz domyślny odtwarzacz wideo", + "WEB_PLAYER_SHARED_CONTROLS": "Ujednolicone sterowanie odtwarzaczami internetowymi (eksperymentalne)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)", "LANGUAGE_DESCRIPTION": "Wybierz język aplikacji", "THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 56cae6e28..51ecc82f8 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Limpando…", "EPG_URL_ERROR": "O valor não é uma URL válida", "VIDEO_PLAYER_DESCRIPTION": "Selecionar reprodutor de vídeo padrão", + "WEB_PLAYER_SHARED_CONTROLS": "Controlos unificados para leitores web (experimental)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)", "LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo", "THEME_DESCRIPTION": "Selecionar tema visual do aplicativo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index cf9809bbf..1fa420759 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Очистка…", "EPG_URL_ERROR": "Введенное значение не является URL-адресом.", "VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер", + "WEB_PLAYER_SHARED_CONTROLS": "Единые элементы управления для веб-плееров (экспериментально)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.", "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 446aa5c17..74a629255 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "Temizleniyor…", "EPG_URL_ERROR": "Girilen değer geçerli bir URL değildir", "VIDEO_PLAYER_DESCRIPTION": "Varsayılan video oynatıcıyı seçin", + "WEB_PLAYER_SHARED_CONTROLS": "Web oynatıcıları için birleşik kontroller (deneysel)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan.", "STREAM_FORMAT_DESCRIPTION": "Varsayılan yayın formatını seçin (xtream)", "LANGUAGE_DESCRIPTION": "Uygulamanın dilini seçin", "THEME_DESCRIPTION": "Uygulamanın görünüm temasını seçin", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 2b5fa958f..03fa17ac3 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "清除中…", "EPG_URL_ERROR": "该值不是有效的 URL", "VIDEO_PLAYER_DESCRIPTION": "选择默认视频播放器", + "WEB_PLAYER_SHARED_CONTROLS": "Web 播放器统一控件(实验性)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。", "STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)", "LANGUAGE_DESCRIPTION": "选择应用程序的语言", "THEME_DESCRIPTION": "选择应用程序的视觉主题", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 0e122c3fb..97c7004c9 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -296,6 +296,8 @@ "CLEARING_EPG_DATA": "正在清除…", "EPG_URL_ERROR": "該值不是有效的網址", "VIDEO_PLAYER_DESCRIPTION": "選擇預設影片播放器", + "WEB_PLAYER_SHARED_CONTROLS": "Web 播放器統一控制項(實驗性)", + "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。", "STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)", "LANGUAGE_DESCRIPTION": "選擇應用程式語言", "THEME_DESCRIPTION": "選擇應用程式視覺主題", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 7de280344..3ce8f7207 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -14,14 +14,15 @@ consumers and includes: - the standalone `app-player-controls` presentation component and its transient-state collaborators; - a generic `WebVideoControlsAdapter` plus small host helpers; -- a default-off web rollout token; +- a persisted, default-off web-player preference resolved through an immutable + per-host rollout token; - the component-scoped `EmbeddedMpvControlsAdapter`; - an `EmbeddedMpvPlayerComponent` host integration for the frame-copy engine; -- a feature-flagged `HtmlVideoPlayerComponent` integration backed by +- a preference-guarded `HtmlVideoPlayerComponent` integration backed by `WebVideoControlsAdapter` and a player-local engine bridge; -- a feature-flagged `VjsPlayerComponent` integration backed by a +- a preference-guarded `VjsPlayerComponent` integration backed by a component-scoped `WebVideoControlsAdapter` and Video.js bridge; -- a feature-flagged `ArtPlayerComponent` integration backed by a +- a preference-guarded `ArtPlayerComponent` integration backed by a component-scoped `WebVideoControlsAdapter`, neutral web-video source bridge, and player-local source/video sessions; and - focused unit/component tests. @@ -36,14 +37,14 @@ When `WEB_PLAYER_SHARED_CONTROLS` is enabled, the built-in HTML5 player mounts the same presentation component over its real player shell and disables the native video controls. Its neutral source bridge supplies HLS/native tracks, corrected MPEG-TS VOD duration, and authoritative live/VOD metadata to the -generic web adapter. When the flag is disabled, the native controls and legacy -series navigation remain unchanged and the adapter is not attached. +generic web adapter. When the host token resolves to false, the native controls +and legacy series navigation remain unchanged and the adapter is not attached. Video.js consumes the same token and shared presentation atomically. Its bridge binds the adapter to the current Video.js Tech `