From 5231169a71349417cae9fbd8459dcf4333340872 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 14:50:10 +0200 Subject: [PATCH] refactor(format): consolidate duration formatters into format/formatDuration (Phase L, part 2) (#690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mm:ss track-time formatter was hand-rolled in 11 places and the h:mm:ss total-duration formatter in 4 — extract two tested functions: - formatTrackTime(seconds, fallback='0:00') — m:ss, used for track / playback times. fallback param covers the '–' placeholder rows. - formatLongDuration(seconds) — h:mm:ss when >=1h, else m:ss, used for album / queue totals. Behaviour preserved per call site: the unified guard (!seconds || !isFinite || <0 -> fallback) produces identical output to every prior variant for all real inputs; SongRow keeps its '–' via the fallback arg. Removes the formatter exports from 6 componentHelpers files (playerBarHelpers / fullscreenPlayerHelpers deleted — they only exported the formatter) and 7 inline component copies. + formatDuration.test.ts --- src/components/AlbumHeader.tsx | 11 +--- src/components/MobilePlayerView.tsx | 14 ++--- src/components/OrbitSessionBar.tsx | 7 ++- src/components/PlayerBar.tsx | 2 +- src/components/QueuePanel.tsx | 5 +- src/components/SongInfoModal.tsx | 9 +--- src/components/SongRow.tsx | 10 +--- .../albumTrackList/AlbumTrackListMobile.tsx | 4 +- src/components/albumTrackList/TrackRow.tsx | 5 +- .../artistDetail/ArtistDetailTopTracks.tsx | 4 +- .../favorites/FavoritesSongsTracklist.tsx | 3 +- src/components/fullscreenPlayer/FsSeekbar.tsx | 10 ++-- src/components/miniPlayer/MiniControls.tsx | 6 +-- src/components/nowPlaying/AlbumCard.tsx | 5 +- src/components/nowPlaying/Hero.tsx | 4 +- src/components/nowPlaying/RadioView.tsx | 6 +-- src/components/nowPlaying/TopSongsCard.tsx | 4 +- src/components/playerBar/PlaybackClock.tsx | 8 +-- .../playerBar/PlayerSeekbarSection.tsx | 8 +-- .../playlist/PlaylistSongSearchPanel.tsx | 4 +- .../playlist/PlaylistSuggestions.tsx | 5 +- src/components/playlist/PlaylistTracklist.tsx | 5 +- src/components/queuePanel/QueueHeader.tsx | 11 ++-- src/components/queuePanel/QueueList.tsx | 4 +- src/pages/PlaylistDetail.tsx | 1 - .../componentHelpers/albumTrackListHelpers.ts | 8 --- .../componentHelpers/artistDetailHelpers.ts | 6 --- .../fullscreenPlayerHelpers.ts | 6 --- .../componentHelpers/miniPlayerHelpers.ts | 7 --- .../componentHelpers/playerBarHelpers.ts | 6 --- .../componentHelpers/playlistDetailHelpers.ts | 6 --- .../componentHelpers/queuePanelHelpers.tsx | 7 --- src/utils/format/formatDuration.test.ts | 53 +++++++++++++++++++ src/utils/format/formatDuration.ts | 23 ++++++++ 34 files changed, 139 insertions(+), 138 deletions(-) delete mode 100644 src/utils/componentHelpers/fullscreenPlayerHelpers.ts delete mode 100644 src/utils/componentHelpers/playerBarHelpers.ts create mode 100644 src/utils/format/formatDuration.test.ts create mode 100644 src/utils/format/formatDuration.ts diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index e6ee9a97..d0b5f01b 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -12,14 +12,7 @@ import StarRating from './StarRating'; import { copyEntityShareLink } from '../utils/share/copyEntityShareLink'; import { showToast } from '../utils/ui/toast'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; - -function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; - return `${m}:${s.toString().padStart(2, '0')}`; -} +import { formatLongDuration } from '../utils/format/formatDuration'; function formatSize(bytes?: number): string { if (!bytes) return ''; @@ -208,7 +201,7 @@ export default function AlbumHeader({ {info.year && {info.year}} {info.genre && · {info.genre}} · {songs.length} Tracks - · {formatDuration(totalDuration)} + · {formatLongDuration(totalDuration)} {formatLabel && · {formatLabel}} {info.recordLabel && ( <> diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index 89cadf83..78de6554 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useCachedUrl } from './CachedImage'; +import { formatTrackTime } from '../utils/format/formatDuration'; import LyricsPane from './LyricsPane'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import PlaybackDelayModal from './PlaybackDelayModal'; @@ -66,13 +67,6 @@ function useAlbumAccentColor(imageUrl: string): string { return color; } -function formatTime(seconds: number): string { - if (!seconds || isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - // ── Queue Drawer ────────────────────────────────────────────────────────────── function QueueDrawer({ onClose }: { onClose: () => void }) { @@ -119,7 +113,7 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
{track.artist}
- {formatTime(track.duration)} + {formatTrackTime(track.duration)} ); }) @@ -373,8 +367,8 @@ export default function MobilePlayerView() {
- {formatTime(effectiveTime)} - -{formatTime(Math.max(0, duration - effectiveTime))} + {formatTrackTime(effectiveTime)} + -{formatTrackTime(Math.max(0, duration - effectiveTime))}
diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index d2b3014f..187c7b5f 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -19,6 +19,7 @@ import OrbitSettingsPopover from './OrbitSettingsPopover'; import OrbitSharePopover from './OrbitSharePopover'; import OrbitDiagnosticsPopover from './OrbitDiagnosticsPopover'; import ConfirmModal from './ConfirmModal'; +import { formatTrackTime } from '../utils/format/formatDuration'; /** * Orbit — top-strip session indicator. @@ -34,11 +35,9 @@ import ConfirmModal from './ConfirmModal'; const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000; +/** `m:ss` countdown from a millisecond value. */ function formatCountdown(ms: number): string { - const clamped = Math.max(0, Math.round(ms / 1000)); - const m = Math.floor(clamped / 60); - const s = clamped % 60; - return `${m}:${s.toString().padStart(2, '0')}`; + return formatTrackTime(Math.round(ms / 1000)); } export default function OrbitSessionBar() { diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 3ec6d546..f9d51d65 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -28,7 +28,7 @@ import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat'; import { usePreviewStore } from '../store/previewStore'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; -import { formatTime } from '../utils/componentHelpers/playerBarHelpers'; +import { formatTrackTime } from '../utils/format/formatDuration'; import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock'; import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo'; import { PlayerTransportControls } from './playerBar/PlayerTransportControls'; diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index ce605214..790ec26b 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -23,10 +23,7 @@ import NowPlayingInfo from './NowPlayingInfo'; import { TFunction } from 'i18next'; import { useLuckyMixStore } from '../store/luckyMixStore'; import { useQueueToolbarStore } from '../store/queueToolbarStore'; -import { - DurationMode, - formatTime, -} from '../utils/componentHelpers/queuePanelHelpers'; +import { DurationMode } from '../utils/componentHelpers/queuePanelHelpers'; import { SavePlaylistModal } from './queuePanel/SavePlaylistModal'; import { LoadPlaylistModal } from './queuePanel/LoadPlaylistModal'; import { QueueHeader } from './queuePanel/QueueHeader'; diff --git a/src/components/SongInfoModal.tsx b/src/components/SongInfoModal.tsx index 0ed4e65b..05955ec9 100644 --- a/src/components/SongInfoModal.tsx +++ b/src/components/SongInfoModal.tsx @@ -10,12 +10,7 @@ import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { copyTextToClipboard } from '../utils/server/serverMagicString'; import { showToast } from '../utils/ui/toast'; - -function formatDuration(s: number): string { - const m = Math.floor(s / 60); - const sec = s % 60; - return `${m}:${sec.toString().padStart(2, '0')}`; -} +import { formatTrackTime } from '../utils/format/formatDuration'; function formatSize(bytes?: number): string | null { if (!bytes) return null; @@ -152,7 +147,7 @@ export default function SongInfoModal() { )} - + diff --git a/src/components/SongRow.tsx b/src/components/SongRow.tsx index f8c9ec55..f4dfd740 100644 --- a/src/components/SongRow.tsx +++ b/src/components/SongRow.tsx @@ -8,13 +8,7 @@ import { usePlayerStore } from '../store/playerStore'; import { enqueueAndPlay } from '../utils/playback/playSong'; import { useDragDrop } from '../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; - -function fmtDuration(s: number): string { - if (!s || !isFinite(s)) return '–'; - const m = Math.floor(s / 60); - const sec = Math.floor(s % 60); - return `${m}:${sec.toString().padStart(2, '0')}`; -} +import { formatTrackTime } from '../utils/format/formatDuration'; interface Props { song: SubsonicSong; @@ -108,7 +102,7 @@ function SongRow({ song }: Props) {
{song.genre ?? '—'}
-
{fmtDuration(song.duration)}
+
{formatTrackTime(song.duration, '–')}
); } diff --git a/src/components/albumTrackList/AlbumTrackListMobile.tsx b/src/components/albumTrackList/AlbumTrackListMobile.tsx index c57eb44c..81159b6f 100644 --- a/src/components/albumTrackList/AlbumTrackListMobile.tsx +++ b/src/components/albumTrackList/AlbumTrackListMobile.tsx @@ -3,7 +3,7 @@ import { AudioLines } from 'lucide-react'; import type { SubsonicSong } from '../../api/subsonicTypes'; import type { Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { formatDuration } from '../../utils/componentHelpers/albumTrackListHelpers'; +import { formatLongDuration } from '../../utils/format/formatDuration'; interface Props { discNums: number[]; @@ -71,7 +71,7 @@ export function AlbumTrackListMobile({ )} {song.title} - {formatDuration(song.duration)} + {formatLongDuration(song.duration)} ); })} diff --git a/src/components/albumTrackList/TrackRow.tsx b/src/components/albumTrackList/TrackRow.tsx index 9d4bcb7d..c035bd92 100644 --- a/src/components/albumTrackList/TrackRow.tsx +++ b/src/components/albumTrackList/TrackRow.tsx @@ -10,7 +10,8 @@ import { useSelectionStore } from '../../store/selectionStore'; import { useThemeStore } from '../../store/themeStore'; import { usePreviewStore } from '../../store/previewStore'; import StarRating from '../StarRating'; -import { codecLabel, formatDuration, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers'; +import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers'; +import { formatLongDuration } from '../../utils/format/formatDuration'; type ContextMenuFn = ( x: number, @@ -173,7 +174,7 @@ export const TrackRow = React.memo(function TrackRow({ case 'duration': return (
- {formatDuration(song.duration)} + {formatLongDuration(song.duration)}
); case 'format': diff --git a/src/components/artistDetail/ArtistDetailTopTracks.tsx b/src/components/artistDetail/ArtistDetailTopTracks.tsx index ad5a4c50..6676fb16 100644 --- a/src/components/artistDetail/ArtistDetailTopTracks.tsx +++ b/src/components/artistDetail/ArtistDetailTopTracks.tsx @@ -6,7 +6,7 @@ import { usePlayerStore } from '../../store/playerStore'; import { usePreviewStore } from '../../store/previewStore'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { formatDuration } from '../../utils/componentHelpers/artistDetailHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; import ArtistSuggestionTrackCover from './ArtistSuggestionTrackCover'; interface Props { @@ -100,7 +100,7 @@ export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSong {song.album}
- {formatDuration(song.duration)} + {formatTrackTime(song.duration)}
); diff --git a/src/components/favorites/FavoritesSongsTracklist.tsx b/src/components/favorites/FavoritesSongsTracklist.tsx index 5234e174..7884f6d8 100644 --- a/src/components/favorites/FavoritesSongsTracklist.tsx +++ b/src/components/favorites/FavoritesSongsTracklist.tsx @@ -13,6 +13,7 @@ import { useSelectionStore } from '../../store/selectionStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; +import { formatTrackTime } from '../../utils/format/formatDuration'; import { AddToPlaylistSubmenu } from '../ContextMenu'; import StarRating from '../StarRating'; @@ -348,7 +349,7 @@ export default function FavoritesSongsTracklist({ ); case 'duration': return (
- {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} + {formatTrackTime(song.duration)}
); case 'remove': return ( diff --git a/src/components/fullscreenPlayer/FsSeekbar.tsx b/src/components/fullscreenPlayer/FsSeekbar.tsx index 20ebaf34..b8176e7b 100644 --- a/src/components/fullscreenPlayer/FsSeekbar.tsx +++ b/src/components/fullscreenPlayer/FsSeekbar.tsx @@ -1,7 +1,7 @@ import React, { memo, useCallback, useEffect, useRef } from 'react'; import { usePlayerStore } from '../../store/playerStore'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress'; -import { formatTime } from '../../utils/componentHelpers/fullscreenPlayerHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; // Full-width seekbar — imperative DOM updates, zero React re-renders on tick. export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) { @@ -19,7 +19,7 @@ export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: numbe pendingSeekRef.current = p; if (timeRef.current) { const previewTime = duration > 0 ? p * duration : s.currentTime; - timeRef.current.textContent = formatTime(previewTime); + timeRef.current.textContent = formatTrackTime(previewTime); } if (playedRef.current) playedRef.current.style.width = `${p * 100}%`; if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`; @@ -36,7 +36,7 @@ export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: numbe useEffect(() => { const s = getPlaybackProgressSnapshot(); const pct = s.progress * 100; - if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime); + if (timeRef.current) timeRef.current.textContent = formatTrackTime(s.currentTime); if (playedRef.current) playedRef.current.style.width = `${pct}%`; if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`; if (inputRef.current) inputRef.current.value = String(s.progress); @@ -44,7 +44,7 @@ export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: numbe return subscribePlaybackProgress(state => { if (isDraggingRef.current) return; const p = state.progress * 100; - if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime); + if (timeRef.current) timeRef.current.textContent = formatTrackTime(state.currentTime); if (playedRef.current) playedRef.current.style.width = `${p}%`; if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`; if (inputRef.current) inputRef.current.value = String(state.progress); @@ -62,7 +62,7 @@ export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: numbe
- {formatTime(duration)} + {formatTrackTime(duration)}
diff --git a/src/components/miniPlayer/MiniControls.tsx b/src/components/miniPlayer/MiniControls.tsx index cc5ca061..4516310d 100644 --- a/src/components/miniPlayer/MiniControls.tsx +++ b/src/components/miniPlayer/MiniControls.tsx @@ -1,5 +1,5 @@ import { Pause, Play, SkipBack, SkipForward } from 'lucide-react'; -import { fmt } from '../../utils/componentHelpers/miniPlayerHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; import type { MiniControlAction } from '../../utils/miniPlayerBridge'; interface Props { @@ -26,11 +26,11 @@ export function MiniControls({ isPlaying, currentTime, duration, progress, contr
-
{fmt(currentTime)}
+
{formatTrackTime(currentTime)}
-
{fmt(duration)}
+
{formatTrackTime(duration)}
); diff --git a/src/components/nowPlaying/AlbumCard.tsx b/src/components/nowPlaying/AlbumCard.tsx index 704d4fe1..57144abe 100644 --- a/src/components/nowPlaying/AlbumCard.tsx +++ b/src/components/nowPlaying/AlbumCard.tsx @@ -2,7 +2,8 @@ import React, { memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Disc3, ExternalLink, Star } from 'lucide-react'; import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes'; -import { formatTime, formatTotalDuration } from '../../utils/componentHelpers/nowPlayingHelpers'; +import { formatTotalDuration } from '../../utils/componentHelpers/nowPlayingHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; interface AlbumCardProps { album: SubsonicAlbum | null; @@ -79,7 +80,7 @@ const AlbumCard = memo(function AlbumCard({ album, songs, currentTrackId, albumN : track.track ?? '—'} {track.title} - {formatTime(track.duration)} + {formatTrackTime(track.duration)}
); })} diff --git a/src/components/nowPlaying/Hero.tsx b/src/components/nowPlaying/Hero.tsx index a84e6deb..f3e6c7c8 100644 --- a/src/components/nowPlaying/Hero.tsx +++ b/src/components/nowPlaying/Hero.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react'; import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm'; import LastfmIcon from '../LastfmIcon'; -import { formatTime } from '../../utils/componentHelpers/nowPlayingHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; interface HeroProps { track: { title: string; artist: string; album: string; year?: number; @@ -83,7 +83,7 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l {track.samplingRate && {(track.samplingRate / 1000).toFixed(1)} kHz} {track.bitDepth && {track.bitDepth}-bit} {hiRes && Hi-Res} - {track.duration > 0 && {formatTime(track.duration)}} + {track.duration > 0 && {formatTrackTime(track.duration)}}
diff --git a/src/components/nowPlaying/RadioView.tsx b/src/components/nowPlaying/RadioView.tsx index a4a3a7f1..96cc9b03 100644 --- a/src/components/nowPlaying/RadioView.tsx +++ b/src/components/nowPlaying/RadioView.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Cast, Clock, Radio, SkipForward, Users } from 'lucide-react'; import type { useRadioMetadata } from '../../hooks/useRadioMetadata'; import { usePlayerStore } from '../../store/playerStore'; -import { formatTime } from '../../utils/componentHelpers/nowPlayingHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; type NonNullStoreField> = NonNullable[K]>; @@ -38,11 +38,11 @@ const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCov
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
- {formatTime(radioMeta.elapsed)} + {formatTrackTime(radioMeta.elapsed)}
- {formatTime(radioMeta.duration)} + {formatTrackTime(radioMeta.duration)}
)}
diff --git a/src/components/nowPlaying/TopSongsCard.tsx b/src/components/nowPlaying/TopSongsCard.tsx index 3a7e9a01..239c781a 100644 --- a/src/components/nowPlaying/TopSongsCard.tsx +++ b/src/components/nowPlaying/TopSongsCard.tsx @@ -2,7 +2,7 @@ import React, { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { ExternalLink, Play, TrendingUp } from 'lucide-react'; import type { SubsonicSong } from '../../api/subsonicTypes'; -import { formatTime } from '../../utils/componentHelpers/nowPlayingHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; interface TopSongsCardProps { artistName: string; @@ -44,7 +44,7 @@ const TopSongsCard = memo(function TopSongsCard({ artistName, artistId, songs, c {s.title} {s.album && {s.album}} - {formatTime(s.duration)} + {formatTrackTime(s.duration)} ); diff --git a/src/components/playerBar/PlaybackClock.tsx b/src/components/playerBar/PlaybackClock.tsx index e8d53a13..193f0761 100644 --- a/src/components/playerBar/PlaybackClock.tsx +++ b/src/components/playerBar/PlaybackClock.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useRef } from 'react'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress'; -import { formatTime } from '../../utils/componentHelpers/playerBarHelpers'; +import { 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. */ @@ -8,10 +8,10 @@ export const PlaybackTime = memo(function PlaybackTime({ className }: { classNam const spanRef = useRef(null); useEffect(() => { if (spanRef.current) { - spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime); + spanRef.current.textContent = formatTrackTime(getPlaybackProgressSnapshot().currentTime); } return subscribePlaybackProgress(state => { - if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime); + if (spanRef.current) spanRef.current.textContent = formatTrackTime(state.currentTime); }); }, []); return ; @@ -25,7 +25,7 @@ export const RemainingTime = memo(function RemainingTime({ duration, className } const updateRemaining = () => { if (spanRef.current) { const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime); - spanRef.current.textContent = `-${formatTime(remaining)}`; + spanRef.current.textContent = `-${formatTrackTime(remaining)}`; } }; updateRemaining(); diff --git a/src/components/playerBar/PlayerSeekbarSection.tsx b/src/components/playerBar/PlayerSeekbarSection.tsx index e3822e2b..a725fe7c 100644 --- a/src/components/playerBar/PlayerSeekbarSection.tsx +++ b/src/components/playerBar/PlayerSeekbarSection.tsx @@ -2,7 +2,7 @@ import { ArrowLeftRight } from 'lucide-react'; import type { TFunction } from 'i18next'; import type { RadioMetadata } from '../../hooks/useRadioMetadata'; import { useThemeStore } from '../../store/themeStore'; -import { formatTime } from '../../utils/componentHelpers/playerBarHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; import WaveformSeek from '../WaveformSeek'; import { PlaybackTime, RemainingTime } from './PlaybackClock'; @@ -27,7 +27,7 @@ export function PlayerSeekbarSection({ <> {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? ( <> - {formatTime(radioMeta.elapsed)} + {formatTrackTime(radioMeta.elapsed)}
- {formatTime(radioMeta.duration)} + {formatTrackTime(radioMeta.duration)} ) : ( <> @@ -65,7 +65,7 @@ export function PlayerSeekbarSection({ }} data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')} > - {localShowRemaining ? : formatTime(duration)} + {localShowRemaining ? : formatTrackTime(duration)} diff --git a/src/components/playlist/PlaylistSongSearchPanel.tsx b/src/components/playlist/PlaylistSongSearchPanel.tsx index 4499b299..365261ac 100644 --- a/src/components/playlist/PlaylistSongSearchPanel.tsx +++ b/src/components/playlist/PlaylistSongSearchPanel.tsx @@ -5,7 +5,7 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl' import type { SubsonicSong } from '../../api/subsonicTypes'; import { usePlayerStore } from '../../store/playerStore'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { formatDuration } from '../../utils/componentHelpers/playlistDetailHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; import CachedImage from '../CachedImage'; import { AddToPlaylistSubmenu } from '../ContextMenu'; @@ -132,7 +132,7 @@ export default function PlaylistSongSearchPanel({ {song.title} {song.artist} · {song.album}
- {formatDuration(song.duration ?? 0)} + {formatTrackTime(song.duration ?? 0)} ); })} diff --git a/src/components/playlist/PlaylistSuggestions.tsx b/src/components/playlist/PlaylistSuggestions.tsx index b74bd827..9419020b 100644 --- a/src/components/playlist/PlaylistSuggestions.tsx +++ b/src/components/playlist/PlaylistSuggestions.tsx @@ -8,7 +8,8 @@ import { usePlayerStore } from '../../store/playerStore'; import { usePreviewStore } from '../../store/previewStore'; import { useThemeStore } from '../../store/themeStore'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { codecLabel, formatDuration } from '../../utils/componentHelpers/playlistDetailHelpers'; +import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); @@ -156,7 +157,7 @@ export default function PlaylistSuggestions({ ); case 'favorite': return
; case 'rating': return
; - case 'duration': return
{formatDuration(song.duration ?? 0)}
; + case 'duration': return
{formatTrackTime(song.duration ?? 0)}
; case 'format': return (
{(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} diff --git a/src/components/playlist/PlaylistTracklist.tsx b/src/components/playlist/PlaylistTracklist.tsx index fb3c206b..3e2ab7c7 100644 --- a/src/components/playlist/PlaylistTracklist.tsx +++ b/src/components/playlist/PlaylistTracklist.tsx @@ -13,7 +13,8 @@ import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; -import { codecLabel, formatDuration } from '../../utils/componentHelpers/playlistDetailHelpers'; +import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs'; import StarRating from '../StarRating'; import { AddToPlaylistSubmenu } from '../ContextMenu'; @@ -405,7 +406,7 @@ export default function PlaylistTracklist({
); case 'rating': return handleRate(song.id, r)} />; - case 'duration': return
{formatDuration(song.duration ?? 0)}
; + case 'duration': return
{formatTrackTime(song.duration ?? 0)}
; case 'format': return (
{(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx index 1487bb99..ab51a416 100644 --- a/src/components/queuePanel/QueueHeader.tsx +++ b/src/components/queuePanel/QueueHeader.tsx @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import { usePlayerStore } from '../../store/playerStore'; import type { Track } from '../../store/playerStoreTypes'; import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers'; +import { formatLongDuration } from '../../utils/format/formatDuration'; interface Props { queue: Track[]; @@ -34,12 +35,6 @@ export function QueueHeader({ const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration); - const fmt = (secs: number) => { - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - const s = secs % 60; - return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`; - }; const fmtEta = (secs: number) => { const finishTime = new Date(Date.now() + secs * 1000); return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime); @@ -47,8 +42,8 @@ export function QueueHeader({ let dur: string | null = null; if (queue.length > 0) { - if (durationMode === 'total') dur = fmt(Math.floor(totalSecs)); - else if (durationMode === 'remaining') dur = `-${fmt(Math.floor(remainingSecs))}`; + if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs)); + else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`; else dur = fmtEta(remainingSecs); } diff --git a/src/components/queuePanel/QueueList.tsx b/src/components/queuePanel/QueueList.tsx index c4e74350..024a1645 100644 --- a/src/components/queuePanel/QueueList.tsx +++ b/src/components/queuePanel/QueueList.tsx @@ -5,7 +5,7 @@ import OverlayScrollArea from '../OverlayScrollArea'; import { usePlayerStore } from '../../store/playerStore'; import { useLuckyMixStore } from '../../store/luckyMixStore'; import type { Track, PlayerState } from '../../store/playerStoreTypes'; -import { formatTime } from '../../utils/componentHelpers/queuePanelHelpers'; +import { formatTrackTime } from '../../utils/format/formatDuration'; type StartDrag = ( payload: { data: string; label: string }, @@ -125,7 +125,7 @@ export function QueueList({ })()}
- {formatTime(track.duration)} + {formatTrackTime(track.duration)}
{luckyRolling && isPlaying && ( diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 9d43c4e5..53756e99 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -20,7 +20,6 @@ import { useDragDrop } from '../contexts/DragDropContext'; import { useTranslation } from 'react-i18next'; import StarRating from '../components/StarRating'; import { - formatDuration, formatSize, totalDurationLabel, isSmartPlaylistName, diff --git a/src/utils/componentHelpers/albumTrackListHelpers.ts b/src/utils/componentHelpers/albumTrackListHelpers.ts index 26a6771d..dce0396b 100644 --- a/src/utils/componentHelpers/albumTrackListHelpers.ts +++ b/src/utils/componentHelpers/albumTrackListHelpers.ts @@ -1,13 +1,5 @@ import type { ColDef } from '../useTracklistColumns'; -export function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; - return `${m}:${s.toString().padStart(2, '0')}`; -} - export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); diff --git a/src/utils/componentHelpers/artistDetailHelpers.ts b/src/utils/componentHelpers/artistDetailHelpers.ts index 1459959b..93a5196a 100644 --- a/src/utils/componentHelpers/artistDetailHelpers.ts +++ b/src/utils/componentHelpers/artistDetailHelpers.ts @@ -1,9 +1,3 @@ -export function formatDuration(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - /** Strip dangerous tags/attributes from server-provided HTML */ export function sanitizeHtml(html: string): string { const parser = new DOMParser(); diff --git a/src/utils/componentHelpers/fullscreenPlayerHelpers.ts b/src/utils/componentHelpers/fullscreenPlayerHelpers.ts deleted file mode 100644 index a6551fcc..00000000 --- a/src/utils/componentHelpers/fullscreenPlayerHelpers.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function formatTime(seconds: number): string { - if (!seconds || isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} diff --git a/src/utils/componentHelpers/miniPlayerHelpers.ts b/src/utils/componentHelpers/miniPlayerHelpers.ts index e40a29e0..7ff10ece 100644 --- a/src/utils/componentHelpers/miniPlayerHelpers.ts +++ b/src/utils/componentHelpers/miniPlayerHelpers.ts @@ -73,10 +73,3 @@ export function initialSnapshot(): MiniSyncPayload { }; } } - -export function fmt(seconds: number): string { - if (!isFinite(seconds) || seconds < 0) seconds = 0; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} diff --git a/src/utils/componentHelpers/playerBarHelpers.ts b/src/utils/componentHelpers/playerBarHelpers.ts deleted file mode 100644 index a6551fcc..00000000 --- a/src/utils/componentHelpers/playerBarHelpers.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function formatTime(seconds: number): string { - if (!seconds || isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} diff --git a/src/utils/componentHelpers/playlistDetailHelpers.ts b/src/utils/componentHelpers/playlistDetailHelpers.ts index a64b350e..da984860 100644 --- a/src/utils/componentHelpers/playlistDetailHelpers.ts +++ b/src/utils/componentHelpers/playlistDetailHelpers.ts @@ -9,12 +9,6 @@ export function sanitizeFilename(name: string): string { .substring(0, 200) || 'download'; } -export function formatDuration(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${String(s).padStart(2, '0')}`; -} - export function formatSize(bytes?: number): string { if (!bytes) return ''; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; diff --git a/src/utils/componentHelpers/queuePanelHelpers.tsx b/src/utils/componentHelpers/queuePanelHelpers.tsx index 77061804..3cbd91b9 100644 --- a/src/utils/componentHelpers/queuePanelHelpers.tsx +++ b/src/utils/componentHelpers/queuePanelHelpers.tsx @@ -4,13 +4,6 @@ import type { Track } from '../../store/playerStoreTypes'; export type DurationMode = 'total' | 'remaining' | 'eta'; -export function formatTime(seconds: number): string { - if (!seconds || isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] { const parts: string[] = []; const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`; diff --git a/src/utils/format/formatDuration.test.ts b/src/utils/format/formatDuration.test.ts new file mode 100644 index 00000000..9f8bf390 --- /dev/null +++ b/src/utils/format/formatDuration.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { formatTrackTime, formatLongDuration } from './formatDuration'; + +describe('formatTrackTime', () => { + it('formats m:ss with zero-padded seconds', () => { + expect(formatTrackTime(5)).toBe('0:05'); + expect(formatTrackTime(65)).toBe('1:05'); + expect(formatTrackTime(599)).toBe('9:59'); + }); + + it('does not roll minutes into hours (used for short track times)', () => { + expect(formatTrackTime(3600)).toBe('60:00'); + expect(formatTrackTime(3661)).toBe('61:01'); + }); + + it('floors fractional seconds', () => { + expect(formatTrackTime(90.9)).toBe('1:30'); + }); + + it('returns the fallback for zero / non-finite / negative input', () => { + expect(formatTrackTime(0)).toBe('0:00'); + expect(formatTrackTime(NaN)).toBe('0:00'); + expect(formatTrackTime(Infinity)).toBe('0:00'); + expect(formatTrackTime(-5)).toBe('0:00'); + expect(formatTrackTime(undefined as unknown as number)).toBe('0:00'); + }); + + it('honours a custom fallback', () => { + expect(formatTrackTime(0, '–')).toBe('–'); + expect(formatTrackTime(NaN, '–')).toBe('–'); + expect(formatTrackTime(42, '–')).toBe('0:42'); + }); +}); + +describe('formatLongDuration', () => { + it('formats m:ss below one hour', () => { + expect(formatLongDuration(5)).toBe('0:05'); + expect(formatLongDuration(125)).toBe('2:05'); + expect(formatLongDuration(3599)).toBe('59:59'); + }); + + it('formats h:mm:ss at or above one hour', () => { + expect(formatLongDuration(3600)).toBe('1:00:00'); + expect(formatLongDuration(3661)).toBe('1:01:01'); + expect(formatLongDuration(7325)).toBe('2:02:05'); + }); + + it('returns 0:00 for zero / non-finite / negative input', () => { + expect(formatLongDuration(0)).toBe('0:00'); + expect(formatLongDuration(NaN)).toBe('0:00'); + expect(formatLongDuration(-1)).toBe('0:00'); + }); +}); diff --git a/src/utils/format/formatDuration.ts b/src/utils/format/formatDuration.ts new file mode 100644 index 00000000..f5801dac --- /dev/null +++ b/src/utils/format/formatDuration.ts @@ -0,0 +1,23 @@ +/** + * `m:ss` track time from a seconds value. Non-positive / non-finite input + * returns `fallback` (default `'0:00'`; pass e.g. `'–'` for placeholder rows). + */ +export function formatTrackTime(seconds: number, fallback = '0:00'): string { + if (!seconds || !isFinite(seconds) || seconds < 0) return fallback; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +/** + * `h:mm:ss` when the duration reaches an hour, otherwise `m:ss`. Used for + * album / queue totals. Non-positive / non-finite input returns `'0:00'`. + */ +export function formatLongDuration(seconds: number): string { + if (!seconds || !isFinite(seconds) || seconds < 0) return '0:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + return `${m}:${s.toString().padStart(2, '0')}`; +}