diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js index 5a4cde20..d268af7c 100644 --- a/ui/src/transcode/browserProfile.js +++ b/ui/src/transcode/browserProfile.js @@ -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, diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js index 360ae788..e79b7a74 100644 --- a/ui/src/transcode/browserProfile.test.js +++ b/ui/src/transcode/browserProfile.test.js @@ -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']) + }) + }) })