@@ -3,7 +3,8 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'
33import CloseIcon from '@mui/icons-material/Close'
44import VolumeOffIcon from '@mui/icons-material/VolumeOff'
55import 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'
78import { createContext , useCallback , useContext , useEffect , useMemo , useRef , useState } from 'react'
89import { usePersistent } from '../hooks/usePersistent'
910
@@ -25,16 +26,71 @@ interface AudioPlayerProviderProps {
2526
2627const 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+
2880export 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