328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
import {useState, useEffect, useRef} from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import List from '@mui/material/List'
|
|
import ListSubheader from '@mui/material/ListSubheader'
|
|
import Typography from '@mui/material/Typography'
|
|
import Box from '@mui/material/Box'
|
|
import Skeleton from '@mui/material/Skeleton'
|
|
import LinearProgress from '@mui/material/LinearProgress'
|
|
import {useTheme, useColorScheme, styled} from '@mui/material/styles'
|
|
import Table from '@mui/material/Table'
|
|
import TableHead from '@mui/material/TableHead'
|
|
import TableBody from '@mui/material/TableBody'
|
|
import TableCell, {tableCellClasses} from '@mui/material/TableCell'
|
|
import TableRow from '@mui/material/TableRow'
|
|
import TableContainer from '@mui/material/TableContainer'
|
|
import Paper from '@mui/material/Paper'
|
|
import DescriptionIcon from '@mui/icons-material/Description'
|
|
import LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
|
|
import {Link} from '@mui/material'
|
|
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
|
const audioMetaCache = {}
|
|
|
|
const StyledTableCell = styled(TableCell)(({theme}) => ({
|
|
[`&.${tableCellClasses.head}`]: {
|
|
backgroundColor: theme.palette.common.black,
|
|
color: theme.palette.common.white,
|
|
},
|
|
[`&.${tableCellClasses.body}`]: {
|
|
fontSize: 14,
|
|
},
|
|
}))
|
|
|
|
const StyledTableRow = styled(TableRow)(({theme}) => ({
|
|
'&:nth-of-type(odd)': {
|
|
backgroundColor: theme.palette.action.hover,
|
|
},
|
|
'&:last-child td, &:last-child th': {
|
|
border: 0,
|
|
},
|
|
}))
|
|
|
|
function formatSize(size) {
|
|
if (size < 1000) {
|
|
return Math.round(size) + ' Kb'
|
|
}
|
|
|
|
const mbSize = size / 1000
|
|
return Math.round(mbSize) + ' Mb'
|
|
}
|
|
|
|
export default function FilesList({files}) {
|
|
const theme = useTheme()
|
|
const {mode} = useColorScheme()
|
|
const [audioMeta, setAudioMeta] = useState(audioMetaCache)
|
|
const [downloading, setDownloading] = useState({})
|
|
const controllersRef = useRef({})
|
|
|
|
const musicFiles = files.filter(file => file.mime.startsWith('audio'))
|
|
const pdfFiles = files.filter(file => file.mime === 'application/pdf')
|
|
|
|
const sortedMusicFiles = musicFiles.sort((a, b) => {
|
|
const extensionOrder = {
|
|
'.flac': 0,
|
|
'.ogg': 1,
|
|
'.aac': 2,
|
|
'.mp3': 3
|
|
}
|
|
return extensionOrder[a.ext.toLowerCase()] - extensionOrder[b.ext.toLowerCase()]
|
|
})
|
|
|
|
useEffect(() => {
|
|
const audioFiles = files.filter(f => f.mime.startsWith('audio'))
|
|
if (audioFiles.length === 0) return
|
|
|
|
let cancelled = false
|
|
|
|
async function fetchAllMeta() {
|
|
const mm = await import('music-metadata-browser')
|
|
const results = {}
|
|
await Promise.all(
|
|
audioFiles.map(async file => {
|
|
if (file.id in audioMetaCache) {
|
|
results[file.id] = audioMetaCache[file.id]
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiUrl}${file.url}`, {
|
|
headers: {Range: 'bytes=0-262143'},
|
|
})
|
|
const buffer = await response.arrayBuffer()
|
|
const meta = await mm.parseBuffer(new Uint8Array(buffer), {mimeType: file.mime, skipCovers: true})
|
|
audioMetaCache[file.id] = meta.format
|
|
results[file.id] = meta.format
|
|
} catch {
|
|
audioMetaCache[file.id] = null
|
|
results[file.id] = null
|
|
}
|
|
})
|
|
)
|
|
if (!cancelled) setAudioMeta(results)
|
|
}
|
|
|
|
fetchAllMeta()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [files])
|
|
|
|
const losslessFormats = new Set(['.flac', '.wav', '.aiff', '.alac'])
|
|
|
|
const qualityBadge = ext => {
|
|
const isHaute = losslessFormats.has(ext.toLowerCase())
|
|
return (
|
|
<Typography variant='caption' sx={{
|
|
backgroundColor: isHaute ? '#07332f' : '#393940',
|
|
color: isHaute ? '#21feec' : '#fff',
|
|
borderRadius: '0.66rem',
|
|
border: mode === 'dark' ? `1px solid ${isHaute ? '#21feec' : '#fff'}` : 'none',
|
|
padding: '0.15rem 0.5rem',
|
|
fontWeight: 'bold',
|
|
letterSpacing: '0.05rem',
|
|
textTransform: 'uppercase',
|
|
}}>{isHaute ? 'Haute' : 'Faible'}</Typography>
|
|
)
|
|
}
|
|
|
|
const renderMeta = file => {
|
|
const format = file.ext.replace('.', '').toUpperCase()
|
|
|
|
if (!(file.id in audioMeta)) {
|
|
return <Skeleton variant='text' width={80} />
|
|
}
|
|
|
|
const meta = audioMeta[file.id]
|
|
const sampleRate = meta?.sampleRate ? `${meta.sampleRate / 1000} kHz` : null
|
|
const bitDepth = meta?.bitsPerSample ? `${meta.bitsPerSample} bits` : null
|
|
const bitrate = meta?.bitrate ? `${Math.round(meta.bitrate / 1000)} kbps` : null
|
|
const details = [sampleRate, bitDepth ?? bitrate].filter(Boolean).join(' · ')
|
|
|
|
return (
|
|
<Box display='flex' flexDirection='column' gap={0.5}>
|
|
<Box display='flex' alignItems='center' gap={1}>
|
|
{qualityBadge(file.ext)}
|
|
<Typography variant='caption' sx={{fontWeight: 'bold', ml: 1}}>{format}</Typography>
|
|
</Box>
|
|
{details && <Typography variant='caption' sx={{color: 'text.secondary'}}>{details}</Typography>}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
useEffect(() => () => {
|
|
Object.values(controllersRef.current).forEach(c => c.abort())
|
|
}, [])
|
|
|
|
const handleClick = async (e, url, fileName, fileId) => {
|
|
e.stopPropagation()
|
|
if (fileId in downloading) return
|
|
|
|
const controller = new AbortController()
|
|
controllersRef.current[fileId] = controller
|
|
|
|
setDownloading(prev => ({...prev, [fileId]: 0}))
|
|
try {
|
|
const response = await fetch(url, {signal: controller.signal})
|
|
const contentLength = +response.headers.get('content-length')
|
|
const reader = response.body.getReader()
|
|
const chunks = []
|
|
let received = 0
|
|
|
|
while (true) {
|
|
const {done, value} = await reader.read()
|
|
if (done) break
|
|
chunks.push(value)
|
|
received += value.length
|
|
if (contentLength) {
|
|
setDownloading(prev => ({...prev, [fileId]: Math.round(received / contentLength * 100)}))
|
|
}
|
|
}
|
|
|
|
const blob = new Blob(chunks, {type: response.headers.get('content-type')})
|
|
const blobUrl = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = blobUrl
|
|
a.download = fileName
|
|
a.click()
|
|
URL.revokeObjectURL(blobUrl)
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') throw error
|
|
} finally {
|
|
delete controllersRef.current[fileId]
|
|
setDownloading(prev => {
|
|
const next = {...prev}
|
|
delete next[fileId]
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleCancel = (e, fileId) => {
|
|
e.stopPropagation()
|
|
controllersRef.current[fileId]?.abort()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{musicFiles.length > 0 && (
|
|
<List
|
|
sx={{width: '100%', maxWidth: 800}}
|
|
component='nav'
|
|
aria-labelledby='nested-list-subheader'
|
|
>
|
|
<ListSubheader disableSticky sx={{backgroundColor: mode === 'light' ? theme.palette.grey[100] : theme.palette.background.default}} color='primary'>
|
|
<Box paddingBlock={1} display='flex' justifyContent='center'>
|
|
<LibraryMusicIcon />
|
|
<Typography gutterBottom marginLeft={1} variant='button'>Musiques</Typography>
|
|
</Box>
|
|
</ListSubheader>
|
|
<TableContainer component={Paper}>
|
|
<Table size='small' aria-label='Musiques'>
|
|
<TableHead>
|
|
<TableRow>
|
|
<StyledTableCell align='center'>FORMAT</StyledTableCell>
|
|
<StyledTableCell />
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{sortedMusicFiles.map(file => (
|
|
<StyledTableRow key={file.id}>
|
|
<StyledTableCell>
|
|
{renderMeta(file)}
|
|
</StyledTableCell>
|
|
<StyledTableCell align='left'>
|
|
<Link
|
|
href='#'
|
|
underline='hover'
|
|
sx={{fontWeight: 'bold', pointerEvents: file.id in downloading ? 'none' : 'auto'}}
|
|
aria-label='download'
|
|
onClick={e => handleClick(e, `${apiUrl}${file.url}`, file.name, file.id)}
|
|
>
|
|
{file.name}
|
|
</Link>
|
|
<small style={{marginLeft: 3}}>({formatSize(file.size)})</small>
|
|
{file.id in downloading && (
|
|
<Box sx={{mt: 0.5}}>
|
|
<LinearProgress
|
|
variant={downloading[file.id] > 0 ? 'determinate' : 'indeterminate'}
|
|
value={downloading[file.id]}
|
|
/>
|
|
<Box display='flex' justifyContent='space-between' alignItems='center'>
|
|
<Typography variant='caption' sx={{color: 'text.secondary'}}>
|
|
{downloading[file.id]} %
|
|
</Typography>
|
|
<Typography
|
|
variant='caption'
|
|
sx={{color: 'error.main', cursor: 'pointer'}}
|
|
onClick={e => handleCancel(e, file.id)}
|
|
>
|
|
Annuler
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</StyledTableCell>
|
|
</StyledTableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</List>
|
|
)}
|
|
|
|
{pdfFiles.length > 0 && (
|
|
<List
|
|
sx={{width: '100%', maxWidth: 800}}
|
|
component='nav'
|
|
aria-labelledby='nested-list-subheader'
|
|
>
|
|
<ListSubheader disableSticky sx={{marginTop: 2, backgroundColor: mode === 'light' ? theme.palette.grey[100] : theme.palette.background.default}} color='primary'>
|
|
<Box paddingBlock={1} display='flex' justifyContent='center' alignSelf='center'>
|
|
<DescriptionIcon />
|
|
<Typography gutterBottom marginLeft={1} variant='button'>Paroles</Typography>
|
|
</Box>
|
|
</ListSubheader>
|
|
<TableContainer component={Paper}>
|
|
<Table size='small' aria-label='Paroles'>
|
|
<TableHead>
|
|
<TableRow>
|
|
<StyledTableCell>LANGUE</StyledTableCell>
|
|
<StyledTableCell />
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{pdfFiles.map(file => (
|
|
<StyledTableRow key={file.id}>
|
|
<StyledTableCell>
|
|
<strong>
|
|
{file.caption}
|
|
</strong>
|
|
</StyledTableCell>
|
|
<StyledTableCell align='left'>
|
|
<Link
|
|
href='#'
|
|
underline='hover'
|
|
sx={{fontWeight: 'bold'}}
|
|
aria-label='download'
|
|
onClick={e => handleClick(e, `${apiUrl}${file.url}`, file.name)}
|
|
>
|
|
{file.name}
|
|
</Link>
|
|
<small style={{marginLeft: 3}}>({formatSize(file.size)})</small>
|
|
</StyledTableCell>
|
|
</StyledTableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</List>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
FilesList.propTypes = {
|
|
files: PropTypes.array.isRequired
|
|
}
|