Files
sky_phone-sky-systems/frontend/src/views/apps/MusicApp.vue
T
2026-08-17 22:19:43 +02:00

2147 lines
57 KiB
Vue

<script setup lang="ts">
import {
SkyBlock,
SkyBlockTitle,
SkyButton,
SkyDialog,
SkyDialogButton,
SkyDropdown,
SkyGlass,
SkyLink,
SkyList,
SkyField,
SkyListItem,
SkyNavbar,
SkyAppPage,
SkyPillNavigation,
SkySegmented,
SkySegmentedButton,
SkySpinner,
SkyRange,
SkySearchbar,
SkySheet,
SkyNotification,
} from '@/ui'
import {
Check,
CirclePlus,
Ellipsis,
ExternalLink,
Library,
ListMusic,
Music2,
Pause,
Play,
Plus,
Search,
Share2,
SkipBack,
SkipForward,
Volume1,
Volume2,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import playlistPlaceholder from '@/assets/img/music/playlist-placeholder.jpg'
import { useMusicStore } from '@/stores/music'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { MusicPlaylist, MusicTrack } from '@/types/music'
import { easyShareMusicTarget } from '@/utils/easyshare'
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
import { musicEscapeLayer } from '@/utils/musicEscape'
type MusicTab = 'library' | 'playlists' | 'search'
type MusicSheet =
| 'playlist'
| 'playlist-picker'
| 'rename'
| 'track-picker'
| 'youtube'
type MusicMenuAction =
| 'add-songs'
| 'add-to-playlist'
| 'add-youtube'
| 'delete-playlist'
| 'new-playlist'
| 'remove-from-library'
| 'remove-from-playlist'
| 'rename-playlist'
| 'share-playlist'
| 'share-track'
type MusicMenuItem = {
destructive?: boolean
id: MusicMenuAction
label: string
separatorBefore?: boolean
}
type PlaylistArtworkTrack = MusicTrack & { artwork: string }
const MUSIC_SHEET_TRANSITION_MS = 420
const music = useMusicStore()
const easyShare = useEasyShareStore()
const phone = usePhoneStore()
const route = useRoute()
const activeTab = ref<MusicTab>('library')
const tabs = [
{ id: 'library', icon: Library },
{ id: 'playlists', icon: ListMusic },
{ id: 'search', icon: Search },
] as const
const activeTabIndex = computed(() =>
tabs.findIndex((item) => item.id === activeTab.value),
)
const activePlaylist = ref<MusicPlaylist | null>(null)
const addMenuOpened = ref(false)
const actionMenuOpened = ref(false)
const actionTrack = ref<MusicTrack | null>(null)
const menuTarget = ref<HTMLElement | null>(null)
const activeSheet = ref<MusicSheet | null>(null)
const playerOpened = ref(false)
const youtubeUrl = ref('')
const youtubeTitle = ref('')
const youtubeArtist = ref('')
const playlistName = ref('')
const playlistCreationTrack = ref<MusicTrack | null>(null)
const searchQuery = ref('')
const toastText = ref('')
const confirmRemoveTrack = ref(false)
const confirmDeletePlaylist = ref(false)
const scrollEl = ref<HTMLElement | null>(null)
let playerPickerTimer: number | null = null
const allTracks = computed(() => music.allTracks)
const normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase())
const searchResults = computed(() => {
if (!normalizedSearch.value) return allTracks.value
return allTracks.value.filter((track) =>
`${track.title} ${track.artist}`
.toLowerCase()
.includes(normalizedSearch.value),
)
})
const playlistTracks = computed(() =>
activePlaylist.value ? music.tracksForPlaylist(activePlaylist.value) : [],
)
const availablePlaylistTracks = computed(() => {
const playlist = activePlaylist.value
if (!playlist) return []
return allTracks.value.filter((track) => !playlistHasTrack(playlist, track))
})
const recentTracks = computed(() => allTracks.value.slice(0, 6))
const featuredTrack = computed(() => allTracks.value[0] ?? null)
const sheetTitle = computed(() => {
if (activeSheet.value === 'youtube') return phone.t('Apps.music.addYouTube')
if (activeSheet.value === 'playlist-picker')
return phone.t('Apps.music.choosePlaylist')
if (activeSheet.value === 'track-picker')
return phone.t('Apps.music.addSongs')
if (activeSheet.value === 'rename')
return phone.t('Apps.music.renamePlaylist')
return phone.t('Apps.music.newPlaylist')
})
const pageTitle = computed(() => {
if (activePlaylist.value) return activePlaylist.value.name
return phone.t(`Apps.music.tabs.${activeTab.value}`)
})
const menuLabel = computed(() => {
if (addMenuOpened.value && activePlaylist.value) {
return phone.t('Apps.music.playlistActions')
}
return phone.t(
addMenuOpened.value ? 'Apps.music.addMusic' : 'Apps.music.songActions',
)
})
const menuItems = computed<MusicMenuItem[]>(() => {
if (addMenuOpened.value) {
if (activePlaylist.value) {
return [
{
id: 'share-playlist',
label: phone.t('Apps.easyShare.name'),
},
{ id: 'add-songs', label: phone.t('Apps.music.addSongs') },
{
id: 'rename-playlist',
label: phone.t('Apps.music.renamePlaylist'),
},
{
destructive: true,
id: 'delete-playlist',
label: phone.t('Apps.music.deletePlaylist'),
separatorBefore: true,
},
]
}
return [
{ id: 'add-youtube', label: phone.t('Apps.music.addYouTube') },
{ id: 'new-playlist', label: phone.t('Apps.music.newPlaylist') },
]
}
if (!actionMenuOpened.value) return []
const items: MusicMenuItem[] = [
{ id: 'share-track', label: phone.t('Apps.easyShare.name') },
{
id: 'add-to-playlist',
label: phone.t('Apps.music.addToPlaylist'),
},
]
if (activePlaylist.value) {
items.push({
destructive: true,
id: 'remove-from-playlist',
label: phone.t('Apps.music.removeFromPlaylist'),
separatorBefore: true,
})
}
if (actionTrack.value?.source === 'youtube') {
items.push({
destructive: true,
id: 'remove-from-library',
label: phone.t('Apps.music.removeFromLibrary'),
separatorBefore: !activePlaylist.value,
})
}
return items
})
function eventValue(event: Event): string {
return (event.target as HTMLInputElement | null)?.value ?? ''
}
function showToast(path: string): void {
toastText.value = phone.t(path)
window.setTimeout(() => {
if (toastText.value === phone.t(path)) toastText.value = ''
}, 2200)
}
function errorText(): string {
return phone.t(`Apps.music.errors.${music.error || 'default'}`)
}
function closeMenus(): void {
addMenuOpened.value = false
actionMenuOpened.value = false
}
function dismissMenus(): void {
closeMenus()
actionTrack.value = null
}
function openAddMenu(event: MouseEvent): void {
menuTarget.value = event.currentTarget as HTMLElement
actionTrack.value = null
actionMenuOpened.value = false
addMenuOpened.value = true
}
function openSheet(sheet: MusicSheet): void {
closeMenus()
activeSheet.value = sheet
music.error = ''
if (sheet === 'youtube') {
youtubeUrl.value = ''
youtubeTitle.value = ''
youtubeArtist.value = ''
}
if (sheet === 'playlist') playlistName.value = ''
if (sheet === 'rename') playlistName.value = activePlaylist.value?.name ?? ''
}
function closeSheet(): void {
if (music.isLoading) return
activeSheet.value = null
music.error = ''
actionTrack.value = null
playlistCreationTrack.value = null
}
function openNewPlaylist(track: MusicTrack | null = null): void {
if (music.isLoading) return
playlistCreationTrack.value = track
openSheet('playlist')
}
function openPlaylistPicker(track: MusicTrack | null): void {
if (!track) return
actionTrack.value = track
openSheet('playlist-picker')
}
function openActivePlaylistTrackPicker(): void {
if (!activePlaylist.value) return
actionTrack.value = null
openSheet('track-picker')
}
function openCurrentTrackPlaylistPicker(): void {
const track = music.currentTrack
if (!track) return
playerOpened.value = false
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
playerPickerTimer = window.setTimeout(() => {
playerPickerTimer = null
if (!playerOpened.value) openPlaylistPicker(track)
}, MUSIC_SHEET_TRANSITION_MS)
}
function requestDeletePlaylist(): void {
closeMenus()
confirmDeletePlaylist.value = true
}
function requestRemoveTrack(): void {
closeMenus()
confirmRemoveTrack.value = true
}
function cancelRemoveTrack(): void {
confirmRemoveTrack.value = false
actionTrack.value = null
}
function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
event.stopPropagation()
menuTarget.value = event.currentTarget as HTMLElement
actionTrack.value = track
addMenuOpened.value = false
actionMenuOpened.value = true
}
function selectMenuItem(id: string): void {
switch (id as MusicMenuAction) {
case 'share-playlist':
shareActivePlaylist()
break
case 'add-songs':
openActivePlaylistTrackPicker()
break
case 'rename-playlist':
openSheet('rename')
break
case 'delete-playlist':
requestDeletePlaylist()
break
case 'add-youtube':
openSheet('youtube')
break
case 'new-playlist':
openNewPlaylist()
break
case 'share-track':
shareTrack()
break
case 'add-to-playlist':
openPlaylistPicker(actionTrack.value)
break
case 'remove-from-playlist':
void removeFromActivePlaylist()
break
case 'remove-from-library':
requestRemoveTrack()
break
}
}
function shareTrack(
selectedTrack: MusicTrack | null = actionTrack.value,
): void {
const track = selectedTrack
if (!track) return
closeMenus()
playerOpened.value = false
easyShare.open({
appId: 'music',
copyText: `${track.title}${track.artist}`,
id: track.id,
imageUrl: track.artwork,
kind: 'track',
link: `skyphone://music/${track.source}/${track.id}`,
meta: { source: track.source },
subtitle: track.artist,
title: track.title,
})
}
function shareActivePlaylist(): void {
const playlist = activePlaylist.value
if (!playlist) return
closeMenus()
easyShare.open({
appId: 'music',
copyText: `${playlist.name} · ${playlist.entries.length}`,
id: playlist.id,
imageUrl: playlistArtwork(playlist)[0]?.artwork || playlistPlaceholder,
kind: 'playlist',
link: `skyphone://music/playlist/${playlist.id}`,
subtitle: phone.t('Apps.music.songCount', {
count: String(playlist.entries.length),
}),
title: playlist.name,
})
}
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(
track: MusicTrack,
queue = allTracks.value,
): Promise<void> {
await music.play(track, queue)
if (music.playbackError) showToast('Apps.music.errors.playback_failed')
}
async function playFeatured(): Promise<void> {
if (featuredTrack.value) await playTrack(featuredTrack.value)
}
async function submitYouTube(): Promise<void> {
if (!youtubeUrl.value.trim()) return
if (
await music.addYouTube(
youtubeUrl.value.trim(),
youtubeTitle.value.trim(),
youtubeArtist.value.trim(),
)
) {
activeSheet.value = null
showToast('Apps.music.songAdded')
}
}
async function submitPlaylist(): Promise<void> {
if (music.isLoading) return
const name = playlistName.value.trim()
if (!name) return
if (activeSheet.value === 'rename' && activePlaylist.value) {
if (!(await music.renamePlaylist(activePlaylist.value.id, name))) return
activePlaylist.value =
music.playlists.find(
(playlist) => playlist.id === activePlaylist.value?.id,
) ?? null
showToast('Apps.music.playlistRenamed')
activeSheet.value = null
return
}
const previousIds = new Set(music.playlists.map((playlist) => playlist.id))
const pendingTrack = playlistCreationTrack.value
if (!(await music.createPlaylist(name))) return
const createdPlaylist = music.playlists.find(
(playlist) => !previousIds.has(playlist.id),
)
if (pendingTrack) {
playlistCreationTrack.value = null
if (!createdPlaylist) {
music.error = 'request_failed'
activeSheet.value = 'playlist-picker'
return
}
if (!(await music.addToPlaylist(createdPlaylist.id, pendingTrack))) {
activeSheet.value = 'playlist-picker'
return
}
showToast('Apps.music.playlistCreatedWithSong')
actionTrack.value = null
} else {
showToast('Apps.music.playlistCreated')
}
playlistCreationTrack.value = null
activeSheet.value = null
}
async function addTrackToPlaylist(playlist: MusicPlaylist): Promise<void> {
const track = actionTrack.value
if (music.isLoading || !track || playlistHasTrack(playlist, track)) return
if (await music.addToPlaylist(playlist.id, track)) {
activeSheet.value = null
actionTrack.value = null
showToast('Apps.music.addedToPlaylist')
}
}
async function addTrackToActivePlaylist(track: MusicTrack): Promise<void> {
const playlist = activePlaylist.value
if (music.isLoading || !playlist || playlistHasTrack(playlist, track)) return
if (await music.addToPlaylist(playlist.id, track)) {
activePlaylist.value =
music.playlists.find((candidate) => candidate.id === playlist.id) ?? null
showToast('Apps.music.addedToPlaylist')
}
}
async function removeFromActivePlaylist(): Promise<void> {
if (!activePlaylist.value || !actionTrack.value) return
const playlistId = activePlaylist.value.id
if (await music.removeFromPlaylist(playlistId, actionTrack.value)) {
activePlaylist.value =
music.playlists.find((playlist) => playlist.id === playlistId) ?? null
actionMenuOpened.value = false
actionTrack.value = null
showToast('Apps.music.removedFromPlaylist')
}
}
async function removePersonalTrack(): Promise<void> {
if (!actionTrack.value || actionTrack.value.source !== 'youtube') return
if (await music.removeYouTube(actionTrack.value.id)) {
confirmRemoveTrack.value = false
actionMenuOpened.value = false
actionTrack.value = null
showToast('Apps.music.songRemoved')
}
}
async function deleteActivePlaylist(): Promise<void> {
if (!activePlaylist.value) return
if (await music.deletePlaylist(activePlaylist.value.id)) {
confirmDeletePlaylist.value = false
activePlaylist.value = null
showToast('Apps.music.playlistDeleted')
}
}
function hasPlaylistArtwork(track: MusicTrack): track is PlaylistArtworkTrack {
return typeof track.artwork === 'string' && track.artwork.trim().length > 0
}
function playlistArtwork(playlist: MusicPlaylist): PlaylistArtworkTrack[] {
return music
.tracksForPlaylist(playlist)
.filter(hasPlaylistArtwork)
.slice(0, 4)
}
function playlistHasTrack(playlist: MusicPlaylist, track: MusicTrack): boolean {
return playlist.entries.some(
(entry) => entry.source === track.source && entry.songId === track.id,
)
}
function actionTrackIsInPlaylist(playlist: MusicPlaylist): boolean {
return Boolean(
actionTrack.value && playlistHasTrack(playlist, actionTrack.value),
)
}
function fallbackArtwork(
track: Pick<MusicTrack, 'id'>,
): Record<string, string> {
let hash = 0
for (const character of track.id)
hash = (hash * 31 + character.charCodeAt(0)) | 0
const hue = Math.abs(hash) % 360
return {
background: `linear-gradient(145deg, hsl(${hue} 78% 62%), hsl(${(hue + 58) % 360} 72% 38%))`,
}
}
function hideBrokenArtwork(event: Event): void {
const image = event.currentTarget as HTMLImageElement
image.style.display = 'none'
}
function usePlaylistPlaceholder(event: Event): void {
const image = event.currentTarget as HTMLImageElement
if (image.dataset.playlistFallback === 'true') {
image.style.display = 'none'
return
}
image.dataset.playlistFallback = 'true'
image.src = playlistPlaceholder
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00'
const minutes = Math.floor(seconds / 60)
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
}
function updateSeek(event: Event): void {
music.seek(Number(eventValue(event)))
}
function updateVolume(event: Event): void {
music.setVolume(Number(eventValue(event)) / 100)
}
function onKeydown(event: KeyboardEvent): void {
const layer = musicEscapeLayer({
actionMenuOpened: actionMenuOpened.value,
activeSheet: Boolean(activeSheet.value),
addMenuOpened: addMenuOpened.value,
confirmDeletePlaylist: confirmDeletePlaylist.value,
confirmRemoveTrack: confirmRemoveTrack.value,
playerOpened: playerOpened.value,
})
if (!layer) return
if (!consumeEscape(event)) return
if (layer === 'remove-track-confirmation') {
cancelRemoveTrack()
} else if (layer === 'delete-playlist-confirmation') {
confirmDeletePlaylist.value = false
} else if (layer === 'sheet') {
closeSheet()
} else if (layer === 'menu') {
dismissMenus()
} else if (layer === 'player') {
playerOpened.value = false
}
}
watch(
() => music.playlists,
() => {
if (!activePlaylist.value) return
activePlaylist.value =
music.playlists.find(
(playlist) => playlist.id === activePlaylist.value?.id,
) ?? null
},
)
onMounted(async () => {
window.addEventListener('keydown', onKeydown, true)
await music.load()
const target = easyShareMusicTarget(
route.query.easyShareKind,
route.query.easyShareLink,
)
if (!target) return
if (target.kind === 'playlist') {
const playlist = music.playlists.find((entry) => entry.id === target.id)
if (playlist) openPlaylist(playlist)
return
}
const track = music.allTracks.find(
(entry) => entry.id === target.id && entry.source === target.source,
)
if (!track) return
await playTrack(track)
playerOpened.value = true
})
onBeforeUnmount(() => {
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
window.removeEventListener('keydown', onKeydown, true)
})
</script>
<template>
<sky-app-page
component="main"
class="music-app sky-ui-provider"
:class="{
'music-app--playlist': activePlaylist,
'music-app--playing': music.currentTrack,
'sky-ui-provider--dark': phone.isDarkMode,
}"
>
<sky-navbar
component="nav"
class="music-navbar"
title-class="music-navbar-title"
:title="activePlaylist ? activePlaylist.name : phone.t('Apps.music.name')"
:show-back="Boolean(activePlaylist)"
back-appearance="surface"
:back-label="phone.t('Apps.music.tabs.playlists')"
@back="closePlaylist"
>
<template #right>
<sky-link
v-if="activePlaylist"
component="button"
icon-only
:aria-label="phone.t('Apps.music.playlistActions')"
aria-haspopup="menu"
:aria-expanded="addMenuOpened"
@click="openAddMenu"
>
<Ellipsis :size="22" />
</sky-link>
<sky-link
v-else
component="button"
icon-only
:aria-label="phone.t('Apps.music.addMusic')"
aria-haspopup="menu"
:aria-expanded="addMenuOpened"
@click="openAddMenu"
>
<Plus :size="24" />
</sky-link>
</template>
</sky-navbar>
<div ref="scrollEl" class="music-scroll">
<div
v-if="music.isLoading && !allTracks.length && !music.playlists.length"
class="music-loading"
>
<sky-spinner />
<span>{{ phone.t('Apps.music.loading') }}</span>
</div>
<template v-else-if="activePlaylist">
<section class="music-playlist-hero">
<div class="music-playlist-art">
<template v-if="playlistArtwork(activePlaylist).length">
<div
v-for="track in playlistArtwork(activePlaylist)"
:key="`${track.source}:${track.id}`"
:style="fallbackArtwork(track)"
>
<img
:src="track.artwork"
alt=""
@error="usePlaylistPlaceholder"
/>
</div>
</template>
<span v-else class="music-playlist-placeholder">
<img :src="playlistPlaceholder" alt="" />
</span>
</div>
<h1>{{ activePlaylist.name }}</h1>
<p>
{{
phone.t('Apps.music.songCount', {
count: String(playlistTracks.length),
})
}}
</p>
<div class="music-playlist-actions">
<sky-button
large
rounded
:disabled="!playlistTracks.length"
@click="
playlistTracks[0] &&
playTrack(playlistTracks[0], playlistTracks)
"
>
<Play :size="18" fill="currentColor" />
{{ phone.t('Apps.music.play') }}
</sky-button>
<sky-button
large
rounded
tonal
@click="openActivePlaylistTrackPicker"
>
<CirclePlus :size="18" />
{{ phone.t('Apps.music.addSongs') }}
</sky-button>
</div>
</section>
<sky-list v-if="playlistTracks.length" nested class="music-track-list">
<sky-list-item
v-for="track in playlistTracks"
:key="`${track.source}:${track.id}`"
link
:chevron="false"
:title="track.title"
:subtitle="track.artist"
@click="playTrack(track, playlistTracks)"
>
<template #media>
<span class="music-row-art" :style="fallbackArtwork(track)">
<img
v-if="track.artwork"
:src="track.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="20" />
</span>
</template>
<template #after>
<sky-link
component="button"
icon-only
:aria-label="phone.t('Apps.music.songActions')"
aria-haspopup="menu"
:aria-expanded="
actionMenuOpened &&
actionTrack?.id === track.id &&
actionTrack?.source === track.source
"
@click="openTrackMenu($event, track)"
>
<Ellipsis :size="20" />
</sky-link>
</template>
</sky-list-item>
</sky-list>
<sky-block v-else class="music-empty" inset>
<ListMusic :size="43" />
<strong>{{ phone.t('Apps.music.emptyPlaylist') }}</strong>
<span>{{ phone.t('Apps.music.emptyPlaylistBody') }}</span>
</sky-block>
</template>
<template v-else-if="activeTab === 'library'">
<header class="music-large-title">
<h1>{{ pageTitle }}</h1>
<p>{{ phone.t('Apps.music.librarySubtitle') }}</p>
</header>
<sky-glass
v-if="featuredTrack"
class="music-featured"
:highlight="false"
>
<button type="button" @click="playFeatured">
<div class="music-featured-copy">
<small>{{ phone.t('Apps.music.featured') }}</small>
<h2>{{ featuredTrack.title }}</h2>
<p>{{ featuredTrack.artist }}</p>
<span
><Play :size="16" fill="currentColor" />
{{ phone.t('Apps.music.play') }}</span
>
</div>
<div
class="music-featured-art"
:style="fallbackArtwork(featuredTrack)"
>
<img
v-if="featuredTrack.artwork"
:src="featuredTrack.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="52" />
</div>
</button>
</sky-glass>
<template v-if="recentTracks.length">
<sky-block-title class="music-section-title">{{
phone.t('Apps.music.recentlyAdded')
}}</sky-block-title>
<section class="music-album-grid">
<button
v-for="track in recentTracks"
:key="`${track.source}:${track.id}`"
type="button"
@click="playTrack(track)"
>
<span class="music-album-art" :style="fallbackArtwork(track)">
<img
v-if="track.artwork"
:src="track.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="40" />
</span>
<strong>{{ track.title }}</strong>
<small>{{ track.artist }}</small>
</button>
</section>
<sky-block-title class="music-section-title">{{
phone.t('Apps.music.songs')
}}</sky-block-title>
<sky-list nested class="music-track-list">
<sky-list-item
v-for="track in allTracks"
:key="`${track.source}:${track.id}`"
link
:chevron="false"
:title="track.title"
:subtitle="track.artist"
@click="playTrack(track)"
>
<template #media>
<span class="music-row-art" :style="fallbackArtwork(track)">
<img
v-if="track.artwork"
:src="track.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="20" />
</span>
</template>
<template #after>
<sky-link
component="button"
icon-only
:aria-label="phone.t('Apps.music.songActions')"
aria-haspopup="menu"
:aria-expanded="
actionMenuOpened &&
actionTrack?.id === track.id &&
actionTrack?.source === track.source
"
@click="openTrackMenu($event, track)"
>
<Ellipsis :size="20" />
</sky-link>
</template>
</sky-list-item>
</sky-list>
</template>
<sky-block v-else class="music-empty music-empty--library" inset>
<Music2 :size="48" />
<strong>{{ phone.t('Apps.music.emptyLibrary') }}</strong>
<span>{{ phone.t('Apps.music.emptyLibraryBody') }}</span>
<sky-button rounded @click="openSheet('youtube')">
<ExternalLink :size="17" />
{{ phone.t('Apps.music.addYouTube') }}
</sky-button>
</sky-block>
</template>
<template v-else-if="activeTab === 'playlists'">
<header class="music-large-title">
<h1>{{ pageTitle }}</h1>
<p>{{ phone.t('Apps.music.playlistsSubtitle') }}</p>
</header>
<section v-if="music.playlists.length" class="music-playlist-grid">
<button
v-for="playlist in music.playlists"
:key="playlist.id"
type="button"
@click="openPlaylist(playlist)"
>
<span class="music-playlist-tile">
<template v-if="playlistArtwork(playlist).length">
<i
v-for="track in playlistArtwork(playlist)"
:key="`${track.source}:${track.id}`"
:style="fallbackArtwork(track)"
>
<img
:src="track.artwork"
alt=""
@error="usePlaylistPlaceholder"
/>
</i>
</template>
<span v-else class="music-playlist-placeholder">
<img :src="playlistPlaceholder" alt="" />
</span>
</span>
<strong>{{ playlist.name }}</strong>
<small>
{{
phone.t('Apps.music.songCount', {
count: String(playlist.entries.length),
})
}}
</small>
</button>
</section>
<sky-block v-else class="music-empty music-empty--library" inset>
<ListMusic :size="48" />
<strong>{{ phone.t('Apps.music.noPlaylists') }}</strong>
<span>{{ phone.t('Apps.music.noPlaylistsBody') }}</span>
<sky-button rounded @click="openNewPlaylist()">
<Plus :size="17" />
{{ phone.t('Apps.music.newPlaylist') }}
</sky-button>
</sky-block>
</template>
<template v-else>
<header class="music-large-title music-large-title--search">
<h1>{{ pageTitle }}</h1>
</header>
<sky-searchbar
class="music-searchbar"
:value="searchQuery"
:placeholder="phone.t('Apps.music.searchPlaceholder')"
@input="searchQuery = eventValue($event)"
@clear="searchQuery = ''"
/>
<sky-list v-if="searchResults.length" nested class="music-track-list">
<sky-list-item
v-for="track in searchResults"
:key="`${track.source}:${track.id}`"
link
:chevron="false"
:title="track.title"
:subtitle="track.artist"
@click="playTrack(track, searchResults)"
>
<template #media>
<span class="music-row-art" :style="fallbackArtwork(track)">
<img
v-if="track.artwork"
:src="track.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="20" />
</span>
</template>
<template #after>
<sky-link
component="button"
icon-only
:aria-label="phone.t('Apps.music.songActions')"
aria-haspopup="menu"
:aria-expanded="
actionMenuOpened &&
actionTrack?.id === track.id &&
actionTrack?.source === track.source
"
@click="openTrackMenu($event, track)"
>
<Ellipsis :size="20" />
</sky-link>
</template>
</sky-list-item>
</sky-list>
<sky-block v-else class="music-empty" inset>
<Search :size="42" />
<strong>{{ phone.t('Apps.music.noResults') }}</strong>
<span>{{ phone.t('Apps.music.noResultsBody') }}</span>
</sky-block>
</template>
</div>
<sky-glass
v-if="music.currentTrack"
class="music-mini-player"
:highlight="false"
@click="playerOpened = true"
>
<span class="music-mini-art" :style="fallbackArtwork(music.currentTrack)">
<img
v-if="music.currentTrack.artwork"
:src="music.currentTrack.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="19" />
</span>
<span class="music-mini-copy">
<strong>{{ music.currentTrack.title }}</strong>
<small>{{ music.currentTrack.artist }}</small>
</span>
<button
type="button"
:aria-label="
phone.t(music.isPlaying ? 'Common.pause' : 'Apps.music.play')
"
@click.stop="music.toggle"
>
<Pause v-if="music.isPlaying" :size="23" fill="currentColor" />
<Play v-else :size="23" fill="currentColor" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.music.next')"
@click.stop="music.next"
>
<SkipForward :size="23" fill="currentColor" />
</button>
</sky-glass>
<SkyPillNavigation
v-if="!activePlaylist"
class="music-navigation"
layout="full"
:label="phone.t('Apps.music.navigation')"
>
<SkySegmented
strong
rounded
navigation
:active-index="activeTabIndex"
:aria-label="phone.t('Apps.music.navigation')"
:data-active-tab="activeTab"
:item-count="tabs.length"
>
<SkySegmentedButton
v-for="item in tabs"
:key="item.id"
:active="activeTab === item.id"
:aria-label="phone.t(`Apps.music.tabs.${item.id}`)"
type="button"
@click="selectTab(item.id)"
>
<span class="music-navigation__item">
<component
:is="item.icon"
:size="20"
:stroke-width="2"
aria-hidden="true"
/>
<span>{{ phone.t(`Apps.music.tabs.${item.id}`) }}</span>
</span>
</SkySegmentedButton>
</SkySegmented>
</SkyPillNavigation>
<SkyDropdown
:items="menuItems"
:label="menuLabel"
:opened="addMenuOpened || actionMenuOpened"
:target="menuTarget"
@backdropclick="dismissMenus"
@escape="dismissMenus"
@positionerror="dismissMenus"
@select="selectMenuItem"
/>
<div class="music-form-sheet">
<sky-sheet :opened="Boolean(activeSheet)" @backdropclick="closeSheet">
<section class="music-sheet-content">
<header>
<sky-link component="button" @click="closeSheet">{{
phone.t(
activeSheet === 'track-picker'
? 'Common.done'
: 'Common.cancel',
)
}}</sky-link>
<strong>{{ sheetTitle }}</strong>
<span />
</header>
<template v-if="activeSheet === 'youtube'">
<div class="music-sheet-icon"><ExternalLink :size="28" /></div>
<p>{{ phone.t('Apps.music.youtubeBody') }}</p>
<sky-list inset strong>
<sky-field
:label="phone.t('Apps.music.youtubeUrl')"
input-id="music-youtube-url"
inputmode="url"
:placeholder="phone.t('Apps.music.youtubePlaceholder')"
type="url"
:value="youtubeUrl"
@input="youtubeUrl = eventValue($event)"
@keydown.enter="handleEnterAction($event, submitYouTube)"
/>
<sky-field
: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="handleEnterAction($event, submitYouTube)"
/>
<sky-field
: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="handleEnterAction($event, submitYouTube)"
/>
</sky-list>
<p v-if="music.error" class="music-form-error" role="alert">
{{ errorText() }}
</p>
<sky-button
large
rounded
:disabled="music.isLoading || !youtubeUrl.trim()"
@click="submitYouTube"
>
<sky-spinner v-if="music.isLoading" />
<template v-else>{{
phone.t('Apps.music.addToLibrary')
}}</template>
</sky-button>
</template>
<template v-else-if="activeSheet === 'playlist-picker'">
<sky-list
v-if="music.playlists.length"
inset
strong
class="music-picker-list"
>
<sky-list-item
v-for="playlist in music.playlists"
:key="playlist.id"
:link="!actionTrackIsInPlaylist(playlist)"
link-component="button"
:chevron="false"
:link-props="{
type: 'button',
disabled:
music.isLoading || actionTrackIsInPlaylist(playlist),
}"
:title="playlist.name"
:subtitle="
phone.t('Apps.music.songCount', {
count: String(playlist.entries.length),
})
"
@click="addTrackToPlaylist(playlist)"
>
<template #media><ListMusic :size="22" /></template>
<template #after>
<span
v-if="actionTrackIsInPlaylist(playlist)"
class="music-picker-added"
>
<Check :size="16" />
{{ phone.t('Apps.music.alreadyAdded') }}
</span>
<CirclePlus v-else :size="19" />
</template>
</sky-list-item>
</sky-list>
<sky-button
v-if="music.playlists.length"
rounded
tonal
class="music-picker-create"
:disabled="music.isLoading"
@click="openNewPlaylist(actionTrack)"
>
<Plus :size="17" /> {{ phone.t('Apps.music.newPlaylist') }}
</sky-button>
<sky-block v-else class="music-empty" inset>
<ListMusic :size="42" />
<strong>{{ phone.t('Apps.music.noPlaylists') }}</strong>
<span>{{ phone.t('Apps.music.createPlaylistFirst') }}</span>
<sky-button
rounded
:disabled="music.isLoading"
@click="openNewPlaylist(actionTrack)"
>{{ phone.t('Apps.music.newPlaylist') }}</sky-button
>
</sky-block>
<p v-if="music.error" class="music-form-error" role="alert">
{{ errorText() }}
</p>
</template>
<template v-else-if="activeSheet === 'track-picker'">
<p>{{ phone.t('Apps.music.addSongsBody') }}</p>
<sky-list
v-if="availablePlaylistTracks.length"
inset
strong
class="music-picker-list"
>
<sky-list-item
v-for="track in availablePlaylistTracks"
:key="`${track.source}:${track.id}`"
link
link-component="button"
:chevron="false"
:link-props="{ type: 'button', disabled: music.isLoading }"
:title="track.title"
:subtitle="track.artist"
@click="addTrackToActivePlaylist(track)"
>
<template #media>
<span class="music-row-art" :style="fallbackArtwork(track)">
<img
v-if="track.artwork"
:src="track.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="20" />
</span>
</template>
<template #after><CirclePlus :size="20" /></template>
</sky-list-item>
</sky-list>
<sky-block v-else class="music-empty" inset>
<Check v-if="allTracks.length" :size="42" />
<Music2 v-else :size="42" />
<strong>{{
phone.t(
allTracks.length
? 'Apps.music.allSongsAdded'
: 'Apps.music.emptyLibrary',
)
}}</strong>
<span>{{
phone.t(
allTracks.length
? 'Apps.music.allSongsAddedBody'
: 'Apps.music.emptyLibraryBody',
)
}}</span>
</sky-block>
<p v-if="music.error" class="music-form-error" role="alert">
{{ errorText() }}
</p>
</template>
<template v-else>
<div class="music-sheet-icon"><ListMusic :size="29" /></div>
<p>{{ phone.t('Apps.music.playlistBody') }}</p>
<sky-list inset strong>
<sky-field
:label="phone.t('Apps.music.playlistName')"
input-id="music-playlist-name"
maxlength="80"
:disabled="music.isLoading"
:placeholder="phone.t('Apps.music.playlistPlaceholder')"
:value="playlistName"
@input="playlistName = eventValue($event)"
@keydown.enter="handleEnterAction($event, submitPlaylist)"
/>
</sky-list>
<p v-if="music.error" class="music-form-error" role="alert">
{{ errorText() }}
</p>
<sky-button
large
rounded
:disabled="music.isLoading || !playlistName.trim()"
@click="submitPlaylist"
>
<sky-spinner v-if="music.isLoading" />
<template v-else>{{ phone.t('Common.save') }}</template>
</sky-button>
</template>
</section>
</sky-sheet>
</div>
<div class="music-player-sheet">
<sky-sheet :opened="playerOpened" @backdropclick="playerOpened = false">
<section v-if="music.currentTrack" class="music-player">
<header>
<sky-link
component="button"
icon-only
:aria-label="phone.t('Common.close')"
@click="playerOpened = false"
>
<X :size="20" />
</sky-link>
<span>{{ phone.t('Apps.music.nowPlaying') }}</span>
<div class="music-player-header-actions">
<sky-link
component="button"
icon-only
:aria-label="phone.t('Apps.easyShare.share')"
@click="shareTrack(music.currentTrack)"
>
<Share2 :size="20" />
</sky-link>
<sky-link
component="button"
icon-only
:aria-label="phone.t('Apps.music.addToPlaylist')"
@click="openCurrentTrackPlaylistPicker"
>
<CirclePlus :size="21" />
</sky-link>
</div>
</header>
<div
class="music-player-art"
:style="fallbackArtwork(music.currentTrack)"
>
<img
v-if="music.currentTrack.artwork"
:src="music.currentTrack.artwork"
:alt="music.currentTrack.title"
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="86" />
</div>
<div class="music-player-copy">
<strong>{{ music.currentTrack.title }}</strong>
<span>{{ music.currentTrack.artist }}</span>
</div>
<div class="music-progress">
<sky-range
:value="music.currentTime"
:min="0"
:max="Math.max(1, music.duration)"
:aria-label="phone.t('Apps.music.progress')"
@input="updateSeek"
/>
<div>
<span>{{ formatTime(music.currentTime) }}</span
><span
>-{{
formatTime(Math.max(0, music.duration - music.currentTime))
}}</span
>
</div>
</div>
<div class="music-player-controls">
<button
type="button"
:aria-label="phone.t('Apps.music.previous')"
@click="music.previous"
>
<SkipBack :size="38" fill="currentColor" />
</button>
<button
type="button"
:aria-label="
phone.t(music.isPlaying ? 'Common.pause' : 'Apps.music.play')
"
@click="music.toggle"
>
<Pause v-if="music.isPlaying" :size="50" fill="currentColor" />
<Play v-else :size="50" fill="currentColor" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.music.next')"
@click="music.next"
>
<SkipForward :size="38" fill="currentColor" />
</button>
</div>
<div class="music-volume">
<Volume1 :size="18" />
<sky-range
:value="Math.round(music.volume * 100)"
:min="0"
:max="100"
:aria-label="phone.t('Apps.music.volume')"
@input="updateVolume"
/>
<Volume2 :size="20" />
</div>
<p v-if="music.playbackError" class="music-player-error">
{{ phone.t('Apps.music.errors.playback_failed') }}
</p>
</section>
</sky-sheet>
</div>
<sky-dialog
:opened="confirmRemoveTrack"
:title="phone.t('Apps.music.removeSongTitle')"
:content="phone.t('Apps.music.removeSongBody')"
@backdropclick="cancelRemoveTrack"
>
<template #buttons>
<sky-dialog-button @click="cancelRemoveTrack">{{
phone.t('Common.cancel')
}}</sky-dialog-button>
<sky-dialog-button strong @click="removePersonalTrack">{{
phone.t('Common.delete')
}}</sky-dialog-button>
</template>
</sky-dialog>
<sky-dialog
:opened="confirmDeletePlaylist"
:title="phone.t('Apps.music.deletePlaylistTitle')"
:content="phone.t('Apps.music.deletePlaylistBody')"
@backdropclick="confirmDeletePlaylist = false"
>
<template #buttons>
<sky-dialog-button @click="confirmDeletePlaylist = false">{{
phone.t('Common.cancel')
}}</sky-dialog-button>
<sky-dialog-button strong @click="deleteActivePlaylist">{{
phone.t('Common.delete')
}}</sky-dialog-button>
</template>
</sky-dialog>
<sky-notification :opened="Boolean(toastText)" :text="toastText" />
</sky-app-page>
</template>
<style scoped>
.music-app {
--music-accent: #fa2d48;
--music-mini-player-bottom: calc(
var(--sky-safe-area-bottom) + var(--sky-tabbar-height) + var(--sky-space-2)
);
--music-mini-player-height: 58px;
--sky-app-accent: var(--music-accent);
--music-bg: #f7f7fa;
--music-card: rgb(255 255 255 / 88%);
--music-label: #111114;
--music-muted: #74747c;
--music-line: rgb(18 18 23 / 9%);
position: relative;
display: flex !important;
flex-direction: column;
height: 100%;
overflow: hidden;
isolation: isolate;
background: var(--music-bg);
color: var(--music-label);
}
:global(.dark .music-app) {
--music-bg: #09090b;
--music-card: rgb(31 31 35 / 88%);
--music-label: #f7f7f8;
--music-muted: #9b9ba2;
--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: relative;
flex: 1 1 auto;
min-height: 0;
padding: 8px 0
calc(
var(--sky-safe-area-bottom) + var(--sky-tabbar-height) +
var(--sky-space-3)
);
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: none;
}
.music-app--playing .music-scroll {
padding-bottom: calc(
var(--music-mini-player-bottom) + var(--music-mini-player-height) +
var(--sky-space-3)
);
}
.music-app--playlist .music-scroll {
padding-bottom: 42px;
}
.music-app--playlist.music-app--playing .music-scroll {
padding-bottom: 100px;
}
.music-scroll::-webkit-scrollbar {
display: none;
}
.music-loading,
.music-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--music-muted);
text-align: center;
}
.music-loading {
min-height: 330px;
font-size: 13px;
}
.music-empty {
min-height: 230px;
padding: 28px;
}
.music-empty--library {
min-height: 410px;
}
.music-empty strong {
color: var(--music-label);
font-size: 18px;
}
.music-empty span {
max-width: 260px;
font-size: 13px;
line-height: 1.45;
}
.music-empty :deep(button) {
margin-top: 8px;
}
.music-large-title {
padding: 10px 18px 16px;
}
.music-large-title h1 {
margin: 0;
font-size: 34px;
font-weight: 780;
letter-spacing: -1.2px;
}
.music-large-title p {
margin: 4px 0 0;
color: var(--music-muted);
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;
overflow: hidden;
}
.music-featured > button {
width: 100%;
min-height: 174px;
padding: 18px;
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
align-items: center;
gap: 13px;
background: transparent;
color: inherit;
text-align: left;
}
.music-featured-copy {
min-width: 0;
}
.music-featured-copy small {
color: var(--music-accent);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.65px;
text-transform: uppercase;
}
.music-featured-copy h2 {
margin: 7px 0 2px;
display: -webkit-box;
overflow: hidden;
font-size: 24px;
line-height: 1.05;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.music-featured-copy p {
margin: 0;
overflow: hidden;
color: var(--music-muted);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-featured-copy span {
margin-top: 18px;
display: flex;
align-items: center;
gap: 5px;
color: var(--music-accent);
font-size: 13px;
font-weight: 700;
}
.music-featured-art,
.music-album-art,
.music-row-art,
.music-mini-art,
.music-player-art {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
color: rgb(255 255 255 / 90%);
box-shadow: 0 12px 28px rgb(0 0 0 / 22%);
}
.music-featured-art {
width: 132px;
aspect-ratio: 1;
border-radius: 18px;
}
.music-featured-art img,
.music-album-art img,
.music-row-art img,
.music-mini-art img,
.music-player-art img,
.music-playlist-tile img,
.music-playlist-art img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.music-album-grid,
.music-playlist-grid {
padding: 0 14px 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 12px;
}
.music-album-grid button,
.music-playlist-grid > button {
min-width: 0;
padding: 0;
background: transparent;
color: inherit;
text-align: left;
}
.music-album-art,
.music-playlist-tile {
width: 100%;
aspect-ratio: 1;
margin-bottom: 8px;
border-radius: 14px;
}
.music-album-art {
display: grid;
place-items: center;
}
.music-album-grid strong,
.music-album-grid small,
.music-playlist-grid strong,
.music-playlist-grid small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-album-grid strong,
.music-playlist-grid strong {
font-size: 14px;
font-weight: 650;
}
.music-album-grid small,
.music-playlist-grid small {
margin-top: 2px;
color: var(--music-muted);
font-size: 12px;
}
.music-track-list {
margin-top: 0;
}
.music-track-list :deep(li) {
min-height: 61px;
}
.music-track-list :deep(.text-sm) {
color: var(--music-muted);
}
.music-row-art {
width: 43px;
height: 43px;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
}
.music-track-list :deep(button) {
color: var(--music-accent);
}
.music-searchbar {
width: calc(100% - 24px);
margin: 0 12px 10px;
}
.music-playlist-tile,
.music-playlist-art {
display: grid;
grid-template-columns: repeat(2, 1fr);
overflow: hidden;
background: linear-gradient(145deg, #ff5877, #9b1caf);
color: #fff;
box-shadow: 0 12px 25px rgb(0 0 0 / 16%);
}
.music-playlist-tile i,
.music-playlist-art > div {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.music-playlist-placeholder {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
position: relative;
grid-column: 1 / -1;
grid-row: 1 / -1;
overflow: hidden;
}
.music-playlist-tile > svg,
.music-playlist-art > svg {
grid-column: 1 / -1;
align-self: center;
justify-self: center;
}
.music-playlist-hero {
padding: 14px 26px 22px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.music-playlist-art {
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 {
margin: 0 0 16px;
color: var(--music-muted);
font-size: 13px;
}
.music-playlist-hero :deep(button) {
width: 100%;
}
.music-playlist-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.music-playlist-actions :deep(button) {
min-width: 0;
padding-inline: 10px;
}
.music-mini-player {
position: absolute;
z-index: 32;
right: calc(var(--sky-safe-area-right) + var(--sky-space-4));
bottom: var(--music-mini-player-bottom);
left: calc(var(--sky-safe-area-left) + var(--sky-space-4));
height: var(--music-mini-player-height);
padding: 6px 10px 6px 7px;
border-radius: 17px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 36px 36px;
align-items: center;
gap: 7px;
overflow: hidden;
}
.music-app--playlist .music-mini-player {
bottom: 29px;
}
.music-mini-art {
width: 44px;
height: 44px;
border-radius: 10px;
box-shadow: 0 3px 9px rgb(0 0 0 / 20%);
}
.music-mini-copy {
min-width: 0;
}
.music-mini-copy strong,
.music-mini-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-mini-copy strong {
font-size: 13px;
}
.music-mini-copy small {
color: var(--music-muted);
font-size: 11px;
}
.music-mini-player > button,
.music-player-controls button {
display: grid;
place-items: center;
padding: 0;
background: transparent;
color: var(--music-label);
}
.music-navigation {
z-index: 31;
}
.music-navigation__item {
min-width: 0;
max-width: 100%;
display: flex;
align-items: center;
flex-direction: column;
gap: 2px;
line-height: 1;
}
.music-navigation__item > span:last-child {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-form-sheet,
.music-player-sheet {
--music-accent: #fa2d48;
display: contents;
color: var(--music-label);
}
.music-sheet-content {
min-height: 390px;
max-height: 690px;
padding: 0 15px 34px;
overflow-y: auto;
}
.music-sheet-content > header,
.music-player > header {
height: 54px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.music-sheet-content > header strong,
.music-player > header span {
font-size: 14px;
font-weight: 700;
}
.music-sheet-icon {
width: 62px;
height: 62px;
margin: 15px auto 9px;
border-radius: 18px;
display: grid;
place-items: center;
background: linear-gradient(145deg, #ff5877, #d5117b);
color: #fff;
box-shadow: 0 10px 25px rgb(213 17 123 / 24%);
}
.music-sheet-content > p {
margin: 0 auto 18px;
max-width: 290px;
color: var(--music-muted);
font-size: 13px;
line-height: 1.45;
text-align: center;
}
.music-sheet-content > :deep(button) {
margin-top: 15px;
}
.music-sheet-content .music-form-error {
margin: 10px 6px 0;
color: var(--music-accent);
text-align: left;
}
.music-picker-list {
margin-top: 12px;
}
.music-picker-added {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--music-muted);
font-size: 11px;
}
.music-picker-create {
width: calc(100% - 32px);
margin: 14px 16px 0 !important;
}
.music-player-sheet :deep(.sky-sheet__panel) {
background: rgb(22 22 25 / 96%) !important;
}
.music-player {
min-height: 740px;
padding: 0 27px 28px;
color: #fff;
background:
radial-gradient(circle at 50% 25%, rgb(250 45 72 / 18%), transparent 42%),
#151518;
}
.music-player > header {
color: rgb(255 255 255 / 68%);
}
.music-player > header :deep(button) {
justify-self: start;
color: #fff;
}
.music-player-header-actions {
justify-self: end;
display: flex;
align-items: center;
gap: 5px;
}
.music-player-art {
width: 100%;
aspect-ratio: 1;
margin-top: 25px;
border-radius: 24px;
box-shadow: 0 30px 65px rgb(0 0 0 / 42%);
}
.music-player-copy {
margin-top: 30px;
min-width: 0;
}
.music-player-copy strong,
.music-player-copy span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-player-copy strong {
font-size: 21px;
}
.music-player-copy span {
margin-top: 3px;
color: rgb(255 255 255 / 60%);
font-size: 18px;
}
.music-progress {
margin-top: 19px;
}
.music-progress > div {
margin-top: -2px;
display: flex;
justify-content: space-between;
color: rgb(255 255 255 / 48%);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.music-player-controls {
height: 104px;
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
}
.music-player-controls button {
color: #fff;
}
.music-player-controls button:active,
.music-mini-player > button:active {
transform: scale(0.9);
}
.music-volume {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 9px;
color: rgb(255 255 255 / 58%);
}
.music-player-error {
color: #ff6b76;
font-size: 12px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.music-player-controls button,
.music-mini-player > button {
transition: none;
}
}
</style>