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>
</>
)}