mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-01 10:48:55 +00:00
feat: add playlist song picker
This commit is contained in:
@@ -10,6 +10,9 @@ import {
|
||||
} from 'vitest'
|
||||
|
||||
import type { MusicTrack } from '@/types/music'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
class FakeAudio extends EventTarget {
|
||||
static latest: FakeAudio | null = null
|
||||
@@ -117,6 +120,7 @@ beforeEach(() => {
|
||||
createObjectUrlMock.mockReset()
|
||||
createObjectUrlMock.mockReturnValue('blob:sky-music')
|
||||
revokeObjectUrlMock.mockReset()
|
||||
vi.mocked(nuiCall).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -198,3 +202,52 @@ describe('music server playback', () => {
|
||||
expect(createObjectUrlMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('music playlists', () => {
|
||||
it('adds a track with the expected payload and applies the refreshed playlist', async () => {
|
||||
const serverTrack = track('night-drive')
|
||||
vi.mocked(nuiCall).mockResolvedValueOnce({
|
||||
data: {
|
||||
playlists: [
|
||||
{
|
||||
createdAt: 1,
|
||||
entries: [{ songId: serverTrack.id, source: serverTrack.source }],
|
||||
id: 'playlist-1',
|
||||
name: 'Night Ride',
|
||||
},
|
||||
],
|
||||
serverTracks: [serverTrack],
|
||||
youtubeTracks: [],
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const music = useMusicStore()
|
||||
|
||||
const success = await music.addToPlaylist('playlist-1', serverTrack)
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenCalledWith('music:add-to-playlist', {
|
||||
playlistId: 'playlist-1',
|
||||
songId: 'night-drive',
|
||||
source: 'server',
|
||||
})
|
||||
expect(music.playlists[0]?.entries).toEqual([
|
||||
{ songId: 'night-drive', source: 'server' },
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces a duplicate-track error and releases the loading state', async () => {
|
||||
const serverTrack = track('night-drive')
|
||||
vi.mocked(nuiCall).mockResolvedValueOnce({
|
||||
error: 'song_already_in_playlist',
|
||||
success: false,
|
||||
})
|
||||
const music = useMusicStore()
|
||||
|
||||
const success = await music.addToPlaylist('playlist-1', serverTrack)
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(music.error).toBe('song_already_in_playlist')
|
||||
expect(music.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1866,10 +1866,16 @@ const defaultLocales: LocaleTree = {
|
||||
songRemoved: 'Song removed from your library.',
|
||||
newPlaylist: 'New Playlist',
|
||||
renamePlaylist: 'Rename Playlist',
|
||||
addSongs: 'Add Songs',
|
||||
addSongsBody: 'Choose songs from your library for this playlist.',
|
||||
allSongsAdded: 'All Songs Added',
|
||||
allSongsAddedBody:
|
||||
'Every song in your library is already in this playlist.',
|
||||
playlistBody: 'Give this playlist a name you will recognize.',
|
||||
playlistName: 'Playlist Name',
|
||||
playlistPlaceholder: 'My Playlist',
|
||||
playlistCreated: 'Playlist created.',
|
||||
playlistCreatedWithSong: 'Playlist created and song added.',
|
||||
playlistRenamed: 'Playlist renamed.',
|
||||
playlistDeleted: 'Playlist deleted.',
|
||||
playlistActions: 'Playlist actions',
|
||||
@@ -1880,6 +1886,7 @@ const defaultLocales: LocaleTree = {
|
||||
choosePlaylist: 'Choose a Playlist',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
addedToPlaylist: 'Added to playlist.',
|
||||
alreadyAdded: 'Added',
|
||||
removeFromPlaylist: 'Remove from Playlist',
|
||||
removedFromPlaylist: 'Removed from playlist.',
|
||||
createPlaylistFirst: 'Create a playlist before adding this song.',
|
||||
@@ -1910,6 +1917,7 @@ const defaultLocales: LocaleTree = {
|
||||
playlist_not_found: 'That playlist no longer exists.',
|
||||
playlist_song_limit: 'That playlist has reached its song limit.',
|
||||
invalid_song: 'That song cannot be added to this playlist.',
|
||||
song_already_in_playlist: 'That song is already in this playlist.',
|
||||
rate_limited: 'Too many music changes. Try again shortly.',
|
||||
playback_failed: 'This song could not be played.',
|
||||
request_failed: 'Music is temporarily unavailable.',
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
kToolbarPane,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
Check,
|
||||
CirclePlus,
|
||||
Ellipsis,
|
||||
ExternalLink,
|
||||
@@ -43,19 +44,25 @@ import {
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useMusicStore } from '@/stores/music'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MusicPlaylist, MusicTrack } from '@/types/music'
|
||||
|
||||
type MusicTab = 'library' | 'playlists' | 'search'
|
||||
type MusicSheet = 'playlist' | 'playlist-picker' | 'rename' | 'youtube'
|
||||
type MusicSheet =
|
||||
| 'playlist'
|
||||
| 'playlist-picker'
|
||||
| 'rename'
|
||||
| 'track-picker'
|
||||
| 'youtube'
|
||||
|
||||
const MUSIC_POPOVER_WIDTH = 240
|
||||
const MUSIC_POPOVER_ITEM_HEIGHT = 52
|
||||
const MUSIC_POPOVER_INSET = 8
|
||||
const MUSIC_POPOVER_GAP = 7
|
||||
const MUSIC_SHEET_TRANSITION_MS = 420
|
||||
|
||||
const music = useMusicStore()
|
||||
const phone = usePhoneStore()
|
||||
@@ -74,11 +81,13 @@ 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())
|
||||
@@ -93,12 +102,19 @@ const searchResults = computed(() => {
|
||||
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')
|
||||
@@ -128,6 +144,11 @@ function closeMenus(): void {
|
||||
actionMenuOpened.value = false
|
||||
}
|
||||
|
||||
function dismissMenus(): void {
|
||||
closeMenus()
|
||||
actionTrack.value = null
|
||||
}
|
||||
|
||||
function positionPopover(target: HTMLElement, itemCount: number): boolean {
|
||||
const app = target.closest<HTMLElement>('.music-app')
|
||||
if (!app) return false
|
||||
@@ -168,7 +189,8 @@ function positionPopover(target: HTMLElement, itemCount: number): boolean {
|
||||
|
||||
function openAddMenu(event: MouseEvent): void {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (!positionPopover(target, 2)) return
|
||||
if (!positionPopover(target, activePlaylist.value ? 3 : 2)) return
|
||||
actionTrack.value = null
|
||||
actionMenuOpened.value = false
|
||||
addMenuOpened.value = true
|
||||
}
|
||||
@@ -190,6 +212,37 @@ 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 {
|
||||
@@ -202,6 +255,11 @@ function requestRemoveTrack(): void {
|
||||
confirmRemoveTrack.value = true
|
||||
}
|
||||
|
||||
function cancelRemoveTrack(): void {
|
||||
confirmRemoveTrack.value = false
|
||||
actionTrack.value = null
|
||||
}
|
||||
|
||||
function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
|
||||
event.stopPropagation()
|
||||
actionTrack.value = track
|
||||
@@ -262,29 +320,63 @@ async function submitYouTube(): Promise<void> {
|
||||
}
|
||||
|
||||
async function submitPlaylist(): Promise<void> {
|
||||
if (music.isLoading) return
|
||||
const name = playlistName.value.trim()
|
||||
if (!name) return
|
||||
const success =
|
||||
activeSheet.value === 'rename' && activePlaylist.value
|
||||
? await music.renamePlaylist(activePlaylist.value.id, name)
|
||||
: await music.createPlaylist(name)
|
||||
if (!success) 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> {
|
||||
if (!actionTrack.value) return
|
||||
if (await music.addToPlaylist(playlist.id, actionTrack.value)) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -296,6 +388,7 @@ async function removeFromActivePlaylist(): Promise<void> {
|
||||
activePlaylist.value =
|
||||
music.playlists.find((playlist) => playlist.id === playlistId) ?? null
|
||||
actionMenuOpened.value = false
|
||||
actionTrack.value = null
|
||||
showToast('Apps.music.removedFromPlaylist')
|
||||
}
|
||||
}
|
||||
@@ -305,6 +398,7 @@ async function removePersonalTrack(): Promise<void> {
|
||||
if (await music.removeYouTube(actionTrack.value.id)) {
|
||||
confirmRemoveTrack.value = false
|
||||
actionMenuOpened.value = false
|
||||
actionTrack.value = null
|
||||
showToast('Apps.music.songRemoved')
|
||||
}
|
||||
}
|
||||
@@ -322,6 +416,18 @@ function playlistArtwork(playlist: MusicPlaylist): MusicTrack[] {
|
||||
return music.tracksForPlaylist(playlist).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> {
|
||||
@@ -367,6 +473,10 @@ watch(
|
||||
onMounted(() => {
|
||||
void music.load()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -448,17 +558,29 @@ onMounted(() => {
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
:disabled="!playlistTracks.length"
|
||||
@click="
|
||||
playlistTracks[0] && playTrack(playlistTracks[0], playlistTracks)
|
||||
"
|
||||
>
|
||||
<Play :size="18" fill="currentColor" />
|
||||
{{ phone.t('Apps.music.play') }}
|
||||
</k-button>
|
||||
<div class="music-playlist-actions">
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
:disabled="!playlistTracks.length"
|
||||
@click="
|
||||
playlistTracks[0] &&
|
||||
playTrack(playlistTracks[0], playlistTracks)
|
||||
"
|
||||
>
|
||||
<Play :size="18" fill="currentColor" />
|
||||
{{ phone.t('Apps.music.play') }}
|
||||
</k-button>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
tonal
|
||||
@click="openActivePlaylistTrackPicker"
|
||||
>
|
||||
<CirclePlus :size="18" />
|
||||
{{ phone.t('Apps.music.addSongs') }}
|
||||
</k-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<k-list v-if="playlistTracks.length" nested class="music-track-list">
|
||||
@@ -649,7 +771,7 @@ onMounted(() => {
|
||||
<ListMusic :size="48" />
|
||||
<strong>{{ phone.t('Apps.music.noPlaylists') }}</strong>
|
||||
<span>{{ phone.t('Apps.music.noPlaylistsBody') }}</span>
|
||||
<k-button rounded @click="openSheet('playlist')">
|
||||
<k-button rounded @click="openNewPlaylist()">
|
||||
<Plus :size="17" />
|
||||
{{ phone.t('Apps.music.newPlaylist') }}
|
||||
</k-button>
|
||||
@@ -797,7 +919,7 @@ onMounted(() => {
|
||||
class="music-popover-dismiss"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@click="closeMenus"
|
||||
@click="dismissMenus"
|
||||
/>
|
||||
<section
|
||||
v-if="addMenuOpened"
|
||||
@@ -812,6 +934,12 @@ onMounted(() => {
|
||||
>
|
||||
<k-list nested>
|
||||
<template v-if="activePlaylist">
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
@click="openActivePlaylistTrackPicker"
|
||||
>
|
||||
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addSongs') }}
|
||||
</k-list-button>
|
||||
<k-list-button link-component="button" @click="openSheet('rename')">
|
||||
<ListMusic :size="18" />
|
||||
{{ phone.t('Apps.music.renamePlaylist') }}
|
||||
@@ -828,7 +956,7 @@ onMounted(() => {
|
||||
<k-list-button link-component="button" @click="openSheet('youtube')">
|
||||
<ExternalLink :size="18" /> {{ phone.t('Apps.music.addYouTube') }}
|
||||
</k-list-button>
|
||||
<k-list-button link-component="button" @click="openSheet('playlist')">
|
||||
<k-list-button link-component="button" @click="openNewPlaylist()">
|
||||
<ListMusic :size="18" /> {{ phone.t('Apps.music.newPlaylist') }}
|
||||
</k-list-button>
|
||||
</template>
|
||||
@@ -849,7 +977,7 @@ onMounted(() => {
|
||||
<k-list nested>
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
@click="openSheet('playlist-picker')"
|
||||
@click="openPlaylistPicker(actionTrack)"
|
||||
>
|
||||
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
|
||||
</k-list-button>
|
||||
@@ -880,7 +1008,9 @@ onMounted(() => {
|
||||
<section class="music-sheet-content">
|
||||
<header>
|
||||
<k-link component="button" @click="closeSheet">{{
|
||||
phone.t('Common.cancel')
|
||||
phone.t(
|
||||
activeSheet === 'track-picker' ? 'Common.done' : 'Common.cancel',
|
||||
)
|
||||
}}</k-link>
|
||||
<strong>{{ sheetTitle }}</strong>
|
||||
<span />
|
||||
@@ -943,7 +1073,13 @@ onMounted(() => {
|
||||
<k-list-item
|
||||
v-for="playlist in music.playlists"
|
||||
:key="playlist.id"
|
||||
link
|
||||
: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', {
|
||||
@@ -953,15 +1089,94 @@ onMounted(() => {
|
||||
@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>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<k-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') }}
|
||||
</k-button>
|
||||
<k-block v-else class="music-empty" inset>
|
||||
<ListMusic :size="42" />
|
||||
<strong>{{ phone.t('Apps.music.noPlaylists') }}</strong>
|
||||
<span>{{ phone.t('Apps.music.createPlaylistFirst') }}</span>
|
||||
<k-button rounded @click="openSheet('playlist')">{{
|
||||
phone.t('Apps.music.newPlaylist')
|
||||
}}</k-button>
|
||||
<k-button
|
||||
rounded
|
||||
:disabled="music.isLoading"
|
||||
@click="openNewPlaylist(actionTrack)"
|
||||
>{{ phone.t('Apps.music.newPlaylist') }}</k-button
|
||||
>
|
||||
</k-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>
|
||||
<k-list
|
||||
v-if="availablePlaylistTracks.length"
|
||||
inset
|
||||
strong
|
||||
class="music-picker-list"
|
||||
>
|
||||
<k-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>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<k-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>
|
||||
</k-block>
|
||||
<p v-if="music.error" class="music-form-error" role="alert">
|
||||
{{ errorText() }}
|
||||
@@ -976,6 +1191,7 @@ onMounted(() => {
|
||||
: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)"
|
||||
@@ -1014,7 +1230,14 @@ onMounted(() => {
|
||||
<X :size="20" />
|
||||
</k-link>
|
||||
<span>{{ phone.t('Apps.music.nowPlaying') }}</span>
|
||||
<i />
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.music.addToPlaylist')"
|
||||
@click="openCurrentTrackPlaylistPicker"
|
||||
>
|
||||
<CirclePlus :size="21" />
|
||||
</k-link>
|
||||
</header>
|
||||
<div
|
||||
class="music-player-art"
|
||||
@@ -1096,10 +1319,10 @@ onMounted(() => {
|
||||
:opened="confirmRemoveTrack"
|
||||
:title="phone.t('Apps.music.removeSongTitle')"
|
||||
:content="phone.t('Apps.music.removeSongBody')"
|
||||
@backdropclick="confirmRemoveTrack = false"
|
||||
@backdropclick="cancelRemoveTrack"
|
||||
>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="confirmRemoveTrack = false">{{
|
||||
<k-dialog-button @click="cancelRemoveTrack">{{
|
||||
phone.t('Common.cancel')
|
||||
}}</k-dialog-button>
|
||||
<k-dialog-button strong @click="removePersonalTrack">{{
|
||||
@@ -1494,6 +1717,18 @@ onMounted(() => {
|
||||
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;
|
||||
@@ -1627,6 +1862,19 @@ onMounted(() => {
|
||||
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-popover-dismiss {
|
||||
position: absolute;
|
||||
z-index: 199;
|
||||
@@ -1688,6 +1936,10 @@ onMounted(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.music-player > header > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.music-player-art {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
|
||||
@@ -1675,17 +1675,22 @@ app.post('/api/:endpoint', async (request, response) => {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!playlist.entries.some(
|
||||
playlist.entries.some(
|
||||
(entry) =>
|
||||
entry.source === request.body.source &&
|
||||
entry.songId === request.body.songId,
|
||||
)
|
||||
) {
|
||||
playlist.entries.push({
|
||||
source: request.body.source,
|
||||
songId: request.body.songId,
|
||||
response.json({
|
||||
success: false,
|
||||
error: 'song_already_in_playlist',
|
||||
})
|
||||
return
|
||||
}
|
||||
playlist.entries.push({
|
||||
source: request.body.source,
|
||||
songId: request.body.songId,
|
||||
})
|
||||
response.json({ success: true, data: musicBootstrap() })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -704,11 +704,14 @@ Locales["en"] = {
|
||||
youtubeTitlePlaceholder = "Use the YouTube video title", youtubeArtist = "Artist (optional)",
|
||||
youtubeArtistPlaceholder = "Use the YouTube channel", addToLibrary = "Add to Library",
|
||||
songAdded = "Song added to your library.", songRemoved = "Song removed from your library.",
|
||||
newPlaylist = "New Playlist", renamePlaylist = "Rename Playlist", playlistBody = "Give this playlist a name you will recognize.",
|
||||
newPlaylist = "New Playlist", renamePlaylist = "Rename Playlist", addSongs = "Add Songs",
|
||||
addSongsBody = "Choose songs from your library for this playlist.", allSongsAdded = "All Songs Added",
|
||||
allSongsAddedBody = "Every song in your library is already in this playlist.", playlistBody = "Give this playlist a name you will recognize.",
|
||||
playlistName = "Playlist Name", playlistPlaceholder = "My Playlist", playlistCreated = "Playlist created.",
|
||||
playlistCreatedWithSong = "Playlist created and song added.",
|
||||
playlistRenamed = "Playlist renamed.", playlistDeleted = "Playlist deleted.", playlistActions = "Playlist actions",
|
||||
deletePlaylist = "Delete Playlist", deletePlaylistTitle = "Delete Playlist?", deletePlaylistBody = "The playlist will be deleted. Songs stay in your library.",
|
||||
choosePlaylist = "Choose a Playlist", addToPlaylist = "Add to Playlist", addedToPlaylist = "Added to playlist.",
|
||||
choosePlaylist = "Choose a Playlist", addToPlaylist = "Add to Playlist", addedToPlaylist = "Added to playlist.", alreadyAdded = "Added",
|
||||
removeFromPlaylist = "Remove from Playlist", removedFromPlaylist = "Removed from playlist.", createPlaylistFirst = "Create a playlist before adding this song.",
|
||||
songActions = "Song actions", removeFromLibrary = "Remove from Library", removeSongTitle = "Remove Song?",
|
||||
removeSongBody = "This YouTube song will also be removed from your playlists.", songCount = "{count} songs",
|
||||
@@ -722,7 +725,7 @@ Locales["en"] = {
|
||||
song_limit = "Your personal song limit has been reached.", song_not_found = "That song is no longer in your library.",
|
||||
invalid_playlist = "Choose a valid playlist name.", playlist_limit = "Your playlist limit has been reached.",
|
||||
playlist_not_found = "That playlist no longer exists.", playlist_song_limit = "That playlist has reached its song limit.",
|
||||
invalid_song = "That song cannot be added to this playlist.", rate_limited = "Too many music changes. Try again shortly.",
|
||||
invalid_song = "That song cannot be added to this playlist.", song_already_in_playlist = "That song is already in this playlist.", rate_limited = "Too many music changes. Try again shortly.",
|
||||
playback_failed = "This song could not be played.", request_failed = "Music is temporarily unavailable.", default = "The Music request failed.",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -190,6 +190,28 @@ local function affected_rows(result)
|
||||
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
end
|
||||
|
||||
local playlist_add_locks = {}
|
||||
|
||||
local function with_playlist_add_lock(playlist_id, callback)
|
||||
local previous_lock = playlist_add_locks[playlist_id]
|
||||
local current_lock = promise.new()
|
||||
playlist_add_locks[playlist_id] = current_lock
|
||||
|
||||
if previous_lock then
|
||||
Citizen.Await(previous_lock)
|
||||
end
|
||||
|
||||
local success, result = xpcall(callback, debug.traceback)
|
||||
current_lock:resolve(true)
|
||||
if playlist_add_locks[playlist_id] == current_lock then
|
||||
playlist_add_locks[playlist_id] = nil
|
||||
end
|
||||
if not success then
|
||||
error(result, 0)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function uuid()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = rows[1] and rows[1].id
|
||||
@@ -701,27 +723,40 @@ Bridge.Callbacks.Register("sky_phone:music:add-to-playlist", function(source, da
|
||||
return { success = false, error = "invalid_song" }
|
||||
end
|
||||
|
||||
local counts = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`
|
||||
FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
|
||||
]], { playlist_id })
|
||||
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
|
||||
return { success = false, error = "playlist_song_limit" }
|
||||
local mutation_error = with_playlist_add_lock(playlist_id, function()
|
||||
local counts = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`,
|
||||
COALESCE(MAX(`source` = ? AND `song_id` = ?), 0) AS `already_exists`
|
||||
FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
|
||||
]], { source_type, song_id, playlist_id })
|
||||
if tonumber(counts[1] and counts[1].already_exists) == 1 then
|
||||
return "song_already_in_playlist"
|
||||
end
|
||||
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
|
||||
return "playlist_song_limit"
|
||||
end
|
||||
local insert_result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_music_playlist_items`
|
||||
(`playlist_id`, `source`, `song_id`, `position`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
]], {
|
||||
playlist_id,
|
||||
source_type,
|
||||
song_id,
|
||||
(tonumber(counts[1] and counts[1].last_position) or 0) + 1,
|
||||
})
|
||||
if affected_rows(insert_result) ~= 1 then
|
||||
return "song_already_in_playlist"
|
||||
end
|
||||
Bridge.Database.Query(
|
||||
"UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
|
||||
{ playlist_id }
|
||||
)
|
||||
return nil
|
||||
end)
|
||||
if mutation_error then
|
||||
return { success = false, error = mutation_error }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_music_playlist_items`
|
||||
(`playlist_id`, `source`, `song_id`, `position`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
]], {
|
||||
playlist_id,
|
||||
source_type,
|
||||
song_id,
|
||||
(tonumber(counts[1] and counts[1].last_position) or 0) + 1,
|
||||
})
|
||||
Bridge.Database.Query(
|
||||
"UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
|
||||
{ playlist_id }
|
||||
)
|
||||
return { success = true, data = bootstrap(account_id, imei) }
|
||||
end)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user