feat: music app

This commit is contained in:
Dominik
2026-08-09 17:05:59 +02:00
parent 550982f847
commit 8fc09d8a55
22 changed files with 3119 additions and 19 deletions
+26
View File
@@ -20,6 +20,32 @@ 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 live in `frontend/public/music`. Define their stable ID, title, artist,
file, and optional artwork in `sky_phone/config/music.lua`, then run `build_frontend.bat`:
```lua
Tracks = {
{
Id = "night-drive",
Title = "Night Drive",
Artist = "Sky Records",
File = "music/night-drive.ogg",
Artwork = "music/night-drive.webp",
},
}
```
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 accounts
FlipTok profiles use their own username and password login. Registration requires a linked iFruit FlipTok profiles use their own username and password login. Registration requires a linked iFruit
+6
View File
@@ -0,0 +1,6 @@
Place server-owned .mp3 and .ogg files in this directory.
Define each published song in sky_phone/config/music.lua, then run
build_frontend.bat so the files are copied into the deployable NUI.
Optional artwork can be stored here as .png, .jpg, .jpeg or .webp.
@@ -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

+22 -6
View File
@@ -7,6 +7,7 @@ import {
Camera, Camera,
Flashlight, Flashlight,
Moon, Moon,
Pause,
Plane, Plane,
Play, Play,
Signal, Signal,
@@ -28,6 +29,7 @@ import {
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import { useMusicStore } from '@/stores/music'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
const props = defineProps<{ opened: boolean }>() const props = defineProps<{ opened: boolean }>()
@@ -41,6 +43,7 @@ type ConnectivityPreference =
| 'wifiEnabled' | 'wifiEnabled'
const phone = usePhoneStore() const phone = usePhoneStore()
const music = useMusicStore()
const router = useRouter() const router = useRouter()
const panel = ref<HTMLElement | null>(null) const panel = ref<HTMLElement | null>(null)
const brightness = ref(phone.preferences.settings.screenBrightness) const brightness = ref(phone.preferences.settings.screenBrightness)
@@ -315,27 +318,40 @@ onBeforeUnmount(() => {
> >
<div class="control-center__media-copy"> <div class="control-center__media-copy">
<span>{{ phone.t('ControlCenter.media') }}</span> <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>
<div class="control-center__media-controls"> <div class="control-center__media-controls">
<button <button
type="button" type="button"
disabled :disabled="!music.currentTrack"
:aria-label="phone.t('ControlCenter.previous')" :aria-label="phone.t('ControlCenter.previous')"
@click="music.previous"
> >
<SkipBack aria-hidden="true" /> <SkipBack aria-hidden="true" />
</button> </button>
<button <button
type="button" type="button"
disabled :disabled="!music.currentTrack && !music.allTracks.length"
:aria-label="phone.t('ControlCenter.play')" :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>
<button <button
type="button" type="button"
disabled :disabled="!music.currentTrack"
:aria-label="phone.t('ControlCenter.next')" :aria-label="phone.t('ControlCenter.next')"
@click="music.next"
> >
<SkipForward aria-hidden="true" /> <SkipForward aria-hidden="true" />
</button> </button>
@@ -250,6 +250,7 @@ function openWidget(): void {
clock: '/apps/clock', clock: '/apps/clock',
contacts: '/apps/phone', contacts: '/apps/phone',
date: '/apps/calendar', date: '/apps/calendar',
music: '/apps/music',
transactions: '/apps/banking', transactions: '/apps/banking',
wallet: '/apps/banking', wallet: '/apps/banking',
weather: '/apps/weather', weather: '/apps/weather',
+16
View File
@@ -26,6 +26,7 @@ import {
Tag, Tag,
MapPinHouse, MapPinHouse,
Flame, Flame,
Music2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue' import { defineAsyncComponent, markRaw } from 'vue'
@@ -58,6 +59,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.svg' import flareIcon from '@/assets/img/app-icons/flare.svg'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import musicIcon from '@/assets/img/app-icons/music.svg'
import type { import type {
LaunchablePhoneAppDefinition, LaunchablePhoneAppDefinition,
LaunchablePhoneAppId, LaunchablePhoneAppId,
@@ -65,6 +67,20 @@ import type {
} from '@/types/apps' } from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [ export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MusicApp.vue')),
),
dockOrder: null,
gridOrder: 25,
icon: markRaw(Music2),
iconClass: 'app-icon--music',
iconImage: musicIcon,
id: 'music',
labelKey: 'Apps.music.name',
route: '/apps/music',
},
{ {
category: 'social', category: 'social',
component: markRaw( component: markRaw(
+12 -13
View File
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useBankingStore } from '@/stores/banking' import { useBankingStore } from '@/stores/banking'
import { useCallsStore } from '@/stores/calls' import { useCallsStore } from '@/stores/calls'
import { useMusicStore } from '@/stores/music'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import { useWeatherStore } from '@/stores/weather' import { useWeatherStore } from '@/stores/weather'
@@ -9,14 +10,6 @@ const now = ref(new Date())
let clockConsumers = 0 let clockConsumers = 0
let clockInterval: number | undefined 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() { export function useClockService() {
const phone = usePhoneStore() const phone = usePhoneStore()
onMounted(() => { onMounted(() => {
@@ -80,15 +73,21 @@ export function useWeatherService() {
} }
export function useMusicService() { export function useMusicService() {
const music = useMusicStore()
return { return {
current: computed(() => tracks[trackIndex.value]), current: computed(
() =>
music.currentTrack ?? {
artist: 'Music',
title: 'Not Playing',
},
),
next(): void { next(): void {
trackIndex.value = (trackIndex.value + 1) % tracks.length void music.next()
playing.value = true
}, },
playing, playing: computed(() => music.isPlaying),
toggle(): void { toggle(): void {
playing.value = !playing.value void music.toggle()
}, },
} }
} }
+418
View File
@@ -0,0 +1,418 @@
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 = 'metadata'
let audioBound = false
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 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', () => {
const store = useMusicStore()
if (store.currentTrack?.source === 'server') {
store.duration = Number.isFinite(audio.duration) ? audio.duration : 0
}
})
audio.addEventListener('timeupdate', () => {
const store = useMusicStore()
if (store.currentTrack?.source === 'server') {
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) => {
const previousReady = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previousReady?.()
if (window.YT?.Player) resolve(window.YT)
else reject(new Error('YouTube player API did not initialize.'))
}
const existing = document.querySelector<HTMLScriptElement>(
'script[data-sky-phone-youtube-api]',
)
if (existing) return
const script = document.createElement('script')
script.dataset.skyPhoneYoutubeApi = 'true'
script.src = 'https://www.youtube.com/iframe_api'
script.async = true
script.onerror = () =>
reject(new Error('YouTube player API failed to load.'))
document.head.append(script)
})
return youtubeApiPromise
}
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,
playsinline: 1,
rel: 0,
},
events: {
onError: () => {
const activeStore = useMusicStore()
if (activeStore.currentTrack?.source !== 'youtube') return
activeStore.isPlaying = false
activeStore.playbackError = 'playback_failed'
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 {
audio.pause()
audio.removeAttribute('src')
audio.load()
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> {
return this.request('music:bootstrap', {})
},
async addYouTube(url: string): Promise<boolean> {
return this.request('music:add-youtube', { 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 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) {
audio.src = new URL(track.url, window.location.href).href
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) {
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 {
const next = Math.max(0, Math.min(this.duration || seconds, seconds))
this.currentTime = next
if (this.currentTrack?.source === 'server') audio.currentTime = next
else youtubePlayer?.seekTo(next, true)
},
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
},
},
})
+76
View File
@@ -1835,6 +1835,82 @@ const defaultLocales: LocaleTree = {
default: 'The mail request failed.', 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. The title and artist are added automatically.',
youtubeUrl: 'YouTube URL',
youtubePlaceholder: 'https://youtube.com/watch?v=...',
addToLibrary: 'Add to Library',
songAdded: 'Song added to your library.',
songRemoved: 'Song removed from your library.',
newPlaylist: 'New Playlist',
renamePlaylist: 'Rename Playlist',
playlistBody: 'Give this playlist a name you will recognize.',
playlistName: 'Playlist Name',
playlistPlaceholder: 'My Playlist',
playlistCreated: 'Playlist created.',
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.',
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.',
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.',
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: { notes: {
name: 'Notes', name: 'Notes',
note: 'Note', note: 'Note',
+1
View File
@@ -15,6 +15,7 @@ export type PhoneAppId =
| 'map' | 'map'
| 'notes' | 'notes'
| 'radio' | 'radio'
| 'music'
| 'photos' | 'photos'
| 'app-store' | 'app-store'
| 'settings' | 'settings'
+30
View File
@@ -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[]
}
+1
View File
@@ -93,6 +93,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
map: { enabled: true, sounds: true }, map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true },
radio: { enabled: true, sounds: true }, radio: { enabled: true, sounds: true },
music: { enabled: true, sounds: true },
photos: { enabled: true, sounds: true }, photos: { enabled: true, sounds: true },
settings: { enabled: true, sounds: true }, settings: { enabled: true, sounds: true },
} }
File diff suppressed because it is too large Load Diff
+169
View File
@@ -44,6 +44,64 @@ const radioData = {
settings: { autoRejoin: false, notifications: true }, settings: { autoRejoin: false, notifications: true },
volume: 50, 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,
}
}
let mockBankBalance = 24787 let mockBankBalance = 24787
let mockCashBalance = 2350 let mockCashBalance = 2350
let nextBankTransactionId = 7 let nextBankTransactionId = 7
@@ -1501,6 +1559,117 @@ function flareBootstrap() {
app.post('/api/:endpoint', (request, response) => { app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body) console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint 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 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 (musicYoutubeTracks.some((track) => track.videoId === videoId)) {
response.json({ success: false, error: 'song_exists' })
return
}
musicYoutubeTracks.unshift({
id: `music-youtube-${musicSequence++}`,
source: 'youtube',
videoId,
title: `YouTube ${videoId}`,
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,
)
) {
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') { if (endpoint === 'radio:get') {
response.json({ success: true, data: radioData }) response.json({ success: true, data: radioData })
return return
+30
View File
@@ -693,6 +693,36 @@ Locales["en"] = {
fog = "Reduced visibility in low-lying areas.", snow = "Cold conditions with snow across the region.", 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. The title and artist are added automatically.",
youtubeUrl = "YouTube URL", youtubePlaceholder = "https://youtube.com/watch?v=...", addToLibrary = "Add to Library",
songAdded = "Song added to your library.", songRemoved = "Song removed from your library.",
newPlaylist = "New Playlist", renamePlaylist = "Rename Playlist", playlistBody = "Give this playlist a name you will recognize.",
playlistName = "Playlist Name", playlistPlaceholder = "My Playlist", playlistCreated = "Playlist created.",
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.",
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.", 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.", 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 = { notes = {
name = "Notes", note = "Note", back = "Back", actions = "Note actions", name = "Notes", note = "Note", back = "Back", actions = "Note actions",
newNote = "New Note", searchPlaceholder = "Search Notes", newNote = "New Note", searchPlaceholder = "Search Notes",
+20
View File
@@ -0,0 +1,20 @@
Config.Music = {
-- Put MP3/OGG files in frontend/public/music and rebuild the frontend.
-- File and Artwork paths are relative to the published NUI root.
Tracks = {
-- {
-- Id = "night-drive",
-- Title = "Night Drive",
-- Artist = "Sky Records",
-- File = "music/night-drive.ogg",
-- Artwork = "music/night-drive.webp",
-- },
},
MaximumPersonalSongs = 100,
MaximumPlaylists = 50,
MaximumPlaylistSongs = 250,
PlaylistNameMaxLength = 80,
ActionsPerMinute = 30,
MetadataTimeoutMs = 4500,
}
+3
View File
@@ -36,6 +36,7 @@ server_scripts {
'@oxmysql/lib/MySQL.lua', '@oxmysql/lib/MySQL.lua',
'config/config.lua', 'config/config.lua',
'config/media.lua', 'config/media.lua',
'config/music.lua',
'config/locales/*.lua', 'config/locales/*.lua',
'source/bridge/server/database.lua', 'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua', 'source/bridge/server/migrations.lua',
@@ -62,6 +63,7 @@ server_scripts {
'source/server/picstagram.lua', 'source/server/picstagram.lua',
'source/server/map.lua', 'source/server/map.lua',
'source/server/calendar.lua', 'source/server/calendar.lua',
'source/server/music.lua',
'source/server/radio.lua', 'source/server/radio.lua',
} }
@@ -69,6 +71,7 @@ files {
'source/html/index.html', 'source/html/index.html',
'source/html/assets/**', 'source/html/assets/**',
'source/html/img/**', 'source/html/img/**',
'source/html/music/**',
} }
ui_page 'source/html/index.html' ui_page 'source/html/index.html'
+8
View File
@@ -116,6 +116,14 @@ local server_callbacks = {
"calendar:create", "calendar:create",
"calendar:update", "calendar:update",
"calendar:delete", "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:markers",
"map:create-marker", "map:create-marker",
"map:delete-marker", "map:delete-marker",
+69
View File
@@ -735,6 +735,75 @@ local schema = {
}, },
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", 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", name = "sky_phone_radio_profiles",
columns = { columns = {
+538
View File
@@ -0,0 +1,538 @@
Bridge.Database.AfterMigration("sky_phone", function()
local server_tracks = {}
local server_tracks_by_id = {}
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 affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
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()
for index, configured in ipairs(Config.Music.Tracks or {}) do
local id = trim(configured.Id)
local title = trim(configured.Title)
local artist = trim(configured.Artist)
local file = trim(configured.File)
local artwork = trim(configured.Artwork)
local valid_file = file
and file:match("^music/[%w%._%-%/]+%.[mM][pP]3$")
or file and file:match("^music/[%w%._%-%/]+%.[oO][gG][gG]$")
if not id
or not id:match("^[%w%-_]+$")
or #id > 48
or not title
or not text_length(title)
or text_length(title) > 160
or not artist
or not text_length(artist)
or text_length(artist) > 120
or not valid_file
or (artwork and not artwork:match("^music/[%w%._%-%/]+%.[pP][nN][gG]$")
and not artwork:match("^music/[%w%._%-%/]+%.[jJ][pP][eE]?[gG]$")
and not artwork:match("^music/[%w%._%-%/]+%.[wW][eE][bB][pP]$"))
or server_tracks_by_id[id]
then
Bridge.Debug(
"error",
"[sky_phone] Ignored invalid or duplicate Config.Music.Tracks entry at index %s.",
tostring(index),
{ always = true }
)
else
local track = {
id = id,
source = "server",
title = title,
artist = artist,
url = file,
artwork = artwork,
}
server_tracks[#server_tracks + 1] = track
server_tracks_by_id[id] = track
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 video_id = parse_youtube_id(type(data) == "table" and data.url or nil)
if not video_id then
return { success = false, error = "invalid_youtube_url" }
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 = fetch_youtube_metadata(video_id) or {
title = "YouTube " .. video_id,
artist = "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, metadata.title, metadata.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, metadata.title, metadata.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 counts = Bridge.Database.Query([[
SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`
FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
]], { playlist_id })
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
return { success = false, error = "playlist_song_limit" }
end
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,
})
Bridge.Database.Query(
"UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
{ playlist_id }
)
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)
+48
View File
@@ -468,6 +468,46 @@ local function link_account(source, account)
]], ]],
params = { account.id, session.imei }, 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 }) then
return { success = false, error = "request_failed" } return { success = false, error = "request_failed" }
end end
@@ -997,6 +1037,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", query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei }, 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", query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei }, params = { session.imei },
+44
View File
@@ -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 FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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` ( CREATE TABLE IF NOT EXISTS `sky_phone_radio_profiles` (
`identifier` VARCHAR(80) NOT NULL, `identifier` VARCHAR(80) NOT NULL,
`history` LONGTEXT NOT NULL, `history` LONGTEXT NOT NULL,