Skip to content

Commit c685757

Browse files
committed
show metadata
1 parent fa8e9d9 commit c685757

3 files changed

Lines changed: 343 additions & 52 deletions

File tree

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"jspdf": "^2.5.1",
5151
"mfm-js": "^0.24.0",
5252
"mfm-renderer-react": "^0.0.9",
53+
"music-metadata": "^11.12.1",
5354
"notistack": "^3.0.2",
5455
"protobufjs": "^7.3.2",
5556
"react": "^18.2.0",

app/src/context/AudioPlayer.tsx

Lines changed: 207 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'
33
import CloseIcon from '@mui/icons-material/Close'
44
import VolumeOffIcon from '@mui/icons-material/VolumeOff'
55
import VolumeUpIcon from '@mui/icons-material/VolumeUp'
6-
import { Box, IconButton, Paper, Slider } from '@mui/material'
6+
import { Box, IconButton, Paper, Slider, Typography } from '@mui/material'
7+
import { parseWebStream } from 'music-metadata'
78
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
89
import { usePersistent } from '../hooks/usePersistent'
910

@@ -25,16 +26,71 @@ interface AudioPlayerProviderProps {
2526

2627
const clampVolume = (value: number): number => Math.max(0, Math.min(1, value))
2728

29+
const formatTimeLabel = (seconds: number): string => {
30+
const safe = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0
31+
const hour = Math.floor(safe / 3600)
32+
const minute = Math.floor((safe % 3600) / 60)
33+
const second = safe % 60
34+
35+
if (hour > 0) {
36+
return `${hour}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`
37+
}
38+
return `${minute}:${String(second).padStart(2, '0')}`
39+
}
40+
41+
interface TrackMetadata {
42+
title: string | null
43+
composer: string | null
44+
artwork: { data: Uint8Array; format?: string } | null
45+
}
46+
47+
const readTrackMetadataFromTag = async (input: string): Promise<TrackMetadata> => {
48+
try {
49+
const response = await fetch(input)
50+
if (!response.ok || !response.body) return { title: null, composer: null, artwork: null }
51+
52+
const contentLength = response.headers.get('Content-Length')
53+
const metadata = await parseWebStream(
54+
response.body,
55+
{
56+
mimeType: response.headers.get('Content-Type') ?? undefined,
57+
size: contentLength ? Number(contentLength) : undefined
58+
},
59+
{
60+
duration: false,
61+
skipCovers: false,
62+
skipPostHeaders: true
63+
}
64+
)
65+
66+
const title = metadata.common.title?.trim()
67+
const composer = metadata.common.composer?.join(', ')?.trim()
68+
const picture = metadata.common.picture?.[0]
69+
70+
return {
71+
title: title && title.length > 0 ? title : null,
72+
composer: composer && composer.length > 0 ? composer : null,
73+
artwork: picture ? { data: picture.data, format: picture.format } : null
74+
}
75+
} catch {
76+
return { title: null, composer: null, artwork: null }
77+
}
78+
}
79+
2880
export const AudioPlayerProvider = (props: AudioPlayerProviderProps): JSX.Element => {
2981
const audioRef = useRef<HTMLAudioElement>(null)
3082
const progressTimerRef = useRef<number | null>(null)
83+
const artworkObjectUrlRef = useRef<string | null>(null)
3184
const [src, setSrc] = useState<string | null>(null)
3285
const [isPlaying, setIsPlaying] = useState(false)
3386
const [duration, setDuration] = useState(0)
3487
const [currentTime, setCurrentTime] = useState(0)
3588
const [seekingTime, setSeekingTime] = useState<number | null>(null)
3689
const [volumePanelOpen, setVolumePanelOpen] = useState(false)
3790
const [volume, setVolume] = usePersistent<number>('AudioPlayerVolume', 0.8)
91+
const [trackTitle, setTrackTitle] = useState<string | null>(null)
92+
const [trackComposer, setTrackComposer] = useState<string | null>(null)
93+
const [artworkUrl, setArtworkUrl] = useState<string | null>(null)
3894

3995
const play = useCallback(
4096
(nextSrc: string) => {
@@ -104,6 +160,52 @@ export const AudioPlayerProvider = (props: AudioPlayerProviderProps): JSX.Elemen
104160
audioRef.current.volume = clampVolume(volume)
105161
}, [volume])
106162

163+
useEffect(() => {
164+
if (!src) {
165+
setTrackTitle(null)
166+
setTrackComposer(null)
167+
setArtworkUrl(null)
168+
if (artworkObjectUrlRef.current) {
169+
URL.revokeObjectURL(artworkObjectUrlRef.current)
170+
artworkObjectUrlRef.current = null
171+
}
172+
return
173+
}
174+
175+
setTrackTitle(null)
176+
setTrackComposer(null)
177+
setArtworkUrl(null)
178+
if (artworkObjectUrlRef.current) {
179+
URL.revokeObjectURL(artworkObjectUrlRef.current)
180+
artworkObjectUrlRef.current = null
181+
}
182+
let active = true
183+
void readTrackMetadataFromTag(src).then((metadata) => {
184+
if (!active) return
185+
setTrackTitle(metadata.title)
186+
setTrackComposer(metadata.composer)
187+
if (metadata.artwork) {
188+
const artworkBlob = new Blob([metadata.artwork.data], { type: metadata.artwork.format || 'image/jpeg' })
189+
const objectUrl = URL.createObjectURL(artworkBlob)
190+
artworkObjectUrlRef.current = objectUrl
191+
setArtworkUrl(objectUrl)
192+
}
193+
})
194+
195+
return () => {
196+
active = false
197+
}
198+
}, [src])
199+
200+
useEffect(() => {
201+
return () => {
202+
if (artworkObjectUrlRef.current) {
203+
URL.revokeObjectURL(artworkObjectUrlRef.current)
204+
artworkObjectUrlRef.current = null
205+
}
206+
}
207+
}, [])
208+
107209
useEffect(() => {
108210
if (!isPlaying || seekingTime !== null) return
109211

@@ -153,7 +255,7 @@ export const AudioPlayerProvider = (props: AudioPlayerProviderProps): JSX.Elemen
153255
{src && (
154256
<Paper
155257
sx={{
156-
height: '56px',
258+
height: '64px',
157259
margin: { xs: 0.5, sm: 1 },
158260
borderRadius: 2,
159261
display: 'flex',
@@ -173,25 +275,109 @@ export const AudioPlayerProvider = (props: AudioPlayerProviderProps): JSX.Elemen
173275
</IconButton>
174276
</Box>
175277

176-
<Box sx={{ flex: 1, px: 1, display: 'flex', alignItems: 'center' }}>
177-
<Slider
178-
size="small"
179-
min={0}
180-
max={duration > 0 ? duration : 1}
181-
value={Math.min(seekValue, duration > 0 ? duration : 1)}
182-
sx={{ my: 0 }}
183-
onChange={(_, value) => {
184-
const nextTime = Array.isArray(value) ? value[0] : value
185-
setSeekingTime(nextTime)
186-
}}
187-
onChangeCommitted={(_, value) => {
188-
if (!audioRef.current) return
189-
const nextTime = Array.isArray(value) ? value[0] : value
190-
audioRef.current.currentTime = nextTime
191-
setCurrentTime(nextTime)
192-
setSeekingTime(null)
193-
}}
194-
/>
278+
<Box
279+
sx={{
280+
px: 0.75,
281+
display: 'flex',
282+
alignItems: 'center',
283+
minWidth: 0,
284+
maxWidth: 260,
285+
flexShrink: 1
286+
}}
287+
>
288+
{(artworkUrl || trackTitle || trackComposer) && (
289+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
290+
{artworkUrl && (
291+
<Box
292+
component="img"
293+
src={artworkUrl}
294+
alt="album art"
295+
sx={{
296+
width: 28,
297+
height: 28,
298+
borderRadius: 0.75,
299+
objectFit: 'cover',
300+
flexShrink: 0
301+
}}
302+
/>
303+
)}
304+
<Box sx={{ minWidth: 0 }}>
305+
<Typography
306+
variant="caption"
307+
sx={{
308+
display: 'block',
309+
lineHeight: 1.2,
310+
overflow: 'hidden',
311+
textOverflow: 'ellipsis',
312+
whiteSpace: 'nowrap'
313+
}}
314+
>
315+
{trackTitle || ''}
316+
</Typography>
317+
{trackComposer && (
318+
<Typography
319+
variant="caption"
320+
sx={{
321+
display: 'block',
322+
lineHeight: 1.1,
323+
fontSize: '0.68rem',
324+
opacity: 0.75,
325+
overflow: 'hidden',
326+
textOverflow: 'ellipsis',
327+
whiteSpace: 'nowrap'
328+
}}
329+
>
330+
{trackComposer}
331+
</Typography>
332+
)}
333+
</Box>
334+
</Box>
335+
)}
336+
</Box>
337+
338+
<Box sx={{ flex: 1, px: 0.5, display: 'flex', alignItems: 'center', minWidth: 0 }}>
339+
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', gap: 1 }}>
340+
<Typography
341+
variant="caption"
342+
sx={{
343+
minWidth: 36,
344+
textAlign: 'right',
345+
lineHeight: 1,
346+
fontVariantNumeric: 'tabular-nums'
347+
}}
348+
>
349+
{formatTimeLabel(seekValue)}
350+
</Typography>
351+
<Slider
352+
size="small"
353+
min={0}
354+
max={duration > 0 ? duration : 1}
355+
value={Math.min(seekValue, duration > 0 ? duration : 1)}
356+
sx={{ my: 0, flex: 1 }}
357+
onChange={(_, value) => {
358+
const nextTime = Array.isArray(value) ? value[0] : value
359+
setSeekingTime(nextTime)
360+
}}
361+
onChangeCommitted={(_, value) => {
362+
if (!audioRef.current) return
363+
const nextTime = Array.isArray(value) ? value[0] : value
364+
audioRef.current.currentTime = nextTime
365+
setCurrentTime(nextTime)
366+
setSeekingTime(null)
367+
}}
368+
/>
369+
<Typography
370+
variant="caption"
371+
sx={{
372+
minWidth: 36,
373+
textAlign: 'left',
374+
lineHeight: 1,
375+
fontVariantNumeric: 'tabular-nums'
376+
}}
377+
>
378+
{formatTimeLabel(duration)}
379+
</Typography>
380+
</Box>
195381
</Box>
196382

197383
<Box sx={{ display: 'flex', alignItems: 'center' }}>

0 commit comments

Comments
 (0)