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
@@ -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,
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',
+16
View File
@@ -26,6 +26,7 @@ import {
Tag,
MapPinHouse,
Flame,
Music2,
} from 'lucide-vue-next'
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 flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import musicIcon from '@/assets/img/app-icons/music.svg'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -65,6 +67,20 @@ import type {
} from '@/types/apps'
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',
component: markRaw(
+12 -13
View File
@@ -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()
},
}
}
+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.',
},
},
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: {
name: 'Notes',
note: 'Note',
+1
View File
@@ -15,6 +15,7 @@ export type PhoneAppId =
| 'map'
| 'notes'
| 'radio'
| 'music'
| 'photos'
| 'app-store'
| '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 },
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