From ca502ad8336d2b1da97a3d276301db90828a3d31 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:53:36 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 301 +++++++++++------- src/components/PlayerBar.tsx | 1 - src/components/playerBar/PlaybackClock.tsx | 56 +++- .../playerBar/PlayerSeekbarSection.tsx | 23 +- src/styles/layout/player-bar.css | 26 +- src/utils/format/formatDuration.test.ts | 50 ++- src/utils/format/formatDuration.ts | 40 +++ 7 files changed, 354 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1654e4..117292f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -422,116 +422,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed -### Player — prefs survive restart when queue persist hits quota - -**By [@cucadmuh](https://github.com/cucadmuh), reported by norp on the Psysonic Discord, PR [#958](https://github.com/Psychotoxical/psysonic/pull/958)** - -* Volume, repeat mode, queue panel visibility, and the Last.fm loved-track cache no longer depend on the quota-bound `psysonic-player` blob (full `queueItems` since thin-state #872). Each pref now has its own small localStorage key with legacy migration from the old blob. -* Startup no longer overwrites saved prefs before Zustand rehydration finishes; persisted volume is pushed to the Rust engine on boot. - - -### All Albums — genre filter respects sidebar library scope - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#959](https://github.com/Psychotoxical/psysonic/pull/959)** - -* With multiple music libraries, narrowing the sidebar to one library no longer leaves the Genre filter showing server-wide genres — options now come from the scoped local index catalog (same scope as the album grid). - - -### Now Playing — multi-artist links and About the Artist tabs - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#960](https://github.com/Psychotoxical/psysonic/pull/960)** - -* Tracks with OpenSubsonic `artists[]` (e.g. Navidrome `feat.` splits) now expose per-artist links on the Now Playing hero and in the queue current-track row — same interaction as player bar and album track lists. -* About the Artist loads bio for each performer; when multiple artist ids are present, tabs switch between their bios, images, and similar artists instead of showing one joined name with a single profile. - - -### Composers — page search keeps role-split credits - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#961](https://github.com/Psychotoxical/psysonic/pull/961)** - -* Scoped search on Composers no longer replaces the Navidrome role-scoped catalog with generic artist index/search3 hits that merge split composer credits into one joined name and id — results stay split like the scroll overview. - - -### Browse grids — multi-select ring no longer clips (WebKitGTK) - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#962](https://github.com/Psychotoxical/psysonic/pull/962)** - -* Multi-select rings on Artists, All Albums, Playlists, and related card grids use an inset `::after` overlay (same approach as card focus rings) instead of `outline` on `overflow: hidden` tiles — fixes top-row clipping and the ~1px gap vs the inner border on Wayland/WebKitGTK. - - -### Composers — hide performer-only artists from role catalog - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#963](https://github.com/Psychotoxical/psysonic/pull/963)** - -* Navidrome's composer role list can include artists with zero composer album credits (e.g. Apollo 440 with performer albums only). Composers browse/search now drops rows where `stats.composer.albumCount` is zero so ghost composer cards no longer appear. - - -### Mainstage — Because you listened respects sidebar library - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#964](https://github.com/Psychotoxical/psysonic/pull/964)** - -* The recommendation rail picks albums from Last.fm similar artists via `getArtist`, which can ignore `musicFolderId` — picks are now filtered to the scoped library album set, and the rail cache invalidates when the sidebar library filter changes. - - -### Build a Mix — keyword blocks and scoped genre list - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#965](https://github.com/Psychotoxical/psysonic/pull/965)** - -* Random Mix keyword filter (click-to-block artist/genre) now applies even when "Exclude audiobooks" is off — blocking the only track in a library shows an empty state after Remix instead of the excluded song. -* Genre Mix loads genres through the scoped catalog (`fetchGenreCatalog` / local index) instead of server-wide `getGenres`, matching the sidebar library filter. - - -### Artist detail — external link buttons keep border on hover - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#966](https://github.com/Psychotoxical/psysonic/pull/966)** - -* Last.fm, Wikipedia, and Favorite used a hover border color that matched the card background — the rim disappeared instead of highlighting the inner fill like Play/Shuffle/Radio (`btn-surface`). -* Playlist detail — Play and Add Songs now show tooltips like the other header actions; track count uses proper pluralization (`1 song` vs `N songs`) with standard `count` interpolation. -* Suggested Songs rows now render BPM (and other optional columns like genre, play count, last played) — the column switch was missing those cases. - - -### Player transport — custom delay input validation - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#967](https://github.com/Psychotoxical/psysonic/pull/967)** - -* Absurd custom minute values (e.g. eleven nines) no longer arm an immediate timer while the preview still shows a far-future start time — input is capped to the browser delay limit and Apply stays disabled when out of range. -* Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses. - - -### Settings — in-page search coverage and junk-query filtering - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#968](https://github.com/Psychotoxical/psysonic/pull/968)** - -* AudioMuse-AI (Navidrome) is indexed and selectable from settings search; choosing it opens Servers and scrolls to the plugin toggle when shown. -* In-app and global shortcut labels (e.g. Volume up / Volume down) appear as search hits and focus the parent shortcuts subsection. -* Nonsense queries no longer return unrelated fuzzy matches (e.g. long repeated letters). - - -### Floating player bar — remove full-width background strip - -**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#969](https://github.com/Psychotoxical/psysonic/pull/969)** - -* Floating mode no longer stretches the player bar between sidebar and queue with fixed `left`/`right` — only the centered pill is painted over the page instead of a full-width black band behind the rounded corners. - - -### Smart Playlist editor — themed sort, stable toggles, exclude-all genres - -**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#970](https://github.com/Psychotoxical/psysonic/pull/970)** - -* Sort dropdown uses the themed `CustomSelect` instead of a native `` whose option list followed system styling. +* Include/Exclude genre and year-range mode buttons no longer jump ~1px when selected — matched button box model and disabled hover translate on mode toggles. +* Selected genres are color-coded (primary for include, danger for exclude) so they are distinguishable from available chips. +* Excluding all genres collapses to a single untagged-genre rule instead of hundreds of `notContains` filters that stalled Navidrome; empty smart playlists settle without a false "Playlist not found" after a long spinner. + + + ### Random Mix — audiobook exclusion no longer drops normal music **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#973](https://github.com/Psychotoxical/psysonic/pull/973) — reported by zunoz on Discord** @@ -855,6 +890,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * "Exclude audiobooks & radio plays" no longer treats **Thriller** and **Fantasy** as audiobook keywords. They matched regular music (Trance/Metal genre tags, a track titled "Thriller") because the filter scans genre, title, album and artist, so a handful of legitimate songs were dropped from each mix. * The exclusion's toggle area is tightened so only the checkbox and its title respond to a click — the description text and surrounding empty space no longer toggle it. + ### Cursors and Favorites sorting **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#974](https://github.com/Psychotoxical/psysonic/pull/974) — reported by zunoz on Discord** @@ -862,6 +898,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The queue collapse handle now shows a hand cursor like every other button; the thin resize line beside it keeps the resize cursor. * On Favorites, the **Plays**, **Last Played** and **BPM** columns are now actually sortable — they showed a clickable cursor but clicking did nothing. + ### Mainstage — no more blank start page **By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#975](https://github.com/Psychotoxical/psysonic/pull/975)** @@ -869,6 +906,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Hiding **Mainstage** from the sidebar no longer leaves the app opening on a blank page — it now starts on the first visible library entry instead. * When every Mainstage section is turned off, the page shows a short message with a shortcut into **Settings → Personalisation** rather than appearing empty. + ### Tracks — spacing, Duration column, header hover, multi-artist links **By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#976](https://github.com/Psychotoxical/psysonic/pull/976)** @@ -878,6 +916,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The track-list column header keeps its background on hover instead of turning transparent and letting rows show through. * **Track of the moment** and the browse rows split multi-artist tracks into individually clickable artist links, matching the album track list. + ### Queue, Genre cards, and the Artists index **By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#977](https://github.com/Psychotoxical/psysonic/pull/977)** @@ -886,12 +925,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Album cards on a **Genre** page split multi-artist credits into individually clickable artist links, matching the rest of the app. * On the **Artists** page the `#` index button now holds only names that start with a number; accented and non-Latin names (Æ Ø Å, Chinese, Japanese, Cyrillic, …) move to a new **Other** section instead of the `#` catch-all. + ### Artist detail — credit on "Also featured on" compilations **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#979](https://github.com/Psychotoxical/psysonic/pull/979)** * Compilation albums under **Also featured on** show their album artist (e.g. *Various Artists*) again instead of a bare `—`, and the credit links to the artist when the server provides one. + ### Playlist — Suggested Songs row matches the playlist **By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#980](https://github.com/Psychotoxical/psysonic/pull/980)** @@ -899,6 +940,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Suggested Songs now show the favorite heart and star rating like the playlist above, so the Favorite/Rating columns no longer leave an empty gap. * Tracks with several artists split into individually clickable names, matching the rest of the app — and reading the same before and after you add the track. + ### Small windows — usable layout when scaled down **By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#981](https://github.com/Psychotoxical/psysonic/pull/981)** @@ -907,18 +949,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The minimum window size is a touch larger so the layout can no longer be shrunk past the point where it breaks. * On a short window the Now Playing cover scales down to fit instead of overlapping the track title. + ### Song rails — consistent navigation buttons **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#982](https://github.com/Psychotoxical/psysonic/pull/982)** * The song rail's previous/next and reroll buttons are now square like every other rail instead of round. + +### Local index live search — no junk hits on `=` and syntax characters + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#983](https://github.com/Psychotoxical/psysonic/pull/983)** + +* Queries such as `1=2` or `M=c` no longer return unrelated albums and artists — FTS5 was parsing `=` and similar characters as query syntax instead of a literal token. +* Wildcard-only queries (`**`, `****`) are rejected for both local index and server search; titles that contain censorship stars (e.g. `***Flawless`) remain searchable. + + + ### Search — song results show their album cover **By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#984](https://github.com/Psychotoxical/psysonic/pull/984)** * Song results in the search dropdown now show their album cover instead of leaving most thumbnails blank. + +### Mainstage album rails — stable New badge on first hover + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#986](https://github.com/Psychotoxical/psysonic/pull/986)** + +* Horizontal album rails (Home New Releases, Discover, Favorites, Statistics, search rows, …) no longer hide the **New** / offline cover badges during hover zoom — cover stacking is shared with grid pages; rails keep dim-on-`::before` for play controls. + + + +### Player bar — stable waveform when showing remaining time + +**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#987](https://github.com/Psychotoxical/psysonic/pull/987)** + +* **Show remaining time** no longer reticks the seekbar width every second — fixed-width playbar clocks stop `WaveformSeek` resize/redraw jitter; clocks sit tighter against the waveform with an inline duration toggle icon. + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 34f32653..93a818e0 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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'; diff --git a/src/components/playerBar/PlaybackClock.tsx b/src/components/playerBar/PlaybackClock.tsx index 193f0761..c7f9e2ac 100644 --- a/src/components/playerBar/PlaybackClock.tsx +++ b/src/components/playerBar/PlaybackClock.tsx @@ -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(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 ; }); -/** 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(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 ; }); diff --git a/src/components/playerBar/PlayerSeekbarSection.tsx b/src/components/playerBar/PlayerSeekbarSection.tsx index a725fe7c..3dee3679 100644 --- a/src/components/playerBar/PlayerSeekbarSection.tsx +++ b/src/components/playerBar/PlayerSeekbarSection.tsx @@ -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 ( -
+
{isRadio ? ( <> {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? ( @@ -50,7 +56,7 @@ export function PlayerSeekbarSection({ ) : ( <> - +
{disableWaveformCanvas ?
@@ -65,8 +71,13 @@ export function PlayerSeekbarSection({ }} data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')} > - {localShowRemaining ? : formatTrackTime(duration)} - + + )} diff --git a/src/styles/layout/player-bar.css b/src/styles/layout/player-bar.css index daf0a7c3..f4c7b45f 100644 --- a/src/styles/layout/player-bar.css +++ b/src/styles/layout/player-bar.css @@ -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); diff --git a/src/utils/format/formatDuration.test.ts b/src/utils/format/formatDuration.test.ts index 9f8bf390..2c009bd5 100644 --- a/src/utils/format/formatDuration.test.ts +++ b/src/utils/format/formatDuration.test.ts @@ -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); + }); +}); diff --git a/src/utils/format/formatDuration.ts b/src/utils/format/formatDuration.ts index f5801dac..f2f4e292 100644 --- a/src/utils/format/formatDuration.ts +++ b/src/utils/format/formatDuration.ts @@ -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}`; +}