feat: improve music playback and asset discovery

This commit is contained in:
Dominik
2026-08-09 21:22:17 +02:00
parent ae12cf2e30
commit cf0a70d834
7 changed files with 565 additions and 69 deletions
+200
View File
@@ -0,0 +1,200 @@
import { createPinia, setActivePinia } from 'pinia'
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest'
import type { MusicTrack } from '@/types/music'
class FakeAudio extends EventTarget {
static latest: FakeAudio | null = null
currentTime = 0
duration = 0
paused = true
preload = ''
seekable = {
end: () => 0,
length: 0,
start: () => 0,
} as TimeRanges
src = ''
volume = 1
load = vi.fn()
constructor() {
super()
FakeAudio.latest = this
}
pause(): void {
if (this.paused) return
this.paused = true
this.dispatchEvent(new Event('pause'))
}
async play(): Promise<void> {
this.paused = false
this.dispatchEvent(new Event('play'))
}
removeAttribute(name: string): void {
if (name === 'src') this.src = ''
}
reset(): void {
this.currentTime = 0
this.duration = 0
this.paused = true
this.seekable = {
end: () => 0,
length: 0,
start: () => 0,
} as TimeRanges
this.src = ''
this.volume = 1
this.load.mockClear()
}
}
const fetchMock = vi.fn<typeof fetch>()
const createObjectUrlMock = vi.fn<(blob: Blob) => string>()
const revokeObjectUrlMock = vi.fn<(url: string) => void>()
let fakeAudio: FakeAudio
let useMusicStore: (typeof import('@/stores/music'))['useMusicStore']
function track(id: string, extension: 'mp3' | 'ogg' = 'ogg'): MusicTrack {
return {
artist: 'Sky Records',
artwork: null,
id,
source: 'server',
title: id,
url: `https://cfx-nui-sky_phone/config/music/${id}.${extension}`,
}
}
function audioResponse(bytes = [1, 2, 3]): Response {
return {
arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from(bytes).buffer),
ok: true,
status: 200,
} as unknown as Response
}
beforeAll(async () => {
vi.stubGlobal('Audio', FakeAudio)
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('window', {
location: {
href: 'https://cfx-nui-sky_phone/source/html/index.html#/music',
},
})
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectUrlMock,
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectUrlMock,
})
;({ useMusicStore } = await import('@/stores/music'))
if (!FakeAudio.latest)
throw new Error('Music audio test double was not used.')
fakeAudio = FakeAudio.latest
})
beforeEach(() => {
setActivePinia(createPinia())
fakeAudio.reset()
fetchMock.mockReset()
createObjectUrlMock.mockReset()
createObjectUrlMock.mockReturnValue('blob:sky-music')
revokeObjectUrlMock.mockReset()
})
afterEach(() => {
useMusicStore().stop()
})
describe('music server playback', () => {
it('loads CFX-NUI audio into a seekable Blob URL', async () => {
fetchMock.mockResolvedValue(audioResponse())
const music = useMusicStore()
await music.play(track('night-drive'), [track('night-drive')])
expect(fetchMock).toHaveBeenCalledWith(
'https://cfx-nui-sky_phone/config/music/night-drive.ogg',
{ signal: expect.any(AbortSignal) },
)
expect(createObjectUrlMock).toHaveBeenCalledOnce()
expect(createObjectUrlMock.mock.calls[0]?.[0].type).toBe('audio/ogg')
expect(fakeAudio.src).toBe('blob:sky-music')
expect(music.isPlaying).toBe(true)
})
it('updates duration and seeks within the track bounds', async () => {
fetchMock.mockResolvedValue(audioResponse())
const music = useMusicStore()
const serverTrack = track('bounded-seek', 'mp3')
await music.play(serverTrack, [serverTrack])
fakeAudio.duration = 120
fakeAudio.dispatchEvent(new Event('loadedmetadata'))
music.seek(42)
expect(music.duration).toBe(120)
expect(fakeAudio.currentTime).toBe(42)
expect(music.currentTime).toBe(42)
music.seek(999)
expect(fakeAudio.currentTime).toBe(120)
music.seek(-10)
expect(fakeAudio.currentTime).toBe(0)
})
it('revokes the active Blob URL when playback stops', async () => {
fetchMock.mockResolvedValue(audioResponse())
const music = useMusicStore()
const serverTrack = track('cleanup')
await music.play(serverTrack, [serverTrack])
music.stop()
expect(revokeObjectUrlMock).toHaveBeenCalledOnce()
expect(revokeObjectUrlMock).toHaveBeenCalledWith('blob:sky-music')
expect(fakeAudio.src).toBe('')
})
it('does not let a stale audio request replace a newer track', async () => {
let resolveFirstRequest: ((response: Response) => void) | undefined
fetchMock
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFirstRequest = resolve
}),
)
.mockResolvedValueOnce(audioResponse([4, 5, 6]))
const music = useMusicStore()
const firstTrack = track('first')
const secondTrack = track('second')
const firstPlay = music.play(firstTrack, [firstTrack, secondTrack])
await music.play(secondTrack, [firstTrack, secondTrack])
resolveFirstRequest?.(audioResponse())
await firstPlay
expect(music.currentTrack?.id).toBe('second')
expect(fakeAudio.src).toBe('blob:sky-music')
expect(createObjectUrlMock).toHaveBeenCalledOnce()
})
})
+112 -15
View File
@@ -49,9 +49,12 @@ declare global {
}
const audio = new Audio()
audio.preload = 'metadata'
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
let audioLoadController: AbortController | null = null
let audioObjectUrl: string | null = null
let playbackGeneration = 0
let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null
@@ -62,6 +65,88 @@ export function musicTrackKey(
return `${track.source}:${track.id}`
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function resetAudioSource(): void {
audioLoadController?.abort()
audioLoadController = null
audio.pause()
audio.removeAttribute('src')
audio.load()
if (audioObjectUrl) {
URL.revokeObjectURL(audioObjectUrl)
audioObjectUrl = null
}
}
function serverAudioMimeType(url: URL): string {
return url.pathname.toLowerCase().endsWith('.ogg')
? 'audio/ogg'
: 'audio/mpeg'
}
async function loadServerAudioSource(source: string): Promise<boolean> {
const resolvedUrl = new URL(source, window.location.href)
const isCfxAsset =
resolvedUrl.protocol === 'https:' &&
resolvedUrl.hostname.startsWith('cfx-nui-')
if (!isCfxAsset) {
audio.src = resolvedUrl.href
audio.load()
return true
}
const controller = new AbortController()
audioLoadController = controller
try {
const response = await fetch(resolvedUrl.href, {
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`Music asset request failed (${response.status}).`)
}
const bytes = await response.arrayBuffer()
if (controller.signal.aborted || audioLoadController !== controller)
return false
const objectUrl = URL.createObjectURL(
new Blob([bytes], { type: serverAudioMimeType(resolvedUrl) }),
)
if (controller.signal.aborted || audioLoadController !== controller) {
URL.revokeObjectURL(objectUrl)
return false
}
audioObjectUrl = objectUrl
audio.src = objectUrl
audio.load()
return true
} finally {
if (audioLoadController === controller) audioLoadController = null
}
}
function currentAudioDuration(): number {
if (Number.isFinite(audio.duration) && audio.duration > 0)
return audio.duration
if (audio.seekable.length) {
const end = audio.seekable.end(audio.seekable.length - 1)
if (Number.isFinite(end) && end > 0) return end
}
return 0
}
function syncAudioDuration(): void {
const store = useMusicStore()
if (store.currentTrack?.source === 'server') {
store.duration = currentAudioDuration()
}
}
function stopYoutubeProgress(): void {
if (youtubeProgressTimer !== null) {
window.clearInterval(youtubeProgressTimer)
@@ -82,15 +167,14 @@ function startYoutubeProgress(): void {
function bindAudioEvents(): void {
if (audioBound) return
audioBound = true
audio.addEventListener('durationchange', () => {
const store = useMusicStore()
if (store.currentTrack?.source === 'server') {
store.duration = Number.isFinite(audio.duration) ? audio.duration : 0
}
})
audio.addEventListener('durationchange', syncAudioDuration)
audio.addEventListener('loadedmetadata', syncAudioDuration)
audio.addEventListener('canplay', syncAudioDuration)
audio.addEventListener('progress', syncAudioDuration)
audio.addEventListener('timeupdate', () => {
const store = useMusicStore()
if (store.currentTrack?.source === 'server') {
syncAudioDuration()
store.currentTime = audio.currentTime
}
})
@@ -260,9 +344,8 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
}
function stopActiveMedia(): void {
audio.pause()
audio.removeAttribute('src')
audio.load()
playbackGeneration += 1
resetAudioSource()
youtubePlayer?.pauseVideo()
stopYoutubeProgress()
}
@@ -379,6 +462,7 @@ export const useMusicStore = defineStore('music', {
async play(track: MusicTrack, queue?: MusicTrack[]): Promise<void> {
bindAudioEvents()
stopActiveMedia()
const requestedGeneration = playbackGeneration
const playableQueue = queue?.length ? [...queue] : [...this.allTracks]
if (!playableQueue.length) playableQueue.push(track)
const index = playableQueue.findIndex(
@@ -393,7 +477,8 @@ export const useMusicStore = defineStore('music', {
this.playbackError = ''
try {
if (track.source === 'server' && track.url) {
audio.src = new URL(track.url, window.location.href).href
const loaded = await loadServerAudioSource(track.url)
if (!loaded || requestedGeneration !== playbackGeneration) return
audio.volume = this.volume
await audio.play()
} else if (track.source === 'youtube' && track.videoId) {
@@ -402,6 +487,8 @@ export const useMusicStore = defineStore('music', {
throw new Error('Track has no playable source.')
}
} catch (error) {
if (requestedGeneration !== playbackGeneration || isAbortError(error))
return
console.error('[Music] Playback failed:', error)
this.isPlaying = false
this.playbackError = 'playback_failed'
@@ -449,10 +536,20 @@ export const useMusicStore = defineStore('music', {
await this.play(this.queue[index]!, this.queue)
},
seek(seconds: number): void {
const next = Math.max(0, Math.min(this.duration || seconds, seconds))
this.currentTime = next
if (this.currentTrack?.source === 'server') audio.currentTime = next
else youtubePlayer?.seekTo(next, true)
if (!Number.isFinite(seconds)) return
const duration = this.duration || currentAudioDuration()
const next = Math.max(0, Math.min(duration || seconds, seconds))
if (this.currentTrack?.source === 'server') {
try {
audio.currentTime = next
this.currentTime = next
} catch (error) {
console.warn('[Music] Audio seek failed:', error)
}
} else {
youtubePlayer?.seekTo(next, true)
this.currentTime = next
}
},
setVolume(value: number): void {
this.volume = Math.max(0, Math.min(1, value))
+1
View File
@@ -1430,6 +1430,7 @@ onMounted(() => {
}
.music-searchbar {
width: calc(100% - 24px);
margin: 0 12px 10px;
}