feat: allow refreshing playlists (#1579)

This commit is contained in:
Phan An 2022-11-08 20:35:18 +01:00 committed by GitHub
parent 3b15622693
commit 1e12e55de3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 186 additions and 138 deletions

View file

@ -23,14 +23,14 @@ new class extends UnitTestCase {
screen: 'Playlist'
}, { id: playlist.id.toString() })
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))
return { rendered, fetchMock }
}
protected test () {
it('renders the playlist', async () => {
const { getByTestId, queryByTestId } = (await this.renderComponent(factory<Song>('song', 10))).rendered
const { rendered: { getByTestId, queryByTestId } } = (await this.renderComponent(factory<Song>('song', 10)))
await waitFor(() => {
getByTestId('song-list')
@ -39,7 +39,7 @@ new class extends UnitTestCase {
})
it('displays the empty state if playlist is empty', async () => {
const { getByTestId, queryByTestId } = (await this.renderComponent([])).rendered
const { rendered: { getByTestId, queryByTestId } } = (await this.renderComponent([]))
await waitFor(() => {
getByTestId('screen-empty-state')
@ -49,7 +49,7 @@ new class extends UnitTestCase {
it('downloads the playlist', async () => {
const downloadMock = this.mock(downloadService, 'fromPlaylist')
const { getByText } = (await this.renderComponent(factory<Song>('song', 10))).rendered
const { rendered: { getByText } } = (await this.renderComponent(factory<Song>('song', 10)))
await this.tick()
await fireEvent.click(getByText('Download All'))
@ -58,14 +58,20 @@ new class extends UnitTestCase {
})
it('deletes the playlist', async () => {
const { getByTitle } = (await this.renderComponent([])).rendered
// mock *after* rendering to not tamper with "ACTIVATE_SCREEN" emission
const emitMock = this.mock(eventBus, 'emit')
const { rendered: { getByTitle } } = (await this.renderComponent([]))
await fireEvent.click(getByTitle('Delete this playlist'))
await waitFor(() => expect(emitMock).toHaveBeenCalledWith('PLAYLIST_DELETE', playlist))
})
it('refreshes the playlist', async () => {
const { rendered: { getByTitle }, fetchMock } = (await this.renderComponent([]))
await fireEvent.click(getByTitle('Refresh'))
expect(fetchMock).toHaveBeenCalledWith(playlist, true)
})
}
}

View file

@ -1,6 +1,6 @@
<template>
<section v-if="playlist" id="playlistWrapper">
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout">
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout" :disabled="loading">
{{ playlist.name }}
<ControlsToggle v-if="songs.length" v-model="showingControls"/>
@ -29,11 +29,12 @@
@deletePlaylist="destroy"
@playAll="playAll"
@playSelected="playSelected"
@refresh="fetchSongs(true)"
/>
</template>
</ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongListSkeleton v-show="loading"/>
<SongList
v-if="!loading && songs.length"
ref="songList"
@ -83,7 +84,10 @@ const playlistId = ref<number>()
const playlist = ref<Playlist>()
const loading = ref(false)
const controlsConfig: Partial<SongListControlsConfig> = { deletePlaylist: true }
const controlsConfig: Partial<SongListControlsConfig> = {
deletePlaylist: true,
refresh: true
}
const {
SongList,
@ -115,9 +119,9 @@ const editPlaylist = () => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playli
const removeSelected = async () => await removeSongsFromPlaylist(playlist.value!, selectedSongs.value)
const fetchSongs = async () => {
const fetchSongs = async (refresh = false) => {
loading.value = true
songs.value = await songStore.fetchForPlaylist(playlist.value!)
songs.value = await songStore.fetchForPlaylist(playlist.value!, refresh)
loading.value = false
sort()
}

View file

@ -6,7 +6,7 @@
<template v-slot:controls>
<BtnGroup uppercased v-if="hasUploadFailures">
<Btn data-testid="upload-retry-all-btn" green @click="retryAll">
<icon :icon="faRotateBack"/>
<icon :icon="faRotateRight"/>
Retry All
</Btn>
<Btn data-testid="upload-remove-all-btn" orange @click="removeFailedEntries">
@ -58,7 +58,7 @@
</template>
<script lang="ts" setup>
import { faRotateBack, faTrashCan, faUpload, faWarning } from '@fortawesome/free-solid-svg-icons'
import { faRotateRight, faTrashCan, faUpload, faWarning } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, ref, toRef } from 'vue'
import { isDirectoryReadingSupported as canDropFolders } from '@/utils'

View file

@ -1,38 +1,6 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<section id="songsWrapper">
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5="">
<div class="thumbnail-stack single" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-v-55bfc268="" data-v-5691beb5-s=""><span data-testid="thumbnail" data-v-55bfc268=""></span></div>
</aside>
<main data-v-5691beb5="">
<div class="heading-wrapper" data-v-5691beb5="">
<h1 class="name" data-v-5691beb5=""> All Songs
<!--v-if-->
</h1><span class="meta text-secondary" data-v-5691beb5=""><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34:17:36</span></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="" style="display: none;">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
</ul>
</section>
<section class="new-playlist" data-testid="new-playlist" data-v-42061e3e="">
<p data-v-42061e3e="">or create a new playlist</p>
<form class="form-save form-simple form-new-playlist" data-v-42061e3e=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-42061e3e=""><button type="submit" title="Save" data-v-e368fe26="" data-v-42061e3e="">⏎</button></form>
</section>
</div>
</div>
</main>
</header><br data-testid="song-list">
</section>
`;
exports[`renders 2`] = `
<section id="songsWrapper">
<header class="screen-header expanded" data-v-5691beb5="">
<aside class="thumbnail-wrapper" data-v-5691beb5="">
@ -44,7 +12,8 @@ exports[`renders 2`] = `
<!--v-if-->
</h1><span class="meta text-secondary" data-v-5691beb5=""><span data-v-5691beb5-s="">420 songs</span><span data-v-5691beb5-s="">34 hr 17 min</span></span>
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s="">
<div class="wrapper" data-v-d396e0d2=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--></span><span class="btn-group" data-v-e884c19a="" data-v-d396e0d2=""></span></div>
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="" style="display: none;">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>

View file

@ -1,83 +1,90 @@
<template>
<div class="song-list-controls" data-testid="song-list-controls" ref="el">
<BtnGroup uppercased>
<template v-if="mergedConfig.play">
<template v-if="altPressed">
<Btn
v-if="selectedSongs.length < 2 && songs.length"
class="btn-play-all"
orange
title="Play all songs"
@click.prevent="playAll"
>
<icon :icon="faPlay" fixed-width/>
All
</Btn>
<div ref="el" class="song-list-controls" data-testid="song-list-controls">
<div class="wrapper">
<BtnGroup uppercased>
<template v-if="mergedConfig.play">
<template v-if="altPressed">
<Btn
v-if="selectedSongs.length < 2 && songs.length"
class="btn-play-all"
orange
title="Play all songs"
@click.prevent="playAll"
>
<icon :icon="faPlay" fixed-width/>
All
</Btn>
<Btn
v-if="selectedSongs.length > 1"
class="btn-play-selected"
orange
title="Play selected songs"
@click.prevent="playSelected"
>
<icon :icon="faPlay" fixed-width/>
Selected
</Btn>
<Btn
v-if="selectedSongs.length > 1"
class="btn-play-selected"
orange
title="Play selected songs"
@click.prevent="playSelected"
>
<icon :icon="faPlay" fixed-width/>
Selected
</Btn>
</template>
<template v-else>
<Btn
v-if="selectedSongs.length < 2 && songs.length"
class="btn-shuffle-all"
data-testid="btn-shuffle-all"
orange
title="Shuffle all songs"
@click.prevent="shuffle"
>
<icon :icon="faRandom" fixed-width/>
All
</Btn>
<Btn
v-if="selectedSongs.length > 1"
class="btn-shuffle-selected"
data-testid="btn-shuffle-selected"
orange
title="Shuffle selected songs"
@click.prevent="shuffleSelected"
>
<icon :icon="faRandom" fixed-width/>
Selected
</Btn>
</template>
</template>
<template v-else>
<Btn
v-if="selectedSongs.length < 2 && songs.length"
class="btn-shuffle-all"
data-testid="btn-shuffle-all"
orange
title="Shuffle all songs"
@click.prevent="shuffle"
>
<icon :icon="faRandom" fixed-width/>
All
</Btn>
<Btn
v-if="selectedSongs.length"
:title="`${showingAddToMenu ? 'Cancel' : 'Add selected songs to…'}`"
class="btn-add-to"
data-testid="add-to-btn"
green
@click.prevent.stop="toggleAddToMenu"
>
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
</Btn>
<Btn
v-if="selectedSongs.length > 1"
class="btn-shuffle-selected"
data-testid="btn-shuffle-selected"
orange
title="Shuffle selected songs"
@click.prevent="shuffleSelected"
>
<icon :icon="faRandom" fixed-width/>
Selected
</Btn>
</template>
</template>
<Btn v-if="showClearQueueButton" red title="Clear current queue" @click.prevent="clearQueue">Clear</Btn>
</BtnGroup>
<Btn
v-if="selectedSongs.length"
:title="`${showingAddToMenu ? 'Cancel' : 'Add selected songs to…'}`"
class="btn-add-to"
data-testid="add-to-btn"
green
@click.prevent.stop="toggleAddToMenu"
>
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
</Btn>
<BtnGroup>
<Btn v-if="config.refresh" v-koel-tooltip green title="Refresh" @click.prevent="refresh">
<icon :icon="faRotateRight" fixed-width/>
</Btn>
<Btn v-if="showClearQueueButton" red title="Clear current queue" @click.prevent="clearQueue">Clear</Btn>
<Btn
v-if="showDeletePlaylistButton"
v-koel-tooltip
class="del btn-delete-playlist"
red
title="Delete this playlist"
@click.prevent="deletePlaylist"
>
<icon :icon="faTrashCan"/>
</Btn>
</BtnGroup>
<Btn
v-if="showDeletePlaylistButton"
v-koel-tooltip
class="del btn-delete-playlist"
red
title="Delete this playlist"
@click.prevent="deletePlaylist"
>
<icon :icon="faTrashCan"/>
</Btn>
</BtnGroup>
</div>
<AddToMenu
v-koel-clickaway="closeAddToMenu"
@ -90,7 +97,7 @@
</template>
<script lang="ts" setup>
import { faPlay, faRandom, faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs } from 'vue'
import { SelectedSongsKey, SongsKey } from '@/symbols'
import { requireInjection } from '@/utils'
@ -118,14 +125,15 @@ const mergedConfig = computed((): SongListControlsConfig => Object.assign({
newPlaylist: true
},
clearQueue: false,
deletePlaylist: false
deletePlaylist: false,
refresh: false
}, config.value)
)
const showClearQueueButton = computed(() => mergedConfig.value.clearQueue)
const showDeletePlaylistButton = computed(() => mergedConfig.value.deletePlaylist)
const emit = defineEmits(['playAll', 'playSelected', 'clearQueue', 'deletePlaylist'])
const emit = defineEmits(['playAll', 'playSelected', 'clearQueue', 'deletePlaylist', 'refresh'])
const shuffle = () => emit('playAll', true)
const shuffleSelected = () => emit('playSelected', true)
@ -133,6 +141,7 @@ const playAll = () => emit('playAll', false)
const playSelected = () => emit('playSelected', false)
const clearQueue = () => emit('clearQueue')
const deletePlaylist = () => emit('deletePlaylist')
const refresh = () => emit('refresh')
const closeAddToMenu = () => (showingAddToMenu.value = false)
const registerKeydown = (event: KeyboardEvent) => event.key === 'Alt' && (altPressed.value = true)
const registerKeyup = (event: KeyboardEvent) => event.key === 'Alt' && (altPressed.value = false)
@ -168,5 +177,10 @@ onUnmounted(() => {
<style lang="scss" scoped>
.song-list-controls {
position: relative;
.wrapper {
display: flex;
gap: .5rem;
}
}
</style>

View file

@ -8,7 +8,7 @@
.btn-group {
--radius: 5px;
display: flex;
display: inline-flex;
position: relative;
flex-wrap: nowrap;

View file

@ -1,5 +1,5 @@
<template>
<header class="screen-header" :class="layout">
<header class="screen-header" :class="[ layout, disabled ? 'disabled' : '' ]">
<aside class="thumbnail-wrapper">
<slot name="thumbnail"></slot>
</aside>
@ -20,7 +20,13 @@
</template>
<script lang="ts" setup>
const props = withDefaults(defineProps<{ layout?: ScreenHeaderLayout }>(), { layout: 'expanded' })
const props = withDefaults(defineProps<{
layout?: ScreenHeaderLayout,
disabled?: boolean,
}>(), {
layout: 'expanded',
disabled: false
})
</script>
<style lang="scss" scoped>
@ -40,6 +46,15 @@ header.screen-header {
line-height: normal;
padding: 1.8rem;
&.disabled {
opacity: .5;
cursor: not-allowed;
*, *::before, *::after {
pointer-events: none;
}
}
&.expanded {
.thumbnail-wrapper {
margin-right: 1.5rem;

View file

@ -3,7 +3,7 @@ import isMobile from 'ismobilejs'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import { authService, http } from '@/services'
import { authService, cache, http } from '@/services'
import { albumStore, artistStore, commonStore, overviewStore, preferenceStore, songStore, SongUpdateResult } from '.'
new class extends UnitTestCase {
@ -226,10 +226,37 @@ new class extends UnitTestCase {
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault', songs)
await songStore.fetchForPlaylist(playlist)
const fetched = await songStore.fetchForPlaylist(playlist)
expect(getMock).toHaveBeenCalledWith('playlists/42/songs')
expect(syncMock).toHaveBeenCalledWith(songs)
expect(fetched).toEqual(songs)
})
it('fetches for playlist with cache', async () => {
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
cache.set(['playlist.songs', playlist.id], songs)
const getMock = this.mock(http, 'get')
const fetched = await songStore.fetchForPlaylist(playlist)
expect(getMock).not.toHaveBeenCalled()
expect(fetched).toEqual(songs)
})
it('fetches for playlist discarding cache', async () => {
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
cache.set(['playlist.songs', playlist.id], songs)
const getMock = this.mock(http, 'get').mockResolvedValueOnce([])
await songStore.fetchForPlaylist(playlist, true)
expect(getMock).toHaveBeenCalled()
expect(cache.get(['playlist.songs', playlist.id])).toEqual([])
})
it('paginates', async () => {

View file

@ -140,24 +140,37 @@ export const songStore = {
watch(() => song.play_count, () => overviewStore.refresh())
},
async cacheable (key: any, fetcher: Promise<Song[]>) {
const songs = await cache.remember<Song[]>(key, async () => this.syncWithVault(await fetcher))
return songs.filter(song => !song.deleted)
},
ensureNotDeleted: (songs: Song | Song[]) => arrayify(songs).filter(song => !song.deleted),
async fetchForAlbum (album: Album | number) {
const id = typeof album === 'number' ? album : album.id
return await this.cacheable(['album.songs', id], http.get<Song[]>(`albums/${id}/songs`))
return this.ensureNotDeleted(await cache.remember<Song[]>(
[`album.songs`, id],
async () => this.syncWithVault(await http.get<Song[]>(`albums/${id}/songs`))
))
},
async fetchForArtist (artist: Artist | number) {
const id = typeof artist === 'number' ? artist : artist.id
return await this.cacheable(['artist.songs', id], http.get<Song[]>(`artists/${id}/songs`))
return this.ensureNotDeleted(await cache.remember<Song[]>(
[`artist.songs`, id],
async () => this.syncWithVault(await http.get<Song[]>(`artists/${id}/songs`))
))
},
async fetchForPlaylist (playlist: Playlist | number) {
async fetchForPlaylist (playlist: Playlist | number, refresh = false) {
const id = typeof playlist === 'number' ? playlist : playlist.id
return await this.cacheable(['playlist.songs', id], http.get<Song[]>(`playlists/${id}/songs`))
if (refresh) {
cache.remove(['playlist.songs', id])
}
return this.ensureNotDeleted(await cache.remember<Song[]>(
[`playlist.songs`, id],
async () => this.syncWithVault(await http.get<Song[]>(`playlists/${id}/songs`))
))
},
async fetchForPlaylistFolder (folder: PlaylistFolder) {

View file

@ -3,8 +3,7 @@ import DialogBox from '@/components/ui/DialogBox.vue'
import MessageToaster from '@/components/ui/MessageToaster.vue'
import Router from '@/router'
export interface ReadonlyInjectionKey<T> extends InjectionKey<[Readonly<T> | DeepReadonly<T>, Closure]> {
}
export type ReadonlyInjectionKey<T> = InjectionKey<[Readonly<T> | DeepReadonly<T>, Closure]>
export const RouterKey: InjectionKey<Router> = Symbol('Router')
export const ScreenNameKey: ReadonlyInjectionKey<ScreenName> = Symbol('ScreenName')

View file

@ -324,6 +324,7 @@ interface SongListControlsConfig {
addTo: AddToMenuConfig
clearQueue: boolean
deletePlaylist: boolean
refresh: boolean
}
type ThemeableProperty = '--color-text-primary'