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
+14 -4
View File
@@ -26,7 +26,7 @@ The standalone `Music` app plays audio only inside the current player's NUI. It
world sound, voice channel, positional event, or 3D-audio state that another player can hear.
Server-owned MP3/OGG tracks and their optional artwork live directly in
`sky_phone/config/music`. Define their stable ID, title, artist, and paths in
`sky_phone/config/music`. Define only their stable ID, title, and artist in
`sky_phone/config/music.lua`:
```lua
@@ -35,15 +35,25 @@ Tracks = {
Id = "night-drive",
Title = "Night Drive",
Artist = "Sky Records",
File = "config/music/night-drive.ogg",
Artwork = "config/music/night-drive.webp",
},
}
```
The resource searches `config/music` and all of its subdirectories for files whose name matches
the ID. For the example above, place `night-drive.ogg` or `night-drive.mp3` anywhere below that
directory. Optional artwork uses the same name with `.webp`, `.png`, `.jpg`, or `.jpeg`. If both
audio formats exist, OGG wins; artwork priority is WEBP, PNG, JPG, then JPEG. More than one file
with the selected extension and ID is ambiguous and the server reports it in the console. Folder
names may contain spaces, but must not contain path separators or control characters. Do not name
a folder itself with a supported audio or artwork extension.
No frontend build is needed when tracks change. Restart the resource so FiveM republishes the
files and reloads the track configuration. Keep existing track IDs stable because playlists store
those IDs.
those IDs. Moving matching files between subdirectories does not affect playlists.
When upgrading from the previous path-based configuration, rename each audio and artwork file to
its existing ID and remove the `File` and `Artwork` fields. Do not change the ID itself, otherwise
existing playlist entries can no longer resolve the track.
Players can add public YouTube video links to their own library. Metadata is requested through
YouTube's oEmbed endpoint on the server, while playback uses the embedded YouTube player only on
+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;
}
+3 -4
View File
@@ -1,13 +1,12 @@
Config.Music = {
-- Put server-owned audio and artwork directly in config/music.
-- Paths must stay inside that directory. Restart the resource after changes.
-- Files are found recursively in config/music by their Id. For example,
-- Id = "night-drive" finds night-drive.ogg/mp3 and optional artwork with
-- the same name (.webp/.png/.jpg/.jpeg), even inside subdirectories.
Tracks = {
-- {
-- Id = "night-drive",
-- Title = "Night Drive",
-- Artist = "Sky Records",
-- File = "config/music/night-drive.ogg",
-- Artwork = "config/music/night-drive.webp",
-- },
},
+24 -7
View File
@@ -1,11 +1,28 @@
Place server-owned .mp3 and .ogg files in this directory.
Optional artwork can be stored here as .png, .jpg, .jpeg or .webp. Register
each track in ../music.lua using paths such as:
In ../music.lua, register only Id, Title and Artist. The resource searches this
directory and every subdirectory for files whose name matches the Id. Example:
File = "config/music/night-drive.ogg"
Artwork = "config/music/night-drive.webp"
Tracks = {
{ Id = "night-drive", Title = "Night Drive", Artist = "Sky Records" },
}
Use file names containing only letters, numbers, dots, underscores or hyphens.
Subdirectories are supported. Restart the sky_phone resource after changing
tracks or files; a frontend build is not required.
The entry above finds, for example:
config/music/electronic/night-drive.ogg
config/music/electronic/covers/night-drive.webp
Audio must be named <Id>.ogg or <Id>.mp3. Artwork is optional and must be named
<Id>.webp, <Id>.png, <Id>.jpg or <Id>.jpeg. If multiple formats exist, OGG is
preferred over MP3; artwork priority is WEBP, PNG, JPG, then JPEG. Do not place
the same preferred extension for one Id in multiple folders.
Matching is case-insensitive, but keep Id spelling stable because saved
playlists reference it. Folder names may contain spaces, but must not contain
path separators or control characters. Do not give a folder an audio or artwork
extension. Restart the sky_phone resource after changing tracks or files; a
frontend build is not required.
Upgrading from the old path-based setup: rename each audio and artwork file to
its existing Id, then remove File and Artwork from the track entry. Do not
change the Id itself.
+211 -39
View File
@@ -1,9 +1,20 @@
Bridge.Database.AfterMigration("sky_phone", function()
local server_tracks = {}
local server_tracks_by_id = {}
local music_asset_prefix = "config/music/"
local audio_extensions = { mp3 = true, ogg = true }
local artwork_extensions = { jpeg = true, jpg = true, png = true, webp = true }
local configured_track_ids = {}
local music_asset_directory = "config/music"
local music_asset_prefix = music_asset_directory .. "/"
local music_asset_extensions = {
audio = { "ogg", "mp3" },
artwork = { "webp", "png", "jpg", "jpeg" },
}
local music_asset_types = {}
for kind, extensions in pairs(music_asset_extensions) do
for _, extension in ipairs(extensions) do
music_asset_types[extension] = kind
end
end
local function trim(value)
return type(value) == "string" and value:match("^%s*(.-)%s*$") or nil
@@ -27,27 +38,149 @@ local function optional_text(value)
return normalized ~= "" and normalized or nil
end
local function normalize_music_asset_path(value, allowed_extensions)
local path = trim(value)
if not path
or path:sub(1, #music_asset_prefix) ~= music_asset_prefix
or not path:match("^[%w%._%-%/]+$")
or path:find("..", 1, true)
or path:find("//", 1, true)
or path:find("\\", 1, true)
then
local function safe_music_asset_name(name)
return type(name) == "string"
and name ~= ""
and name ~= "."
and name ~= ".."
and not name:find("[/\\]")
and not name:find("%c")
end
local function add_music_asset(index, relative_path, file_name)
local stem, extension = file_name:match("^(.+)%.([^.]+)$")
extension = extension and extension:lower() or nil
local kind = extension and music_asset_types[extension] or nil
if not stem or not kind then
return
end
local id = stem:lower()
local by_id = index[kind][id]
if not by_id then
by_id = {}
index[kind][id] = by_id
end
local candidates = by_id[extension]
if not candidates then
candidates = {}
by_id[extension] = candidates
end
candidates[#candidates + 1] = music_asset_prefix .. relative_path
end
local function discover_music_assets()
local index = { audio = {}, artwork = {} }
if type(io.readdir) ~= "function" then
Bridge.Debug(
"error",
"[sky_phone] Automatic music discovery requires io.readdir. Update the FXServer artifacts.",
{ always = true }
)
return nil
end
local extension = path:match("%.([%w]+)$")
if not extension or not allowed_extensions[extension:lower()] then
local mount_root = ("@%s/%s"):format(GetCurrentResourceName(), music_asset_directory)
local visited_entries = 0
local failure_reason = nil
local function walk(mount_path, relative_directory, depth)
if failure_reason then
return true
end
if depth > 32 then
failure_reason = "directory nesting exceeds 32 levels"
return true
end
local directory = io.readdir(mount_path)
if not directory then
failure_reason = ("could not read '%s'"):format(mount_path)
return true
end
local names = {}
local has_entries = false
for name in directory:lines() do
has_entries = true
if safe_music_asset_name(name) then
names[#names + 1] = name
else
Bridge.Debug(
"warn",
"[sky_phone] Skipped unsafe music path entry '%s'.",
tostring(name),
{ always = true }
)
end
end
directory:close()
table.sort(names)
for _, name in ipairs(names) do
if visited_entries >= 10000 then
failure_reason = "more than 10000 files and directories were found"
return true
end
visited_entries = visited_entries + 1
local relative_path = relative_directory == ""
and name
or (relative_directory .. "/" .. name)
local asset_path = mount_path .. "/" .. name
-- Cfx returns an empty directory listing for regular files. Walking every
-- entry is portable; io.open cannot distinguish directories on Linux.
if not walk(asset_path, relative_path, depth + 1) then
add_music_asset(index, relative_path, name)
end
end
return has_entries
end
local success, scan_error = pcall(walk, mount_root, "", 0)
if not success then
failure_reason = tostring(scan_error)
end
if failure_reason then
Bridge.Debug(
"error",
"[sky_phone] Music asset discovery failed; no server tracks were loaded: %s",
failure_reason,
{ always = true }
)
return nil
end
return path
return index
end
local function resolve_music_asset(index, id, kind)
local by_extension = index[kind][id:lower()]
if not by_extension then
return nil, "missing"
end
for _, extension in ipairs(music_asset_extensions[kind]) do
local candidates = by_extension[extension]
if candidates then
table.sort(candidates)
if #candidates > 1 then
return nil, "ambiguous", candidates
end
return candidates[1]
end
end
return nil, "missing"
end
local function music_asset_url(path)
return ("https://cfx-nui-%s/%s"):format(GetCurrentResourceName(), path)
local encoded_path = path:gsub("([^A-Za-z0-9%-%._~/])", function(character)
return ("%%%02X"):format(character:byte())
end)
return ("https://cfx-nui-%s/%s"):format(GetCurrentResourceName(), encoded_path)
end
local function affected_rows(result)
@@ -67,20 +200,20 @@ local function uuid()
end
local function normalize_server_tracks()
local music_assets = discover_music_assets()
if not music_assets then
return
end
for index, configured in ipairs(Config.Music.Tracks or {}) do
local id = trim(configured.Id)
local title = trim(configured.Title)
local artist = trim(configured.Artist)
local id = type(configured) == "table" and trim(configured.Id) or nil
local title = type(configured) == "table" and trim(configured.Title) or nil
local artist = type(configured) == "table" and trim(configured.Artist) or nil
local title_length = text_length(title)
local artist_length = text_length(artist)
local file = normalize_music_asset_path(configured.File, audio_extensions)
local configured_artwork = optional_text(configured.Artwork)
local artwork = configured_artwork
and normalize_music_asset_path(configured_artwork, artwork_extensions)
or nil
if not id
or not id:match("^[%w%-_]+$")
or not id:match("^[A-Za-z0-9_-]+$")
or #id > 48
or not title_length
or title_length < 1
@@ -88,27 +221,66 @@ local function normalize_server_tracks()
or not artist_length
or artist_length < 1
or artist_length > 120
or not file
or (configured_artwork and not artwork)
or server_tracks_by_id[id]
then
Bridge.Debug(
"error",
"[sky_phone] Ignored invalid or duplicate Config.Music.Tracks entry at index %s.",
"[sky_phone] Ignored invalid Config.Music.Tracks entry at index %s. Only Id, Title, and Artist are required.",
tostring(index),
{ always = true }
)
elseif configured_track_ids[id:lower()] then
Bridge.Debug(
"error",
"[sky_phone] Ignored duplicate Config.Music.Tracks Id '%s' at index %s. IDs are case-insensitive.",
id,
tostring(index),
{ always = true }
)
else
local track = {
id = id,
source = "server",
title = title,
artist = artist,
url = music_asset_url(file),
artwork = artwork and music_asset_url(artwork) or nil,
}
server_tracks[#server_tracks + 1] = track
server_tracks_by_id[id] = track
configured_track_ids[id:lower()] = true
local file, file_error, file_candidates = resolve_music_asset(music_assets, id, "audio")
local artwork, artwork_error, artwork_candidates = resolve_music_asset(music_assets, id, "artwork")
if file_error == "missing" then
Bridge.Debug(
"error",
"[sky_phone] Ignored music track '%s': no %s.ogg or %s.mp3 was found under config/music.",
id,
id,
id,
{ always = true }
)
elseif file_error == "ambiguous" then
Bridge.Debug(
"error",
"[sky_phone] Ignored music track '%s': multiple equally preferred audio files were found: %s",
id,
table.concat(file_candidates, ", "),
{ always = true }
)
else
if artwork_error == "ambiguous" then
Bridge.Debug(
"warn",
"[sky_phone] Music track '%s' was loaded without artwork because multiple equally preferred images were found: %s",
id,
table.concat(artwork_candidates, ", "),
{ always = true }
)
artwork = nil
end
local track = {
id = id,
source = "server",
title = title,
artist = artist,
url = music_asset_url(file),
artwork = artwork and music_asset_url(artwork) or nil,
}
server_tracks[#server_tracks + 1] = track
server_tracks_by_id[id] = track
end
end
end
end