diff --git a/frontend/src/assets/img/music/playlist-placeholder.jpg b/frontend/src/assets/img/music/playlist-placeholder.jpg new file mode 100644 index 0000000..9919c08 Binary files /dev/null and b/frontend/src/assets/img/music/playlist-placeholder.jpg differ diff --git a/frontend/src/views/apps/MusicApp.menu.contract.test.ts b/frontend/src/views/apps/MusicApp.menu.contract.test.ts index 682782c..7c0c09f 100644 --- a/frontend/src/views/apps/MusicApp.menu.contract.test.ts +++ b/frontend/src/views/apps/MusicApp.menu.contract.test.ts @@ -3,43 +3,106 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./MusicApp.vue', import.meta.url), 'utf8') -const menuSource = source.slice( - source.indexOf('') + ''.length, +const playlistPlaceholder = readFileSync( + new URL('../../assets/img/music/playlist-placeholder.jpg', import.meta.url), +) +const menuSource = source.match(//)?.[0] ?? '' +const menuItemsSource = source.slice( + source.indexOf('const menuItems = computed'), + source.indexOf('function eventValue'), +) +const menuDispatcherSource = source.slice( + source.indexOf('function selectMenuItem'), + source.indexOf('function shareTrack'), ) describe('MusicApp action menu contract', () => { - it('uses the shared anchored Sky popover and its close paths', () => { - expect(source).toContain('SkyPopover,') + it('uses the shared anchored Sky dropdown and its close paths', () => { + expect(source).toContain('SkyDropdown,') expect(source).toContain('const menuTarget = ref(null)') expect(source.split('menuTarget.value = event.currentTarget')).toHaveLength( 3, ) - expect(menuSource).toContain( - ':opened="addMenuOpened || actionMenuOpened"', - ) + expect(menuSource).toContain(':items="menuItems"') + expect(menuSource).toContain(':label="menuLabel"') + expect(menuSource).toContain(':opened="addMenuOpened || actionMenuOpened"') expect(menuSource).toContain(':target="menuTarget"') expect(menuSource).toContain('@backdropclick="dismissMenus"') expect(menuSource).toContain('@escape="dismissMenus"') expect(menuSource).toContain('@positionerror="dismissMenus"') + expect(menuSource).toContain('@select="selectMenuItem"') }) - it('keeps every add, playlist, and track action in the central menu', () => { - expect(menuSource).toContain('@click="shareActivePlaylist"') - expect(menuSource).toContain('@click="openActivePlaylistTrackPicker"') - expect(menuSource).toContain('openSheet') - expect(menuSource).toContain('rename') - expect(menuSource).toContain('@click="requestDeletePlaylist"') - expect(menuSource).toContain('youtube') - expect(menuSource).toContain('@click="openNewPlaylist()"') - expect(menuSource).toContain('@click="shareTrack()"') - expect(menuSource).toContain('@click="openPlaylistPicker(actionTrack)"') - expect(menuSource).toContain('@click="removeFromActivePlaylist"') - expect(menuSource).toContain('@click="requestRemoveTrack"') - expect(menuSource.split('variant="danger"')).toHaveLength(4) + it('exposes accessible expanded state on every plus and more trigger', () => { + expect(source.match(/aria-haspopup="menu"/g)).toHaveLength(5) + expect(source.split(':aria-expanded="addMenuOpened"')).toHaveLength(3) + expect(source.split('actionTrack?.id === track.id')).toHaveLength(4) + expect(source.split('actionTrack?.source === track.source')).toHaveLength(4) }) - it('does not retain the manual popover geometry or dismiss overlay', () => { + it('keeps every add, playlist, and track action in the central dropdown', () => { + for (const id of [ + 'share-playlist', + 'add-songs', + 'rename-playlist', + 'delete-playlist', + 'add-youtube', + 'new-playlist', + 'share-track', + 'add-to-playlist', + 'remove-from-playlist', + 'remove-from-library', + ]) { + expect(menuItemsSource).toContain(`id: '${id}'`) + expect(menuDispatcherSource).toContain(`case '${id}'`) + } + + for (const action of [ + 'shareActivePlaylist()', + 'openActivePlaylistTrackPicker()', + "openSheet('rename')", + 'requestDeletePlaylist()', + "openSheet('youtube')", + 'openNewPlaylist()', + 'shareTrack()', + 'openPlaylistPicker(actionTrack.value)', + 'void removeFromActivePlaylist()', + 'requestRemoveTrack()', + ]) { + expect(menuDispatcherSource).toContain(action) + } + + expect(menuItemsSource.match(/destructive: true/g)).toHaveLength(3) + expect(menuItemsSource.match(/separatorBefore:/g)).toHaveLength(3) + }) + + it('uses the generated playlist artwork when no usable preview exists', () => { + expect(source).toContain( + "import playlistPlaceholder from '@/assets/img/music/playlist-placeholder.jpg'", + ) + expect(source).toContain("phone.t('Apps.music.playlistActions')") + expect(source).not.toContain('playlist-placeholder.png') + expect([...playlistPlaceholder.subarray(0, 3)]).toEqual([0xff, 0xd8, 0xff]) + expect(playlistPlaceholder.byteLength).toBeGreaterThan(80_000) + expect(playlistPlaceholder.byteLength).toBeLessThan(150_000) + expect(source).toContain('track.artwork.trim().length > 0') + expect(source).toContain('.filter(hasPlaylistArtwork)') + expect(source.match(/class="music-playlist-placeholder"/g)).toHaveLength(2) + expect(source.match(/@error="usePlaylistPlaceholder"/g)).toHaveLength(2) + expect(source).toContain('image.src = playlistPlaceholder') + expect(source).toContain( + 'imageUrl: playlistArtwork(playlist)[0]?.artwork || playlistPlaceholder', + ) + expect(source).toMatch( + /\.music-playlist-placeholder\s*\{[^}]*grid-column:\s*1 \/ -1;[^}]*grid-row:\s*1 \/ -1;/s, + ) + }) + + it('does not retain the generic or manually positioned popover menu', () => { + expect(source).not.toContain('SkyPopover,') + expect(source).not.toContain(' { 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(() => { + 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 ?? '' @@ -237,6 +317,41 @@ function openTrackMenu(event: MouseEvent, track: MusicTrack): void { 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 { @@ -265,6 +380,7 @@ function shareActivePlaylist(): void { 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', { @@ -416,8 +532,15 @@ async function deleteActivePlaylist(): Promise { } } -function playlistArtwork(playlist: MusicPlaylist): MusicTrack[] { - return music.tracksForPlaylist(playlist).slice(0, 4) +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 { @@ -449,6 +572,16 @@ function hideBrokenArtwork(event: Event): void { 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) @@ -554,6 +687,8 @@ onBeforeUnmount(() => { component="button" icon-only :aria-label="phone.t('Apps.music.playlistActions')" + aria-haspopup="menu" + :aria-expanded="addMenuOpened" @click="openAddMenu" > @@ -563,6 +698,8 @@ onBeforeUnmount(() => { component="button" icon-only :aria-label="phone.t('Apps.music.addMusic')" + aria-haspopup="menu" + :aria-expanded="addMenuOpened" @click="openAddMenu" > @@ -589,14 +726,15 @@ onBeforeUnmount(() => { :style="fallbackArtwork(track)" > - + + +

{{ activePlaylist.name }}

@@ -657,6 +795,12 @@ onBeforeUnmount(() => { 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)" > @@ -761,6 +905,12 @@ onBeforeUnmount(() => { 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)" > @@ -800,14 +950,15 @@ onBeforeUnmount(() => { :style="fallbackArtwork(track)" > - + + + {{ playlist.name }} @@ -867,6 +1018,12 @@ onBeforeUnmount(() => { 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)" > @@ -956,82 +1113,16 @@ onBeforeUnmount(() => { - - - - - - - + @select="selectMenuItem" + />

@@ -1729,6 +1820,17 @@ onBeforeUnmount(() => { 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;