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')}`;
+}