fix: refine music app UI and YouTube support

This commit is contained in:
Dominik
2026-08-09 19:30:20 +02:00
parent 8fc09d8a55
commit 49dc82499c
7 changed files with 461 additions and 148 deletions
+74 -19
View File
@@ -50,6 +50,7 @@ declare global {
const audio = new Audio()
audio.preload = 'metadata'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
@@ -118,27 +119,75 @@ 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
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>(
let script = 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)
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()
@@ -170,15 +219,18 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
controls: 0,
disablekb: 1,
fs: 0,
origin: window.location.origin,
playsinline: 1,
rel: 0,
},
events: {
onError: () => {
const activeStore = useMusicStore()
if (activeStore.currentTrack?.source !== 'youtube') return
activeStore.isPlaying = false
activeStore.playbackError = 'playback_failed'
if (activeStore.currentTrack?.source === 'youtube') {
activeStore.isPlaying = false
activeStore.playbackError = 'playback_failed'
}
resetYoutubePlayer()
reject(new Error('YouTube player rejected the track.'))
},
onReady: (event) => {
@@ -274,10 +326,13 @@ export const useMusicStore = defineStore('music', {
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): Promise<boolean> {
return this.request('music:add-youtube', { url })
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 })
+6 -1
View File
@@ -1854,9 +1854,13 @@ const defaultLocales: LocaleTree = {
addMusic: 'Add music',
addYouTube: 'Add from YouTube',
youtubeBody:
'Paste a public YouTube link. The title and artist are added automatically.',
'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.',
@@ -1897,6 +1901,7 @@ const defaultLocales: LocaleTree = {
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.',
+313 -113
View File
@@ -15,7 +15,6 @@ import {
kNavbar,
kNavbarBackLink,
kPage,
kPopover,
kPreloader,
kRange,
kSearchbar,
@@ -43,7 +42,8 @@ import {
Volume2,
X,
} from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useMusicStore } from '@/stores/music'
import { usePhoneStore } from '@/stores/phone'
@@ -52,23 +52,33 @@ import type { MusicPlaylist, MusicTrack } from '@/types/music'
type MusicTab = 'library' | 'playlists' | 'search'
type MusicSheet = 'playlist' | 'playlist-picker' | 'rename' | 'youtube'
const MUSIC_POPOVER_WIDTH = 240
const MUSIC_POPOVER_ITEM_HEIGHT = 52
const MUSIC_POPOVER_INSET = 8
const MUSIC_POPOVER_GAP = 7
const music = useMusicStore()
const phone = usePhoneStore()
const activeTab = ref<MusicTab>('library')
const activePlaylist = ref<MusicPlaylist | null>(null)
const addMenuOpened = ref(false)
const addMenuTarget = ref<HTMLElement | null>(null)
const actionMenuOpened = ref(false)
const actionMenuTarget = ref<HTMLElement | null>(null)
const actionTrack = ref<MusicTrack | null>(null)
const popoverStyle = ref<CSSProperties>({
top: `${MUSIC_POPOVER_INSET}px`,
left: `${MUSIC_POPOVER_INSET}px`,
})
const activeSheet = ref<MusicSheet | null>(null)
const playerOpened = ref(false)
const youtubeUrl = ref('')
const youtubeTitle = ref('')
const youtubeArtist = ref('')
const playlistName = ref('')
const searchQuery = ref('')
const toastText = ref('')
const confirmRemoveTrack = ref(false)
const confirmDeletePlaylist = ref(false)
const scrollEl = ref<HTMLElement | null>(null)
const allTracks = computed(() => music.allTracks)
const normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase())
@@ -113,17 +123,65 @@ function errorText(): string {
return phone.t(`Apps.music.errors.${music.error || 'default'}`)
}
function closeMenus(): void {
addMenuOpened.value = false
actionMenuOpened.value = false
}
function positionPopover(target: HTMLElement, itemCount: number): boolean {
const app = target.closest<HTMLElement>('.music-app')
if (!app) return false
const appRect = app.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const scale = app.offsetWidth ? appRect.width / app.offsetWidth : 1
const targetLeft = (targetRect.left - appRect.left) / scale
const targetTop = (targetRect.top - appRect.top) / scale
const targetWidth = targetRect.width / scale
const targetHeight = targetRect.height / scale
const popoverHeight = itemCount * MUSIC_POPOVER_ITEM_HEIGHT + 2
const desiredLeft = targetLeft + targetWidth - MUSIC_POPOVER_WIDTH
const belowTop = targetTop + targetHeight + MUSIC_POPOVER_GAP
const desiredTop =
belowTop + popoverHeight <= app.offsetHeight - MUSIC_POPOVER_INSET
? belowTop
: targetTop - popoverHeight - MUSIC_POPOVER_GAP
popoverStyle.value = {
left: `${Math.max(
MUSIC_POPOVER_INSET,
Math.min(
desiredLeft,
app.offsetWidth - MUSIC_POPOVER_WIDTH - MUSIC_POPOVER_INSET,
),
)}px`,
top: `${Math.max(
MUSIC_POPOVER_INSET,
Math.min(
desiredTop,
app.offsetHeight - popoverHeight - MUSIC_POPOVER_INSET,
),
)}px`,
}
return true
}
function openAddMenu(event: MouseEvent): void {
addMenuTarget.value = event.currentTarget as HTMLElement
const target = event.currentTarget as HTMLElement
if (!positionPopover(target, 2)) return
actionMenuOpened.value = false
addMenuOpened.value = true
}
function openSheet(sheet: MusicSheet): void {
addMenuOpened.value = false
actionMenuOpened.value = false
closeMenus()
activeSheet.value = sheet
music.error = ''
if (sheet === 'youtube') youtubeUrl.value = ''
if (sheet === 'youtube') {
youtubeUrl.value = ''
youtubeTitle.value = ''
youtubeArtist.value = ''
}
if (sheet === 'playlist') playlistName.value = ''
if (sheet === 'rename') playlistName.value = activePlaylist.value?.name ?? ''
}
@@ -135,29 +193,46 @@ function closeSheet(): void {
}
function requestDeletePlaylist(): void {
addMenuOpened.value = false
closeMenus()
confirmDeletePlaylist.value = true
}
function requestRemoveTrack(): void {
actionMenuOpened.value = false
closeMenus()
confirmRemoveTrack.value = true
}
function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
event.stopPropagation()
actionTrack.value = track
actionMenuTarget.value = event.currentTarget as HTMLElement
const itemCount =
1 + (activePlaylist.value ? 1 : 0) + (track.source === 'youtube' ? 1 : 0)
if (!positionPopover(event.currentTarget as HTMLElement, itemCount)) return
addMenuOpened.value = false
actionMenuOpened.value = true
}
function openPlaylist(playlist: MusicPlaylist): void {
activePlaylist.value = playlist
activeTab.value = 'playlists'
scrollToTop()
}
function closePlaylist(): void {
activePlaylist.value = null
scrollToTop()
}
function scrollToTop(): void {
void nextTick(() => {
if (scrollEl.value) scrollEl.value.scrollTop = 0
})
}
function selectTab(tab: MusicTab): void {
activeTab.value = tab
activePlaylist.value = null
scrollToTop()
}
async function playTrack(
@@ -174,7 +249,13 @@ async function playFeatured(): Promise<void> {
async function submitYouTube(): Promise<void> {
if (!youtubeUrl.value.trim()) return
if (await music.addYouTube(youtubeUrl.value.trim())) {
if (
await music.addYouTube(
youtubeUrl.value.trim(),
youtubeTitle.value.trim(),
youtubeArtist.value.trim(),
)
) {
activeSheet.value = null
showToast('Apps.music.songAdded')
}
@@ -289,8 +370,18 @@ onMounted(() => {
</script>
<template>
<k-page component="main" class="music-app">
<k-page
component="main"
class="music-app"
:class="{
'music-app--playlist': activePlaylist,
'music-app--playing': music.currentTrack,
}"
>
<k-navbar
component="nav"
class="music-navbar"
title-class="music-navbar-title"
:title="activePlaylist ? activePlaylist.name : phone.t('Apps.music.name')"
>
<template v-if="activePlaylist" #left>
@@ -321,10 +412,7 @@ onMounted(() => {
</template>
</k-navbar>
<div
class="music-scroll"
:class="{ 'music-scroll--playing': music.currentTrack }"
>
<div ref="scrollEl" class="music-scroll">
<div
v-if="music.isLoading && !allTracks.length && !music.playlists.length"
class="music-loading"
@@ -446,7 +534,7 @@ onMounted(() => {
</k-glass>
<template v-if="recentTracks.length">
<k-block-title>{{
<k-block-title class="music-section-title">{{
phone.t('Apps.music.recentlyAdded')
}}</k-block-title>
<section class="music-album-grid">
@@ -470,7 +558,9 @@ onMounted(() => {
</button>
</section>
<k-block-title>{{ phone.t('Apps.music.songs') }}</k-block-title>
<k-block-title class="music-section-title">{{
phone.t('Apps.music.songs')
}}</k-block-title>
<k-list nested class="music-track-list">
<k-list-item
v-for="track in allTracks"
@@ -658,6 +748,9 @@ onMounted(() => {
<k-tabbar
v-if="!activePlaylist"
component="nav"
icons
labels
class="music-tabbar"
:aria-label="phone.t('Apps.music.navigation')"
>
@@ -666,7 +759,7 @@ onMounted(() => {
component="button"
:active="activeTab === 'library'"
:link-props="{ type: 'button' }"
@click="activeTab = 'library'"
@click="selectTab('library')"
>
<template #icon
><k-icon><Library class="w-7 h-7" /></k-icon
@@ -677,7 +770,7 @@ onMounted(() => {
component="button"
:active="activeTab === 'playlists'"
:link-props="{ type: 'button' }"
@click="activeTab = 'playlists'"
@click="selectTab('playlists')"
>
<template #icon
><k-icon><ListMusic class="w-7 h-7" /></k-icon
@@ -688,7 +781,7 @@ onMounted(() => {
component="button"
:active="activeTab === 'search'"
:link-props="{ type: 'button' }"
@click="activeTab = 'search'"
@click="selectTab('search')"
>
<template #icon
><k-icon><Search class="w-7 h-7" /></k-icon
@@ -698,86 +791,86 @@ onMounted(() => {
</k-toolbar-pane>
</k-tabbar>
<Teleport to="body">
<k-popover
:opened="addMenuOpened"
:target="addMenuTarget ?? undefined"
angle
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
@backdropclick="addMenuOpened = false"
>
<k-list nested>
<template v-if="activePlaylist">
<k-list-button link-component="button" @click="openSheet('rename')">
<ListMusic :size="18" />
{{ phone.t('Apps.music.renamePlaylist') }}
</k-list-button>
<k-list-button
link-component="button"
class="music-destructive"
@click="requestDeletePlaylist"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.deletePlaylist') }}
</k-list-button>
</template>
<template v-else>
<k-list-button
link-component="button"
@click="openSheet('youtube')"
>
<ExternalLink :size="18" /> {{ phone.t('Apps.music.addYouTube') }}
</k-list-button>
<k-list-button
link-component="button"
@click="openSheet('playlist')"
>
<ListMusic :size="18" /> {{ phone.t('Apps.music.newPlaylist') }}
</k-list-button>
</template>
</k-list>
</k-popover>
<button
v-if="addMenuOpened || actionMenuOpened"
type="button"
class="music-popover-dismiss"
aria-hidden="true"
tabindex="-1"
@click="closeMenus"
/>
<section
v-if="addMenuOpened"
class="music-popover"
:style="popoverStyle"
:class="{
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
role="group"
:aria-label="phone.t('Apps.music.addMusic')"
>
<k-list nested>
<template v-if="activePlaylist">
<k-list-button link-component="button" @click="openSheet('rename')">
<ListMusic :size="18" />
{{ phone.t('Apps.music.renamePlaylist') }}
</k-list-button>
<k-list-button
link-component="button"
class="music-destructive"
@click="requestDeletePlaylist"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.deletePlaylist') }}
</k-list-button>
</template>
<template v-else>
<k-list-button link-component="button" @click="openSheet('youtube')">
<ExternalLink :size="18" /> {{ phone.t('Apps.music.addYouTube') }}
</k-list-button>
<k-list-button link-component="button" @click="openSheet('playlist')">
<ListMusic :size="18" /> {{ phone.t('Apps.music.newPlaylist') }}
</k-list-button>
</template>
</k-list>
</section>
<k-popover
:opened="actionMenuOpened"
:target="actionMenuTarget ?? undefined"
angle
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
@backdropclick="actionMenuOpened = false"
>
<k-list nested>
<k-list-button
link-component="button"
@click="openSheet('playlist-picker')"
>
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
</k-list-button>
<k-list-button
v-if="activePlaylist"
link-component="button"
class="music-destructive"
@click="removeFromActivePlaylist"
>
<X :size="18" /> {{ phone.t('Apps.music.removeFromPlaylist') }}
</k-list-button>
<k-list-button
v-if="actionTrack?.source === 'youtube'"
link-component="button"
class="music-destructive"
@click="requestRemoveTrack"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.removeFromLibrary') }}
</k-list-button>
</k-list>
</k-popover>
</Teleport>
<section
v-if="actionMenuOpened"
class="music-popover"
:style="popoverStyle"
:class="{
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
role="group"
:aria-label="phone.t('Apps.music.songActions')"
>
<k-list nested>
<k-list-button
link-component="button"
@click="openSheet('playlist-picker')"
>
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
</k-list-button>
<k-list-button
v-if="activePlaylist"
link-component="button"
class="music-destructive"
@click="removeFromActivePlaylist"
>
<X :size="18" /> {{ phone.t('Apps.music.removeFromPlaylist') }}
</k-list-button>
<k-list-button
v-if="actionTrack?.source === 'youtube'"
link-component="button"
class="music-destructive"
@click="requestRemoveTrack"
>
<Trash2 :size="18" /> {{ phone.t('Apps.music.removeFromLibrary') }}
</k-list-button>
</k-list>
</section>
<k-sheet
:opened="Boolean(activeSheet)"
@@ -807,6 +900,24 @@ onMounted(() => {
@input="youtubeUrl = eventValue($event)"
@keydown.enter="submitYouTube"
/>
<k-list-input
:label="phone.t('Apps.music.youtubeTitle')"
input-id="music-youtube-title"
maxlength="160"
:placeholder="phone.t('Apps.music.youtubeTitlePlaceholder')"
:value="youtubeTitle"
@input="youtubeTitle = eventValue($event)"
@keydown.enter="submitYouTube"
/>
<k-list-input
:label="phone.t('Apps.music.youtubeArtist')"
input-id="music-youtube-artist"
maxlength="120"
:placeholder="phone.t('Apps.music.youtubeArtistPlaceholder')"
:value="youtubeArtist"
@input="youtubeArtist = eventValue($event)"
@keydown.enter="submitYouTube"
/>
</k-list>
<p v-if="music.error" class="music-form-error" role="alert">
{{ errorText() }}
@@ -1027,9 +1138,14 @@ onMounted(() => {
--music-label: #111114;
--music-muted: #74747c;
--music-line: rgb(18 18 23 / 9%);
--k-safe-area-top: 46px;
--k-safe-area-bottom: 25px;
position: relative;
display: flex !important;
flex-direction: column;
height: 100%;
overflow: hidden;
isolation: isolate;
background: var(--music-bg);
color: var(--music-label);
}
@@ -1042,17 +1158,38 @@ onMounted(() => {
--music-line: rgb(255 255 255 / 10%);
}
.music-navbar {
z-index: 30;
flex: 0 0 auto;
}
.music-navbar :deep(.music-navbar-title) {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
.music-scroll {
position: absolute;
inset: 47px 0 75px;
padding: 8px 0 50px;
position: relative;
flex: 1 1 auto;
min-height: 0;
padding: 8px 0 24px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: none;
}
.music-scroll--playing {
bottom: 136px;
.music-app--playing .music-scroll {
padding-bottom: 82px;
}
.music-app--playlist .music-scroll {
padding-bottom: 42px;
}
.music-app--playlist.music-app--playing .music-scroll {
padding-bottom: 100px;
}
.music-scroll::-webkit-scrollbar {
@@ -1116,6 +1253,13 @@ onMounted(() => {
font-size: 13px;
}
.music-section-title {
min-height: 24px;
margin: 28px 14px 12px !important;
padding: 0 !important;
line-height: 24px;
}
.music-featured {
margin: 0 14px 6px;
border-radius: 24px;
@@ -1149,10 +1293,12 @@ onMounted(() => {
.music-featured-copy h2 {
margin: 7px 0 2px;
display: -webkit-box;
overflow: hidden;
font-size: 24px;
line-height: 1.05;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.music-featured-copy p {
@@ -1313,7 +1459,7 @@ onMounted(() => {
}
.music-playlist-hero {
padding: 18px 26px 22px;
padding: 14px 26px 22px;
display: flex;
flex-direction: column;
align-items: center;
@@ -1321,15 +1467,20 @@ onMounted(() => {
}
.music-playlist-art {
width: 214px;
height: 214px;
width: min(214px, 72%);
aspect-ratio: 1;
grid-template-rows: repeat(2, minmax(0, 1fr));
border-radius: 22px;
}
.music-playlist-hero h1 {
max-width: 100%;
margin: 19px 0 2px;
overflow: hidden;
font-size: 26px;
letter-spacing: -0.7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-playlist-hero p {
@@ -1344,9 +1495,9 @@ onMounted(() => {
.music-mini-player {
position: absolute;
z-index: 14;
z-index: 32;
right: 8px;
bottom: 76px;
bottom: 111px;
left: 8px;
height: 58px;
padding: 6px 10px 6px 7px;
@@ -1358,6 +1509,10 @@ onMounted(() => {
overflow: hidden;
}
.music-app--playlist .music-mini-player {
bottom: 29px;
}
.music-mini-art {
width: 44px;
height: 44px;
@@ -1396,7 +1551,17 @@ onMounted(() => {
}
.music-tabbar {
z-index: 12;
z-index: 31;
flex: 0 0 auto;
}
.music-tabbar :deep(.k-toolbar-pane) {
width: 100% !important;
}
.music-tabbar :deep(.k-toolbar-pane > .k-link) {
min-width: 0 !important;
flex: 1 1 33.333%;
}
.music-form-sheet,
@@ -1461,6 +1626,41 @@ onMounted(() => {
margin-top: 12px;
}
.music-popover-dismiss {
position: absolute;
z-index: 199;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
}
.music-popover {
position: absolute !important;
z-index: 200 !important;
width: 240px !important;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 28px;
background: #303034 !important;
box-shadow: none !important;
translate: none !important;
transform: none !important;
transition: none !important;
}
.music-popover :deep(.k-list) {
z-index: auto;
background: transparent;
}
.music-popover.phone-app--light {
border-color: rgb(0 0 0 / 10%);
background: #f8f8fa !important;
}
.music-destructive {
color: #ff453a !important;
}
+36 -3
View File
@@ -102,6 +102,31 @@ function musicBootstrap() {
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
@@ -1556,7 +1581,7 @@ 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') {
@@ -1565,6 +1590,8 @@ app.post('/api/:endpoint', (request, response) => {
}
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]
@@ -1572,16 +1599,22 @@ app.post('/api/:endpoint', (request, response) => {
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: `YouTube ${videoId}`,
artist: 'YouTube',
title: customTitle || metadata?.title || `YouTube ${videoId}`,
artist: customArtist || metadata?.artist || 'YouTube',
artwork: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
createdAt: Date.now(),
})
+3 -2
View File
@@ -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',
},
})
+6 -3
View File
@@ -699,8 +699,10 @@ Locales["en"] = {
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",
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", playlistBody = "Give this playlist a name you will recognize.",
playlistName = "Playlist Name", playlistPlaceholder = "My Playlist", playlistCreated = "Playlist created.",
@@ -715,7 +717,8 @@ Locales["en"] = {
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.",
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.",
+23 -7
View File
@@ -19,6 +19,11 @@ local function truncate_text(value, maximum)
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 affected_rows(result)
if type(result) == "number" then
return result
@@ -299,10 +304,20 @@ Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data)
if not imei then
return error_response
end
local video_id = parse_youtube_id(type(data) == "table" and data.url or nil)
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 }
@@ -321,23 +336,24 @@ Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data)
return { success = false, error = "song_limit" }
end
local metadata = fetch_youtube_metadata(video_id) or {
title = "YouTube " .. video_id,
artist = "YouTube",
}
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, metadata.title, metadata.artist })
]], { 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, metadata.title, metadata.artist })
]], { id, imei, video_id, title, artist })
end
return { success = true, data = bootstrap(account_id, imei) }
end)