mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
2147 lines
57 KiB
Vue
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>
|