mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
MERGE - integrate Music app with Housing
Preserve the Housing app registry while integrating the Music app and its frontend, server, configuration, database, and locale changes from origin/dev.
This commit is contained in:
@@ -20,6 +20,47 @@ MusicTracks = {
|
||||
}
|
||||
```
|
||||
|
||||
## Music app
|
||||
|
||||
The standalone `Music` app plays audio only inside the current player's NUI. It never creates a
|
||||
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 only their stable ID, title, and artist in
|
||||
`sky_phone/config/music.lua`:
|
||||
|
||||
```lua
|
||||
Tracks = {
|
||||
{
|
||||
Id = "night-drive",
|
||||
Title = "Night Drive",
|
||||
Artist = "Sky Records",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
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. 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
|
||||
that player's NUI. Personal songs and playlists are stored per linked iFruit account, or per phone
|
||||
IMEI while signed out, in the `sky_phone_music_*` tables. Limits and rate controls are configured in
|
||||
`config/music.lua`.
|
||||
|
||||
## FlipTok accounts
|
||||
|
||||
FlipTok profiles use their own username and password login. Registration requires a linked iFruit
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Music">
|
||||
<defs>
|
||||
<linearGradient id="music-gradient" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff5c78"/>
|
||||
<stop offset=".52" stop-color="#fa2d63"/>
|
||||
<stop offset="1" stop-color="#d5117b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#music-gradient)"/>
|
||||
<path fill="#fff" d="M91.5 24.5v57.3c0 11.8-8.8 20.7-20.4 20.7-9.3 0-16.7-5.7-16.7-13.6 0-8.2 7.8-14.5 17.9-14.5 4.1 0 7.4.9 9.8 2.2V45.4L50 52.1v38.1c0 11.8-8.8 20.7-20.4 20.7-9.3 0-16.7-5.7-16.7-13.6 0-8.2 7.8-14.5 17.9-14.5 4 0 7.4.9 9.8 2.2V36.8c0-3.2 2.2-5.9 5.3-6.5l38.2-8c3.8-.8 7.4 2.1 7.4 6.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 754 B |
@@ -7,6 +7,7 @@ import {
|
||||
Camera,
|
||||
Flashlight,
|
||||
Moon,
|
||||
Pause,
|
||||
Plane,
|
||||
Play,
|
||||
Signal,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useMusicStore } from '@/stores/music'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const props = defineProps<{ opened: boolean }>()
|
||||
@@ -41,6 +43,7 @@ type ConnectivityPreference =
|
||||
| 'wifiEnabled'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const music = useMusicStore()
|
||||
const router = useRouter()
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const brightness = ref(phone.preferences.settings.screenBrightness)
|
||||
@@ -315,27 +318,40 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="control-center__media-copy">
|
||||
<span>{{ phone.t('ControlCenter.media') }}</span>
|
||||
<strong>{{ phone.t('ControlCenter.notPlaying') }}</strong>
|
||||
<strong>{{
|
||||
music.currentTrack?.title ?? phone.t('ControlCenter.notPlaying')
|
||||
}}</strong>
|
||||
<small v-if="music.currentTrack">{{
|
||||
music.currentTrack.artist
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="control-center__media-controls">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
:disabled="!music.currentTrack"
|
||||
:aria-label="phone.t('ControlCenter.previous')"
|
||||
@click="music.previous"
|
||||
>
|
||||
<SkipBack aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
:aria-label="phone.t('ControlCenter.play')"
|
||||
:disabled="!music.currentTrack && !music.allTracks.length"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
music.isPlaying ? 'Common.pause' : 'ControlCenter.play',
|
||||
)
|
||||
"
|
||||
@click="music.toggle"
|
||||
>
|
||||
<Play aria-hidden="true" />
|
||||
<Pause v-if="music.isPlaying" aria-hidden="true" />
|
||||
<Play v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
:disabled="!music.currentTrack"
|
||||
:aria-label="phone.t('ControlCenter.next')"
|
||||
@click="music.next"
|
||||
>
|
||||
<SkipForward aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
this.paused = false
|
||||
this.dispatchEvent(new Event('play'))
|
||||
}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
if (name === 'src') this.src = ''
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.currentTime = 0
|
||||
this.duration = 0
|
||||
this.paused = true
|
||||
this.seekable = {
|
||||
end: () => 0,
|
||||
length: 0,
|
||||
start: () => 0,
|
||||
} as TimeRanges
|
||||
this.src = ''
|
||||
this.volume = 1
|
||||
this.load.mockClear()
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const createObjectUrlMock = vi.fn<(blob: Blob) => string>()
|
||||
const revokeObjectUrlMock = vi.fn<(url: string) => void>()
|
||||
let fakeAudio: FakeAudio
|
||||
let useMusicStore: (typeof import('@/stores/music'))['useMusicStore']
|
||||
|
||||
function track(id: string, extension: 'mp3' | 'ogg' = 'ogg'): MusicTrack {
|
||||
return {
|
||||
artist: 'Sky Records',
|
||||
artwork: null,
|
||||
id,
|
||||
source: 'server',
|
||||
title: id,
|
||||
url: `https://cfx-nui-sky_phone/config/music/${id}.${extension}`,
|
||||
}
|
||||
}
|
||||
|
||||
function audioResponse(bytes = [1, 2, 3]): Response {
|
||||
return {
|
||||
arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from(bytes).buffer),
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('Audio', FakeAudio)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
href: 'https://cfx-nui-sky_phone/source/html/index.html#/music',
|
||||
},
|
||||
})
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectUrlMock,
|
||||
})
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectUrlMock,
|
||||
})
|
||||
;({ useMusicStore } = await import('@/stores/music'))
|
||||
if (!FakeAudio.latest)
|
||||
throw new Error('Music audio test double was not used.')
|
||||
fakeAudio = FakeAudio.latest
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
fakeAudio.reset()
|
||||
fetchMock.mockReset()
|
||||
createObjectUrlMock.mockReset()
|
||||
createObjectUrlMock.mockReturnValue('blob:sky-music')
|
||||
revokeObjectUrlMock.mockReset()
|
||||
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<Response>((resolve) => {
|
||||
resolveFirstRequest = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(audioResponse([4, 5, 6]))
|
||||
const music = useMusicStore()
|
||||
const firstTrack = track('first')
|
||||
const secondTrack = track('second')
|
||||
|
||||
const firstPlay = music.play(firstTrack, [firstTrack, secondTrack])
|
||||
await music.play(secondTrack, [firstTrack, secondTrack])
|
||||
resolveFirstRequest?.(audioResponse())
|
||||
await firstPlay
|
||||
|
||||
expect(music.currentTrack?.id).toBe('second')
|
||||
expect(fakeAudio.src).toBe('blob:sky-music')
|
||||
expect(createObjectUrlMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, number | string>
|
||||
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<YouTubeApi> | null = null
|
||||
let youtubePlayer: YouTubePlayer | null = null
|
||||
let youtubeProgressTimer: number | null = null
|
||||
|
||||
export function musicTrackKey(
|
||||
track: Pick<MusicTrack, 'id' | 'source'>,
|
||||
): 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<boolean> {
|
||||
const resolvedUrl = new URL(source, window.location.href)
|
||||
const isCfxAsset =
|
||||
resolvedUrl.protocol === 'https:' &&
|
||||
resolvedUrl.hostname.startsWith('cfx-nui-')
|
||||
|
||||
if (!isCfxAsset) {
|
||||
audio.src = resolvedUrl.href
|
||||
audio.load()
|
||||
return true
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
audioLoadController = controller
|
||||
try {
|
||||
const response = await fetch(resolvedUrl.href, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Music asset request failed (${response.status}).`)
|
||||
}
|
||||
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted || audioLoadController !== controller)
|
||||
return false
|
||||
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([bytes], { type: serverAudioMimeType(resolvedUrl) }),
|
||||
)
|
||||
if (controller.signal.aborted || audioLoadController !== controller) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
return false
|
||||
}
|
||||
|
||||
audioObjectUrl = objectUrl
|
||||
audio.src = objectUrl
|
||||
audio.load()
|
||||
return true
|
||||
} finally {
|
||||
if (audioLoadController === controller) audioLoadController = null
|
||||
}
|
||||
}
|
||||
|
||||
function currentAudioDuration(): number {
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0)
|
||||
return audio.duration
|
||||
if (audio.seekable.length) {
|
||||
const end = audio.seekable.end(audio.seekable.length - 1)
|
||||
if (Number.isFinite(end) && end > 0) return end
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function syncAudioDuration(): void {
|
||||
const store = useMusicStore()
|
||||
if (store.currentTrack?.source === 'server') {
|
||||
store.duration = currentAudioDuration()
|
||||
}
|
||||
}
|
||||
|
||||
function stopYoutubeProgress(): void {
|
||||
if (youtubeProgressTimer !== null) {
|
||||
window.clearInterval(youtubeProgressTimer)
|
||||
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<YouTubeApi> {
|
||||
if (window.YT?.Player) return Promise.resolve(window.YT)
|
||||
if (youtubeApiPromise) return youtubeApiPromise
|
||||
youtubeApiPromise = new Promise<YouTubeApi>((resolve, reject) => {
|
||||
let settled = false
|
||||
const previousReady = window.onYouTubeIframeAPIReady
|
||||
let script = document.querySelector<HTMLScriptElement>(
|
||||
'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<void> {
|
||||
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<void>((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<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<MusicBootstrap>(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<boolean> {
|
||||
void loadYouTubeApi().catch((error) => {
|
||||
console.warn('[Music] YouTube API preload failed:', error)
|
||||
})
|
||||
return this.request('music:bootstrap', {})
|
||||
},
|
||||
async addYouTube(url: string, title = '', artist = ''): Promise<boolean> {
|
||||
return this.request('music:add-youtube', { artist, title, url })
|
||||
},
|
||||
async removeYouTube(id: string): Promise<boolean> {
|
||||
return this.request('music:remove-youtube', { id })
|
||||
},
|
||||
async createPlaylist(name: string): Promise<boolean> {
|
||||
return this.request('music:create-playlist', { name })
|
||||
},
|
||||
async renamePlaylist(id: string, name: string): Promise<boolean> {
|
||||
return this.request('music:rename-playlist', { id, name })
|
||||
},
|
||||
async deletePlaylist(id: string): Promise<boolean> {
|
||||
return this.request('music:delete-playlist', { id })
|
||||
},
|
||||
async addToPlaylist(
|
||||
playlistId: string,
|
||||
track: MusicTrack,
|
||||
): Promise<boolean> {
|
||||
return this.request('music:add-to-playlist', {
|
||||
playlistId,
|
||||
songId: track.id,
|
||||
source: track.source,
|
||||
})
|
||||
},
|
||||
async removeFromPlaylist(
|
||||
playlistId: string,
|
||||
track: MusicTrack,
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PhoneAppId =
|
||||
| 'map'
|
||||
| 'notes'
|
||||
| 'radio'
|
||||
| 'music'
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
| 'settings'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 <Id>.ogg or <Id>.mp3. Artwork is optional and must be named
|
||||
<Id>.webp, <Id>.png, <Id>.jpg or <Id>.jpeg. If multiple formats exist, OGG is
|
||||
preferred over MP3; artwork priority is WEBP, PNG, JPG, then JPEG. Do not place
|
||||
the same preferred extension for one Id in multiple folders.
|
||||
|
||||
Matching is case-insensitive, but keep Id spelling stable because saved
|
||||
playlists reference it. Folder names may contain spaces, but must not contain
|
||||
path separators or control characters. Do not give a folder an audio or artwork
|
||||
extension. Restart the sky_phone resource after changing tracks or files; a
|
||||
frontend build is not required.
|
||||
|
||||
Upgrading from the old path-based setup: rename each audio and artwork file to
|
||||
its existing Id, then remove File and Artwork from the track entry. Do not
|
||||
change the Id itself.
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user