diff --git a/frontend/src/stores/music.test.ts b/frontend/src/stores/music.test.ts index 5b18293..d56673f 100644 --- a/frontend/src/stores/music.test.ts +++ b/frontend/src/stores/music.test.ts @@ -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) + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index b4a4927..18208b1 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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.', diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue index 5564c54..9ba20b8 100644 --- a/frontend/src/views/apps/MusicApp.vue +++ b/frontend/src/views/apps/MusicApp.vue @@ -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(null) const searchQuery = ref('') const toastText = ref('') const confirmRemoveTrack = ref(false) const confirmDeletePlaylist = ref(false) const scrollEl = ref(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('.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 { } async function submitPlaylist(): Promise { + 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 { - 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 { + 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 { 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 { 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, ): Record { @@ -367,6 +473,10 @@ watch( onMounted(() => { void music.load() }) + +onBeforeUnmount(() => { + if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer) +}) + +