-
+
+
diff --git a/frontend/src/components/SpringboardWidget.vue b/frontend/src/components/SpringboardWidget.vue
index 57d8276..f851a42 100644
--- a/frontend/src/components/SpringboardWidget.vue
+++ b/frontend/src/components/SpringboardWidget.vue
@@ -250,6 +250,7 @@ function openWidget(): void {
clock: '/apps/clock',
contacts: '/apps/phone',
date: '/apps/calendar',
+ music: '/apps/music',
transactions: '/apps/banking',
wallet: '/apps/banking',
weather: '/apps/weather',
diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts
index 88c34a9..57d2c5c 100644
--- a/frontend/src/config/apps.test.ts
+++ b/frontend/src/config/apps.test.ts
@@ -52,6 +52,12 @@ describe('app registry', () => {
labelKey: 'Apps.house.name',
route: '/apps/house',
})
+ expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({
+ category: 'utilities',
+ gridOrder: 26,
+ labelKey: 'Apps.music.name',
+ route: '/apps/music',
+ })
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
gridOrder: 21,
labelKey: 'Apps.calendar.name',
@@ -121,6 +127,7 @@ describe('app registry', () => {
expect(isPhoneAppId('photos')).toBe(true)
expect(isPhoneAppId('clock')).toBe(true)
expect(isPhoneAppId('skyride')).toBe(true)
+ expect(isPhoneAppId('music')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts
index 1d3a57c..3a27840 100644
--- a/frontend/src/config/apps.ts
+++ b/frontend/src/config/apps.ts
@@ -28,6 +28,7 @@ import {
MapPinHouse,
Flame,
House,
+ Music2,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
@@ -62,6 +63,7 @@ import flareIcon from '@/assets/img/app-icons/flare.svg'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import skyRideIcon from '@/assets/img/app-icons/skyride.svg'
+import musicIcon from '@/assets/img/app-icons/music.svg'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -69,6 +71,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
+ {
+ category: 'utilities',
+ component: markRaw(
+ defineAsyncComponent(() => import('@/views/apps/MusicApp.vue')),
+ ),
+ dockOrder: null,
+ gridOrder: 26,
+ icon: markRaw(Music2),
+ iconClass: 'app-icon--music',
+ iconImage: musicIcon,
+ id: 'music',
+ labelKey: 'Apps.music.name',
+ route: '/apps/music',
+ },
{
category: 'social',
component: markRaw(
diff --git a/frontend/src/services/widgetServices.ts b/frontend/src/services/widgetServices.ts
index ac6e05e..2e9c232 100644
--- a/frontend/src/services/widgetServices.ts
+++ b/frontend/src/services/widgetServices.ts
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useBankingStore } from '@/stores/banking'
import { useCallsStore } from '@/stores/calls'
+import { useMusicStore } from '@/stores/music'
import { usePhoneStore } from '@/stores/phone'
import { useWeatherStore } from '@/stores/weather'
@@ -9,14 +10,6 @@ const now = ref(new Date())
let clockConsumers = 0
let clockInterval: number | undefined
-const tracks = [
- { artist: 'Sky Radio', title: 'Night Drive' },
- { artist: 'Los Santos FM', title: 'Pacific Coast' },
- { artist: 'Mirror Park', title: 'After Hours' },
-]
-const trackIndex = ref(0)
-const playing = ref(false)
-
export function useClockService() {
const phone = usePhoneStore()
onMounted(() => {
@@ -80,15 +73,21 @@ export function useWeatherService() {
}
export function useMusicService() {
+ const music = useMusicStore()
return {
- current: computed(() => tracks[trackIndex.value]),
+ current: computed(
+ () =>
+ music.currentTrack ?? {
+ artist: 'Music',
+ title: 'Not Playing',
+ },
+ ),
next(): void {
- trackIndex.value = (trackIndex.value + 1) % tracks.length
- playing.value = true
+ void music.next()
},
- playing,
+ playing: computed(() => music.isPlaying),
toggle(): void {
- playing.value = !playing.value
+ void music.toggle()
},
}
}
diff --git a/frontend/src/stores/music.test.ts b/frontend/src/stores/music.test.ts
new file mode 100644
index 0000000..d56673f
--- /dev/null
+++ b/frontend/src/stores/music.test.ts
@@ -0,0 +1,253 @@
+import { createPinia, setActivePinia } from 'pinia'
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest'
+
+import type { MusicTrack } from '@/types/music'
+import { nuiCall } from '@/utils/nui'
+
+vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
+
+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()
+ vi.mocked(nuiCall).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()
+ })
+})
+
+describe('music playlists', () => {
+ it('adds a track with the expected payload and applies the refreshed playlist', async () => {
+ const serverTrack = track('night-drive')
+ vi.mocked(nuiCall).mockResolvedValueOnce({
+ data: {
+ playlists: [
+ {
+ createdAt: 1,
+ entries: [{ songId: serverTrack.id, source: serverTrack.source }],
+ id: 'playlist-1',
+ name: 'Night Ride',
+ },
+ ],
+ serverTracks: [serverTrack],
+ youtubeTracks: [],
+ },
+ success: true,
+ })
+ const music = useMusicStore()
+
+ const success = await music.addToPlaylist('playlist-1', serverTrack)
+
+ expect(success).toBe(true)
+ expect(nuiCall).toHaveBeenCalledWith('music:add-to-playlist', {
+ playlistId: 'playlist-1',
+ songId: 'night-drive',
+ source: 'server',
+ })
+ expect(music.playlists[0]?.entries).toEqual([
+ { songId: 'night-drive', source: 'server' },
+ ])
+ })
+
+ it('surfaces a duplicate-track error and releases the loading state', async () => {
+ const serverTrack = track('night-drive')
+ vi.mocked(nuiCall).mockResolvedValueOnce({
+ error: 'song_already_in_playlist',
+ success: false,
+ })
+ const music = useMusicStore()
+
+ const success = await music.addToPlaylist('playlist-1', serverTrack)
+
+ expect(success).toBe(false)
+ expect(music.error).toBe('song_already_in_playlist')
+ expect(music.isLoading).toBe(false)
+ })
+})
diff --git a/frontend/src/stores/music.ts b/frontend/src/stores/music.ts
new file mode 100644
index 0000000..754b4cc
--- /dev/null
+++ b/frontend/src/stores/music.ts
@@ -0,0 +1,570 @@
+import { defineStore } from 'pinia'
+
+import type {
+ MusicBootstrap,
+ MusicPlaylist,
+ MusicPlaylistEntry,
+ MusicTrack,
+} from '@/types/music'
+import { nuiCall } from '@/utils/nui'
+
+type YouTubePlayer = {
+ destroy: () => void
+ getCurrentTime: () => number
+ getDuration: () => number
+ loadVideoById: (videoId: string) => void
+ pauseVideo: () => void
+ playVideo: () => void
+ seekTo: (seconds: number, allowSeekAhead: boolean) => void
+ setVolume: (volume: number) => void
+}
+
+type YouTubeApi = {
+ Player: new (
+ target: HTMLElement,
+ options: {
+ events: {
+ onError: () => void
+ onReady: (event: { target: YouTubePlayer }) => void
+ onStateChange: (event: { data: number }) => void
+ }
+ height: string
+ playerVars: Record
+ videoId: string
+ width: string
+ },
+ ) => YouTubePlayer
+ PlayerState: {
+ ENDED: number
+ PAUSED: number
+ PLAYING: number
+ }
+}
+
+declare global {
+ interface Window {
+ YT?: YouTubeApi
+ onYouTubeIframeAPIReady?: () => void
+ }
+}
+
+const audio = new Audio()
+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
+
+export function musicTrackKey(
+ track: Pick,
+): string {
+ 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)
+ youtubeProgressTimer = null
+ }
+}
+
+function startYoutubeProgress(): void {
+ stopYoutubeProgress()
+ youtubeProgressTimer = window.setInterval(() => {
+ const store = useMusicStore()
+ if (!youtubePlayer || store.currentTrack?.source !== 'youtube') return
+ store.currentTime = Math.max(0, youtubePlayer.getCurrentTime() || 0)
+ store.duration = Math.max(0, youtubePlayer.getDuration() || 0)
+ }, 500)
+}
+
+function bindAudioEvents(): void {
+ if (audioBound) return
+ audioBound = true
+ 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
+ }
+ })
+ audio.addEventListener('play', () => {
+ const store = useMusicStore()
+ if (store.currentTrack?.source === 'server') store.isPlaying = true
+ })
+ audio.addEventListener('pause', () => {
+ const store = useMusicStore()
+ if (store.currentTrack?.source === 'server') store.isPlaying = false
+ })
+ audio.addEventListener('ended', () => {
+ const store = useMusicStore()
+ if (store.currentTrack?.source === 'server') void store.next()
+ })
+ audio.addEventListener('error', () => {
+ const store = useMusicStore()
+ if (store.currentTrack?.source === 'server') {
+ store.isPlaying = false
+ store.playbackError = 'playback_failed'
+ }
+ })
+}
+
+function loadYouTubeApi(): Promise {
+ if (window.YT?.Player) return Promise.resolve(window.YT)
+ if (youtubeApiPromise) return youtubeApiPromise
+ youtubeApiPromise = new Promise((resolve, reject) => {
+ let settled = false
+ const previousReady = window.onYouTubeIframeAPIReady
+ let script = document.querySelector(
+ 'script[data-sky-phone-youtube-api]',
+ )
+ let timeout = 0
+
+ const cleanup = (): void => {
+ window.clearTimeout(timeout)
+ script?.removeEventListener('error', handleError)
+ }
+ const fail = (error: Error): void => {
+ if (settled) return
+ settled = true
+ cleanup()
+ if (!window.YT?.Player) script?.remove()
+ if (window.onYouTubeIframeAPIReady === handleReady)
+ window.onYouTubeIframeAPIReady = previousReady
+ youtubeApiPromise = null
+ reject(error)
+ }
+ const handleReady = (): void => {
+ try {
+ previousReady?.()
+ } catch (error) {
+ console.error('[Music] A previous YouTube callback failed:', error)
+ }
+ if (!window.YT?.Player) {
+ fail(new Error('YouTube player API did not initialize.'))
+ return
+ }
+ if (settled) return
+ settled = true
+ cleanup()
+ resolve(window.YT)
+ }
+ function handleError(): void {
+ fail(new Error('YouTube player API failed to load.'))
+ }
+
+ window.onYouTubeIframeAPIReady = handleReady
+ if (!script) {
+ script = document.createElement('script')
+ script.dataset.skyPhoneYoutubeApi = 'true'
+ script.src = 'https://www.youtube.com/iframe_api'
+ script.async = true
+ document.head.append(script)
+ }
+ script.addEventListener('error', handleError, { once: true })
+ timeout = window.setTimeout(
+ () => fail(new Error('YouTube player API timed out.')),
+ YOUTUBE_API_TIMEOUT_MS,
+ )
+ })
+ return youtubeApiPromise
+}
+
+function resetYoutubePlayer(): void {
+ stopYoutubeProgress()
+ const player = youtubePlayer
+ youtubePlayer = null
+ try {
+ player?.destroy()
+ } catch (error) {
+ console.error('[Music] YouTube player cleanup failed:', error)
+ }
+ document.getElementById('sky-phone-youtube-player')?.remove()
+}
+
+async function loadYouTubeTrack(videoId: string): Promise {
+ const api = await loadYouTubeApi()
+ const store = useMusicStore()
+ if (youtubePlayer) {
+ youtubePlayer.setVolume(store.volume * 100)
+ youtubePlayer.loadVideoById(videoId)
+ youtubePlayer.playVideo()
+ startYoutubeProgress()
+ return
+ }
+
+ const host = document.createElement('div')
+ host.id = 'sky-phone-youtube-player'
+ host.style.position = 'fixed'
+ host.style.left = '-10000px'
+ host.style.top = '0'
+ host.style.width = '200px'
+ host.style.height = '200px'
+ host.style.pointerEvents = 'none'
+ document.body.append(host)
+
+ await new Promise((resolve, reject) => {
+ youtubePlayer = new api.Player(host, {
+ height: '200',
+ width: '200',
+ videoId,
+ playerVars: {
+ autoplay: 1,
+ controls: 0,
+ disablekb: 1,
+ fs: 0,
+ origin: window.location.origin,
+ playsinline: 1,
+ rel: 0,
+ },
+ events: {
+ onError: () => {
+ const activeStore = useMusicStore()
+ if (activeStore.currentTrack?.source === 'youtube') {
+ activeStore.isPlaying = false
+ activeStore.playbackError = 'playback_failed'
+ }
+ resetYoutubePlayer()
+ reject(new Error('YouTube player rejected the track.'))
+ },
+ onReady: (event) => {
+ youtubePlayer = event.target
+ event.target.setVolume(useMusicStore().volume * 100)
+ event.target.playVideo()
+ startYoutubeProgress()
+ resolve()
+ },
+ onStateChange: (event) => {
+ const activeStore = useMusicStore()
+ if (activeStore.currentTrack?.source !== 'youtube') return
+ if (event.data === api.PlayerState.PLAYING) {
+ activeStore.isPlaying = true
+ activeStore.playbackError = ''
+ startYoutubeProgress()
+ } else if (event.data === api.PlayerState.PAUSED) {
+ activeStore.isPlaying = false
+ } else if (event.data === api.PlayerState.ENDED) {
+ activeStore.isPlaying = false
+ void activeStore.next()
+ }
+ },
+ },
+ })
+ })
+}
+
+function stopActiveMedia(): void {
+ playbackGeneration += 1
+ resetAudioSource()
+ youtubePlayer?.pauseVideo()
+ stopYoutubeProgress()
+}
+
+export const useMusicStore = defineStore('music', {
+ state: () => ({
+ currentTime: 0,
+ currentTrack: null as MusicTrack | null,
+ duration: 0,
+ error: '',
+ isLoading: false,
+ isPlaying: false,
+ playbackError: '',
+ playlists: [] as MusicPlaylist[],
+ queue: [] as MusicTrack[],
+ queueIndex: -1,
+ serverTracks: [] as MusicTrack[],
+ volume: 0.72,
+ youtubeTracks: [] as MusicTrack[],
+ }),
+ getters: {
+ allTracks(state): MusicTrack[] {
+ return [...state.serverTracks, ...state.youtubeTracks]
+ },
+ },
+ actions: {
+ applyBootstrap(data: MusicBootstrap): void {
+ this.serverTracks = data.serverTracks
+ this.youtubeTracks = data.youtubeTracks
+ this.playlists = data.playlists
+ const tracks = new Map(
+ [...data.serverTracks, ...data.youtubeTracks].map((track) => [
+ musicTrackKey(track),
+ track,
+ ]),
+ )
+ if (this.currentTrack) {
+ const current = tracks.get(musicTrackKey(this.currentTrack))
+ if (current) this.currentTrack = current
+ else this.stop()
+ }
+ this.queue = this.queue
+ .map((track) => tracks.get(musicTrackKey(track)))
+ .filter((track): track is MusicTrack => Boolean(track))
+ this.queueIndex = this.currentTrack
+ ? this.queue.findIndex(
+ (track) =>
+ musicTrackKey(track) === musicTrackKey(this.currentTrack!),
+ )
+ : -1
+ },
+ async request(
+ endpoint: string,
+ payload: Record,
+ ): Promise {
+ this.isLoading = true
+ const response = await nuiCall(endpoint, payload)
+ this.isLoading = false
+ this.error = response.success ? '' : (response.error ?? 'default')
+ if (response.success && response.data) this.applyBootstrap(response.data)
+ return response.success
+ },
+ async load(): Promise {
+ void loadYouTubeApi().catch((error) => {
+ console.warn('[Music] YouTube API preload failed:', error)
+ })
+ return this.request('music:bootstrap', {})
+ },
+ async addYouTube(url: string, title = '', artist = ''): Promise {
+ return this.request('music:add-youtube', { artist, title, url })
+ },
+ async removeYouTube(id: string): Promise {
+ return this.request('music:remove-youtube', { id })
+ },
+ async createPlaylist(name: string): Promise {
+ return this.request('music:create-playlist', { name })
+ },
+ async renamePlaylist(id: string, name: string): Promise {
+ return this.request('music:rename-playlist', { id, name })
+ },
+ async deletePlaylist(id: string): Promise {
+ return this.request('music:delete-playlist', { id })
+ },
+ async addToPlaylist(
+ playlistId: string,
+ track: MusicTrack,
+ ): Promise {
+ return this.request('music:add-to-playlist', {
+ playlistId,
+ songId: track.id,
+ source: track.source,
+ })
+ },
+ async removeFromPlaylist(
+ playlistId: string,
+ track: MusicTrack,
+ ): Promise {
+ return this.request('music:remove-from-playlist', {
+ playlistId,
+ songId: track.id,
+ source: track.source,
+ })
+ },
+ trackForEntry(entry: MusicPlaylistEntry): MusicTrack | undefined {
+ return this.allTracks.find(
+ (track) => track.source === entry.source && track.id === entry.songId,
+ )
+ },
+ tracksForPlaylist(playlist: MusicPlaylist): MusicTrack[] {
+ return playlist.entries
+ .map((entry) => this.trackForEntry(entry))
+ .filter((track): track is MusicTrack => Boolean(track))
+ },
+ 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(
+ (candidate) => musicTrackKey(candidate) === musicTrackKey(track),
+ )
+ this.queue = playableQueue
+ this.queueIndex = index >= 0 ? index : 0
+ this.currentTrack = index >= 0 ? playableQueue[index]! : track
+ this.currentTime = 0
+ this.duration = 0
+ this.isPlaying = false
+ this.playbackError = ''
+ try {
+ if (track.source === 'server' && track.url) {
+ 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) {
+ await loadYouTubeTrack(track.videoId)
+ } else {
+ 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'
+ }
+ },
+ async toggle(): Promise {
+ if (!this.currentTrack) {
+ const first = this.queue[0] ?? this.allTracks[0]
+ if (first)
+ await this.play(
+ first,
+ this.queue.length ? this.queue : this.allTracks,
+ )
+ return
+ }
+ if (this.isPlaying) {
+ if (this.currentTrack.source === 'server') audio.pause()
+ else youtubePlayer?.pauseVideo()
+ this.isPlaying = false
+ return
+ }
+ try {
+ if (this.currentTrack.source === 'server') await audio.play()
+ else youtubePlayer?.playVideo()
+ this.isPlaying = true
+ } catch (error) {
+ console.error('[Music] Resume failed:', error)
+ this.playbackError = 'playback_failed'
+ }
+ },
+ async next(): Promise {
+ if (!this.queue.length) return
+ const index =
+ this.queueIndex < 0 ? 0 : (this.queueIndex + 1) % this.queue.length
+ await this.play(this.queue[index]!, this.queue)
+ },
+ async previous(): Promise {
+ if (!this.queue.length) return
+ if (this.currentTime > 3) {
+ this.seek(0)
+ return
+ }
+ const index =
+ this.queueIndex <= 0 ? this.queue.length - 1 : this.queueIndex - 1
+ await this.play(this.queue[index]!, this.queue)
+ },
+ seek(seconds: number): void {
+ 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))
+ audio.volume = this.volume
+ youtubePlayer?.setVolume(this.volume * 100)
+ },
+ stop(): void {
+ stopActiveMedia()
+ this.currentTrack = null
+ this.currentTime = 0
+ this.duration = 0
+ this.isPlaying = false
+ this.playbackError = ''
+ this.queue = []
+ this.queueIndex = -1
+ },
+ },
+})
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index bbb767d..82099e3 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -2128,6 +2128,95 @@ const defaultLocales: LocaleTree = {
default: 'The mail request failed.',
},
},
+ music: {
+ name: 'Music',
+ loading: 'Loading Music...',
+ navigation: 'Music navigation',
+ tabs: { library: 'Library', playlists: 'Playlists', search: 'Search' },
+ librarySubtitle: 'Your music, all in one place.',
+ playlistsSubtitle: 'Mix server tracks and your YouTube favorites.',
+ featured: 'Made for the city',
+ recentlyAdded: 'Recently Added',
+ songs: 'Songs',
+ play: 'Play',
+ previous: 'Previous track',
+ next: 'Next track',
+ nowPlaying: 'Now Playing',
+ progress: 'Playback position',
+ volume: 'Music volume',
+ addMusic: 'Add music',
+ addYouTube: 'Add from YouTube',
+ youtubeBody:
+ 'Paste a public YouTube link. Leave the optional fields empty to use the video title and channel automatically.',
+ youtubeUrl: 'YouTube URL',
+ youtubePlaceholder: 'https://youtube.com/watch?v=...',
+ youtubeTitle: 'Song title (optional)',
+ youtubeTitlePlaceholder: 'Use the YouTube video title',
+ youtubeArtist: 'Artist (optional)',
+ youtubeArtistPlaceholder: 'Use the YouTube channel',
+ addToLibrary: 'Add to Library',
+ songAdded: 'Song added to your library.',
+ songRemoved: 'Song removed from your library.',
+ newPlaylist: 'New Playlist',
+ renamePlaylist: 'Rename Playlist',
+ addSongs: 'Add Songs',
+ addSongsBody: 'Choose songs from your library for this playlist.',
+ allSongsAdded: 'All Songs Added',
+ allSongsAddedBody:
+ 'Every song in your library is already in this playlist.',
+ playlistBody: 'Give this playlist a name you will recognize.',
+ playlistName: 'Playlist Name',
+ playlistPlaceholder: 'My Playlist',
+ playlistCreated: 'Playlist created.',
+ playlistCreatedWithSong: 'Playlist created and song added.',
+ playlistRenamed: 'Playlist renamed.',
+ playlistDeleted: 'Playlist deleted.',
+ playlistActions: 'Playlist actions',
+ deletePlaylist: 'Delete Playlist',
+ deletePlaylistTitle: 'Delete Playlist?',
+ deletePlaylistBody:
+ 'The playlist will be deleted. Songs stay in your library.',
+ choosePlaylist: 'Choose a Playlist',
+ addToPlaylist: 'Add to Playlist',
+ addedToPlaylist: 'Added to playlist.',
+ alreadyAdded: 'Added',
+ removeFromPlaylist: 'Remove from Playlist',
+ removedFromPlaylist: 'Removed from playlist.',
+ createPlaylistFirst: 'Create a playlist before adding this song.',
+ songActions: 'Song actions',
+ removeFromLibrary: 'Remove from Library',
+ removeSongTitle: 'Remove Song?',
+ removeSongBody:
+ 'This YouTube song will also be removed from your playlists.',
+ songCount: '{count} songs',
+ emptyLibrary: 'Your Library is Empty',
+ emptyLibraryBody:
+ 'Add a YouTube song or ask the server owner to publish MP3 and OGG tracks.',
+ emptyPlaylist: 'No Songs Yet',
+ emptyPlaylistBody: 'Add songs from your library to this playlist.',
+ noPlaylists: 'No Playlists',
+ noPlaylistsBody: 'Create a playlist for your favorite songs.',
+ searchPlaceholder: 'Artists, Songs and Playlists',
+ noResults: 'No Results',
+ noResultsBody: 'Try another song title or artist.',
+ errors: {
+ invalid_youtube_url: 'Paste a valid public YouTube video link.',
+ invalid_song_metadata: 'Use a shorter song title or artist name.',
+ song_exists: 'That YouTube song is already in your library.',
+ song_limit: 'Your personal song limit has been reached.',
+ song_not_found: 'That song is no longer in your library.',
+ invalid_playlist: 'Choose a valid playlist name.',
+ playlist_limit: 'Your playlist limit has been reached.',
+ playlist_not_found: 'That playlist no longer exists.',
+ playlist_song_limit: 'That playlist has reached its song limit.',
+ invalid_song: 'That song cannot be added to this playlist.',
+ song_already_in_playlist: 'That song is already in this playlist.',
+ rate_limited: 'Too many music changes. Try again shortly.',
+ playback_failed: 'This song could not be played.',
+ request_failed: 'Music is temporarily unavailable.',
+ default: 'The Music request failed.',
+ },
+ },
notes: {
name: 'Notes',
note: 'Note',
diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts
index 732dcf9..dbb80ae 100644
--- a/frontend/src/types/apps.ts
+++ b/frontend/src/types/apps.ts
@@ -16,6 +16,7 @@ export type PhoneAppId =
| 'map'
| 'notes'
| 'radio'
+ | 'music'
| 'photos'
| 'app-store'
| 'settings'
diff --git a/frontend/src/types/music.ts b/frontend/src/types/music.ts
new file mode 100644
index 0000000..0716601
--- /dev/null
+++ b/frontend/src/types/music.ts
@@ -0,0 +1,30 @@
+export type MusicSource = 'server' | 'youtube'
+
+export type MusicTrack = {
+ artist: string
+ artwork: string | null
+ createdAt?: number
+ id: string
+ source: MusicSource
+ title: string
+ url?: string
+ videoId?: string
+}
+
+export type MusicPlaylistEntry = {
+ songId: string
+ source: MusicSource
+}
+
+export type MusicPlaylist = {
+ createdAt: number
+ entries: MusicPlaylistEntry[]
+ id: string
+ name: string
+}
+
+export type MusicBootstrap = {
+ playlists: MusicPlaylist[]
+ serverTracks: MusicTrack[]
+ youtubeTracks: MusicTrack[]
+}
diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts
index 86a2c67..77d9600 100644
--- a/frontend/src/utils/preferences.ts
+++ b/frontend/src/utils/preferences.ts
@@ -95,6 +95,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
radio: { enabled: true, sounds: true },
+ music: { enabled: true, sounds: true },
photos: { enabled: true, sounds: true },
settings: { enabled: true, sounds: true },
}
diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue
new file mode 100644
index 0000000..9ba20b8
--- /dev/null
+++ b/frontend/src/views/apps/MusicApp.vue
@@ -0,0 +1,2023 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ music.currentTrack.title }}
+ {{ music.currentTrack.artist }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.music.tabs.library') }}
+
+
+
+ {{ phone.t('Apps.music.tabs.playlists') }}
+
+
+
+ {{ phone.t('Apps.music.tabs.search') }}
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.music.addSongs') }}
+
+
+
+ {{ phone.t('Apps.music.renamePlaylist') }}
+
+
+ {{ phone.t('Apps.music.deletePlaylist') }}
+
+
+
+
+ {{ phone.t('Apps.music.addYouTube') }}
+
+
+ {{ phone.t('Apps.music.newPlaylist') }}
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.music.addToPlaylist') }}
+
+
+ {{ phone.t('Apps.music.removeFromPlaylist') }}
+
+
+ {{ phone.t('Apps.music.removeFromLibrary') }}
+
+
+
+
+
+
+
+ {{
+ phone.t(
+ activeSheet === 'track-picker' ? 'Common.done' : 'Common.cancel',
+ )
+ }}
+ {{ sheetTitle }}
+
+
+
+
+
+ {{ phone.t('Apps.music.youtubeBody') }}
+
+
+
+
+
+
+ {{ errorText() }}
+
+
+
+ {{ phone.t('Apps.music.addToLibrary') }}
+
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.music.alreadyAdded') }}
+
+
+
+
+
+
+ {{ phone.t('Apps.music.newPlaylist') }}
+
+
+
+ {{ phone.t('Apps.music.noPlaylists') }}
+ {{ phone.t('Apps.music.createPlaylistFirst') }}
+ {{ phone.t('Apps.music.newPlaylist') }}
+
+
+ {{ errorText() }}
+
+
+
+
+ {{ phone.t('Apps.music.addSongsBody') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ phone.t(
+ allTracks.length
+ ? 'Apps.music.allSongsAdded'
+ : 'Apps.music.emptyLibrary',
+ )
+ }}
+ {{
+ phone.t(
+ allTracks.length
+ ? 'Apps.music.allSongsAddedBody'
+ : 'Apps.music.emptyLibraryBody',
+ )
+ }}
+
+
+ {{ errorText() }}
+
+
+
+
+
+ {{ phone.t('Apps.music.playlistBody') }}
+
+
+
+
+ {{ errorText() }}
+
+
+
+ {{ phone.t('Common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.music.nowPlaying') }}
+
+
+
+
+
+
+
+
+
+ {{ music.currentTrack.title }}
+ {{ music.currentTrack.artist }}
+
+
+
+
+ {{ formatTime(music.currentTime) }} -{{
+ formatTime(Math.max(0, music.duration - music.currentTime))
+ }}
+
+
+
+
+
+
+
+
+
+ {{ phone.t('Apps.music.errors.playback_failed') }}
+
+
+
+
+
+
+ {{
+ phone.t('Common.cancel')
+ }}
+ {{
+ phone.t('Common.delete')
+ }}
+
+
+
+
+
+ {{
+ phone.t('Common.cancel')
+ }}
+ {{
+ phone.t('Common.delete')
+ }}
+
+
+
+ {{
+ toastText
+ }}
+
+
+
+
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index e2a1648..a482c22 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -48,6 +48,89 @@ const radioData = {
settings: { autoRejoin: false, notifications: true },
volume: 50,
}
+let musicSequence = 3
+const musicServerTracks = [
+ {
+ id: 'city-after-dark',
+ source: 'server',
+ title: 'City After Dark',
+ artist: 'Sky Records',
+ url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
+ artwork: 'https://picsum.photos/seed/sky-music-city/600/600',
+ },
+ {
+ id: 'pacific-drive',
+ source: 'server',
+ title: 'Pacific Drive',
+ artist: 'Vespucci FM',
+ url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
+ artwork: 'https://picsum.photos/seed/sky-music-pacific/600/600',
+ },
+ {
+ id: 'neon-rain',
+ source: 'server',
+ title: 'Neon Rain',
+ artist: 'Mirror Park',
+ url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
+ artwork: 'https://picsum.photos/seed/sky-music-neon/600/600',
+ },
+]
+let musicYoutubeTracks = [
+ {
+ id: 'music-youtube-1',
+ source: 'youtube',
+ videoId: 'dQw4w9WgXcQ',
+ title: 'Never Gonna Give You Up',
+ artist: 'Rick Astley',
+ artwork: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
+ createdAt: Date.now() - 120000,
+ },
+]
+let musicPlaylists = [
+ {
+ id: 'music-playlist-1',
+ name: 'Night Ride',
+ createdAt: Date.now() - 86400000,
+ entries: [
+ { source: 'server', songId: 'city-after-dark' },
+ { source: 'server', songId: 'neon-rain' },
+ { source: 'youtube', songId: 'music-youtube-1' },
+ ],
+ },
+]
+
+function musicBootstrap() {
+ return {
+ serverTracks: musicServerTracks,
+ youtubeTracks: musicYoutubeTracks,
+ playlists: musicPlaylists,
+ }
+}
+
+async function fetchYoutubeMetadata(videoId) {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 4500)
+ try {
+ const watchUrl = `https://www.youtube.com/watch?v=${videoId}`
+ const response = await fetch(
+ `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(watchUrl)}`,
+ {
+ headers: { Accept: 'application/json' },
+ signal: controller.signal,
+ },
+ )
+ if (!response.ok) return null
+ const data = await response.json()
+ const title = typeof data.title === 'string' ? data.title.trim() : ''
+ const artist =
+ typeof data.author_name === 'string' ? data.author_name.trim() : ''
+ return title && artist ? { artist, title } : null
+ } catch {
+ return null
+ } finally {
+ clearTimeout(timeout)
+ }
+}
let mockBankBalance = 24787
let mockCashBalance = 2350
let nextBankTransactionId = 7
@@ -1693,9 +1776,133 @@ function flareBootstrap() {
}
}
-app.post('/api/:endpoint', (request, response) => {
+app.post('/api/:endpoint', async (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
+ if (endpoint === 'music:bootstrap') {
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:add-youtube') {
+ const value = String(request.body.url ?? '')
+ const customTitle = String(request.body.title ?? '').trim()
+ const customArtist = String(request.body.artist ?? '').trim()
+ const videoId =
+ value.match(/[?&]v=([\w-]{11})/)?.[1] ??
+ value.match(/youtu\.be\/([\w-]{11})/)?.[1]
+ if (!videoId) {
+ response.json({ success: false, error: 'invalid_youtube_url' })
+ return
+ }
+ if (customTitle.length > 160 || customArtist.length > 120) {
+ response.json({ success: false, error: 'invalid_song_metadata' })
+ return
+ }
+ if (musicYoutubeTracks.some((track) => track.videoId === videoId)) {
+ response.json({ success: false, error: 'song_exists' })
+ return
+ }
+ const metadata =
+ !customTitle || !customArtist ? await fetchYoutubeMetadata(videoId) : null
+ musicYoutubeTracks.unshift({
+ id: `music-youtube-${musicSequence++}`,
+ source: 'youtube',
+ videoId,
+ title: customTitle || metadata?.title || `YouTube ${videoId}`,
+ artist: customArtist || metadata?.artist || 'YouTube',
+ artwork: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
+ createdAt: Date.now(),
+ })
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:remove-youtube') {
+ musicYoutubeTracks = musicYoutubeTracks.filter(
+ (track) => track.id !== request.body.id,
+ )
+ musicPlaylists.forEach((playlist) => {
+ playlist.entries = playlist.entries.filter(
+ (entry) =>
+ !(entry.source === 'youtube' && entry.songId === request.body.id),
+ )
+ })
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:create-playlist') {
+ const name = String(request.body.name ?? '').trim()
+ if (!name) {
+ response.json({ success: false, error: 'invalid_playlist' })
+ return
+ }
+ musicPlaylists.unshift({
+ id: `music-playlist-${musicSequence++}`,
+ name,
+ createdAt: Date.now(),
+ entries: [],
+ })
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:rename-playlist') {
+ const playlist = musicPlaylists.find((item) => item.id === request.body.id)
+ if (!playlist) {
+ response.json({ success: false, error: 'playlist_not_found' })
+ return
+ }
+ playlist.name = String(request.body.name ?? '').trim()
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:delete-playlist') {
+ musicPlaylists = musicPlaylists.filter(
+ (playlist) => playlist.id !== request.body.id,
+ )
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:add-to-playlist') {
+ const playlist = musicPlaylists.find(
+ (item) => item.id === request.body.playlistId,
+ )
+ if (!playlist) {
+ response.json({ success: false, error: 'playlist_not_found' })
+ return
+ }
+ if (
+ playlist.entries.some(
+ (entry) =>
+ entry.source === request.body.source &&
+ entry.songId === request.body.songId,
+ )
+ ) {
+ response.json({
+ success: false,
+ error: 'song_already_in_playlist',
+ })
+ return
+ }
+ playlist.entries.push({
+ source: request.body.source,
+ songId: request.body.songId,
+ })
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
+ if (endpoint === 'music:remove-from-playlist') {
+ const playlist = musicPlaylists.find(
+ (item) => item.id === request.body.playlistId,
+ )
+ if (playlist) {
+ playlist.entries = playlist.entries.filter(
+ (entry) =>
+ entry.source !== request.body.source ||
+ entry.songId !== request.body.songId,
+ )
+ }
+ response.json({ success: true, data: musicBootstrap() })
+ return
+ }
if (endpoint === 'radio:get') {
response.json({ success: true, data: radioData })
return
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index f20ba6c..298a056 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -33,7 +33,8 @@ export default defineConfig({
allow: [fileURLToPath(new URL('.', import.meta.url))],
strict: false,
},
- host: '127.0.0.1',
+ // YouTube rejects otherwise valid embeds when the dev page uses the
+ // numeric loopback origin. Keep the local UI on the localhost origin.
+ host: 'localhost',
},
})
-
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 9f61f80..319a2b9 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -813,6 +813,42 @@ Locales["en"] = {
fog = "Reduced visibility in low-lying areas.", snow = "Cold conditions with snow across the region.",
},
},
+ music = {
+ name = "Music", loading = "Loading Music...", navigation = "Music navigation",
+ tabs = { library = "Library", playlists = "Playlists", search = "Search" },
+ librarySubtitle = "Your music, all in one place.", playlistsSubtitle = "Mix server tracks and your YouTube favorites.",
+ featured = "Made for the city", recentlyAdded = "Recently Added", songs = "Songs", play = "Play",
+ previous = "Previous track", next = "Next track", nowPlaying = "Now Playing", progress = "Playback position", volume = "Music volume",
+ addMusic = "Add music", addYouTube = "Add from YouTube", youtubeBody = "Paste a public YouTube link. Leave the optional fields empty to use the video title and channel automatically.",
+ youtubeUrl = "YouTube URL", youtubePlaceholder = "https://youtube.com/watch?v=...", youtubeTitle = "Song title (optional)",
+ youtubeTitlePlaceholder = "Use the YouTube video title", youtubeArtist = "Artist (optional)",
+ youtubeArtistPlaceholder = "Use the YouTube channel", addToLibrary = "Add to Library",
+ songAdded = "Song added to your library.", songRemoved = "Song removed from your library.",
+ newPlaylist = "New Playlist", renamePlaylist = "Rename Playlist", addSongs = "Add Songs",
+ addSongsBody = "Choose songs from your library for this playlist.", allSongsAdded = "All Songs Added",
+ allSongsAddedBody = "Every song in your library is already in this playlist.", playlistBody = "Give this playlist a name you will recognize.",
+ playlistName = "Playlist Name", playlistPlaceholder = "My Playlist", playlistCreated = "Playlist created.",
+ playlistCreatedWithSong = "Playlist created and song added.",
+ playlistRenamed = "Playlist renamed.", playlistDeleted = "Playlist deleted.", playlistActions = "Playlist actions",
+ deletePlaylist = "Delete Playlist", deletePlaylistTitle = "Delete Playlist?", deletePlaylistBody = "The playlist will be deleted. Songs stay in your library.",
+ choosePlaylist = "Choose a Playlist", addToPlaylist = "Add to Playlist", addedToPlaylist = "Added to playlist.", alreadyAdded = "Added",
+ removeFromPlaylist = "Remove from Playlist", removedFromPlaylist = "Removed from playlist.", createPlaylistFirst = "Create a playlist before adding this song.",
+ songActions = "Song actions", removeFromLibrary = "Remove from Library", removeSongTitle = "Remove Song?",
+ removeSongBody = "This YouTube song will also be removed from your playlists.", songCount = "{count} songs",
+ emptyLibrary = "Your Library is Empty", emptyLibraryBody = "Add a YouTube song or ask the server owner to publish MP3 and OGG tracks.",
+ emptyPlaylist = "No Songs Yet", emptyPlaylistBody = "Add songs from your library to this playlist.",
+ noPlaylists = "No Playlists", noPlaylistsBody = "Create a playlist for your favorite songs.",
+ searchPlaceholder = "Artists, Songs and Playlists", noResults = "No Results", noResultsBody = "Try another song title or artist.",
+ errors = {
+ invalid_youtube_url = "Paste a valid public YouTube video link.", invalid_song_metadata = "Use a shorter song title or artist name.",
+ song_exists = "That YouTube song is already in your library.",
+ song_limit = "Your personal song limit has been reached.", song_not_found = "That song is no longer in your library.",
+ invalid_playlist = "Choose a valid playlist name.", playlist_limit = "Your playlist limit has been reached.",
+ playlist_not_found = "That playlist no longer exists.", playlist_song_limit = "That playlist has reached its song limit.",
+ invalid_song = "That song cannot be added to this playlist.", song_already_in_playlist = "That song is already in this playlist.", rate_limited = "Too many music changes. Try again shortly.",
+ playback_failed = "This song could not be played.", request_failed = "Music is temporarily unavailable.", default = "The Music request failed.",
+ },
+ },
notes = {
name = "Notes", note = "Note", back = "Back", actions = "Note actions",
newNote = "New Note", searchPlaceholder = "Search Notes",
diff --git a/sky_phone/config/music.lua b/sky_phone/config/music.lua
new file mode 100644
index 0000000..734848b
--- /dev/null
+++ b/sky_phone/config/music.lua
@@ -0,0 +1,19 @@
+Config.Music = {
+ -- 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",
+ -- },
+ },
+
+ MaximumPersonalSongs = 100,
+ MaximumPlaylists = 50,
+ MaximumPlaylistSongs = 250,
+ PlaylistNameMaxLength = 80,
+ ActionsPerMinute = 30,
+ MetadataTimeoutMs = 4500,
+}
diff --git a/sky_phone/config/music/README.txt b/sky_phone/config/music/README.txt
new file mode 100644
index 0000000..db3d7e9
--- /dev/null
+++ b/sky_phone/config/music/README.txt
@@ -0,0 +1,28 @@
+Place server-owned .mp3 and .ogg files in this directory.
+
+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:
+
+ Tracks = {
+ { Id = "night-drive", Title = "Night Drive", Artist = "Sky Records" },
+ }
+
+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/fxmanifest.lua b/sky_phone/fxmanifest.lua
index 69b2434..f248c62 100644
--- a/sky_phone/fxmanifest.lua
+++ b/sky_phone/fxmanifest.lua
@@ -40,6 +40,7 @@ server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
'config/media.lua',
+ 'config/music.lua',
'config/locales/*.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
@@ -70,6 +71,7 @@ server_scripts {
'source/server/map.lua',
'source/server/skyride.lua',
'source/server/calendar.lua',
+ 'source/server/music.lua',
'source/server/radio.lua',
}
@@ -77,6 +79,7 @@ files {
'source/html/index.html',
'source/html/assets/**',
'source/html/img/**',
+ 'config/music/**',
}
ui_page 'source/html/index.html'
diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua
index 586d795..2e4ccd4 100644
--- a/sky_phone/source/client/main.lua
+++ b/sky_phone/source/client/main.lua
@@ -116,6 +116,14 @@ local server_callbacks = {
"calendar:create",
"calendar:update",
"calendar:delete",
+ "music:bootstrap",
+ "music:add-youtube",
+ "music:remove-youtube",
+ "music:create-playlist",
+ "music:rename-playlist",
+ "music:delete-playlist",
+ "music:add-to-playlist",
+ "music:remove-from-playlist",
"map:markers",
"map:create-marker",
"map:delete-marker",
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua
index 5250e10..7205772 100644
--- a/sky_phone/source/server/db_migrate.lua
+++ b/sky_phone/source/server/db_migrate.lua
@@ -735,6 +735,75 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
+ {
+ name = "sky_phone_music_youtube_songs",
+ columns = {
+ { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "account_id", type = "BIGINT UNSIGNED NULL" },
+ { name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "video_id", type = "CHAR(11) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "title", type = "VARCHAR(160) NOT NULL" },
+ { name = "artist", type = "VARCHAR(120) NOT NULL" },
+ { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
+ },
+ primaryKey = "id",
+ uniqueKeys = {
+ { name = "uniq_sky_phone_music_youtube_account", columns = "(`account_id`, `video_id`)" },
+ { name = "uniq_sky_phone_music_youtube_device", columns = "(`device_imei`, `video_id`)" },
+ },
+ indexes = {
+ { name = "idx_sky_phone_music_youtube_account", columns = "(`account_id`, `created_at`)" },
+ { name = "idx_sky_phone_music_youtube_device", columns = "(`device_imei`, `created_at`)" },
+ },
+ foreignKeys = {
+ { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
+ { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
+ },
+ tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+ },
+ {
+ name = "sky_phone_music_playlists",
+ columns = {
+ { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "account_id", type = "BIGINT UNSIGNED NULL" },
+ { name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "name", type = "VARCHAR(80) NOT NULL" },
+ { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
+ { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
+ },
+ primaryKey = "id",
+ indexes = {
+ { name = "idx_sky_phone_music_playlists_account", columns = "(`account_id`, `updated_at`)" },
+ { name = "idx_sky_phone_music_playlists_device", columns = "(`device_imei`, `updated_at`)" },
+ },
+ foreignKeys = {
+ { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
+ { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
+ },
+ tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+ },
+ {
+ name = "sky_phone_music_playlist_items",
+ columns = {
+ { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
+ { name = "playlist_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "source", type = "ENUM('server', 'youtube') NOT NULL" },
+ { name = "song_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
+ { name = "position", type = "SMALLINT UNSIGNED NOT NULL" },
+ { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
+ },
+ primaryKey = "id",
+ uniqueKeys = {
+ { name = "uniq_sky_phone_music_playlist_song", columns = "(`playlist_id`, `source`, `song_id`)" },
+ },
+ indexes = {
+ { name = "idx_sky_phone_music_playlist_order", columns = "(`playlist_id`, `position`, `id`)" },
+ },
+ foreignKeys = {
+ { column = "playlist_id", references = "`sky_phone_music_playlists` (`id`) ON DELETE CASCADE" },
+ },
+ tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+ },
{
name = "sky_phone_radio_profiles",
columns = {
diff --git a/sky_phone/source/server/music.lua b/sky_phone/source/server/music.lua
new file mode 100644
index 0000000..63eba59
--- /dev/null
+++ b/sky_phone/source/server/music.lua
@@ -0,0 +1,787 @@
+Bridge.Database.AfterMigration("sky_phone", function()
+local server_tracks = {}
+local server_tracks_by_id = {}
+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
+end
+
+local function text_length(value)
+ return type(value) == "string" and utf8.len(value) or nil
+end
+
+local function truncate_text(value, maximum)
+ local length = text_length(value)
+ if not length or length <= maximum then
+ return value
+ end
+ local boundary = utf8.offset(value, maximum + 1)
+ return boundary and value:sub(1, boundary - 1) or value
+end
+
+local function optional_text(value)
+ local normalized = trim(value)
+ return normalized ~= "" and normalized or nil
+end
+
+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 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 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)
+ 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)
+ if type(result) == "number" then
+ return result
+ end
+ return type(result) == "table" and tonumber(result.affectedRows) or 0
+end
+
+local playlist_add_locks = {}
+
+local function with_playlist_add_lock(playlist_id, callback)
+ local previous_lock = playlist_add_locks[playlist_id]
+ local current_lock = promise.new()
+ playlist_add_locks[playlist_id] = current_lock
+
+ if previous_lock then
+ Citizen.Await(previous_lock)
+ end
+
+ local success, result = xpcall(callback, debug.traceback)
+ current_lock:resolve(true)
+ if playlist_add_locks[playlist_id] == current_lock then
+ playlist_add_locks[playlist_id] = nil
+ end
+ if not success then
+ error(result, 0)
+ end
+ return result
+end
+
+local function uuid()
+ local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
+ local id = rows[1] and rows[1].id
+ if type(id) ~= "string" then
+ error("[sky_phone] Database did not generate a music id.")
+ end
+ return id
+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 = 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)
+
+ if not id
+ or not id:match("^[A-Za-z0-9_-]+$")
+ or #id > 48
+ or not title_length
+ or title_length < 1
+ or title_length > 160
+ or not artist_length
+ or artist_length < 1
+ or artist_length > 120
+ then
+ Bridge.Debug(
+ "error",
+ "[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
+ 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
+
+normalize_server_tracks()
+
+local function session_owner(source)
+ local session, error_response = SkyPhone.RequireSession(source)
+ if not session then
+ return nil, nil, error_response
+ end
+ local account = SkyPhone.RequireAccount(source)
+ return account and account.id or nil, session.imei
+end
+
+local function owner_condition(account_id, imei, alias)
+ local prefix = alias and ("`" .. alias .. "`.") or ""
+ if account_id then
+ return prefix .. "`account_id` = ?", { account_id }
+ end
+ return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { imei }
+end
+
+local function append_params(target, values)
+ for _, value in ipairs(values) do
+ target[#target + 1] = value
+ end
+end
+
+local function youtube_track_dto(row)
+ local video_id = row.video_id
+ return {
+ id = row.id,
+ source = "youtube",
+ videoId = video_id,
+ title = row.title,
+ artist = row.artist,
+ artwork = "https://i.ytimg.com/vi/" .. video_id .. "/hqdefault.jpg",
+ createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
+ }
+end
+
+local function list_youtube_tracks(account_id, imei)
+ local condition, params = owner_condition(account_id, imei)
+ local rows = Bridge.Database.Query(([[
+ SELECT `id`, `video_id`, `title`, `artist`,
+ UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
+ FROM `sky_phone_music_youtube_songs`
+ WHERE %s
+ ORDER BY `created_at` DESC, `id`
+ ]]):format(condition), params)
+ local tracks = {}
+ for index, row in ipairs(rows) do
+ tracks[index] = youtube_track_dto(row)
+ end
+ return tracks
+end
+
+local function list_playlists(account_id, imei)
+ local condition, params = owner_condition(account_id, imei)
+ local rows = Bridge.Database.Query(([[
+ SELECT `id`, `name`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
+ FROM `sky_phone_music_playlists`
+ WHERE %s
+ ORDER BY `updated_at` DESC, `created_at` DESC
+ ]]):format(condition), params)
+ local playlists = {}
+ local playlists_by_id = {}
+ for index, row in ipairs(rows) do
+ local playlist = {
+ id = row.id,
+ name = row.name,
+ entries = {},
+ createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
+ }
+ playlists[index] = playlist
+ playlists_by_id[row.id] = playlist
+ end
+
+ if #rows == 0 then
+ return playlists
+ end
+
+ local item_condition, item_params = owner_condition(account_id, imei, "p")
+ local items = Bridge.Database.Query(([[
+ SELECT `i`.`playlist_id`, `i`.`source`, `i`.`song_id`
+ FROM `sky_phone_music_playlist_items` AS `i`
+ INNER JOIN `sky_phone_music_playlists` AS `p` ON `p`.`id` = `i`.`playlist_id`
+ WHERE %s
+ ORDER BY `i`.`position`, `i`.`id`
+ ]]):format(item_condition), item_params)
+ for _, item in ipairs(items) do
+ local playlist = playlists_by_id[item.playlist_id]
+ if playlist then
+ playlist.entries[#playlist.entries + 1] = {
+ source = item.source,
+ songId = item.song_id,
+ }
+ end
+ end
+ return playlists
+end
+
+local function bootstrap(account_id, imei)
+ return {
+ serverTracks = server_tracks,
+ youtubeTracks = list_youtube_tracks(account_id, imei),
+ playlists = list_playlists(account_id, imei),
+ }
+end
+
+local function parse_youtube_id(value)
+ if type(value) ~= "string" or #value > 500 then
+ return nil
+ end
+ local host, path = value:match("^https?://([^/]+)(/.*)$")
+ if not host or not path then
+ return nil
+ end
+ host = host:lower():gsub(":443$", "")
+ local id
+ if host == "youtu.be" or host == "www.youtu.be" then
+ id = path:match("^/([%w_-]+)")
+ elseif host == "youtube.com"
+ or host == "www.youtube.com"
+ or host == "m.youtube.com"
+ or host == "music.youtube.com"
+ or host == "youtube-nocookie.com"
+ or host == "www.youtube-nocookie.com"
+ then
+ id = path:match("[?&]v=([%w_-]+)")
+ or path:match("^/shorts/([%w_-]+)")
+ or path:match("^/embed/([%w_-]+)")
+ or path:match("^/live/([%w_-]+)")
+ end
+ return id and #id == 11 and id or nil
+end
+
+local function fetch_youtube_metadata(video_id)
+ local request = promise.new()
+ local settled = false
+ local function resolve(value)
+ if settled then
+ return
+ end
+ settled = true
+ request:resolve(value)
+ end
+
+ PerformHttpRequest(
+ "https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" .. video_id,
+ function(status, body)
+ if status ~= 200 or type(body) ~= "string" then
+ resolve(nil)
+ return
+ end
+ local success, decoded = pcall(json.decode, body)
+ if not success or type(decoded) ~= "table" then
+ resolve(nil)
+ return
+ end
+ local title = trim(decoded.title)
+ local artist = trim(decoded.author_name)
+ if not text_length(title) or not text_length(artist) then
+ resolve(nil)
+ return
+ end
+ resolve({
+ title = truncate_text(title, 160),
+ artist = truncate_text(artist, 120),
+ })
+ end,
+ "GET",
+ "",
+ { ["Accept"] = "application/json" }
+ )
+
+ SetTimeout(Config.Music.MetadataTimeoutMs, function()
+ resolve(nil)
+ end)
+ return Citizen.Await(request)
+end
+
+local function owned_playlist(account_id, imei, playlist_id)
+ local condition, owner_params = owner_condition(account_id, imei)
+ local params = { playlist_id }
+ append_params(params, owner_params)
+ local rows = Bridge.Database.Query(([[
+ SELECT `id` FROM `sky_phone_music_playlists`
+ WHERE `id` = ? AND %s LIMIT 1
+ ]]):format(condition), params)
+ return rows[1] ~= nil
+end
+
+local function valid_personal_song(account_id, imei, song_id)
+ local condition, owner_params = owner_condition(account_id, imei)
+ local params = { song_id }
+ append_params(params, owner_params)
+ local rows = Bridge.Database.Query(([[
+ SELECT `id` FROM `sky_phone_music_youtube_songs`
+ WHERE `id` = ? AND %s LIMIT 1
+ ]]):format(condition), params)
+ return rows[1] ~= nil
+end
+
+Bridge.Callbacks.Register("sky_phone:music:bootstrap", function(source)
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local payload = type(data) == "table" and data or {}
+ local video_id = parse_youtube_id(payload.url)
+ if not video_id then
+ return { success = false, error = "invalid_youtube_url" }
+ end
+ local custom_title = optional_text(payload.title)
+ local custom_artist = optional_text(payload.artist)
+ local custom_title_length = text_length(custom_title)
+ local custom_artist_length = text_length(custom_artist)
+ if (custom_title and (not custom_title_length or custom_title_length > 160))
+ or (custom_artist and (not custom_artist_length or custom_artist_length > 120))
+ then
+ return { success = false, error = "invalid_song_metadata" }
+ end
+
+ local condition, owner_params = owner_condition(account_id, imei)
+ local duplicate_params = { video_id }
+ append_params(duplicate_params, owner_params)
+ local duplicate = Bridge.Database.Query(([[
+ SELECT `id` FROM `sky_phone_music_youtube_songs`
+ WHERE `video_id` = ? AND %s LIMIT 1
+ ]]):format(condition), duplicate_params)
+ if duplicate[1] then
+ return { success = false, error = "song_exists" }
+ end
+ local count_rows = Bridge.Database.Query(([[
+ SELECT COUNT(*) AS `count` FROM `sky_phone_music_youtube_songs` WHERE %s
+ ]]):format(condition), owner_params)
+ if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.Music.MaximumPersonalSongs then
+ return { success = false, error = "song_limit" }
+ end
+
+ local metadata = (not custom_title or not custom_artist)
+ and fetch_youtube_metadata(video_id)
+ or nil
+ local title = custom_title or metadata and metadata.title or "YouTube " .. video_id
+ local artist = custom_artist or metadata and metadata.artist or "YouTube"
+ local id = uuid()
+ if account_id then
+ Bridge.Database.Query([[
+ INSERT INTO `sky_phone_music_youtube_songs`
+ (`id`, `account_id`, `device_imei`, `video_id`, `title`, `artist`)
+ VALUES (?, ?, NULL, ?, ?, ?)
+ ]], { id, account_id, video_id, title, artist })
+ else
+ Bridge.Database.Query([[
+ INSERT INTO `sky_phone_music_youtube_songs`
+ (`id`, `account_id`, `device_imei`, `video_id`, `title`, `artist`)
+ VALUES (?, NULL, ?, ?, ?, ?)
+ ]], { id, imei, video_id, title, artist })
+ end
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:remove-youtube", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local song_id = type(data) == "table" and data.id or nil
+ if type(song_id) ~= "string" or not valid_personal_song(account_id, imei, song_id) then
+ return { success = false, error = "song_not_found" }
+ end
+
+ local condition, owner_params = owner_condition(account_id, imei, "p")
+ local item_params = { song_id }
+ append_params(item_params, owner_params)
+ Bridge.Database.Query(([[
+ DELETE `i` FROM `sky_phone_music_playlist_items` AS `i`
+ INNER JOIN `sky_phone_music_playlists` AS `p` ON `p`.`id` = `i`.`playlist_id`
+ WHERE `i`.`source` = 'youtube' AND `i`.`song_id` = ? AND %s
+ ]]):format(condition), item_params)
+
+ local song_condition, song_owner_params = owner_condition(account_id, imei)
+ local delete_params = { song_id }
+ append_params(delete_params, song_owner_params)
+ Bridge.Database.Query(([[
+ DELETE FROM `sky_phone_music_youtube_songs` WHERE `id` = ? AND %s
+ ]]):format(song_condition), delete_params)
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:create-playlist", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local name = trim(type(data) == "table" and data.name or nil)
+ local name_length = text_length(name)
+ if not name_length or name_length < 1 or name_length > Config.Music.PlaylistNameMaxLength then
+ return { success = false, error = "invalid_playlist" }
+ end
+
+ local condition, owner_params = owner_condition(account_id, imei)
+ local count_rows = Bridge.Database.Query(([[
+ SELECT COUNT(*) AS `count` FROM `sky_phone_music_playlists` WHERE %s
+ ]]):format(condition), owner_params)
+ if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.Music.MaximumPlaylists then
+ return { success = false, error = "playlist_limit" }
+ end
+
+ local id = uuid()
+ if account_id then
+ Bridge.Database.Query([[
+ INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `device_imei`, `name`)
+ VALUES (?, ?, NULL, ?)
+ ]], { id, account_id, name })
+ else
+ Bridge.Database.Query([[
+ INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `device_imei`, `name`)
+ VALUES (?, NULL, ?, ?)
+ ]], { id, imei, name })
+ end
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:rename-playlist", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local playlist_id = type(data) == "table" and data.id or nil
+ local name = trim(type(data) == "table" and data.name or nil)
+ local name_length = text_length(name)
+ if type(playlist_id) ~= "string"
+ or not name_length
+ or name_length < 1
+ or name_length > Config.Music.PlaylistNameMaxLength
+ then
+ return { success = false, error = "invalid_playlist" }
+ end
+
+ if not owned_playlist(account_id, imei, playlist_id) then
+ return { success = false, error = "playlist_not_found" }
+ end
+ local condition, owner_params = owner_condition(account_id, imei)
+ local params = { name, playlist_id }
+ append_params(params, owner_params)
+ Bridge.Database.Query(([[
+ UPDATE `sky_phone_music_playlists` SET `name` = ?
+ WHERE `id` = ? AND %s
+ ]]):format(condition), params)
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:delete-playlist", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local playlist_id = type(data) == "table" and data.id or nil
+ if type(playlist_id) ~= "string" then
+ return { success = false, error = "invalid_playlist" }
+ end
+ local condition, owner_params = owner_condition(account_id, imei)
+ local params = { playlist_id }
+ append_params(params, owner_params)
+ local result = Bridge.Database.Query(([[
+ DELETE FROM `sky_phone_music_playlists` WHERE `id` = ? AND %s
+ ]]):format(condition), params)
+ if affected_rows(result) ~= 1 then
+ return { success = false, error = "playlist_not_found" }
+ end
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:add-to-playlist", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local playlist_id = type(data) == "table" and data.playlistId or nil
+ local source_type = type(data) == "table" and data.source or nil
+ local song_id = type(data) == "table" and data.songId or nil
+ if type(playlist_id) ~= "string"
+ or type(song_id) ~= "string"
+ or not owned_playlist(account_id, imei, playlist_id)
+ or (source_type ~= "server" and source_type ~= "youtube")
+ or (source_type == "server" and not server_tracks_by_id[song_id])
+ or (source_type == "youtube" and not valid_personal_song(account_id, imei, song_id))
+ then
+ return { success = false, error = "invalid_song" }
+ end
+
+ local mutation_error = with_playlist_add_lock(playlist_id, function()
+ local counts = Bridge.Database.Query([[
+ SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`,
+ COALESCE(MAX(`source` = ? AND `song_id` = ?), 0) AS `already_exists`
+ FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
+ ]], { source_type, song_id, playlist_id })
+ if tonumber(counts[1] and counts[1].already_exists) == 1 then
+ return "song_already_in_playlist"
+ end
+ if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
+ return "playlist_song_limit"
+ end
+ local insert_result = Bridge.Database.Query([[
+ INSERT IGNORE INTO `sky_phone_music_playlist_items`
+ (`playlist_id`, `source`, `song_id`, `position`)
+ VALUES (?, ?, ?, ?)
+ ]], {
+ playlist_id,
+ source_type,
+ song_id,
+ (tonumber(counts[1] and counts[1].last_position) or 0) + 1,
+ })
+ if affected_rows(insert_result) ~= 1 then
+ return "song_already_in_playlist"
+ end
+ Bridge.Database.Query(
+ "UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
+ { playlist_id }
+ )
+ return nil
+ end)
+ if mutation_error then
+ return { success = false, error = mutation_error }
+ end
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+
+Bridge.Callbacks.Register("sky_phone:music:remove-from-playlist", function(source, data)
+ if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
+ return { success = false, error = "rate_limited" }
+ end
+ local account_id, imei, error_response = session_owner(source)
+ if not imei then
+ return error_response
+ end
+ local playlist_id = type(data) == "table" and data.playlistId or nil
+ local source_type = type(data) == "table" and data.source or nil
+ local song_id = type(data) == "table" and data.songId or nil
+ if type(playlist_id) ~= "string"
+ or type(song_id) ~= "string"
+ or (source_type ~= "server" and source_type ~= "youtube")
+ or not owned_playlist(account_id, imei, playlist_id)
+ then
+ return { success = false, error = "invalid_song" }
+ end
+ Bridge.Database.Query([[
+ DELETE FROM `sky_phone_music_playlist_items`
+ WHERE `playlist_id` = ? AND `source` = ? AND `song_id` = ?
+ ]], { playlist_id, source_type, song_id })
+ return { success = true, data = bootstrap(account_id, imei) }
+end)
+end)
diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua
index 9b7bb9a..72fb684 100644
--- a/sky_phone/source/server/phone.lua
+++ b/sky_phone/source/server/phone.lua
@@ -470,6 +470,46 @@ local function link_account(source, account)
]],
params = { account.id, session.imei },
},
+ {
+ query = [[
+ UPDATE `sky_phone_music_playlist_items` AS `item`
+ INNER JOIN `sky_phone_music_playlists` AS `playlist`
+ ON `playlist`.`id` = `item`.`playlist_id`
+ INNER JOIN `sky_phone_music_youtube_songs` AS `local_song`
+ ON `item`.`source` = 'youtube' AND `local_song`.`id` = `item`.`song_id`
+ INNER JOIN `sky_phone_music_youtube_songs` AS `cloud_song`
+ ON `cloud_song`.`account_id` = ? AND `cloud_song`.`video_id` = `local_song`.`video_id`
+ SET `item`.`song_id` = `cloud_song`.`id`
+ WHERE `playlist`.`device_imei` = ? AND `playlist`.`account_id` IS NULL
+ AND `local_song`.`device_imei` = ? AND `local_song`.`account_id` IS NULL
+ ]],
+ params = { account.id, session.imei, session.imei },
+ },
+ {
+ query = [[
+ DELETE `local_song` FROM `sky_phone_music_youtube_songs` AS `local_song`
+ INNER JOIN `sky_phone_music_youtube_songs` AS `cloud_song`
+ ON `cloud_song`.`account_id` = ? AND `cloud_song`.`video_id` = `local_song`.`video_id`
+ WHERE `local_song`.`device_imei` = ? AND `local_song`.`account_id` IS NULL
+ ]],
+ params = { account.id, session.imei },
+ },
+ {
+ query = [[
+ UPDATE `sky_phone_music_youtube_songs`
+ SET `account_id` = ?, `device_imei` = NULL
+ WHERE `device_imei` = ? AND `account_id` IS NULL
+ ]],
+ params = { account.id, session.imei },
+ },
+ {
+ query = [[
+ UPDATE `sky_phone_music_playlists`
+ SET `account_id` = ?, `device_imei` = NULL
+ WHERE `device_imei` = ? AND `account_id` IS NULL
+ ]],
+ params = { account.id, session.imei },
+ },
}) then
return { success = false, error = "request_failed" }
end
@@ -1001,6 +1041,14 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
+ {
+ query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
+ params = { session.imei },
+ },
+ {
+ query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
+ params = { session.imei },
+ },
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql
index a146dc2..dc53b92 100644
--- a/sky_phone/sql/install.sql
+++ b/sky_phone/sql/install.sql
@@ -241,6 +241,50 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` (
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE IF NOT EXISTS `sky_phone_music_youtube_songs` (
+ `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `account_id` BIGINT UNSIGNED NULL,
+ `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
+ `video_id` CHAR(11) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `title` VARCHAR(160) NOT NULL,
+ `artist` VARCHAR(120) NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_sky_phone_music_youtube_account` (`account_id`, `video_id`),
+ UNIQUE KEY `uniq_sky_phone_music_youtube_device` (`device_imei`, `video_id`),
+ KEY `idx_sky_phone_music_youtube_account` (`account_id`, `created_at`),
+ KEY `idx_sky_phone_music_youtube_device` (`device_imei`, `created_at`),
+ FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
+ FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `sky_phone_music_playlists` (
+ `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `account_id` BIGINT UNSIGNED NULL,
+ `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
+ `name` VARCHAR(80) NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_sky_phone_music_playlists_account` (`account_id`, `updated_at`),
+ KEY `idx_sky_phone_music_playlists_device` (`device_imei`, `updated_at`),
+ FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
+ FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `sky_phone_music_playlist_items` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `playlist_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `source` ENUM('server', 'youtube') NOT NULL,
+ `song_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `position` SMALLINT UNSIGNED NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_sky_phone_music_playlist_song` (`playlist_id`, `source`, `song_id`),
+ KEY `idx_sky_phone_music_playlist_order` (`playlist_id`, `position`, `id`),
+ FOREIGN KEY (`playlist_id`) REFERENCES `sky_phone_music_playlists` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE IF NOT EXISTS `sky_phone_radio_profiles` (
`identifier` VARCHAR(80) NOT NULL,
`history` LONGTEXT NOT NULL,