mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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
This commit is contained in:
1 parent
48e3736460
commit
beb62db314
38 files changed
+1863
-300
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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=<x11-window>` 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 `<canvas data-embedded-mpv-frame>`, 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)**:
|
||||
|
||||
|
||||
@@ -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 📻
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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<void>((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<void>
|
||||
)(
|
||||
{} 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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ export function createSettingsForm(
|
||||
) {
|
||||
return formBuilder.group({
|
||||
player: [VideoPlayer.VideoJs],
|
||||
webPlayerSharedControls: false,
|
||||
...(supportsEpg
|
||||
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
|
||||
: {}),
|
||||
@@ -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,
|
||||
|
||||
@@ -69,6 +69,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isWebPlayerSelected()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-test-id="web-player-shared-controls-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[aria-label]="
|
||||
'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate
|
||||
"
|
||||
formControlName="webPlayerSharedControls"
|
||||
data-test-id="web-player-shared-controls-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
|
||||
@@ -14,10 +14,15 @@ const VLC_PATH_DESCRIPTION =
|
||||
const MPV_ARGUMENTS_PLACEHOLDER =
|
||||
'--ontop\n--autofit=640x360\n--geometry=+80+80';
|
||||
const VLC_ARGUMENTS_PLACEHOLDER = '--video-on-top\n--width=640\n--height=360';
|
||||
const WEB_PLAYER_SHARED_CONTROLS_LABEL =
|
||||
'Unified controls for web players (experimental)';
|
||||
|
||||
describe('SettingsPlaybackSectionComponent', () => {
|
||||
let fixture: ComponentFixture<SettingsPlaybackSectionComponent>;
|
||||
|
||||
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<HTMLInputElement>(
|
||||
'[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<HTMLInputElement>(
|
||||
'[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),
|
||||
|
||||
@@ -53,6 +53,15 @@ export class SettingsPlaybackSectionComponent {
|
||||
readonly supportsExternalPlayerPathSettings = input(false);
|
||||
readonly selectRecordingFolder = output<void>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "اختر السمة البصرية للتطبيق",
|
||||
|
||||
@@ -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": "اختار المظهر المرئي للتطبيق",
|
||||
|
||||
@@ -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": "Выбраць візуальную тэму афармлення",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Επιλέξτε οπτικό θέμα της εφαρμογής",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アプリケーションの視覚テーマを選択",
|
||||
|
||||
@@ -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": "애플리케이션의 시각적 테마 선택",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выбрать визуальную тему оформления",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "选择应用程序的视觉主题",
|
||||
|
||||
@@ -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": "選擇應用程式視覺主題",
|
||||
|
||||
@@ -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 `<video>` and rebinds after
|
||||
`playerreset`, while focused collaborators expose Video.js audio/text tracks
|
||||
and manage raw MPEG-TS playback. With the flag disabled, Video.js keeps its
|
||||
existing skin and legacy series navigation.
|
||||
and manage raw MPEG-TS playback. When the host token resolves to false, Video.js
|
||||
keeps its existing skin and legacy series navigation.
|
||||
|
||||
ArtPlayer is the fourth consumer. Its source session owns HLS, MPEG-TS, native
|
||||
source selection, and delayed `customType` callbacks, while the neutral
|
||||
@@ -56,17 +57,22 @@ event-capture layer over ArtPlayer so shared controls exclusively own surface
|
||||
clicks and double-clicks. Playback diagnostics gate shared interaction and exit
|
||||
only the ArtPlayer shell's own fullscreen. Source replacement and teardown
|
||||
remove exact listeners and engines, and destroyed sessions ignore stale delayed
|
||||
`customType` callbacks. With the flag disabled, the existing ArtPlayer skin,
|
||||
source behavior, and legacy series navigation remain unchanged.
|
||||
`customType` callbacks. When the host token resolves to false, the existing
|
||||
ArtPlayer skin, source behavior, and legacy series navigation remain unchanged.
|
||||
|
||||
`WEB_PLAYER_SHARED_CONTROLS_ENABLED` remains default-off, so none of the three
|
||||
guarded web integrations changes normal runtime behavior.
|
||||
`Settings.webPlayerSharedControls` remains default-off. `WebPlayerViewComponent`
|
||||
snapshots it into `WEB_PLAYER_SHARED_CONTROLS` when a new player host is
|
||||
created, so HTML5, Video.js, and ArtPlayer switch atomically without an
|
||||
application restart. The parent `/workspace` route awaits the initial
|
||||
`SettingsStore` load, including for cold-start direct links to workspace
|
||||
children, before any player host can take this snapshot. Existing sessions
|
||||
never change controls mode in place.
|
||||
|
||||
This rollout is intentionally engine-selective: frame-copy can use normal DOM
|
||||
layering, while the native platform view cannot. The integration also includes
|
||||
a recording coordinator that correlates asynchronous snapshots with the active
|
||||
playback/session owner, serializes toggles, and cancels pending ownership when
|
||||
the session, playback, engine, or component changes.
|
||||
The shared-controls architecture remains engine-selective: frame-copy can use
|
||||
normal DOM layering, while the native platform view cannot. The integration
|
||||
also includes a recording coordinator that correlates asynchronous snapshots
|
||||
with the active playback/session owner, serializes toggles, and cancels pending
|
||||
ownership when the session, playback, engine, or component changes.
|
||||
|
||||
## Why this exists
|
||||
|
||||
@@ -111,10 +117,10 @@ contract does not make a native video surface behave like DOM content.
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ Flag-guarded web hosts │ │ EmbeddedMpvPlayerComponent │
|
||||
│ Per-host preference snapshot │ │ EmbeddedMpvPlayerComponent │
|
||||
│ HTML5 + Video.js + ArtPlayer │ │ frame-copy: shared controls │
|
||||
│ flag on: shared controls │ │ native-view: legacy dock │
|
||||
│ flag off: existing controls │ └──────────────────────────────┘
|
||||
│ true: shared controls │ │ native-view: legacy dock │
|
||||
│ false: existing controls │ └──────────────────────────────┘
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -122,7 +128,7 @@ The embedded host selects controls from the reported engine before rendering
|
||||
them. It never mounts the shared overlay and legacy dock together.
|
||||
The HTML5, Video.js, and ArtPlayer hosts likewise select their existing or
|
||||
shared controls before rendering and never attach the web adapter while the
|
||||
flag-off path is active.
|
||||
preference-off path is active.
|
||||
|
||||
## The contract
|
||||
|
||||
@@ -375,12 +381,13 @@ attachment/projection helpers. Video.js uses its dedicated bridge directly.
|
||||
HTML5 and ArtPlayer share the neutral source bridge and HLS/native-track
|
||||
collaborators under `web-video-support/`.
|
||||
|
||||
The rollout symbols are:
|
||||
The rollout symbols and setting are:
|
||||
|
||||
| Symbol | Default | Current effect |
|
||||
| ------------------------------------ | ------------: | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Keeps existing web-player skins active in normal runtime builds. |
|
||||
| `WEB_PLAYER_SHARED_CONTROLS` | default above | Injectable/test-overridable view consumed by HTML5, Video.js, and ArtPlayer to switch atomically between existing and shared UI. |
|
||||
| Symbol / setting | Default | Current effect |
|
||||
| ------------------------------------ | ---------------: | ----------------------------------------------------------------------- |
|
||||
| `Settings.webPlayerSharedControls` | `false` | Persisted experimental opt-in shown for HTML5, Video.js, and ArtPlayer. |
|
||||
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Default-off fallback for direct component use and focused tests. |
|
||||
| `WEB_PLAYER_SHARED_CONTROLS` | session snapshot | Component-scoped immutable value consumed by the three web engines. |
|
||||
|
||||
With the token enabled, Video.js also disables native controls, Video.js
|
||||
single-click and double-click actions, Video.js hotkeys, and spatial navigation.
|
||||
@@ -403,6 +410,10 @@ semantics, stored volume behavior, and series navigation remain unchanged.
|
||||
The shared contract does not replace either Embedded MPV renderer. The host
|
||||
uses the renderer's reported engine to choose the compatible controls UI.
|
||||
|
||||
The web-player preference does not affect Embedded MPV. Frame-copy always uses
|
||||
the shared DOM controls, while native-view keeps its compositor-safe legacy
|
||||
dock.
|
||||
|
||||
### Frame-copy engine
|
||||
|
||||
The experimental frame-copy engine uploads helper-produced frames to
|
||||
@@ -528,8 +539,8 @@ libs/ui/playback/src/lib/html-video-player/
|
||||
`WebVideoControlsAdapter`. Its bridge/helper filenames re-export the neutral
|
||||
web-video support so existing imports and focused specs remain stable.
|
||||
`HtmlVideoElementSession` separately owns native video-event attachment,
|
||||
persisted volume, start-time/time/ended propagation, and the flag-off post-play
|
||||
caption behavior.
|
||||
persisted volume, start-time/time/ended propagation, and the preference-off
|
||||
post-play caption behavior.
|
||||
|
||||
The guarded Video.js integration lives in:
|
||||
|
||||
@@ -571,11 +582,12 @@ libs/ui/playback/src/lib/art-player/
|
||||
bridge, exact engine/listener cleanup, and a destroyed-session guard for
|
||||
ArtPlayer's delayed `customType` dispatch. `ArtPlayerVideoSession` owns native
|
||||
media errors, readiness, volume persistence, ended/time updates, and exact
|
||||
event cleanup. The setup helper preserves the legacy option set when the flag
|
||||
is off and disables vendor interaction owners when it is on; the component's
|
||||
transparent capture layer blocks ArtPlayer's core surface handlers.
|
||||
event cleanup. The setup helper preserves the legacy option set when the host
|
||||
token resolves to false and disables vendor interaction owners when it resolves
|
||||
to true; the component's transparent capture layer blocks ArtPlayer's core
|
||||
surface handlers.
|
||||
|
||||
Focused specs cover each web engine's flag-off compatibility path,
|
||||
Focused specs cover each web engine's preference-off compatibility path,
|
||||
shared-controls rendering and diagnostic interaction gating, source/element
|
||||
replacement, track-list lifecycle and stable IDs, caption preference and
|
||||
explicit-off behavior, MPEG-TS live/VOD handling and duration projection,
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
# Web-Player Shared Controls Setting Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a persisted experimental Playback checkbox that enables the existing shared controls for newly created HTML5, Video.js, and ArtPlayer sessions without restarting IPTVnator.
|
||||
|
||||
**Architecture:** `SettingsStore` owns a default-off `webPlayerSharedControls` boolean and the Settings form exposes it only for the three web engines. `WebPlayerViewComponent` resolves the saved value into a component-scoped, immutable `WEB_PLAYER_SHARED_CONTROLS` snapshot, preserving each engine's construction-time legacy/shared split while leaving Embedded MPV and external players unchanged.
|
||||
|
||||
**Tech Stack:** Angular standalone components and reactive forms, NgRx Signal Store, Angular DI, Angular Material, ngx-translate JSON locales, Jest/TestBed, Playwright, Nx.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Settings contract and persistence
|
||||
|
||||
- Modify `libs/shared/interfaces/src/lib/settings.interface.ts`
|
||||
- Declare the optional persisted boolean.
|
||||
- Modify `libs/services/src/lib/settings-store.service.ts`
|
||||
- Add the default, normalize stored input, and include the resolved value in
|
||||
complete settings serialization.
|
||||
- Modify `libs/services/src/lib/settings-store.service.spec.ts`
|
||||
- Cover missing, enabled, malformed, and persisted values.
|
||||
|
||||
### Settings form and presentation
|
||||
|
||||
- Modify `apps/web/src/app/settings/settings-form.utils.ts`
|
||||
- Add the form control and form-to-settings mapping.
|
||||
- Modify `apps/web/src/app/settings/settings-playback-section.component.ts`
|
||||
- Identify whether the selected engine is one of the three web players.
|
||||
- Modify `apps/web/src/app/settings/settings-playback-section.component.html`
|
||||
- Render the conditional Material checkbox.
|
||||
- Modify `apps/web/src/app/settings/settings-playback-section.component.spec.ts`
|
||||
- Cover visibility and checkbox binding.
|
||||
- Modify `apps/web/src/app/settings/settings.component.spec.ts`
|
||||
- Keep the canonical test fixture shape current and verify hydration/save.
|
||||
- Modify all JSON files under `apps/web/src/assets/i18n/`
|
||||
- Add the same two `SETTINGS` keys with localized values.
|
||||
|
||||
### Playback session wiring
|
||||
|
||||
- Modify `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
|
||||
- Provide an immutable, settings-backed controls token per player host.
|
||||
- Modify
|
||||
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts`
|
||||
- Verify enabled/disabled resolution and per-host snapshot behavior.
|
||||
|
||||
### End-to-end coverage and documentation
|
||||
|
||||
- Modify `apps/web-e2e/src/settings.e2e.ts`
|
||||
- Verify browser persistence after Save and reload.
|
||||
- Modify `apps/electron-backend-e2e/src/settings.e2e.ts`
|
||||
- Verify desktop persistence across restart and shared controls on the next
|
||||
HTML5 playback session.
|
||||
- Modify `README.md`
|
||||
- Mention the optional unified built-in web-player controls.
|
||||
- Modify `docs/architecture/player-controls-contract.md`
|
||||
- Replace compile-time-only rollout language with the persisted setting and
|
||||
per-session snapshot.
|
||||
- Modify `AGENTS.md` and `CLAUDE.md`
|
||||
- Keep their shared-controls descriptions synchronized.
|
||||
|
||||
## Task 1: Persist the Default-Off Settings Contract
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `libs/shared/interfaces/src/lib/settings.interface.ts`
|
||||
- Modify: `libs/services/src/lib/settings-store.service.ts`
|
||||
- Test: `libs/services/src/lib/settings-store.service.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing store behavior tests**
|
||||
|
||||
Add these cases to `libs/services/src/lib/settings-store.service.spec.ts`.
|
||||
The temporary intersections keep the red test compiling before the shared
|
||||
interface declares the field:
|
||||
|
||||
```ts
|
||||
it('defaults shared web controls to false when the stored field is missing', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
const settings = store.getSettings() as ReturnType<
|
||||
typeof store.getSettings
|
||||
> & {
|
||||
webPlayerSharedControls?: unknown;
|
||||
};
|
||||
expect(settings.webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('restores an enabled shared web controls preference', async () => {
|
||||
storedSettings = {
|
||||
webPlayerSharedControls: true,
|
||||
} as Partial<Settings> & { webPlayerSharedControls: boolean };
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
const settings = store.getSettings() as ReturnType<
|
||||
typeof store.getSettings
|
||||
> & {
|
||||
webPlayerSharedControls?: unknown;
|
||||
};
|
||||
expect(settings.webPlayerSharedControls).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes a malformed shared web controls preference to false', async () => {
|
||||
storedSettings = {
|
||||
webPlayerSharedControls: 'enabled',
|
||||
} as unknown as Partial<Settings>;
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
const settings = store.getSettings() as ReturnType<
|
||||
typeof store.getSettings
|
||||
> & {
|
||||
webPlayerSharedControls?: unknown;
|
||||
};
|
||||
expect(settings.webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('persists the resolved shared web controls preference', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.updateSettings({
|
||||
webPlayerSharedControls: true,
|
||||
} as Partial<Settings> & { webPlayerSharedControls: boolean });
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({
|
||||
webPlayerSharedControls: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused store test and verify the red state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test services --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=settings-store.service.spec
|
||||
```
|
||||
|
||||
Expected: FAIL because `getSettings()` omits
|
||||
`webPlayerSharedControls`, and the preference is not persisted.
|
||||
|
||||
- [ ] **Step 3: Add the shared settings field**
|
||||
|
||||
Add this property beside `player` in
|
||||
`libs/shared/interfaces/src/lib/settings.interface.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.
|
||||
* Missing values remain off for compatibility with older saved settings.
|
||||
*/
|
||||
webPlayerSharedControls?: boolean;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Normalize and serialize the setting**
|
||||
|
||||
In `libs/services/src/lib/settings-store.service.ts`, add the default:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls: false,
|
||||
```
|
||||
|
||||
Insert it immediately after `player: VideoPlayer.VideoJs` in
|
||||
`DEFAULT_SETTINGS`. After `...storedSettings` in `loadSettings()`, override
|
||||
untrusted stored input with an exact boolean:
|
||||
|
||||
```ts
|
||||
patchState(store, {
|
||||
...DEFAULT_SETTINGS,
|
||||
...storedSettings,
|
||||
webPlayerSharedControls: storedSettings.webPlayerSharedControls === true,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
storedSettings.dashboardRails
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Include the resolved field in `getSettings()`:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls:
|
||||
store.webPlayerSharedControls?.() === true,
|
||||
```
|
||||
|
||||
Insert it immediately after `player: store.player()`.
|
||||
|
||||
Once the interface exists, simplify the four new tests to use direct typed
|
||||
access and typed updates:
|
||||
|
||||
```ts
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(true);
|
||||
await store.updateSettings({ webPlayerSharedControls: true });
|
||||
```
|
||||
|
||||
Keep the `unknown as Partial<Settings>` cast only in the malformed-value test.
|
||||
|
||||
- [ ] **Step 5: Run contract and store tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx build shared-interfaces
|
||||
pnpm nx test services --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=settings-store.service.spec
|
||||
```
|
||||
|
||||
Expected: both commands PASS; missing/malformed values resolve to `false`, and
|
||||
`true` is restored and persisted.
|
||||
|
||||
- [ ] **Step 6: Commit the settings contract**
|
||||
|
||||
```bash
|
||||
git add libs/shared/interfaces/src/lib/settings.interface.ts \
|
||||
libs/services/src/lib/settings-store.service.ts \
|
||||
libs/services/src/lib/settings-store.service.spec.ts
|
||||
git commit -m "feat(settings): persist shared web controls preference"
|
||||
```
|
||||
|
||||
## Task 2: Add the Conditional Playback Checkbox
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/src/app/settings/settings-form.utils.ts`
|
||||
- Modify: `apps/web/src/app/settings/settings-playback-section.component.ts`
|
||||
- Modify: `apps/web/src/app/settings/settings-playback-section.component.html`
|
||||
- Test: `apps/web/src/app/settings/settings-playback-section.component.spec.ts`
|
||||
- Test: `apps/web/src/app/settings/settings.component.spec.ts`
|
||||
- Modify: `apps/web/src/assets/i18n/ar.json`
|
||||
- Modify: `apps/web/src/assets/i18n/ary.json`
|
||||
- Modify: `apps/web/src/assets/i18n/by.json`
|
||||
- Modify: `apps/web/src/assets/i18n/de.json`
|
||||
- Modify: `apps/web/src/assets/i18n/el.json`
|
||||
- Modify: `apps/web/src/assets/i18n/en.json`
|
||||
- Modify: `apps/web/src/assets/i18n/es.json`
|
||||
- Modify: `apps/web/src/assets/i18n/fr.json`
|
||||
- Modify: `apps/web/src/assets/i18n/it.json`
|
||||
- Modify: `apps/web/src/assets/i18n/ja.json`
|
||||
- Modify: `apps/web/src/assets/i18n/ko.json`
|
||||
- Modify: `apps/web/src/assets/i18n/nl.json`
|
||||
- Modify: `apps/web/src/assets/i18n/pl.json`
|
||||
- Modify: `apps/web/src/assets/i18n/pt.json`
|
||||
- Modify: `apps/web/src/assets/i18n/ru.json`
|
||||
- Modify: `apps/web/src/assets/i18n/tr.json`
|
||||
- Modify: `apps/web/src/assets/i18n/zh.json`
|
||||
- Modify: `apps/web/src/assets/i18n/zhtw.json`
|
||||
|
||||
- [ ] **Step 1: Write failing visibility and binding tests**
|
||||
|
||||
Add `webPlayerSharedControls` to the local `createForm()` helper in
|
||||
`settings-playback-section.component.spec.ts`:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
```
|
||||
|
||||
Add the following tests:
|
||||
|
||||
```ts
|
||||
it.each([VideoPlayer.VideoJs, VideoPlayer.Html5Player, VideoPlayer.ArtPlayer])(
|
||||
'shows shared controls for the %s web player',
|
||||
(player) => {
|
||||
fixture.componentRef.setInput('form', createForm(player));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="web-player-shared-controls-setting"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([VideoPlayer.EmbeddedMpv, VideoPlayer.MPV, VideoPlayer.VLC])(
|
||||
'hides shared web controls for the %s player',
|
||||
(player) => {
|
||||
fixture.componentRef.setInput('form', createForm(player));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="web-player-shared-controls-setting"]'
|
||||
)
|
||||
).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('binds the shared controls checkbox to the settings form', () => {
|
||||
const form = createForm(VideoPlayer.VideoJs);
|
||||
form.controls['webPlayerSharedControls'].setValue(true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector<HTMLInputElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
|
||||
)?.checked
|
||||
).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
In `settings.component.spec.ts`, add this field to `DEFAULT_SETTINGS`:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls: false,
|
||||
```
|
||||
|
||||
Add explicit hydration and save assertions:
|
||||
|
||||
```ts
|
||||
it('hydrates the shared web controls preference', () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
mockStore._setSettings({
|
||||
webPlayerSharedControls: true,
|
||||
});
|
||||
|
||||
component.setSettings();
|
||||
|
||||
expect(component.settingsForm.get('webPlayerSharedControls')?.value).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the shared web controls preference', 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run focused web tests and verify the red state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test web --skip-nx-cache --runInBand \
|
||||
--testPathPatterns='settings-playback-section.component.spec|settings.component.spec'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production form and Playback section do not yet
|
||||
declare or render the preference.
|
||||
|
||||
- [ ] **Step 3: Add form hydration and serialization**
|
||||
|
||||
In `createSettingsForm()` in
|
||||
`apps/web/src/app/settings/settings-form.utils.ts`, add:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls: false,
|
||||
```
|
||||
|
||||
Insert it immediately after the existing `player` control.
|
||||
|
||||
In `createSettingsFromFormValue()`, add:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls: value.webPlayerSharedControls ?? false,
|
||||
```
|
||||
|
||||
Insert it immediately after the existing `player` mapping.
|
||||
|
||||
The existing `SettingsComponent.setSettings()` `patchValue()` call then
|
||||
hydrates the checkbox without a new component-specific code path.
|
||||
|
||||
- [ ] **Step 4: Add the selected-engine predicate and checkbox**
|
||||
|
||||
Add this method to
|
||||
`apps/web/src/app/settings/settings-playback-section.component.ts`:
|
||||
|
||||
```ts
|
||||
isWebPlayerSelected(): boolean {
|
||||
const player = this.form().value.player;
|
||||
return (
|
||||
player === VideoPlayer.VideoJs ||
|
||||
player === VideoPlayer.Html5Player ||
|
||||
player === VideoPlayer.ArtPlayer
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Insert this row in
|
||||
`apps/web/src/app/settings/settings-playback-section.component.html` after the
|
||||
stream-format selector:
|
||||
|
||||
```html
|
||||
@if (isWebPlayerSelected()) {
|
||||
<div class="setting-item" data-test-id="web-player-shared-controls-setting">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="webPlayerSharedControls"
|
||||
data-test-id="web-player-shared-controls-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused web tests and verify the green state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test web --skip-nx-cache --runInBand \
|
||||
--testPathPatterns='settings-playback-section.component.spec|settings.component.spec'
|
||||
```
|
||||
|
||||
Expected: PASS for all three visible web players, all three hidden non-web
|
||||
players, form hydration, and Save serialization.
|
||||
|
||||
- [ ] **Step 6: Add exact locale values**
|
||||
|
||||
Add `WEB_PLAYER_SHARED_CONTROLS` and
|
||||
`WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION` beside
|
||||
`VIDEO_PLAYER_DESCRIPTION` inside `SETTINGS` in all 18 locale files:
|
||||
|
||||
| Locale | `WEB_PLAYER_SHARED_CONTROLS` | `WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION` |
|
||||
| ------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `en` | Unified controls for web players (experimental) | Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer. |
|
||||
| `ru` | Единые элементы управления для веб-плееров (экспериментально) | Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer. |
|
||||
| `de` | Einheitliche Steuerung für Web-Player (experimentell) | Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer. |
|
||||
| `es` | Controles unificados para reproductores web (experimental) | Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer. |
|
||||
| `fr` | Commandes unifiées pour les lecteurs web (expérimental) | Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer. |
|
||||
| `it` | Controlli unificati per i player web (sperimentale) | Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer. |
|
||||
| `pt` | Controlos unificados para leitores web (experimental) | Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer. |
|
||||
| `nl` | Uniforme bediening voor webspelers (experimenteel) | Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer. |
|
||||
| `pl` | Ujednolicone sterowanie odtwarzaczami internetowymi (eksperymentalne) | Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer. |
|
||||
| `tr` | Web oynatıcıları için birleşik kontroller (deneysel) | HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan. |
|
||||
| `el` | Ενοποιημένα στοιχεία ελέγχου για web players (πειραματικό) | Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer. |
|
||||
| `ja` | Webプレーヤーの統一コントロール(試験的) | HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。 |
|
||||
| `ko` | 웹 플레이어 통합 컨트롤(실험적) | HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다. |
|
||||
| `zh` | Web 播放器统一控件(实验性) | 在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。 |
|
||||
| `zhtw` | Web 播放器統一控制項(實驗性) | 在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。 |
|
||||
| `ar` | عناصر تحكم موحّدة لمشغلات الويب (تجريبي) | استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer. |
|
||||
| `ary` | عناصر تحكم موحدة لمشغلات الويب (تجريبية) | استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer. |
|
||||
| `by` | Адзіныя элементы кіравання для вэб-прайгравальнікаў (эксперыментальна) | Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer. |
|
||||
|
||||
- [ ] **Step 7: Validate locale parity and the full Settings project**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:check
|
||||
pnpm nx test web --skip-nx-cache
|
||||
pnpm nx lint web --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: all commands PASS, with no missing or extra locale keys.
|
||||
|
||||
- [ ] **Step 8: Commit the Settings UI**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/app/settings/settings-form.utils.ts \
|
||||
apps/web/src/app/settings/settings-playback-section.component.ts \
|
||||
apps/web/src/app/settings/settings-playback-section.component.html \
|
||||
apps/web/src/app/settings/settings-playback-section.component.spec.ts \
|
||||
apps/web/src/app/settings/settings.component.spec.ts \
|
||||
apps/web/src/assets/i18n
|
||||
git commit -m "feat(settings): expose shared web controls toggle"
|
||||
```
|
||||
|
||||
## Task 3: Resolve an Immutable Controls Mode per Player Host
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
|
||||
- Test:
|
||||
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write a failing host-provider test**
|
||||
|
||||
Update the Angular import in
|
||||
`web-player-view.component.shared-controls.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
```
|
||||
|
||||
Import the store and rollout token:
|
||||
|
||||
```ts
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls';
|
||||
```
|
||||
|
||||
Add a mutable mock signal beside the suite variables:
|
||||
|
||||
```ts
|
||||
const webPlayerSharedControls = signal(false);
|
||||
```
|
||||
|
||||
Reset and provide it in `beforeEach()`:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls.set(false);
|
||||
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { webPlayerSharedControls },
|
||||
},
|
||||
```
|
||||
|
||||
Insert the provider object into the existing TestBed `providers` array.
|
||||
|
||||
Add this test:
|
||||
|
||||
```ts
|
||||
it('snapshots the saved shared-controls mode for each player host', () => {
|
||||
webPlayerSharedControls.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.injector.get(WEB_PLAYER_SHARED_CONTROLS)).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
webPlayerSharedControls.set(false);
|
||||
expect(fixture.debugElement.injector.get(WEB_PLAYER_SHARED_CONTROLS)).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/new-session.ts'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.injector.get(WEB_PLAYER_SHARED_CONTROLS)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
This proves both required properties: Save affects the next host, and an
|
||||
existing host does not mix construction modes.
|
||||
|
||||
- [ ] **Step 2: Run the host test and verify the red state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test ui-playback --skip-nx-cache --runInBand \
|
||||
--testPathPatterns=web-player-view.component.shared-controls
|
||||
```
|
||||
|
||||
Expected: FAIL because the root token still resolves the compile-time `false`
|
||||
constant instead of the `SettingsStore` signal.
|
||||
|
||||
- [ ] **Step 3: Add the component-scoped token provider**
|
||||
|
||||
Update the services import in
|
||||
`libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`:
|
||||
|
||||
```ts
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
```
|
||||
|
||||
Import the rollout symbols:
|
||||
|
||||
```ts
|
||||
import {
|
||||
WEB_PLAYER_SHARED_CONTROLS,
|
||||
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
|
||||
} from '../player-controls';
|
||||
```
|
||||
|
||||
Add a focused factory before the component decorator:
|
||||
|
||||
```ts
|
||||
function resolveWebPlayerSharedControls(): boolean {
|
||||
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
|
||||
|
||||
return typeof storedValue === 'boolean'
|
||||
? storedValue
|
||||
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
|
||||
}
|
||||
```
|
||||
|
||||
Add the component-scoped provider:
|
||||
|
||||
```ts
|
||||
providers: [
|
||||
{
|
||||
provide: WEB_PLAYER_SHARED_CONTROLS,
|
||||
useFactory: resolveWebPlayerSharedControls,
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
Insert this property into the existing `@Component` metadata.
|
||||
|
||||
Angular creates and caches this primitive in the `WebPlayerViewComponent`
|
||||
element injector. HTML5, Video.js, and ArtPlayer descendants receive one
|
||||
immutable value for their shared construction-time branch. Embedded MPV does
|
||||
not inject this token and therefore remains engine-driven.
|
||||
|
||||
- [ ] **Step 4: Run focused and complete playback tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx test ui-playback --skip-nx-cache --runInBand \
|
||||
--testPathPatterns='web-player-view.component|web-player-controls.flag'
|
||||
pnpm nx test ui-playback --skip-nx-cache
|
||||
pnpm nx lint ui-playback --skip-nx-cache
|
||||
```
|
||||
|
||||
Expected: all commands PASS. Existing engine suites continue to prove that
|
||||
flag-off preserves vendor controls and flag-on mounts exactly one shared UI.
|
||||
|
||||
- [ ] **Step 5: Commit the playback wiring**
|
||||
|
||||
```bash
|
||||
git add libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts \
|
||||
libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts
|
||||
git commit -m "feat(playback): resolve shared controls from settings"
|
||||
```
|
||||
|
||||
## Task 4: Prove Persistence and Next-Session Activation End to End
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web-e2e/src/settings.e2e.ts`
|
||||
- Modify: `apps/electron-backend-e2e/src/settings.e2e.ts`
|
||||
|
||||
- [ ] **Step 1: Add browser Save/reload persistence coverage**
|
||||
|
||||
Add this test to `apps/web-e2e/src/settings.e2e.ts`:
|
||||
|
||||
```ts
|
||||
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();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the Electron restart-persistence test**
|
||||
|
||||
In the existing
|
||||
`persists changed desktop settings across app restart` test, immediately after
|
||||
selecting `html5`, enable the new checkbox:
|
||||
|
||||
```ts
|
||||
const sharedControlsCheckbox = firstLaunch.mainWindow
|
||||
.getByTestId('web-player-shared-controls-setting')
|
||||
.locator('input[type="checkbox"]');
|
||||
await expect(sharedControlsCheckbox).toBeVisible();
|
||||
await sharedControlsCheckbox.check();
|
||||
```
|
||||
|
||||
After the second launch confirms that HTML5 is selected, assert:
|
||||
|
||||
```ts
|
||||
await expect(
|
||||
secondLaunch.mainWindow
|
||||
.getByTestId('web-player-shared-controls-setting')
|
||||
.locator('input[type="checkbox"]')
|
||||
).toBeChecked();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add a desktop next-session playback smoke test**
|
||||
|
||||
Add this test to `apps/electron-backend-e2e/src/settings.e2e.ts`:
|
||||
|
||||
```ts
|
||||
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'
|
||||
);
|
||||
await app.mainWindow
|
||||
.getByTestId('web-player-shared-controls-setting')
|
||||
.locator('input[type="checkbox"]')
|
||||
.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);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run atomized web and Electron settings E2E**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx run web-e2e:e2e-ci--src/settings.e2e.ts
|
||||
pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts
|
||||
```
|
||||
|
||||
Expected: both targets PASS. Browser settings survive reload, desktop settings
|
||||
survive a full app restart, and the first HTML5 session after Save renders
|
||||
`app-player-controls` without native video controls.
|
||||
|
||||
- [ ] **Step 5: Commit E2E coverage**
|
||||
|
||||
```bash
|
||||
git add apps/web-e2e/src/settings.e2e.ts \
|
||||
apps/electron-backend-e2e/src/settings.e2e.ts
|
||||
git commit -m "test(playback): cover shared controls setting"
|
||||
```
|
||||
|
||||
## Task 5: Update Canonical Documentation and Run the Validation Ladder
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/player-controls-contract.md`
|
||||
- Modify: `AGENTS.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Update the top-level Playback feature list**
|
||||
|
||||
Add this bullet under `README.md` → `Features` → `Playback`:
|
||||
|
||||
```md
|
||||
- Optional unified IPTVnator controls for HTML5, Video.js, and ArtPlayer, enabled in **Settings → Playback** _(experimental)_
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the canonical controls contract**
|
||||
|
||||
In `docs/architecture/player-controls-contract.md`:
|
||||
|
||||
1. Replace “a default-off web rollout token” in the current-status list with:
|
||||
|
||||
```md
|
||||
- a persisted, default-off web-player preference resolved through an immutable
|
||||
per-host rollout token;
|
||||
```
|
||||
|
||||
2. Replace the compile-time-only current-status paragraph with:
|
||||
|
||||
```md
|
||||
`Settings.webPlayerSharedControls` remains default-off. `WebPlayerViewComponent`
|
||||
snapshots it into `WEB_PLAYER_SHARED_CONTROLS` when a new player host is
|
||||
created, so HTML5, Video.js, and ArtPlayer switch atomically without an
|
||||
application restart. Existing sessions never change controls mode in place.
|
||||
```
|
||||
|
||||
3. Replace the rollout-symbol table rows with:
|
||||
|
||||
```md
|
||||
| Symbol / setting | Default | Current effect |
|
||||
| ------------------------------------ | ---------------: | ----------------------------------------------------------------------- |
|
||||
| `Settings.webPlayerSharedControls` | `false` | Persisted experimental opt-in shown for HTML5, Video.js, and ArtPlayer. |
|
||||
| `WEB_PLAYER_SHARED_CONTROLS_ENABLED` | `false` | Default-off fallback for direct component use and focused tests. |
|
||||
| `WEB_PLAYER_SHARED_CONTROLS` | session snapshot | Component-scoped immutable value consumed by the three web engines. |
|
||||
```
|
||||
|
||||
4. State next to the Embedded MPV section:
|
||||
|
||||
```md
|
||||
The web-player preference does not affect Embedded MPV. Frame-copy always uses
|
||||
the shared DOM controls, while native-view keeps its compositor-safe legacy
|
||||
dock.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Keep agent documentation synchronized**
|
||||
|
||||
In both `AGENTS.md` and `CLAUDE.md`, update the shared-player-controls
|
||||
description to include these exact facts:
|
||||
|
||||
```md
|
||||
- The persisted `Settings.webPlayerSharedControls` preference is default-off
|
||||
and appears only when HTML5, Video.js, or ArtPlayer is selected.
|
||||
- `WebPlayerViewComponent` snapshots the preference into the component-scoped
|
||||
`WEB_PLAYER_SHARED_CONTROLS` token for each new host; an existing session
|
||||
never changes controls mode in place.
|
||||
- Embedded MPV ignores this preference: frame-copy always uses shared controls,
|
||||
native-view retains the legacy dock, and external MPV/VLC own their UI.
|
||||
```
|
||||
|
||||
Preserve the existing engine lifecycle details around these sentences.
|
||||
|
||||
- [ ] **Step 4: Format and inspect documentation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec prettier --write README.md \
|
||||
docs/architecture/player-controls-contract.md AGENTS.md CLAUDE.md
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: Prettier completes successfully and `git diff --check` prints no
|
||||
errors.
|
||||
|
||||
- [ ] **Step 5: Run the complete regression ladder**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm nx build shared-interfaces --skip-nx-cache
|
||||
pnpm nx test services --skip-nx-cache
|
||||
pnpm nx test web --skip-nx-cache
|
||||
pnpm nx test ui-playback --skip-nx-cache
|
||||
pnpm nx run-many --target=lint \
|
||||
--projects=shared-interfaces,services,web,ui-playback,web-e2e,electron-backend-e2e \
|
||||
--skip-nx-cache
|
||||
pnpm run typecheck:web
|
||||
pnpm run i18n:check
|
||||
pnpm nx build web --configuration=production --skip-nx-cache
|
||||
pnpm nx run web-e2e:e2e-ci--src/settings.e2e.ts
|
||||
pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: every command exits `0`. Record command results in the final handoff;
|
||||
if a platform-dependent Electron E2E cannot run, report the exact environment
|
||||
constraint and the strongest completed substitute instead of claiming it
|
||||
passed.
|
||||
|
||||
- [ ] **Step 6: Review the final diff against the approved design**
|
||||
|
||||
Confirm all of the following before committing:
|
||||
|
||||
- Default and malformed stored values resolve to `false`.
|
||||
- Only HTML5, Video.js, and ArtPlayer expose the checkbox.
|
||||
- Save affects the next player host without an app restart.
|
||||
- One host receives one immutable boolean mode.
|
||||
- Frame-copy Embedded MPV still always uses shared controls.
|
||||
- Native-view Embedded MPV and external MPV/VLC remain unchanged.
|
||||
- All 18 locales contain both keys.
|
||||
- Unit, lint, typecheck, build, web E2E, and Electron E2E results are recorded.
|
||||
- `README.md`, `docs/architecture/player-controls-contract.md`, `AGENTS.md`,
|
||||
and `CLAUDE.md` describe the shipped behavior consistently.
|
||||
|
||||
- [ ] **Step 7: Commit documentation and final integration**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/player-controls-contract.md \
|
||||
AGENTS.md CLAUDE.md
|
||||
git commit -m "docs(playback): document shared controls preference"
|
||||
```
|
||||
@@ -0,0 +1,210 @@
|
||||
# Web-Player Shared Controls Setting Design
|
||||
|
||||
## Context
|
||||
|
||||
The shared `app-player-controls` presentation is already integrated with four
|
||||
playback paths:
|
||||
|
||||
- Embedded MPV frame-copy uses it unconditionally because its video is rendered
|
||||
into a DOM canvas.
|
||||
- HTML5, Video.js, and ArtPlayer can use it behind the default-off
|
||||
`WEB_PLAYER_SHARED_CONTROLS` rollout token.
|
||||
|
||||
The web-player token currently resolves from the compile-time
|
||||
`WEB_PLAYER_SHARED_CONTROLS_ENABLED = false` constant. Enabling the integrations
|
||||
therefore requires a code change, rebuild, and application restart. The
|
||||
integrations are ready for opt-in testing, so the rollout choice should be a
|
||||
persisted user setting instead.
|
||||
|
||||
The Settings route does not keep an active player mounted. The setting can
|
||||
therefore be applied when the next playback session is created; changing an
|
||||
already-running engine or preserving an active playback position is not
|
||||
required.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add an experimental Playback checkbox for opting HTML5, Video.js, and
|
||||
ArtPlayer into the shared controls.
|
||||
- Persist the choice in the canonical `SettingsStore`.
|
||||
- Apply the saved choice to the next web-player session without restarting or
|
||||
reloading the application.
|
||||
- Keep the controls mode immutable for the lifetime of a player session.
|
||||
- Preserve default-off behavior and compatibility with existing stored
|
||||
settings.
|
||||
- Keep Embedded MPV and external-player behavior unchanged.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Make shared web controls the default.
|
||||
- Change an active player between legacy and shared controls.
|
||||
- Add separate preferences for HTML5, Video.js, and ArtPlayer.
|
||||
- Make Embedded MPV native-view use a DOM overlay.
|
||||
- Allow frame-copy Embedded MPV to fall back to the legacy controls dock.
|
||||
- Control the native UI of external MPV or VLC processes.
|
||||
|
||||
## Embedded MPV Semantics
|
||||
|
||||
The new checkbox is intentionally web-player-specific.
|
||||
|
||||
Embedded MPV has two rendering paths:
|
||||
|
||||
- **Frame-copy:** video is uploaded to a renderer canvas. It always uses
|
||||
`app-player-controls`; a second controls toggle would only duplicate an
|
||||
already-required part of this engine.
|
||||
- **Native-view:** video is hosted in a platform-native surface. DOM controls
|
||||
cannot reliably stack above that surface, so the compositor-safe legacy dock
|
||||
remains required.
|
||||
|
||||
The existing `embeddedMpvFrameCopy` setting continues to select the rendering
|
||||
engine and continues to require an application restart. It is independent from
|
||||
the new web-player controls preference.
|
||||
|
||||
External MPV and VLC processes continue to own their own controls UI.
|
||||
|
||||
## Considered Approaches
|
||||
|
||||
### 1. Conditional global web-player preference
|
||||
|
||||
Selected.
|
||||
|
||||
One setting controls HTML5, Video.js, and ArtPlayer together. Its checkbox is
|
||||
shown only while one of those players is selected. This matches the existing
|
||||
single rollout token, keeps the Settings UI relevant to the selected player,
|
||||
and avoids suggesting that the option changes Embedded MPV or external
|
||||
players.
|
||||
|
||||
### 2. Always-visible "shared controls where available" preference
|
||||
|
||||
Rejected because it would be a no-op for frame-copy Embedded MPV, unsupported
|
||||
for native-view, and outside the app for external players. The resulting
|
||||
conditional semantics would be harder to explain than a web-player-specific
|
||||
label.
|
||||
|
||||
### 3. Separate preference for every playback engine
|
||||
|
||||
Rejected because the three web integrations deliberately share one rollout
|
||||
contract. Per-engine preferences would add settings and test combinations
|
||||
without a current product requirement.
|
||||
|
||||
## Settings Contract
|
||||
|
||||
Add an optional boolean to the shared settings interface:
|
||||
|
||||
```ts
|
||||
webPlayerSharedControls?: boolean;
|
||||
```
|
||||
|
||||
The canonical default is `false`. Loading an older settings object without the
|
||||
field must normalize to `false`, and saving settings must write the complete
|
||||
resolved value back to storage.
|
||||
|
||||
The settings form adds a `webPlayerSharedControls` control with a `false`
|
||||
default. Form hydration, serialization, reset behavior, backup/export, and the
|
||||
existing Save workflow continue to use the normal complete `Settings` object.
|
||||
This is renderer-only state; Electron main-process behavior and preload IPC do
|
||||
not need a new command.
|
||||
|
||||
## Runtime Data Flow
|
||||
|
||||
The existing `WEB_PLAYER_SHARED_CONTROLS` injection token remains the
|
||||
immutable, test-overridable session seam used by the three engine components.
|
||||
The compile-time constant remains only as the default-off fallback for direct
|
||||
component use and focused tests.
|
||||
|
||||
Normal runtime resolution moves to `WebPlayerViewComponent`:
|
||||
|
||||
1. `SettingsStore` loads `webPlayerSharedControls`, defaulting to `false`.
|
||||
2. The Settings page updates and persists it through the existing Save action.
|
||||
3. Leaving Settings destroys that route; no player is active during the
|
||||
change.
|
||||
4. The next `WebPlayerViewComponent` resolves a component-scoped boolean
|
||||
snapshot from `SettingsStore`.
|
||||
5. HTML5, Video.js, or ArtPlayer receives that snapshot through
|
||||
`WEB_PLAYER_SHARED_CONTROLS` and constructs exactly one controls system.
|
||||
6. The snapshot does not change until that player host is destroyed and a new
|
||||
playback session is created.
|
||||
|
||||
This preserves the construction-time requirements of Video.js and ArtPlayer:
|
||||
vendor controls, hotkeys, gestures, plugins, source bridges, and the shared
|
||||
adapter are configured atomically. A reactive boolean must not flip only the
|
||||
template while leaving the engine configured for the other mode.
|
||||
|
||||
Embedded MPV does not consume the setting. Its component continues to choose
|
||||
shared controls for `engine === 'frame-copy'` and the legacy dock for the
|
||||
native-view engine.
|
||||
|
||||
## Settings UI
|
||||
|
||||
Add one standard `setting-item` to the Playback section, using the existing
|
||||
Material checkbox and layout styles.
|
||||
|
||||
- Label meaning: **Use unified controls for web players (experimental)**.
|
||||
- Description meaning: use IPTVnator's common controls in HTML5, Video.js, and
|
||||
ArtPlayer.
|
||||
- Visibility: selected player is `videojs`, `html5`, or `artplayer`.
|
||||
- Hidden for Embedded MPV, external MPV, and VLC.
|
||||
- Add a stable test selector to the row and checkbox.
|
||||
- Add the label and description keys to every locale file so the i18n key sets
|
||||
remain aligned.
|
||||
|
||||
No new colors, spacing rules, or one-off UI components are required.
|
||||
|
||||
## Failure and Compatibility Behavior
|
||||
|
||||
- Missing or malformed non-boolean stored values resolve to `false`.
|
||||
- A storage write failure is reported through the existing
|
||||
`SettingsStore.updateSettings()` rejection and must not require
|
||||
player-specific recovery.
|
||||
- Legacy mode remains the safe fallback whenever the setting cannot be
|
||||
resolved.
|
||||
- Direct engine tests can continue overriding
|
||||
`WEB_PLAYER_SHARED_CONTROLS` with `true` or `false`.
|
||||
- The setting does not relax Electron sandboxing and does not alter the
|
||||
frame-copy engine opt-in.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Shared interfaces and settings store
|
||||
|
||||
- Default is `false`.
|
||||
- Older stored settings without the field load as `false`.
|
||||
- A stored `true` value is restored.
|
||||
- `updateSettings()` persists the complete resolved value.
|
||||
|
||||
### Settings form and UI
|
||||
|
||||
- Form creation and serialization include the new field.
|
||||
- The checkbox hydrates from stored settings and is included in the Save
|
||||
payload.
|
||||
- The row is visible for HTML5, Video.js, and ArtPlayer.
|
||||
- The row is hidden for Embedded MPV, external MPV, and VLC.
|
||||
- Translation key parity remains valid across all locales.
|
||||
|
||||
### Playback host
|
||||
|
||||
- A newly created HTML5, Video.js, or ArtPlayer session receives the saved
|
||||
boolean snapshot.
|
||||
- `false` preserves the existing vendor/native UI.
|
||||
- `true` mounts the shared controls path.
|
||||
- Embedded MPV selection ignores the web-player preference.
|
||||
- One session never renders or initializes both controls systems.
|
||||
|
||||
### Validation
|
||||
|
||||
- Run focused Settings form/component and `SettingsStore` tests.
|
||||
- Run the complete `ui-playback` test target because all three web engines
|
||||
share the rollout seam.
|
||||
- Run affected lint targets and the workspace typecheck.
|
||||
- Run the settings E2E flow for checkbox visibility, persistence, and use after
|
||||
returning to playback.
|
||||
- Run the closest Electron playback smoke/E2E coverage because the same
|
||||
Settings surface is used in the desktop app.
|
||||
|
||||
## Documentation Impact
|
||||
|
||||
Update `docs/architecture/player-controls-contract.md` and the shared-controls
|
||||
sections of `AGENTS.md` and `CLAUDE.md` to replace the compile-time-only
|
||||
default-off rollout description with the persisted experimental preference.
|
||||
The Embedded MPV architecture documentation only needs clarification if its
|
||||
existing distinction between frame-copy shared controls and the native-view
|
||||
legacy dock would otherwise become ambiguous.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { of } from 'rxjs';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import {
|
||||
DashboardRailsSettings,
|
||||
Language,
|
||||
@@ -53,6 +53,58 @@ describe('SettingsStore dashboard rail settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shares the pending initial settings load across startup waiters', async () => {
|
||||
const pendingSettings = new Subject<Partial<Settings> | null>();
|
||||
storage.get.mockReturnValue(pendingSettings.asObservable());
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
const firstWaiter = store.loadSettings();
|
||||
const secondWaiter = store.loadSettings();
|
||||
const allWaiters = Promise.all([firstWaiter, secondWaiter]);
|
||||
let waitersResolved = false;
|
||||
void allWaiters.then(() => {
|
||||
waitersResolved = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(waitersResolved).toBe(false);
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
|
||||
pendingSettings.next({ webPlayerSharedControls: true });
|
||||
pendingSettings.complete();
|
||||
await allWaiters;
|
||||
|
||||
expect(storage.get).toHaveBeenCalledTimes(1);
|
||||
expect(firstWaiter).toBe(secondWaiter);
|
||||
expect(waitersResolved).toBe(true);
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(true);
|
||||
});
|
||||
|
||||
it('retries the initial settings load after a storage error', async () => {
|
||||
const failingSettings = new Subject<Partial<Settings> | null>();
|
||||
storage.get
|
||||
.mockReturnValueOnce(failingSettings.asObservable())
|
||||
.mockReturnValueOnce(of({ webPlayerSharedControls: true }));
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const store = injector.get(SettingsStore);
|
||||
const initialLoad = store.loadSettings();
|
||||
|
||||
failingSettings.error(new Error('storage unavailable'));
|
||||
await initialLoad;
|
||||
await store.loadSettings();
|
||||
|
||||
expect(storage.get).toHaveBeenCalledTimes(2);
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(true);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses enabled dashboard rail defaults when no settings are stored', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
@@ -64,6 +116,69 @@ describe('SettingsStore dashboard rail settings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults shared web controls to false when the stored field is missing', async () => {
|
||||
storedSettings = {};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('restores a persisted true shared web controls preference', async () => {
|
||||
storedSettings = {
|
||||
webPlayerSharedControls: true,
|
||||
};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes a persisted string "true" shared web controls preference to false', async () => {
|
||||
storedSettings = {
|
||||
webPlayerSharedControls: 'true' as unknown as boolean,
|
||||
};
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.webPlayerSharedControls?.()).toBe(false);
|
||||
expect(store.getSettings().webPlayerSharedControls).toBe(false);
|
||||
});
|
||||
|
||||
it('persists an updated true shared web controls preference', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.updateSettings({
|
||||
webPlayerSharedControls: true,
|
||||
});
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({
|
||||
webPlayerSharedControls: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes a malformed string "true" shared web controls update as false', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.updateSettings({
|
||||
webPlayerSharedControls: 'true' as unknown as boolean,
|
||||
});
|
||||
|
||||
expect(store.webPlayerSharedControls?.()).toBe(false);
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({
|
||||
webPlayerSharedControls: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deep-merges partial stored dashboard rail settings with enabled defaults', async () => {
|
||||
storedSettings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
webPlayerSharedControls: false,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
@@ -107,176 +108,206 @@ export const SettingsStore = signalStore(
|
||||
() => store.epgViewMode?.() ?? 'timeline'
|
||||
),
|
||||
})),
|
||||
withMethods((store, storage = inject(StorageMap)) => ({
|
||||
async loadSettings() {
|
||||
try {
|
||||
const stored = await firstValueFrom(
|
||||
storage.get(STORE_KEY.Settings)
|
||||
);
|
||||
if (stored) {
|
||||
const storedSettings = stored as Partial<Settings>;
|
||||
patchState(store, {
|
||||
...DEFAULT_SETTINGS,
|
||||
...storedSettings,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
storedSettings.dashboardRails
|
||||
),
|
||||
});
|
||||
void this.sanitizeEmbeddedMpvSelection().catch((error) => {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support while loading settings.',
|
||||
error
|
||||
withMethods((store, storage = inject(StorageMap)) => {
|
||||
let settingsLoadPromise: Promise<void> | undefined;
|
||||
|
||||
return {
|
||||
loadSettings() {
|
||||
if (settingsLoadPromise) {
|
||||
return settingsLoadPromise;
|
||||
}
|
||||
|
||||
settingsLoadPromise = (async () => {
|
||||
const stored = await firstValueFrom(
|
||||
storage.get(STORE_KEY.Settings)
|
||||
);
|
||||
if (stored) {
|
||||
const storedSettings = stored as Partial<Settings>;
|
||||
patchState(store, {
|
||||
...DEFAULT_SETTINGS,
|
||||
...storedSettings,
|
||||
webPlayerSharedControls:
|
||||
storedSettings.webPlayerSharedControls === true,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
storedSettings.dashboardRails
|
||||
),
|
||||
});
|
||||
void this.sanitizeEmbeddedMpvSelection().catch(
|
||||
(error) => {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support while loading settings.',
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
// Keep default settings if loading fails
|
||||
}
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<Settings>) {
|
||||
patchState(store, {
|
||||
...settings,
|
||||
...(settings.dashboardRails !== undefined
|
||||
? {
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
settings.dashboardRails
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Save the complete settings object, not just the partial update
|
||||
const completeSettings = this.getSettings();
|
||||
try {
|
||||
await firstValueFrom(
|
||||
storage.set(STORE_KEY.Settings, completeSettings)
|
||||
);
|
||||
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getSettings() {
|
||||
return {
|
||||
player: store.player(),
|
||||
streamFormat: store.streamFormat(),
|
||||
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
|
||||
language: store.language(),
|
||||
showCaptions: store.showCaptions(),
|
||||
showDashboard: store.showDashboard(),
|
||||
startupBehavior: store.startupBehavior(),
|
||||
showExternalPlaybackBar:
|
||||
store.showExternalPlaybackBar?.() ??
|
||||
DEFAULT_SETTINGS.showExternalPlaybackBar,
|
||||
theme: store.theme(),
|
||||
mpvPlayerPath: store.mpvPlayerPath(),
|
||||
mpvPlayerArguments: store.mpvPlayerArguments(),
|
||||
mpvReuseInstance: store.mpvReuseInstance(),
|
||||
vlcPlayerPath: store.vlcPlayerPath(),
|
||||
vlcPlayerArguments: store.vlcPlayerArguments(),
|
||||
vlcReuseInstance: store.vlcReuseInstance(),
|
||||
remoteControl: store.remoteControl(),
|
||||
remoteControlPort: store.remoteControlPort(),
|
||||
epgUrl: store.epgUrl(),
|
||||
downloadFolder:
|
||||
store.downloadFolder?.() ?? DEFAULT_SETTINGS.downloadFolder,
|
||||
recordingFolder:
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder,
|
||||
embeddedMpvFrameCopy:
|
||||
store.embeddedMpvFrameCopy?.() ?? false,
|
||||
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
epgViewMode:
|
||||
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
store.dashboardRails?.()
|
||||
),
|
||||
preferUploadedEpgOverXtream:
|
||||
store.preferUploadedEpgOverXtream?.() ??
|
||||
DEFAULT_SETTINGS.preferUploadedEpgOverXtream,
|
||||
trustedPrivateNetworkEpgUrls:
|
||||
store.trustedPrivateNetworkEpgUrls?.() ??
|
||||
DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls,
|
||||
trustedInsecureTlsHosts:
|
||||
store.trustedInsecureTlsHosts?.() ??
|
||||
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
|
||||
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
|
||||
};
|
||||
},
|
||||
|
||||
getDownloadFolder() {
|
||||
return store.downloadFolder?.() ?? DEFAULT_SETTINGS.downloadFolder;
|
||||
},
|
||||
|
||||
getRecordingFolder() {
|
||||
return (
|
||||
store.recordingFolder?.() ?? DEFAULT_SETTINGS.recordingFolder
|
||||
);
|
||||
},
|
||||
|
||||
getTrustOptions(): ElectronBridgeTrustOptions {
|
||||
const settings = this.getSettings();
|
||||
return {
|
||||
trustedPrivateNetworkEpgUrls:
|
||||
settings.trustedPrivateNetworkEpgUrls ?? [],
|
||||
trustedInsecureTlsHosts: settings.trustedInsecureTlsHosts ?? [],
|
||||
};
|
||||
},
|
||||
|
||||
getPlayer() {
|
||||
return store.player();
|
||||
},
|
||||
|
||||
isEmbeddedPlayer() {
|
||||
return (
|
||||
store.player() === VideoPlayer.VideoJs ||
|
||||
store.player() === VideoPlayer.Html5Player ||
|
||||
store.player() === VideoPlayer.ArtPlayer ||
|
||||
store.player() === VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
},
|
||||
|
||||
async sanitizeEmbeddedMpvSelection() {
|
||||
if (store.player() !== VideoPlayer.EmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.electron?.getEmbeddedMpvSupport
|
||||
) {
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
}
|
||||
})().catch((error) => {
|
||||
settingsLoadPromise = undefined;
|
||||
console.error('Failed to load settings:', error);
|
||||
// Keep default settings if loading fails
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const support = await window.electron.getEmbeddedMpvSupport();
|
||||
if (!support.supported) {
|
||||
return settingsLoadPromise;
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<Settings>) {
|
||||
patchState(store, {
|
||||
...settings,
|
||||
...(settings.webPlayerSharedControls !== undefined
|
||||
? {
|
||||
webPlayerSharedControls:
|
||||
settings.webPlayerSharedControls === true,
|
||||
}
|
||||
: {}),
|
||||
...(settings.dashboardRails !== undefined
|
||||
? {
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
settings.dashboardRails
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Save the complete settings object, not just the partial update
|
||||
const completeSettings = this.getSettings();
|
||||
try {
|
||||
await firstValueFrom(
|
||||
storage.set(STORE_KEY.Settings, completeSettings)
|
||||
);
|
||||
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getSettings() {
|
||||
return {
|
||||
player: store.player(),
|
||||
webPlayerSharedControls:
|
||||
store.webPlayerSharedControls?.() === true,
|
||||
streamFormat: store.streamFormat(),
|
||||
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
|
||||
language: store.language(),
|
||||
showCaptions: store.showCaptions(),
|
||||
showDashboard: store.showDashboard(),
|
||||
startupBehavior: store.startupBehavior(),
|
||||
showExternalPlaybackBar:
|
||||
store.showExternalPlaybackBar?.() ??
|
||||
DEFAULT_SETTINGS.showExternalPlaybackBar,
|
||||
theme: store.theme(),
|
||||
mpvPlayerPath: store.mpvPlayerPath(),
|
||||
mpvPlayerArguments: store.mpvPlayerArguments(),
|
||||
mpvReuseInstance: store.mpvReuseInstance(),
|
||||
vlcPlayerPath: store.vlcPlayerPath(),
|
||||
vlcPlayerArguments: store.vlcPlayerArguments(),
|
||||
vlcReuseInstance: store.vlcReuseInstance(),
|
||||
remoteControl: store.remoteControl(),
|
||||
remoteControlPort: store.remoteControlPort(),
|
||||
epgUrl: store.epgUrl(),
|
||||
downloadFolder:
|
||||
store.downloadFolder?.() ??
|
||||
DEFAULT_SETTINGS.downloadFolder,
|
||||
recordingFolder:
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder,
|
||||
embeddedMpvFrameCopy:
|
||||
store.embeddedMpvFrameCopy?.() ?? false,
|
||||
coverSize:
|
||||
store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
epgViewMode:
|
||||
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
store.dashboardRails?.()
|
||||
),
|
||||
preferUploadedEpgOverXtream:
|
||||
store.preferUploadedEpgOverXtream?.() ??
|
||||
DEFAULT_SETTINGS.preferUploadedEpgOverXtream,
|
||||
trustedPrivateNetworkEpgUrls:
|
||||
store.trustedPrivateNetworkEpgUrls?.() ??
|
||||
DEFAULT_SETTINGS.trustedPrivateNetworkEpgUrls,
|
||||
trustedInsecureTlsHosts:
|
||||
store.trustedInsecureTlsHosts?.() ??
|
||||
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
|
||||
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
|
||||
};
|
||||
},
|
||||
|
||||
getDownloadFolder() {
|
||||
return (
|
||||
store.downloadFolder?.() ?? DEFAULT_SETTINGS.downloadFolder
|
||||
);
|
||||
},
|
||||
|
||||
getRecordingFolder() {
|
||||
return (
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder
|
||||
);
|
||||
},
|
||||
|
||||
getTrustOptions(): ElectronBridgeTrustOptions {
|
||||
const settings = this.getSettings();
|
||||
return {
|
||||
trustedPrivateNetworkEpgUrls:
|
||||
settings.trustedPrivateNetworkEpgUrls ?? [],
|
||||
trustedInsecureTlsHosts:
|
||||
settings.trustedInsecureTlsHosts ?? [],
|
||||
};
|
||||
},
|
||||
|
||||
getPlayer() {
|
||||
return store.player();
|
||||
},
|
||||
|
||||
isEmbeddedPlayer() {
|
||||
return (
|
||||
store.player() === VideoPlayer.VideoJs ||
|
||||
store.player() === VideoPlayer.Html5Player ||
|
||||
store.player() === VideoPlayer.ArtPlayer ||
|
||||
store.player() === VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
},
|
||||
|
||||
async sanitizeEmbeddedMpvSelection() {
|
||||
if (store.player() !== VideoPlayer.EmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.electron?.getEmbeddedMpvSupport
|
||||
) {
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support; reverting to the default inline player.',
|
||||
error
|
||||
);
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
}
|
||||
},
|
||||
})),
|
||||
try {
|
||||
const support =
|
||||
await window.electron.getEmbeddedMpvSupport();
|
||||
if (!support.supported) {
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support; reverting to the default inline player.',
|
||||
error
|
||||
);
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}),
|
||||
withHooks({
|
||||
onInit(store) {
|
||||
store.loadSettings();
|
||||
|
||||
@@ -78,6 +78,11 @@ export function normalizeDashboardRailsSettings(
|
||||
*/
|
||||
export interface Settings {
|
||||
player: VideoPlayer;
|
||||
/**
|
||||
* Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.
|
||||
* Missing values remain off for compatibility with older saved settings.
|
||||
*/
|
||||
webPlayerSharedControls?: boolean;
|
||||
epgUrl: string[];
|
||||
streamFormat: StreamFormat;
|
||||
openStreamOnDoubleClick: boolean;
|
||||
|
||||
+39
-2
@@ -1,5 +1,5 @@
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
type ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
@@ -89,6 +90,8 @@ class StubEmbeddedMpvPlayerComponent {
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
const webPlayerSharedControls = signal(false);
|
||||
|
||||
describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||||
@@ -100,6 +103,8 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
webPlayerSharedControls.set(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
||||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||||
@@ -114,6 +119,10 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsManagedExternalPlayers: false },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { webPlayerSharedControls },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WebPlayerViewComponent, {
|
||||
@@ -146,6 +155,34 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('snapshots the shared controls setting for each player host', () => {
|
||||
webPlayerSharedControls.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.injector.get(WEB_PLAYER_SHARED_CONTROLS)
|
||||
).toBe(true);
|
||||
|
||||
webPlayerSharedControls.set(false);
|
||||
|
||||
expect(
|
||||
fixture.debugElement.injector.get(WEB_PLAYER_SHARED_CONTROLS)
|
||||
).toBe(true);
|
||||
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/next.ts'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.injector.get(WEB_PLAYER_SHARED_CONTROLS)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an explicit VOD value', { isLive: false }, false],
|
||||
[
|
||||
|
||||
@@ -25,10 +25,14 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
import {
|
||||
WEB_PLAYER_SHARED_CONTROLS,
|
||||
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
|
||||
} from '../player-controls';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
@@ -44,6 +48,13 @@ type PlaybackDiagnosticDetail = {
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
function resolveWebPlayerSharedControls(): boolean {
|
||||
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
|
||||
return typeof storedValue === 'boolean'
|
||||
? storedValue
|
||||
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
templateUrl: './web-player-view.component.html',
|
||||
@@ -62,6 +73,12 @@ type PlaybackDiagnosticDetail = {
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: WEB_PLAYER_SHARED_CONTROLS,
|
||||
useFactory: resolveWebPlayerSharedControls,
|
||||
},
|
||||
],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent {
|
||||
|
||||
Reference in new issue
Block a user