fix(player): re-place the timeline label when the player resizes

The label's measured pixel left only re-ran when its anchor or text
changed, so a paused player whose slider the keyboard holds kept a stale
position after the window narrowed, past the player's edge. A
ResizeObserver on the bar and the player now feeds the placement effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-08 18:56:25 +02:00
1 parent 7aca654408
commit 9c0278079c
3 files changed
+52 -4

No files matched your search

@@ -407,7 +407,9 @@ stylesheet centres it on its anchor; once laid out,
`PlayerTimelineComponent` moves it by its measured width
(`clampTimelineLabelLeft`, an `afterRenderEffect`) so it stays 8px inside
the player (`.player-controls-host`) — past the bar's ends when needed, as
the bar is narrower than the player. `xtream-catchup-timeline-label.e2e.ts`
the bar is narrower than the player. A `ResizeObserver` on the bar and the
player re-places it when only the size changes (a paused player whose
slider the keyboard holds, in a narrowed window). `xtream-catchup-timeline-label.e2e.ts`
tabs to the bar with a 120-character programme title at 1280 and 800px, in
both themes and in de and ru.
@@ -466,7 +466,10 @@ describe('PlayerControlsComponent dock', () => {
try {
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 70, bubbles: true })
new MouseEvent('pointermove', {
clientX: 70,
bubbles: true,
})
);
fixture.detectChanges();
const label = query(
@@ -477,11 +480,29 @@ describe('PlayerControlsComponent dock', () => {
expect(label.style.transform).toBe('none');
bar.dispatchEvent(
new MouseEvent('pointermove', { clientX: 730, bubbles: true })
new MouseEvent('pointermove', {
clientX: 730,
bubbles: true,
})
);
fixture.detectChanges();
// Its right edge 8px in from the player's right edge.
expect(label.style.left).toBe(`${800 - 8 - 390 - 70}px`);
// The player narrows with the anchor and text unchanged (a
// paused player, the slider held): the label is re-placed.
host.getBoundingClientRect = () =>
({ left: 0, right: 600, width: 600 }) as DOMRect;
bar.getBoundingClientRect = () =>
({ left: 70, width: 460 }) as DOMRect;
resizeTo(600);
expect(
(
query(
'[data-test-id="player-controls-timeline-label"]'
) as HTMLElement
).style.left
).toBe(`${600 - 8 - 390 - 70}px`);
} finally {
if (offsetWidth) {
Object.defineProperty(
@@ -3,9 +3,11 @@ import {
Component,
ElementRef,
afterRenderEffect,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
@@ -54,16 +56,39 @@ export class PlayerTimelineComponent {
private readonly bar = viewChild<ElementRef<HTMLElement>>('bar');
private readonly labelElement =
viewChild<ElementRef<HTMLElement>>('labelElement');
/**
* Bumped when the bar or the player resizes: the label's pixel `left`
* is stale then, even with its anchor and text unchanged (a paused
* player, the keyboard holding the slider, a narrowed window).
*/
private readonly resized = signal(0);
constructor() {
effect((onCleanup) => {
const bar = this.bar()?.nativeElement;
if (!bar || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() =>
this.resized.update((count) => count + 1)
);
observer.observe(bar);
const player = this.host.closest('.player-controls-host');
if (player) {
observer.observe(player);
}
onCleanup(() => observer.disconnect());
});
// The stylesheet centres the label on its anchor; once laid out, it
// is moved by its measured width to stay inside the player, which a
// long programme or chapter title would otherwise overflow.
afterRenderEffect({
earlyRead: () => {
const percent = this.label().percent();
// A new title or time changes the width to clamp.
// A new title or time changes the width to clamp, and a
// resize the room it is clamped into.
this.label().text();
this.resized();
const label = this.labelElement()?.nativeElement;
const bar = this.bar()?.nativeElement;
if (percent === null || !label || !bar) {