fix(ui): improve browser codec detection and limit Safari transcoding to mp3 (#5171)
* fix: update codec MIME types to support multiple variants for better compatibility Signed-off-by: Deluan <deluan@navidrome.org> * fix: limit Safari transcoding to mp3 Signed-off-by: Deluan <deluan@navidrome.org> * style: format browserProfile test file with prettier * fix: comment Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
// Each entry: { codec name for the server, container, MIME to probe }
|
||||
// Each entry: { codec name for the server, container, mime: [MIME probe strings] }
|
||||
export const CODEC_PROBES = [
|
||||
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
|
||||
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
|
||||
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
|
||||
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
|
||||
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
|
||||
{ codec: 'wav', container: 'wav', mime: 'audio/wav' },
|
||||
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
|
||||
{ codec: 'mp3', container: 'mp3', mime: ['audio/mpeg; codecs="mp3"'] },
|
||||
{ codec: 'opus', container: 'ogg', mime: ['audio/ogg; codecs="opus"'] },
|
||||
{ codec: 'vorbis', container: 'ogg', mime: ['audio/ogg; codecs="vorbis"'] },
|
||||
{
|
||||
codec: 'flac',
|
||||
container: 'flac',
|
||||
mime: ['audio/flac', 'audio/flac; codecs="flac"'],
|
||||
},
|
||||
{ codec: 'wav', container: 'wav', mime: ['audio/wav; codecs="1"'] },
|
||||
{ codec: 'alac', container: 'mp4', mime: ['audio/mp4; codecs="alac"'] },
|
||||
{ codec: 'aac', container: 'mp4', mime: ['audio/mp4; codecs="mp4a.40.2"'] },
|
||||
]
|
||||
|
||||
// Transcoding targets in preference order (lossless first, then lossy).
|
||||
@@ -14,8 +18,27 @@ export const CODEC_PROBES = [
|
||||
// MP3 is always included as a universal fallback.
|
||||
const TRANSCODE_CODECS = ['flac', 'opus', 'mp3']
|
||||
|
||||
// Safari transcoding is limited to mp3 only. Safari cannot reliably stream
|
||||
// Ogg containers (reports canPlayType support but fails on non-seekable
|
||||
// transcoded streams), and FLAC transcoding also fails in practice.
|
||||
const SAFARI_TRANSCODE_CODECS = ['mp3']
|
||||
|
||||
function canPlay(audio, mimeList) {
|
||||
return mimeList.some((m) => {
|
||||
const result = audio.canPlayType(m)
|
||||
return result === 'probably' || result === 'maybe'
|
||||
})
|
||||
}
|
||||
|
||||
function probeSupported(audio, probes) {
|
||||
return probes.filter(({ mime }) => audio.canPlayType(mime) === 'probably')
|
||||
return probes.filter(({ mime }) => canPlay(audio, mime))
|
||||
}
|
||||
|
||||
function isSafari() {
|
||||
const ua = navigator.userAgent
|
||||
return (
|
||||
ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium')
|
||||
)
|
||||
}
|
||||
|
||||
export function detectBrowserProfile() {
|
||||
@@ -29,10 +52,15 @@ export function detectBrowserProfile() {
|
||||
}),
|
||||
)
|
||||
|
||||
// Build transcoding profiles from supported codecs, always keeping mp3 as fallback
|
||||
const transcodingProfiles = TRANSCODE_CODECS.reduce((profiles, codec) => {
|
||||
// Build transcoding profiles from supported codecs, always keeping mp3 as fallback.
|
||||
// Safari is limited to mp3 transcoding only.
|
||||
const transcodeCodecs = isSafari()
|
||||
? SAFARI_TRANSCODE_CODECS
|
||||
: TRANSCODE_CODECS
|
||||
const transcodingProfiles = transcodeCodecs.reduce((profiles, codec) => {
|
||||
const probe = CODEC_PROBES.find((p) => p.codec === codec)
|
||||
if (audio.canPlayType(probe.mime) === 'probably' || codec === 'mp3') {
|
||||
if (!probe) return profiles
|
||||
if (canPlay(audio, probe.mime) || codec === 'mp3') {
|
||||
profiles.push({
|
||||
container: probe.container,
|
||||
audioCodec: codec,
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('detectBrowserProfile', () => {
|
||||
|
||||
it('includes codecs that return "probably"', () => {
|
||||
mockCanPlayType.mockImplementation((mime) => {
|
||||
if (mime === 'audio/mpeg') return 'probably'
|
||||
if (mime === 'audio/mpeg; codecs="mp3"') return 'probably'
|
||||
if (mime === 'audio/ogg; codecs="opus"') return 'probably'
|
||||
return ''
|
||||
})
|
||||
@@ -31,11 +31,15 @@ describe('detectBrowserProfile', () => {
|
||||
expect(codecs).toContain('opus')
|
||||
})
|
||||
|
||||
it('excludes codecs that return "maybe"', () => {
|
||||
mockCanPlayType.mockReturnValue('maybe')
|
||||
it('includes codecs that return "maybe"', () => {
|
||||
mockCanPlayType.mockImplementation((mime) => {
|
||||
if (mime === 'audio/flac') return 'maybe'
|
||||
return ''
|
||||
})
|
||||
|
||||
const profile = detectBrowserProfile()
|
||||
expect(profile.directPlayProfiles).toEqual([])
|
||||
const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs)
|
||||
expect(codecs).toContain('flac')
|
||||
})
|
||||
|
||||
it('excludes codecs that return empty string', () => {
|
||||
@@ -56,7 +60,7 @@ describe('detectBrowserProfile', () => {
|
||||
|
||||
it('filters transcoding profiles by canPlayType', () => {
|
||||
mockCanPlayType.mockImplementation((mime) => {
|
||||
if (mime === 'audio/mpeg') return 'probably'
|
||||
if (mime === 'audio/mpeg; codecs="mp3"') return 'probably'
|
||||
if (mime === 'audio/ogg; codecs="opus"') return 'probably'
|
||||
return ''
|
||||
})
|
||||
@@ -104,8 +108,72 @@ describe('detectBrowserProfile', () => {
|
||||
expect(profile.codecProfiles).toEqual([])
|
||||
})
|
||||
|
||||
it('matches codec when any mime variant returns "probably"', () => {
|
||||
mockCanPlayType.mockImplementation((mime) => {
|
||||
if (mime === 'audio/flac; codecs="flac"') return 'probably'
|
||||
return ''
|
||||
})
|
||||
|
||||
const profile = detectBrowserProfile()
|
||||
const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs)
|
||||
expect(codecs).toContain('flac')
|
||||
})
|
||||
|
||||
it('includes platform info', () => {
|
||||
const profile = detectBrowserProfile()
|
||||
expect(typeof profile.platform).toBe('string')
|
||||
})
|
||||
|
||||
describe('Safari restrictions', () => {
|
||||
beforeEach(() => {
|
||||
// Safari reports canPlayType for Ogg as positive, but can't actually
|
||||
// stream transcoded Ogg. Simulate Safari: supports everything.
|
||||
mockCanPlayType.mockReturnValue('probably')
|
||||
})
|
||||
|
||||
it('still includes ogg in direct play profiles on Safari', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15',
|
||||
})
|
||||
|
||||
const profile = detectBrowserProfile()
|
||||
const containers = profile.directPlayProfiles.flatMap((p) => p.containers)
|
||||
expect(containers).toContain('ogg')
|
||||
})
|
||||
|
||||
it('limits Safari transcoding to mp3 only', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15',
|
||||
})
|
||||
|
||||
const profile = detectBrowserProfile()
|
||||
const codecs = profile.transcodingProfiles.map((p) => p.audioCodec)
|
||||
expect(codecs).toEqual(['mp3'])
|
||||
})
|
||||
|
||||
it('does NOT restrict transcoding on Chrome', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
})
|
||||
|
||||
const profile = detectBrowserProfile()
|
||||
const codecs = profile.transcodingProfiles.map((p) => p.audioCodec)
|
||||
expect(codecs).toContain('opus')
|
||||
expect(codecs).toContain('flac')
|
||||
})
|
||||
|
||||
it('applies same restrictions on iOS Safari', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
})
|
||||
|
||||
const profile = detectBrowserProfile()
|
||||
const codecs = profile.transcodingProfiles.map((p) => p.audioCodec)
|
||||
expect(codecs).toEqual(['mp3'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user