fix(player): stable playbar clocks when showing remaining time (#987)

* chore: restore PR order in CHANGELOG [1.47.0] Fixed section

Re-sort ### blocks ascending by PR number per release-note placement rules after out-of-order Discord-fix batch inserts.

* fix(player): stable playbar clocks when showing remaining time

Pad seekbar time strings to a fixed width so ticking remaining time does not resize WaveformSeek via ResizeObserver; tighten clock-to-waveform spacing and keep the toggle icon inline.

* docs: CHANGELOG and credits for playbar remaining-time fix (PR #987)

* docs: CHANGELOG entry for playbar remaining-time waveform fix (#987)

* chore: drop settingsCredits entry for minor playbar fix (#987)

* docs: move #987 CHANGELOG entry to end of [1.47.0] Fixed section
This commit is contained in:
cucadmuh
2026-06-04 17:53:36 +03:00
committed by GitHub
parent 6da98e476b
commit ca502ad833
7 changed files with 354 additions and 143 deletions
-1
View File
@@ -30,7 +30,6 @@ import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFo
import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { formatTrackTime } from '../utils/format/formatDuration';
import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock';
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
+40 -16
View File
@@ -1,35 +1,59 @@
import { memo, useEffect, useRef } from 'react';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
import { formatTrackTime } from '../../utils/format/formatDuration';
import {
formatPlaybarClock,
formatPlaybarToggleClock,
formatTrackTime,
} from '../../utils/format/formatDuration';
/** Renders the playback clock without ever causing PlayerBar to re-render.
* Updates the DOM directly via an imperative store subscription. */
export const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
export const PlaybackTime = memo(function PlaybackTime({
className,
minuteFieldWidth,
}: {
className?: string;
minuteFieldWidth?: number;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const format = (seconds: number) =>
minuteFieldWidth != null
? formatPlaybarClock(seconds, minuteFieldWidth)
: formatTrackTime(seconds);
if (spanRef.current) {
spanRef.current.textContent = formatTrackTime(getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = format(getPlaybackProgressSnapshot().currentTime);
}
return subscribePlaybackProgress(state => {
if (spanRef.current) spanRef.current.textContent = formatTrackTime(state.currentTime);
if (spanRef.current) spanRef.current.textContent = format(state.currentTime);
});
}, []);
}, [minuteFieldWidth]);
return <span className={className} ref={spanRef} />;
});
/** Renders the remaining time (duration - currentTime) without causing PlayerBar
* to re-render. */
export const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
/** Remaining/duration toggle clock — fixed-length string, imperative updates. */
export const ToggleClock = memo(function ToggleClock({
duration,
minuteFieldWidth,
remaining,
className,
}: {
duration: number;
minuteFieldWidth: number;
remaining: boolean;
className?: string;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const updateRemaining = () => {
if (spanRef.current) {
const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = `-${formatTrackTime(remaining)}`;
}
const update = () => {
if (!spanRef.current) return;
const seconds = remaining
? Math.max(0, duration - getPlaybackProgressSnapshot().currentTime)
: duration;
spanRef.current.textContent = formatPlaybarToggleClock(seconds, minuteFieldWidth, remaining);
};
updateRemaining();
return subscribePlaybackProgress(updateRemaining);
}, [duration]);
update();
return remaining ? subscribePlaybackProgress(update) : undefined;
}, [duration, minuteFieldWidth, remaining]);
return <span className={className} ref={spanRef} />;
});
@@ -2,9 +2,9 @@ import { ArrowLeftRight } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
import { useThemeStore } from '../../store/themeStore';
import { formatTrackTime } from '../../utils/format/formatDuration';
import { formatTrackTime, playbarMinuteFieldWidth } from '../../utils/format/formatDuration';
import WaveformSeek from '../WaveformSeek';
import { PlaybackTime, RemainingTime } from './PlaybackClock';
import { PlaybackTime, ToggleClock } from './PlaybackClock';
interface Props {
isRadio: boolean;
@@ -21,8 +21,14 @@ export function PlayerSeekbarSection({
isRadio, radioMeta, trackId, duration, localShowRemaining, setLocalShowRemaining,
disableWaveformCanvas, t,
}: Props) {
const minuteFieldWidth = playbarMinuteFieldWidth(duration);
const playbarClockStyle = {
'--playbar-clock-body-ch': minuteFieldWidth + 3,
'--playbar-clock-signed-ch': minuteFieldWidth + 4,
} as React.CSSProperties;
return (
<div className="player-waveform-section">
<div className="player-waveform-section" style={playbarClockStyle}>
{isRadio ? (
<>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
@@ -50,7 +56,7 @@ export function PlayerSeekbarSection({
</>
) : (
<>
<PlaybackTime className="player-time" />
<PlaybackTime className="player-time" minuteFieldWidth={minuteFieldWidth} />
<div className="player-waveform-wrap">
{disableWaveformCanvas
? <div className="radio-progress-bar" aria-hidden />
@@ -65,8 +71,13 @@ export function PlayerSeekbarSection({
}}
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
>
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTrackTime(duration)}
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
<ToggleClock
className="player-time-toggle__label"
duration={duration}
minuteFieldWidth={minuteFieldWidth}
remaining={localShowRemaining}
/>
<ArrowLeftRight className="player-time-toggle__icon" size={10} aria-hidden />
</span>
</>
)}
+23 -3
View File
@@ -279,7 +279,7 @@ html[data-platform="windows"] .player-bar.floating {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-2);
gap: 4px;
margin-left: 24px;
margin-right: 24px;
}
@@ -298,7 +298,10 @@ html[data-platform="windows"] .player-bar.floating {
min-width: 2.5rem;
}
.player-time:first-child {
.player-waveform-section > .player-time:first-child {
flex: 0 0 calc(var(--playbar-clock-body-ch, 4) * 1ch);
width: calc(var(--playbar-clock-body-ch, 4) * 1ch);
min-width: unset;
text-align: right;
}
@@ -307,15 +310,32 @@ html[data-platform="windows"] .player-bar.floating {
}
/* Clickable time toggle with swap indicator */
.player-time.player-time-toggle {
min-width: unset;
}
.player-time-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 2px 4px;
gap: 2px;
flex-shrink: 0;
padding: 2px 0 2px 2px;
border-radius: 4px;
transition: background-color 0.15s, color 0.15s;
}
.player-time-toggle__label {
flex: 0 0 calc(var(--playbar-clock-signed-ch, 5) * 1ch);
width: calc(var(--playbar-clock-signed-ch, 5) * 1ch);
font-variant-numeric: tabular-nums;
}
.player-time-toggle__icon {
flex-shrink: 0;
opacity: 0.6;
}
.player-time-toggle:hover {
background-color: var(--surface-hover, rgba(128, 128, 128, 0.2));
color: var(--text-primary);
+49 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { formatTrackTime, formatLongDuration } from './formatDuration';
import {
formatTrackTime,
formatLongDuration,
formatPlaybarClock,
formatPlaybarToggleClock,
playbarMinuteFieldWidth,
PLAYBAR_CLOCK_PAD,
} from './formatDuration';
describe('formatTrackTime', () => {
it('formats m:ss with zero-padded seconds', () => {
@@ -51,3 +58,44 @@ describe('formatLongDuration', () => {
expect(formatLongDuration(-1)).toBe('0:00');
});
});
describe('playbarMinuteFieldWidth', () => {
it('matches the minute digit count of the track duration', () => {
expect(playbarMinuteFieldWidth(65)).toBe(1);
expect(playbarMinuteFieldWidth(599)).toBe(1);
expect(playbarMinuteFieldWidth(600)).toBe(2);
expect(playbarMinuteFieldWidth(3600)).toBe(2);
expect(playbarMinuteFieldWidth(6000)).toBe(3);
});
it('returns 1 for invalid duration', () => {
expect(playbarMinuteFieldWidth(0)).toBe(1);
expect(playbarMinuteFieldWidth(NaN)).toBe(1);
});
});
describe('formatPlaybarClock', () => {
it('pads minutes with figure spaces to a fixed field width', () => {
expect(formatPlaybarClock(65, 1)).toBe('1:05');
expect(formatPlaybarClock(599, 2)).toBe(`${PLAYBAR_CLOCK_PAD}9:59`);
expect(formatPlaybarClock(600, 2)).toBe('10:00');
});
it('keeps remaining/elapsed strings the same length while ticking', () => {
expect(formatPlaybarClock(599, 2).length).toBe(formatPlaybarClock(600, 2).length);
expect(formatPlaybarClock(59, 2).length).toBe(formatPlaybarClock(60, 2).length);
});
it('returns a padded zero clock for invalid input', () => {
expect(formatPlaybarClock(0, 2)).toBe(`${PLAYBAR_CLOCK_PAD}0:00`);
expect(formatPlaybarClock(NaN, 3)).toBe(`${PLAYBAR_CLOCK_PAD.repeat(2)}0:00`);
});
});
describe('formatPlaybarToggleClock', () => {
it('prefixes remaining with minus and duration with a figure space', () => {
expect(formatPlaybarToggleClock(65, 1, true)).toBe('-1:05');
expect(formatPlaybarToggleClock(65, 1, false)).toBe(`${PLAYBAR_CLOCK_PAD}1:05`);
expect(formatPlaybarToggleClock(65, 1, true).length).toBe(formatPlaybarToggleClock(65, 1, false).length);
});
});
+40
View File
@@ -21,3 +21,43 @@ export function formatLongDuration(seconds: number): string {
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** Invisible same-width pad for fixed-length playbar clocks (no leading zeros). */
export const PLAYBAR_CLOCK_PAD = '\u2007';
/** Minute digit count for a fixed-width playbar clock derived from track duration. */
export function playbarMinuteFieldWidth(durationSeconds: number): number {
if (!durationSeconds || !isFinite(durationSeconds) || durationSeconds <= 0) return 1;
return Math.max(1, String(Math.floor(durationSeconds / 60)).length);
}
/**
* Fixed-width `m:ss` for the player seekbar — minutes padded with figure spaces
* so digit columns do not shift while the clock ticks (WebKit waveform resize).
*/
export function formatPlaybarClock(
seconds: number,
minuteFieldWidth: number,
fallback = '0:00',
): string {
if (!seconds || !isFinite(seconds) || seconds < 0) {
if (fallback === '0:00') {
return `${PLAYBAR_CLOCK_PAD.repeat(Math.max(0, minuteFieldWidth - 1))}0:00`;
}
return fallback;
}
const clamped = Math.max(0, seconds);
const m = Math.floor(clamped / 60);
const s = Math.floor(clamped % 60);
return `${String(m).padStart(minuteFieldWidth, PLAYBAR_CLOCK_PAD)}:${s.toString().padStart(2, '0')}`;
}
/** Right-hand playbar toggle: `-m:ss` (remaining) or figure-space + `m:ss` (duration). */
export function formatPlaybarToggleClock(
seconds: number,
minuteFieldWidth: number,
remaining: boolean,
): string {
const body = formatPlaybarClock(seconds, minuteFieldWidth);
return remaining ? `-${body}` : `${PLAYBAR_CLOCK_PAD}${body}`;
}