diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md
index 3c73022f9..a116665ad 100644
--- a/docs/architecture/player-controls-contract.md
+++ b/docs/architecture/player-controls-contract.md
@@ -289,6 +289,10 @@ It owns only transient presentation behavior:
- `ControlsSurface` — pointer/click/double-click surface interactions;
- `ControlsTimeline` — scrub state and timeline projections;
- `ControlsTimelineHover` — the time under the pointer over the timeline;
+- `app-player-timeline` — presentation of the timeline row (current time,
+ segment track, knob, hover label, remaining time / LIVE, recording
+ status); scrub `input`/`change` events go back to the controls component,
+ which owns reveal and seeking;
- `ControlsLayout` — the compact/wide dock mode from the host's width;
- `ControlsSettings` — the settings panel's groups, on/modified state and
open/close transitions (`controls-settings-groups.ts` holds the pure
@@ -369,13 +373,18 @@ bottom-right corner above the controls: the next episode's still (or its
watched, "Up next · in 7 min" and the title. The host supplies the item
through the optional `upNext` input (`PlayerUpNextItem { label, title,
thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows —
-`seriesNavigation` capability, `canNextEpisode`, a finite duration with at
+`seriesNavigation` capability, a finite duration with at
most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, not `ended` (with
autoplay off nothing is scheduled, so no countdown), controls shown,
settings panel closed — and how many minutes remain (never below one). A
still that fails to load falls back to the label tile. A
-click emits `nextEpisodeRequested`, the same output the hosts already
-handle, so the switch keeps fullscreen exactly like the transport button.
+click emits `nextEpisodeRequested` directly — not through the
+transport's `canNextEpisode` guard, which is season-local — so the card
+also works at a season's last episode. `PortalInlinePlayerComponent`
+routes such a request through the Up Next rail selection
+(`upNextEpisodeSelected`) whenever `seriesNavigation.canNext` is false, which
+plays the next season's first episode; either path keeps fullscreen exactly
+like the transport button.
The card is a glass surface that does not fade with the controls; the
compact dock uses a smaller variant without the trailing icon.
@@ -426,7 +435,12 @@ the panel the moment the last group disappears.
the load-file action and the delay / size / color sections that the
popover used to hold (same `player-controls-load-subtitle`,
`player-controls-subtitle-delay`, `player-controls-subtitle-style` test
- ids). A choice applies immediately and **keeps the panel open** —
+ ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from
+ the keyboard (the opener is `:focus-visible`) it takes focus, a pointer
+ open leaves focus alone (a focused control would capture Space from the
+ shortcuts), and closing with focus inside returns it to `tune`. While
+ the compact sheet replaces the dock, the dock is `inert`, so hidden
+ controls leave the tab order. A choice applies immediately and **keeps the panel open** —
`ControlsMenuSelection` no longer closes anything — so alternatives can be
compared against the running video; Escape, the close button, the `tune`
button, a click on the video surface or an outside pointerdown close it.
@@ -1558,6 +1572,9 @@ libs/ui/playback/src/lib/player-controls/
├── controls-settings.ts
├── controls-settings-groups.ts
├── controls-up-next.ts
+├── player-timeline.component.ts
+├── player-timeline.component.html
+├── player-timeline.component.scss
├── player-up-next-card.component.ts
├── player-up-next-card.component.html
├── player-up-next-card.component.scss
diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts
index 33b66dad2..69ba70311 100644
--- a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts
+++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts
@@ -80,17 +80,19 @@ describe('ControlsUpNext', () => {
expect(upNext.minutesLeft()).toBe(1);
});
- it('needs a next episode, a finite duration and a series-capable engine', () => {
+ it('needs a supplied episode, a finite duration and a series-capable engine', () => {
item.set(null);
expect(upNext.visible()).toBe(false);
item.set(next);
+ // The host's item is authoritative: at a season's last episode the
+ // transport cannot step forward, but the next season's first can.
setState({
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1100,
});
- expect(upNext.visible()).toBe(false);
+ expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts
index aac4a688a..0b13db46f 100644
--- a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts
+++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts
@@ -18,9 +18,10 @@ export interface ControlsUpNextDeps {
}
/**
- * When the "Up next" card shows: a series host supplied the next episode,
- * the engine can actually switch to it, the episode has a known length,
- * and no more than the threshold of it is left. Live streams and open-ended
+ * When the "Up next" card shows: a series host supplied the next episode
+ * (possibly the first of the following season, which the transport's
+ * season-local `canNextEpisode` does not cover), the engine plays series,
+ * the episode has a known length, and no more than the threshold is left. Live streams and open-ended
* VOD never qualify because they have no remaining time to count down.
*/
export class ControlsUpNext {
@@ -50,7 +51,6 @@ export class ControlsUpNext {
// An ended episode with autoplay off schedules no switch: a
// countdown would promise one. Autoplay replaces the playback.
state.status === 'ended' ||
- !state.canNextEpisode ||
!this.deps.capabilities().seriesNavigation ||
!this.deps.showControls() ||
this.deps.settingsOpen()
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts
index 3b1d4a3ab..d1318b98c 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts
@@ -108,7 +108,7 @@ describe('PlayerControlsComponent capability contract', () => {
return event.defaultPrevented;
}
- it('keeps narrow-player popovers outside clipping scroll containers', () => {
+ it('lets the compact action cluster wrap instead of clipping or scrolling', () => {
const narrowActionsRule =
PLAYER_CONTROLS_STYLE_SOURCE.match(
/\.player-controls__actions\s*\{([\s\S]*?)\}/g
@@ -120,9 +120,6 @@ describe('PlayerControlsComponent capability contract', () => {
expect(narrowActionsRule).toContain('flex-wrap: wrap');
expect(narrowActionsRule).toContain('overflow: visible');
expect(narrowActionsRule).not.toContain('overflow-x: auto');
- expect(PLAYER_CONTROLS_STYLE_SOURCE).toMatch(
- /\.player-controls__actions \.player-controls__popover-anchor\s*\{\s*position:\s*static;/
- );
});
it('routes shortcuts to the player the user interacted with most recently', () => {
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts
index 50f514890..57e0313fd 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts
@@ -443,6 +443,31 @@ describe('PlayerControlsComponent dock', () => {
expect(next).toHaveBeenCalledTimes(1);
});
+ it('offers the next season across the season boundary', () => {
+ setState({
+ canSeek: true,
+ canNextEpisode: false,
+ durationSeconds: 1200,
+ positionSeconds: 1200 - 5 * 60,
+ });
+ fixture.detectChanges();
+ const card = query('[data-test-id="player-controls-up-next"]');
+ expect(card).not.toBeNull();
+ // The transport's next button is disabled, but the card still
+ // hands the request to the host, which picks the next season.
+ expect(
+ (
+ query(
+ '[data-test-id="player-controls-next-episode"]'
+ ) as HTMLButtonElement
+ ).disabled
+ ).toBe(true);
+ const next = jest.fn();
+ component.nextEpisodeRequested.subscribe(next);
+ card?.click();
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
it('yields to the settings panel and needs a next episode', () => {
nearTheEnd();
fixture.detectChanges();
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html
index 3036acb54..60acd35e0 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html
@@ -125,6 +125,7 @@
[class.player-controls__bar--panel-open]="
settings.isOpen() && layout.roomy()
"
+ [attr.inert]="settings.isOpen() && !layout.roomy() ? '' : null"
[class.player-controls__bar--sheet-open]="
settings.isOpen() && !layout.roomy()
"
@@ -136,123 +137,18 @@
(focusin)="chrome.onFocusIn($event)"
(focusout)="chrome.onFocusOut($event)"
>
-