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
+ {{ errorText() }} +
+ + + +{{ phone.t('Apps.music.addSongsBody') }}
+
{{ 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(() => {