diff --git a/README.md b/README.md index 9a7ba36..9748887 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/src/stores/music.test.ts b/frontend/src/stores/music.test.ts new file mode 100644 index 0000000..5b18293 --- /dev/null +++ b/frontend/src/stores/music.test.ts @@ -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 { + 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() +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((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() + }) +}) diff --git a/frontend/src/stores/music.ts b/frontend/src/stores/music.ts index 0990a0c..754b4cc 100644 --- a/frontend/src/stores/music.ts +++ b/frontend/src/stores/music.ts @@ -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 | 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 { + 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 { } 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 { 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)) diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue index c0f1126..5564c54 100644 --- a/frontend/src/views/apps/MusicApp.vue +++ b/frontend/src/views/apps/MusicApp.vue @@ -1430,6 +1430,7 @@ onMounted(() => { } .music-searchbar { + width: calc(100% - 24px); margin: 0 12px 10px; } diff --git a/sky_phone/config/music.lua b/sky_phone/config/music.lua index 9756d80..734848b 100644 --- a/sky_phone/config/music.lua +++ b/sky_phone/config/music.lua @@ -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", -- }, }, diff --git a/sky_phone/config/music/README.txt b/sky_phone/config/music/README.txt index f7b1a83..db3d7e9 100644 --- a/sky_phone/config/music/README.txt +++ b/sky_phone/config/music/README.txt @@ -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 .ogg or .mp3. Artwork is optional and must be named +.webp, .png, .jpg or .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. diff --git a/sky_phone/source/server/music.lua b/sky_phone/source/server/music.lua index 4899c4c..d6a54ea 100644 --- a/sky_phone/source/server/music.lua +++ b/sky_phone/source/server/music.lua @@ -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