mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
feat: improve music playback and asset discovery
This commit is contained in:
@@ -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
@@ -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))
|
||||
|
||||
@@ -1430,6 +1430,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.music-searchbar {
|
||||
width: calc(100% - 24px);
|
||||
margin: 0 12px 10px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user