{{ row.labelKey | translate }}
{{ row.value }}
@@ -97,10 +102,20 @@
}
@if (showControls()) {
+
+
-
-
-
- @if (capabilities().seriesNavigation) {
-
-
-
- }
-
- @if (capabilities().seek) {
-
-
-
- }
-
-
+
{{ formatTime(timelineValue()) }}
@if (capabilities().seek) {
-
- }
-
-
{{ formatTime(timelineValue()) }}
- @if (state().canSeek) {
-
{{ formatTime(timelineDuration()) }}
- } @else if (state().isLive) {
-
+
+
+ @if (state().canSeek) {
+
- LIVE
-
- } @else {
- --:--
- }
-
+ >
+ }
+ @if (timelineHover.percent() !== null) {
+
+
+ {{ timelineHover.label() }}
+
+ }
+
+
+ } @else {
+
+ }
+ @if (state().canSeek && remainingTimeText(); as remaining) {
+
{{ remaining }}
+ } @else if (state().isLive) {
+
+
+ LIVE
+
+ } @else {
+
--:--
+ }
@if (recordingStatusText(); as recordingStatus) {
-
- @if (capabilities().volume) {
-
-
- @if (hasAudioTracks()) {
-
+
+ @if (capabilities().seriesNavigation) {
- @if (menus.audioOpen()) {
-
-
-
- @for (
- track of state().audioTracks;
- track track.id
- ) {
-
- }
-
-
- }
-
- }
+ }
- @if (hasSubtitleTracks()) {
-
+ @if (capabilities().seek) {
+ }
+
+
+
+ @if (capabilities().seek) {
+
+ }
+
+ @if (capabilities().seriesNavigation) {
+
+ }
+
+
+
+ @if (hasAudioTracks()) {
+
+
+ @if (menus.audioOpen()) {
+
+
+
+ @for (
+ track of state().audioTracks;
+ track track.id
+ ) {
+
+ }
+
+
+ }
+
+ }
+
+ @if (hasSubtitleTracks()) {
+
+
+ @if (menus.subtitleOpen()) {
+
+
+
+
+
+ @if (
+ capabilities().subtitles &&
+ state().subtitleTracks.length > 0
+ ) {
+
+ }
+ @for (
+ track of state().subtitleTracks;
+ track track.id
+ ) {
+
+ }
+ @if (capabilities().externalSubtitles) {
+
+ }
+
+ @if (capabilities().subtitleDelay) {
+
+
+ {{
+ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY'
+ | translate
+ }}
+
+
+
+ {{
+ subtitleDelayLabel(
+ state()
+ .subtitleDelaySeconds
+ )
+ }}
+
+
+
+
+ }
+ @if (capabilities().subtitleStyle) {
+
+
+ {{
+ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
+ | translate
+ }}
+
+
+ @for (
+ preset of subtitleSizePresets;
+ track preset.value
+ ) {
+
+ }
+
+
+ {{
+ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
+ | translate
+ }}
+
+
+ @for (
+ preset of subtitleColorPresets;
+ track preset.label
+ ) {
+
+ }
+
+
+ }
+
+
+ }
+
+ }
+
+ @if (hasQualityLevels()) {
+
+
+ @if (menus.qualityOpen()) {
+
+
+
+
+ @for (
+ level of state().qualityLevels;
+ track level.id
+ ) {
+
+ }
+
+
+ }
+
+ }
+
+ @if (capabilities().playbackSpeed) {
+
+
+ @if (menus.speedOpen()) {
+
+
+
+ @for (
+ preset of state().speedPresets;
+ track preset.value
+ ) {
+
+ }
+
+
+ }
+
+ }
+
+ @if (capabilities().aspectRatio) {
+
+
+ @if (menus.aspectOpen()) {
+
+
+
+ @for (
+ preset of state().aspectPresets;
+ track preset.value
+ ) {
+
+ }
+
+
+ }
+
+ }
+
+ @if (canRecord()) {
+
- @if (menus.subtitleOpen()) {
-
-
-
-
-
- @if (
- capabilities().subtitles &&
- state().subtitleTracks.length > 0
- ) {
-
- }
- @for (
- track of state().subtitleTracks;
- track track.id
- ) {
-
- }
- @if (capabilities().externalSubtitles) {
-
- }
-
- @if (capabilities().subtitleDelay) {
-
-
- {{
- 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY'
- | translate
- }}
-
-
-
- {{
- subtitleDelayLabel(
- state()
- .subtitleDelaySeconds
- )
- }}
-
-
-
-
- }
- @if (capabilities().subtitleStyle) {
-
-
- {{
- 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
- | translate
- }}
-
-
- @for (
- preset of subtitleSizePresets;
- track preset.value
- ) {
-
- }
-
-
- {{
- 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
- | translate
- }}
-
-
- @for (
- preset of subtitleColorPresets;
- track preset.label
- ) {
-
- }
-
-
- }
-
-
- }
-
- }
+ }
- @if (hasQualityLevels()) {
-
+ @if (capabilities().pictureInPicture) {
- @if (menus.qualityOpen()) {
-
-
-
-
- @for (
- level of state().qualityLevels;
- track level.id
- ) {
-
- }
-
-
- }
-
- }
-
- @if (capabilities().playbackSpeed) {
-
-
- @if (menus.speedOpen()) {
-
-
-
- @for (
- preset of state().speedPresets;
- track preset.value
- ) {
-
- }
-
-
- }
-
- }
+ }
- @if (capabilities().aspectRatio) {
-
+ @if (capabilities().fullscreen) {
- @if (menus.aspectOpen()) {
-
-
-
- @for (
- preset of state().aspectPresets;
- track preset.value
- ) {
-
- }
-
-
- }
-
- }
-
- @if (canRecord()) {
-
- }
-
- @if (capabilities().pictureInPicture) {
-
- }
-
- @if (capabilities().fullscreen) {
-
- }
+ }
+
}
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts
index b8e7e24cf..217bb33f8 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts
@@ -416,6 +416,16 @@ describe('PlayerControlsComponent interactions', () => {
expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
});
+ it('mutes on a tap when the slider is already inline (wide dock)', () => {
+ component.volumeInteractions.buttonClick(
+ pointerTypedEvent('click', 'touch'),
+ { inlineSlider: true }
+ );
+
+ expect(fake.commands.setVolume).toHaveBeenCalledWith(0);
+ expect(component.menus.volumeOpen()).toBe(false);
+ });
+
it('mutes directly on a mouse click without opening the popover', () => {
component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'mouse'));
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss
index 6b11fa467..6b2835f6c 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss
@@ -1,24 +1,36 @@
:host {
- --pc-accent: #ffffff;
- --pc-track: rgba(255, 255, 255, 0.3);
- --pc-thumb-ring: rgba(255, 255, 255, 0.95);
- --pc-panel-bg: rgba(18, 18, 20, 0.92);
- --pc-panel-border: rgba(255, 255, 255, 0.16);
- --pc-text: #ffffff;
+ // Overlay palette. Deliberately literal and theme-independent: the shared
+ // controls always sit on video, and the UI guidelines keep them on one
+ // light-on-dark palette in both app themes. The values are the design
+ // tokens of the "Hybrid" controls concept (accent blue = primary action
+ // and progress; cyan = something is on; violet = a value was changed).
+ --pc-accent-blue: #4f8eff;
+ --pc-accent-blue-hover: #3f7ef5;
+ --pc-accent-cyan: #5cd6ff;
+ --pc-accent-violet: #b599ff;
+ --pc-text: #e7ecf3;
+ --pc-text-secondary: #9aa3b2;
+ --pc-text-tertiary: #6b7384;
--pc-text-dim: rgba(255, 255, 255, 0.82);
+ --pc-scrim-ink: 4, 7, 11;
+ --pc-track: rgba(255, 255, 255, 0.18);
+ --pc-hover: rgba(255, 255, 255, 0.1);
+ --pc-glass-bg: rgba(12, 16, 23, 0.72);
+ --pc-glass-border: rgba(255, 255, 255, 0.09);
+ --pc-font-mono:
+ ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
position: absolute;
inset: 0;
z-index: 2;
display: block;
// The control layer is transparent to the mouse so the video underneath
- // stays interactive; only the bar/popovers opt back in to pointer events.
+ // stays interactive; only the dock/popovers opt back in to pointer events.
pointer-events: none;
color: var(--pc-text);
- // Become a query container so popovers can size against the actual player
- // box (the host fills the player via inset:0) instead of fixed pixels or
- // the viewport — they grow to fit when there is room and shrink to scroll
- // when the player is small, never spilling past its edges.
+ // Become a query container so the dock and popovers can size against the
+ // actual player box (the host fills the player via inset:0) instead of
+ // fixed pixels or the viewport.
container-type: size;
container-name: player-controls;
}
@@ -77,34 +89,59 @@
// footage. Deliberately one element rather than a background per consumer: two
// overlapping gradients would darken the overlap twice.
//
-// The stops are the bottom bar's, mirrored, so the two edges of the player read
-// as one system.
+// The stops mirror the bottom scrim so the two edges of the player read as one
+// system; the height follows the player so a tall fullscreen stage gets a
+// proportionally deeper fade, never less than the chrome it backs.
.player-controls__top-scrim {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 1;
- // Mirrors the bar's 48px top padding + 64px min-height.
- height: 112px;
+ height: max(28%, 112px);
opacity: 0;
// Purely decorative: clicks belong to the video surface underneath.
pointer-events: none;
background: linear-gradient(
to bottom,
- rgba(0, 0, 0, 0.78) 0%,
- rgba(0, 0, 0, 0.45) 45%,
+ rgba(var(--pc-scrim-ink), 0.78) 0%,
+ rgba(var(--pc-scrim-ink), 0.45) 45%,
transparent 100%
);
// Fades with the controls, but does not slide: a moving scrim edge is
// visible against video in a way a moving control is not.
- transition: opacity 180ms ease-out;
+ transition: opacity 200ms ease-out;
}
.player-controls__top-scrim--visible {
opacity: 1;
}
+// Backdrop of the dock. Same rule as the top scrim: one gradient for the
+// timeline and the button row together, and no surface under the dock itself
+// — the controls sit directly on the video.
+.player-controls__bottom-scrim {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1;
+ height: 55%;
+ opacity: 0;
+ pointer-events: none;
+ background: linear-gradient(
+ to top,
+ rgba(var(--pc-scrim-ink), 0.92) 0%,
+ rgba(var(--pc-scrim-ink), 0.55) 45%,
+ rgba(var(--pc-scrim-ink), 0) 100%
+ );
+ transition: opacity 200ms ease-out;
+}
+
+.player-controls__bottom-scrim--visible {
+ opacity: 1;
+}
+
.player-controls__title {
position: absolute;
top: 0;
@@ -115,20 +152,20 @@
flex-direction: column;
gap: 4px;
// Bottom padding keeps the text inside the top scrim's opaque half.
- padding: 20px 24px 40px;
+ padding: 20px 28px 40px;
opacity: 0;
// Purely informative: never intercept clicks meant for the video surface.
pointer-events: none;
transform: translateY(-8px);
transition:
- opacity 180ms ease-out,
- transform 200ms ease-out;
+ opacity 200ms ease-out,
+ transform 220ms ease-out;
}
// Only when the corner button is actually rendered: keep the fullscreen title
// from running underneath it (a long title ellipsizes at the full width).
.player-controls__title:has(~ .player-controls__corner) {
- padding-right: 64px;
+ padding-right: 72px;
}
.player-controls__title--visible {
@@ -139,8 +176,8 @@
.player-controls__title-primary {
overflow: hidden;
color: var(--pc-text);
- font-size: 1.15rem;
- font-weight: 600;
+ font-size: 17px;
+ font-weight: 650;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
@@ -149,29 +186,29 @@
.player-controls__title-secondary {
overflow: hidden;
- color: var(--pc-text-dim);
- font-size: 0.85rem;
+ color: var(--pc-text-secondary);
+ font-size: 12.5px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
-// Top-right corner chrome (stream-info button). It fades with the bottom bar
+// Top-right corner chrome (stream-info button). It fades with the dock
// but is its own layer: the title scrim above it is click-through, so the
// button opts back into pointer events on its own.
.player-controls__corner {
position: absolute;
- top: 8px;
- right: 8px;
+ top: 16px;
+ right: 20px;
z-index: 3;
display: flex;
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition:
- opacity 180ms ease-out,
- transform 200ms ease-out;
+ opacity 200ms ease-out,
+ transform 220ms ease-out;
}
.player-controls__corner--visible {
@@ -180,34 +217,23 @@
transform: translateY(0);
}
+// The dock: timeline row above, control row below. No background of its own;
+// the bottom scrim is the backdrop.
.player-controls__bar {
position: absolute;
- right: 0;
- bottom: 0;
- left: 0;
+ right: 32px;
+ bottom: 22px;
+ left: 32px;
z-index: 2;
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
- gap: 16px;
- align-items: center;
- align-content: end;
- min-height: 64px;
- // Generous top padding lets the scrim rise above the controls and fade out.
- padding: 48px 16px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
- // True-overlay scrim: opaque-ish black at the bottom fading to fully
- // transparent at the top, so controls read clearly over full-bleed video.
- background: linear-gradient(
- to top,
- rgba(0, 0, 0, 0.78) 0%,
- rgba(0, 0, 0, 0.45) 45%,
- transparent 100%
- );
transition:
- opacity 180ms ease-out,
- transform 200ms ease-out;
+ opacity 200ms ease-out,
+ transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.player-controls__bar--visible {
@@ -216,27 +242,118 @@
transform: translateY(0);
}
-.player-controls__transport {
- display: flex;
- align-items: center;
- gap: 8px;
-}
+// --- Timeline row -----------------------------------------------------------
.player-controls__timeline {
display: flex;
- flex-direction: column;
- gap: 6px;
+ align-items: center;
+ gap: 14px;
min-width: 0;
}
.player-controls__time {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- color: var(--pc-text-dim);
- font-size: 0.78rem;
+ flex: 0 0 auto;
+ color: var(--pc-text);
+ font-family: var(--pc-font-mono);
+ font-size: 14px;
+ font-weight: 500;
font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.player-controls__time--end {
+ color: var(--pc-text-secondary);
+}
+
+.player-controls__timeline-spacer {
+ flex: 1 1 auto;
+}
+
+// Pointer target of the bar. Taller than the 6px track so it is easy to hit;
+// the transparent range input on top owns dragging and the keyboard.
+.player-controls__timeline-bar {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ height: 18px;
+ cursor: pointer;
+}
+
+.player-controls__timeline-bar--disabled {
+ cursor: default;
+}
+
+.player-controls__timeline-track {
+ position: absolute;
+ top: 50%;
+ right: 0;
+ left: 0;
+ display: flex;
+ gap: 3px;
+ height: 6px;
+ transform: translateY(-50%);
+}
+
+.player-controls__timeline-segment {
+ position: relative;
+ flex: 1 1 auto;
+ overflow: hidden;
+ border-radius: 3px;
+ background: var(--pc-track);
+}
+
+.player-controls__timeline-fill {
+ height: 100%;
+ background: var(--pc-accent-blue);
+ border-radius: inherit;
+}
+
+.player-controls__timeline-knob {
+ position: absolute;
+ top: 50%;
+ width: 14px;
+ height: 14px;
+ margin: -7px 0 0 -7px;
+ border-radius: 50%;
+ background: #ffffff;
+ box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3);
+ pointer-events: none;
+ transition: transform 120ms ease-out;
+}
+
+.player-controls__timeline-bar:hover .player-controls__timeline-knob,
+.player-controls__timeline-bar:has(.player-controls__slider:focus-visible)
+ .player-controls__timeline-knob {
+ transform: scale(1.15);
+}
+
+.player-controls__timeline-marker {
+ position: absolute;
+ top: 50%;
+ width: 2px;
+ height: 12px;
+ margin: -6px 0 0 -1px;
+ border-radius: 1px;
+ background: #ffffff;
+ pointer-events: none;
+}
+
+.player-controls__timeline-label {
+ --hover-x: 0%;
+ position: absolute;
+ bottom: calc(100% + 2px);
+ // Centered on the pointer, but never past the bar's ends.
+ left: clamp(28px, var(--hover-x), calc(100% - 28px));
+ transform: translateX(-50%);
+ padding: 5px 8px;
+ color: var(--pc-text);
+ background: rgba(12, 16, 23, 0.85);
+ border-radius: 6px;
+ font-size: 11.5px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ pointer-events: none;
}
.player-controls__recording-status {
@@ -244,7 +361,7 @@
align-items: center;
gap: 6px;
min-width: 0;
- color: rgba(255, 255, 255, 0.78);
+ color: var(--pc-text-dim);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
@@ -300,10 +417,35 @@
}
}
+// --- Control row ------------------------------------------------------------
+
+.player-controls__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 8px;
+ align-items: center;
+}
+
+.player-controls__left {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+}
+
+.player-controls__transport {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
.player-controls__actions {
display: flex;
align-items: center;
- gap: 8px;
+ justify-content: flex-end;
+ gap: 4px;
+ min-width: 0;
}
.player-controls__popover-anchor {
@@ -311,47 +453,113 @@
display: flex;
}
+.player-controls__volume {
+ align-items: center;
+ gap: 4px;
+}
+
.player-controls__record-button--active {
color: var(--mat-sys-error, #ff5252) !important;
}
-// Scope to :host — the controls render directly under the component host
-// (`.player-controls-host`); there is no `.player-controls` wrapper element, so
-// selectors prefixed with `.player-controls ` never matched (which is why the
-// icons were only white on :hover, the one rule scoped to `.player-controls__bar`).
-:host :is(button[mat-icon-button]) {
- // Material drives the icon colour through MDC tokens, not plain `color`,
- // so force both — otherwise the icons render in the dim theme colour.
+// Primary action: a filled circle in the accent colour. Not a Material icon
+// button on purpose — the ring and the fill are the design, and Material's
+// state layer would tint them.
+.player-controls__play {
+ display: grid;
+ flex: 0 0 auto;
+ place-items: center;
+ width: 52px;
+ height: 52px;
+ margin: 0 6px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
color: #ffffff;
- --mdc-icon-button-icon-color: #ffffff;
- --mat-icon-button-icon-color: #ffffff;
+ background: var(--pc-accent-blue);
+ box-shadow:
+ 0 0 0 6px rgba(79, 142, 255, 0.18),
+ 0 6px 20px rgba(79, 142, 255, 0.35);
+ cursor: pointer;
+ transition:
+ background 120ms ease-out,
+ transform 120ms ease-out;
+}
+
+.player-controls__play mat-icon {
+ width: 30px;
+ height: 30px;
+ font-size: 30px;
+ color: #ffffff;
+}
+
+// Hover darkens rather than lightens: the white glyph keeps its contrast
+// against the fill (the theme E2E rasterizes exactly this state).
+.player-controls__play:hover:not(:disabled) {
+ background: var(--pc-accent-blue-hover);
+ transform: scale(1.04);
+}
+
+.player-controls__play:focus-visible {
+ outline: 2px solid #ffffff;
+ outline-offset: 3px;
+}
+
+.player-controls__play:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+
+// Scope to :host — the controls render directly under the component host
+// (`.player-controls-host`); there is no `.player-controls` wrapper element.
+:host :is(button[mat-icon-button]) {
+ width: 40px;
+ height: 40px;
+ padding: 8px;
+ // Material drives the icon colour and geometry through its tokens, not
+ // plain `color`/`width`, so set both — otherwise the icons render in the
+ // dim theme colour inside a 48px circle.
+ color: var(--pc-text);
+ --mdc-icon-button-icon-color: var(--pc-text);
+ --mat-icon-button-icon-color: var(--pc-text);
+ --mat-icon-button-state-layer-size: 40px;
+ --mdc-icon-button-state-layer-size: 40px;
+ --mat-icon-button-container-shape: 12px;
+ --mdc-icon-button-container-shape: 12px;
+ --mat-icon-button-hover-state-layer-opacity: 0;
+ border-radius: 12px;
+ transition: background 120ms ease-out;
}
:host :is(button[mat-icon-button]) mat-icon {
- color: #ffffff;
+ color: var(--pc-text);
}
:host :is(button[mat-icon-button][disabled]) {
// Keep disabled affordances visibly muted.
- --mdc-icon-button-icon-color: rgba(255, 255, 255, 0.38);
- color: rgba(255, 255, 255, 0.38);
+ --mdc-icon-button-icon-color: var(--pc-text-tertiary);
+ --mat-icon-button-icon-color: var(--pc-text-tertiary);
+ color: var(--pc-text-tertiary);
}
:host :is(button[mat-icon-button][disabled]) mat-icon {
- color: rgba(255, 255, 255, 0.38);
+ color: var(--pc-text-tertiary);
}
:host
:is(.player-controls__bar, .player-controls__corner)
- :is(button[mat-icon-button]:hover) {
+ :is(button[mat-icon-button]:hover:not([disabled])) {
color: #ffffff;
- background: rgba(255, 255, 255, 0.16);
+ background: var(--pc-hover);
}
+// --- Sliders ----------------------------------------------------------------
+
.player-controls__slider {
--slider-progress: 0%;
width: 100%;
height: 18px;
+ margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
@@ -359,7 +567,7 @@
}
.player-controls__slider:focus-visible {
- outline: 2px solid var(--pc-accent);
+ outline: 2px solid var(--pc-text);
outline-offset: 4px;
border-radius: 4px;
}
@@ -369,7 +577,7 @@
border-radius: 999px;
background: linear-gradient(
to right,
- var(--pc-accent) var(--slider-progress),
+ var(--pc-text) var(--slider-progress),
var(--pc-track) var(--slider-progress)
);
}
@@ -377,13 +585,13 @@
.player-controls__slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
- width: 14px;
- height: 14px;
- margin-top: -5px;
- border: 2px solid var(--pc-thumb-ring);
+ width: 12px;
+ height: 12px;
+ margin-top: -4px;
+ border: 0;
border-radius: 50%;
- background: var(--pc-accent);
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
+ background: #ffffff;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
transition: transform 120ms ease-out;
}
@@ -398,7 +606,6 @@
.player-controls__slider:disabled::-webkit-slider-thumb {
background: rgba(255, 255, 255, 0.45);
- border-color: rgba(255, 255, 255, 0.6);
box-shadow: none;
}
@@ -411,16 +618,16 @@
.player-controls__slider::-moz-range-progress {
height: 4px;
border-radius: 999px;
- background: var(--pc-accent);
+ background: var(--pc-text);
}
.player-controls__slider::-moz-range-thumb {
- width: 14px;
- height: 14px;
- border: 2px solid var(--pc-thumb-ring);
+ width: 12px;
+ height: 12px;
+ border: 0;
border-radius: 50%;
- background: var(--pc-accent);
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
+ background: #ffffff;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
transition: transform 120ms ease-out;
}
@@ -431,24 +638,52 @@
.player-controls__slider:disabled::-moz-range-thumb {
background: rgba(255, 255, 255, 0.45);
- border-color: rgba(255, 255, 255, 0.6);
box-shadow: none;
}
+// The timeline's range input is the interaction and accessibility layer over
+// the drawn track: invisible, full-size, and its focus ring is drawn on the
+// track instead (an outline on a transparent element is invisible).
+.player-controls__slider--timeline {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ height: 100%;
+ opacity: 0;
+}
+
+.player-controls__slider--timeline:disabled {
+ cursor: default;
+}
+
+.player-controls__timeline-bar:has(.player-controls__slider:focus-visible)
+ .player-controls__timeline-track {
+ outline: 2px solid var(--pc-text);
+ outline-offset: 5px;
+ border-radius: 4px;
+}
+
+.player-controls__slider--inline {
+ width: 72px;
+ margin-left: 4px;
+}
+
+// --- Popovers ---------------------------------------------------------------
+
.player-controls__panel {
min-width: 0;
color: var(--pc-text);
- background: var(--pc-panel-bg);
- border: 1px solid var(--pc-panel-border);
+ background: var(--pc-glass-bg);
+ border: 1px solid var(--pc-glass-border);
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
- backdrop-filter: blur(18px);
+ backdrop-filter: blur(18px) saturate(1.3);
}
.player-controls__volume-popover {
position: absolute;
bottom: calc(100% + 6px);
- right: 0;
+ left: 0;
display: grid;
grid-template-columns: 1fr;
gap: 4px;
@@ -493,9 +728,9 @@
// where container units are unavailable.
max-width: min(420px, calc(100cqw - 24px), 80vw);
// Height grows to fit the list; only when it would exceed the room above
- // the controls bar does the inner list start scrolling. 96px ≈ the controls
- // bar height plus the 6px gap and a little breathing room.
- max-height: min(calc(100cqh - 96px), 70vh);
+ // the dock does the inner list start scrolling. 120px ≈ the dock height
+ // plus the 6px gap and a little breathing room.
+ max-height: min(calc(100cqh - 120px), 70vh);
padding: 10px 12px;
z-index: 3;
}
@@ -503,7 +738,7 @@
.player-controls__menu-title {
flex: 0 0 auto;
padding: 0 2px 6px;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--pc-text-tertiary);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -521,7 +756,7 @@
flex-direction: column;
min-width: 240px;
max-width: min(360px, calc(100cqw - 24px), 80vw);
- max-height: min(calc(100cqh - 96px), 70vh);
+ max-height: min(calc(100cqh - 120px), 70vh);
padding: 10px 12px;
overflow-y: auto;
scrollbar-width: thin;
@@ -617,7 +852,7 @@
// Single scroll owner for the subtitle popover's list + delay/style sections:
// on short players (e.g. the 220px portal player) the panel's max-height cap
// must scroll ALL content — sections outside the list's own scroll area would
-// otherwise clip or overflow into the controls bar.
+// otherwise clip or overflow into the dock.
.player-controls__subtitle-body {
display: flex;
flex-direction: column;
@@ -642,7 +877,7 @@
.player-controls__subtitle-section-title {
padding: 0 2px 6px;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--pc-text-tertiary);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -718,102 +953,132 @@
border-color: #ffffff;
}
-@media (max-width: 720px) {
- // Moderately tight viewports keep the single-row layout: the transport and
- // action buttons hold their natural width while the middle timeline column
- // (the seek slider) absorbs the shrink. Genuinely narrow players reflow to
- // two rows via the container query below, which wins over this gap.
- .player-controls__bar {
- gap: 8px;
- }
-
- .player-controls__actions {
- min-width: 0;
- }
-
- .player-controls__volume-popover,
- .player-controls__track-popover {
- right: 0;
- left: auto;
- }
-}
-
// Touch scrubbing needs a taller hit strip and thumb than the 18px mouse
-// slider; the 4px visual track is unchanged — only the input's touch area and
-// the thumb grow.
+// slider; the drawn track is unchanged — only the input's touch area and the
+// volume thumb grow.
@media (pointer: coarse) {
.player-controls__slider {
height: 28px;
}
+ .player-controls__timeline-bar {
+ height: 28px;
+ }
+
.player-controls__slider::-webkit-slider-thumb {
- width: 18px;
- height: 18px;
- margin-top: -7px;
+ width: 16px;
+ height: 16px;
+ margin-top: -6px;
}
.player-controls__slider::-moz-range-thumb {
- width: 18px;
- height: 18px;
+ width: 16px;
+ height: 16px;
}
}
-// Narrow players — phone-sized PWA viewports and small windowed/inline
-// players — cannot hold transport + timeline + the variable-count actions
-// cluster in one row (every action button from volume to fullscreen is
-// conditional, so the cluster's width is content-dependent). Give the
-// timeline its own full-width row and split the second row between the two
-// button clusters. Container-based on purpose: a small inline player inside a
-// wide desktop window needs the same treatment as a phone viewport.
-@container player-controls (max-width: 640px) {
- // Matches the bar's reduced padding below: on a small inline player two
- // full-height scrims would tint most of the picture.
+// Compact dock — phone-sized PWA viewports and small windowed/inline players.
+// Keep this in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts,
+// which decides what is rendered; this block only sizes it. Container-based
+// on purpose: a small inline player inside a wide desktop window needs the
+// same treatment as a phone viewport.
+@container player-controls (max-width: 719px) {
.player-controls__top-scrim {
- height: 88px;
+ height: max(28%, 88px);
+ }
+
+ .player-controls__title {
+ padding: 12px 16px 28px;
+ }
+
+ .player-controls__corner {
+ top: 8px;
+ right: 8px;
}
.player-controls__bar {
- grid-template-columns: auto minmax(0, 1fr);
- grid-template-areas:
- 'timeline timeline'
- 'transport actions';
- gap: 0 8px;
- padding: 36px 10px 8px;
- }
-
- .player-controls__transport {
- grid-area: transport;
+ right: 14px;
+ bottom: 10px;
+ left: 14px;
+ gap: 6px;
}
.player-controls__timeline {
- grid-area: timeline;
+ gap: 10px;
+ }
+
+ .player-controls__time {
+ font-size: 13px;
+ }
+
+ .player-controls__timeline-track {
+ height: 5px;
+ }
+
+ .player-controls__timeline-knob {
+ width: 11px;
+ height: 11px;
+ margin: -5.5px 0 0 -5.5px;
+ box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3);
+ }
+
+ .player-controls__row {
+ gap: 4px;
+ }
+
+ .player-controls__transport {
+ gap: 4px;
+ }
+
+ .player-controls__play {
+ width: 36px;
+ height: 36px;
+ margin: 0 4px;
+ box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.18);
+ }
+
+ .player-controls__play mat-icon {
+ width: 22px;
+ height: 22px;
+ font-size: 22px;
}
.player-controls__actions {
- grid-area: actions;
position: relative;
- justify-self: end;
- // When even a dedicated row cannot hold the cluster (worst case:
- // series transport plus audio/subtitles/quality/speed/aspect/PiP),
- // wrap the actions instead of making them a scroll container. A scroll
- // container would clip every absolutely positioned capability panel.
+ // When even the row cannot hold the cluster (worst case: audio/
+ // subtitles/quality/speed/aspect/PiP), wrap the actions instead of
+ // making them a scroll container. A scroll container would clip
+ // every absolutely positioned capability panel.
flex-wrap: wrap;
justify-content: flex-end;
max-width: 100%;
overflow: visible;
}
- // Anchor narrow-player panels to the whole actions cluster. This keeps the
- // right-aligned panels inside the player and above every wrapped action row.
+ // Anchor compact-player panels to the whole actions cluster. This keeps
+ // the right-aligned panels inside the player and above every wrapped
+ // action row.
.player-controls__actions .player-controls__popover-anchor {
position: static;
}
// Compact icon buttons: wins back roughly one button of width per four.
.player-controls__bar :is(button[mat-icon-button]) {
- width: 40px;
- height: 40px;
- padding: 8px;
- --mdc-icon-button-state-layer-size: 40px;
+ width: 32px;
+ height: 32px;
+ padding: 6px;
+ border-radius: 9px;
+ --mat-icon-button-state-layer-size: 32px;
+ --mdc-icon-button-state-layer-size: 32px;
+ --mat-icon-button-container-shape: 9px;
+ --mdc-icon-button-container-shape: 9px;
+ --mat-icon-button-icon-size: 20px;
+ --mdc-icon-button-icon-size: 20px;
+ }
+
+ .player-controls__bar :is(button[mat-icon-button]) mat-icon {
+ width: 20px;
+ height: 20px;
+ font-size: 20px;
}
}
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts
index f40a5a654..a11cb97c1 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts
@@ -56,7 +56,7 @@ describe('PlayerControlsComponent timeline scrubbing', () => {
const currentTimeText = () =>
(
fixture.nativeElement.querySelector(
- '.player-controls__time > span:first-child'
+ '.player-controls__time--current'
) as HTMLElement
).textContent;
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts
index daea28aef..b4a4b45df 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts
@@ -17,18 +17,24 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ControlsChromeInteractions } from './controls-chrome-interactions';
import { ControlsFeedback } from './controls-feedback';
import { ControlsFullscreen } from './controls-fullscreen';
+import { ControlsLayout } from './controls-layout';
import { ControlsMenuSelection } from './controls-menu-selection';
import { type ControlsMenu, ControlsMenuState } from './controls-menu-state';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsStreamStats } from './controls-stream-stats';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
+import { ControlsTimelineHover } from './controls-timeline-hover';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { ControlsVolumeInteractions } from './controls-volume-interactions';
import { ControlsSubtitleSettings } from './controls-subtitle-settings';
-import { formatTime, speedLabel } from './controls-format.utils';
+import {
+ formatRemainingTime,
+ formatTime,
+ speedLabel,
+} from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
@@ -74,6 +80,9 @@ export class PlayerControlsComponent implements OnDestroy {
readonly feedback = new ControlsFeedback();
readonly anyMenuOpen = this.menus.anyOpen;
private readonly shortcuts = new ControlsShortcuts();
+ private readonly layout = new ControlsLayout();
+ /** Compact dock: narrow inline players and phone-sized viewports. */
+ readonly isCompact = computed(() => this.layout.mode() === 'compact');
private readonly visibility = new ControlsVisibility(() => this.canHide());
private readonly fullscreen = new ControlsFullscreen(
() => this.fullscreenTarget() ?? this.playerSurface(),
@@ -134,6 +143,16 @@ export class PlayerControlsComponent implements OnDestroy {
readonly timelineDuration = this.timeline.duration;
readonly timelineValue = this.timeline.value;
readonly timelineProgress = this.timeline.progress;
+ readonly timelineHover = new ControlsTimelineHover({
+ duration: this.timelineDuration,
+ interactive: computed(
+ () => this.capabilities().seek && this.state().canSeek
+ ),
+ });
+ /** `−7:03` while a finite duration is known; the dock prefers it to the total. */
+ readonly remainingTimeText = computed(() =>
+ formatRemainingTime(this.timelineValue(), this.timelineDuration())
+ );
readonly displayVolume = this.volume.value;
readonly isFullscreen = this.fullscreen.isFullscreen;
@@ -189,6 +208,7 @@ export class PlayerControlsComponent implements OnDestroy {
readonly controlsAreVisible = this.vm.controlsAreVisible;
readonly hideCursor = this.vm.hideCursor;
constructor() {
+ this.layout.attach(this.host);
this.shortcuts.attach({
isAvailable: () => this.shortcutsEnabled() && this.showControls(),
hostElement: () => this.host,
@@ -282,6 +302,7 @@ export class PlayerControlsComponent implements OnDestroy {
});
}
ngOnDestroy(): void {
+ this.layout.dispose();
this.shortcuts.detach();
this.feedback.dispose();
this.visibility.dispose();