feat: allow filtering (applicable) song lists (#1635)

This commit is contained in:
Phan An 2022-12-17 19:09:22 +07:00 committed by GitHub
parent 63c155ceaf
commit bfd2bd4fcd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 188 additions and 17 deletions

View file

@ -30,6 +30,7 @@
<template #controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
/>
@ -65,7 +66,7 @@
<div v-show="activeTab === 'OtherAlbums'" class="albums-pane" data-testid="albums-pane">
<template v-if="otherAlbums">
<ul v-if="otherAlbums.length" class="as-list" v-koel-overflow-fade>
<ul v-if="otherAlbums.length" v-koel-overflow-fade class="as-list">
<li v-for="a in otherAlbums" :key="a.id">
<AlbumCard :album="a" layout="compact" />
</li>
@ -129,6 +130,7 @@ const {
onPressEnter,
playAll,
playSelected,
applyFilter,
onScrollBreakpoint
} = useSongList(songs)

View file

@ -29,6 +29,7 @@
<template #controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
/>
@ -63,7 +64,7 @@
</div>
<div v-show="activeTab === 'Albums'" class="albums-pane">
<ul v-if="albums" class="as-list" v-koel-overflow-fade>
<ul v-if="albums" v-koel-overflow-fade class="as-list">
<li v-for="album in albums" :key="album.id">
<AlbumCard :album="album" layout="compact" />
</li>
@ -125,6 +126,7 @@ const {
onPressEnter,
playAll,
playSelected,
applyFilter,
onScrollBreakpoint
} = useSongList(songs)

View file

@ -26,6 +26,7 @@
<template #controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
/>
@ -85,6 +86,7 @@ const {
onPressEnter,
playAll,
playSelected,
applyFilter,
onScrollBreakpoint,
sort
} = useSongList(toRef(favoriteStore.state, 'songs'))

View file

@ -26,6 +26,7 @@
v-if="!isPhone || showingControls"
:config="controlsConfig"
@delete-playlist="destroy"
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
@refresh="fetchSongs(true)"
@ -102,6 +103,7 @@ const {
onPressEnter,
playAll,
playSelected,
applyFilter,
onScrollBreakpoint,
sort
} = useSongList(ref<Song[]>([]))

View file

@ -16,6 +16,7 @@
<template #controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
@filter="applyFilter"
@clear-queue="clearQueue"
@play-all="playAll"
@play-selected="playSelected"
@ -76,6 +77,7 @@ const {
showingControls,
isPhone,
playSelected,
applyFilter,
onScrollBreakpoint
} = useSongList(toRef(queueStore.state, 'songs'))

View file

@ -16,6 +16,7 @@
<template #controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
/>
@ -64,6 +65,7 @@ const {
onPressEnter,
playAll,
playSelected,
applyFilter,
onScrollBreakpoint
} = useSongList(recentlyPlayedSongs)

View file

@ -13,7 +13,10 @@ exports[`renders 1`] = `
</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="">
<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="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>
<!--v-if-->
<!--v-if-->
</div>
<div class="menu-wrapper" data-v-d396e0d2="">
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="">
<section class="existing-playlists" data-v-42061e3e="">

View file

@ -16,6 +16,7 @@
<template #controls>
<SongListControls
v-if="songs.length && (!isPhone || showingControls)"
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
/>
@ -54,6 +55,7 @@ const {
onPressEnter,
playAll,
playSelected,
applyFilter,
sort,
onScrollBreakpoint
} = useSongList(toRef(searchStore.state, 'songs'))

View file

@ -69,7 +69,7 @@
<VirtualScroller
v-slot="{ item }"
:item-height="64"
:items="songRows"
:items="filteredSongRows"
@scroll="onScroll"
@scrolled-to-end="$emit('scrolled-to-end')"
>
@ -94,10 +94,10 @@
import { findIndex } from 'lodash'
import isMobile from 'ismobilejs'
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
import { nextTick, onMounted, Ref, ref, watch } from 'vue'
import { computed, nextTick, onMounted, Ref, ref, watch } from 'vue'
import { eventBus, requireInjection } from '@/utils'
import { useDraggable, useDroppable } from '@/composables'
import { SelectedSongsKey, SongListConfigKey, SongListSortFieldKey, SongListSortOrderKey, SongsKey } from '@/symbols'
import { SelectedSongsKey, SongListConfigKey, SongListFilterKeywordsKey, SongListSortFieldKey, SongListSortOrderKey, SongsKey } from '@/symbols'
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
@ -121,6 +121,8 @@ const [sortField, setSortField] = requireInjection<[Ref<SongListSortField>, Clos
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
const filterKeywords = requireInjection(SongListFilterKeywordsKey, ref(''))
const wrapper = ref<HTMLElement>()
const lastSelectedRow = ref<SongRow>()
const sortFields = ref<SongListSortField[]>([])
@ -128,6 +130,25 @@ const songRows = ref<SongRow[]>([])
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
const filteredSongRows = computed(() => {
const keywords = filterKeywords.value.trim().toLowerCase()
if (!keywords) {
return songRows.value
}
return songRows.value.filter(row => {
const song = row.song
return (
song.title.toLowerCase().includes(keywords) ||
song.artist_name.toLowerCase().includes(keywords) ||
song.album_artist_name.toLowerCase().includes(keywords) ||
song.album_name.toLowerCase().includes(keywords)
)
})
})
let lastScrollTop = 0
const onScroll = (e: Event) => {
@ -303,6 +324,7 @@ onMounted(() => render())
display: flex;
flex-direction: column;
overflow: auto;
flex: 1;
@media screen and (max-width: 768px) {
padding: 0 12px;
@ -439,5 +461,9 @@ onMounted(() => render())
}
}
}
.virtual-scroller {
flex: 1;
}
}
</style>

View file

@ -63,7 +63,7 @@
<Btn v-if="config.clearQueue" red title="Clear current queue" @click.prevent="clearQueue">Clear</Btn>
</BtnGroup>
<BtnGroup>
<BtnGroup v-if="config.refresh || config.deletePlaylist">
<Btn v-if="config.refresh" v-koel-tooltip green title="Refresh" @click.prevent="refresh">
<icon :icon="faRotateRight" fixed-width />
</Btn>
@ -79,6 +79,10 @@
<icon :icon="faTrashCan" />
</Btn>
</BtnGroup>
<BtnGroup v-if="config.filter && songs.length">
<SongListFilter @change="filter" />
</BtnGroup>
</div>
<div ref="addToMenu" v-koel-clickaway="closeAddToMenu" class="menu-wrapper">
@ -89,7 +93,7 @@
<script lang="ts" setup>
import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, onBeforeUnmount, onMounted, Ref, ref, toRefs, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, Ref, ref, watch } from 'vue'
import { SelectedSongsKey, SongsKey } from '@/symbols'
import { requireInjection } from '@/utils'
import { useFloatingUi, useSongListControls } from '@/composables'
@ -98,6 +102,8 @@ import AddToMenu from '@/components/song/AddToMenu.vue'
import Btn from '@/components/ui/Btn.vue'
import BtnGroup from '@/components/ui/BtnGroup.vue'
const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue'))
const config = useSongListControls().getSongListControlsConfig()
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
@ -113,6 +119,7 @@ const showAddToButton = computed(() => Boolean(selectedSongs.value.length))
const emit = defineEmits<{
(e: 'playAll' | 'playSelected', shuffle: boolean): void,
(e: 'filter', keywords: string): void,
(e: 'clearQueue' | 'deletePlaylist' | 'refresh'): void,
}>()
@ -123,6 +130,7 @@ const playSelected = () => emit('playSelected', false)
const clearQueue = () => emit('clearQueue')
const deletePlaylist = () => emit('deletePlaylist')
const refresh = () => emit('refresh')
const filter = (keywords: string) => emit('filter', keywords)
const registerKeydown = (event: KeyboardEvent) => event.key === 'Alt' && (altPressed.value = true)
const registerKeyup = (event: KeyboardEvent) => event.key === 'Alt' && (altPressed.value = false)
@ -169,6 +177,7 @@ onBeforeUnmount(() => {
.wrapper {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
.menu-wrapper {

View file

@ -0,0 +1,16 @@
import { expect, it } from 'vitest'
import { screen } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import SongListFilter from './SongListFilter.vue'
new class extends UnitTestCase {
protected test() {
it('emit an event on input', async () => {
const { emitted } = this.render(SongListFilter)
await this.user.type(screen.getByPlaceholderText('Keywords'), 'cat')
expect(emitted().change).toEqual([['c'], ['ca'], ['cat']])
})
}
}

View file

@ -0,0 +1,77 @@
<template>
<form v-koel-clickaway="maybeClose" @submit.prevent>
<Btn v-koel-tooltip title="Filter" unrounded transparent @click.prevent="toggleInput">
<icon :icon="faFilter" fixed-width />
</Btn>
<input
v-show="showingInput"
ref="input"
v-model="keywords"
type="search"
placeholder="Keywords"
class="text-secondary"
>
</form>
</template>
<script lang="ts" setup>
import { faFilter } from '@fortawesome/free-solid-svg-icons'
import { nextTick, ref, watch } from 'vue'
import Btn from '@/components/ui/Btn.vue'
const emit = defineEmits<{ (event: 'change', value: string): void }>()
const showingInput = ref(false)
const input = ref<HTMLInputElement>()
const keywords = ref('')
watch(keywords, value => emit('change', value))
const toggleInput = () => {
showingInput.value = !showingInput.value
if (showingInput.value) {
nextTick(() => {
input.value?.focus()
input.value?.select()
})
} else {
input.value?.blur()
keywords.value = ''
}
}
const maybeClose = () => {
if (keywords.value.trim() !== '') return
showingInput.value = false
input.value?.blur()
keywords.value = ''
}
</script>
<style lang="scss" scoped>
form {
display: flex;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 4px;
overflow: hidden;
input {
background-color: transparent;
border-radius: 0;
padding-left: 0;
height: unset;
&::placeholder {
color: rgba(255, 255, 255, .5);
}
}
&:focus-within {
background: rgba(0, 0, 0, .1);
border-color: rgba(255, 255, 255, .4);
}
}
</style>

View file

@ -64,6 +64,7 @@
display: flex;
flex-direction: column;
overflow: auto;
flex: 1;
}
:deep(.albums-pane) {

View file

@ -19,7 +19,7 @@ button {
background: var(--color-blue);
color: var(--color-text-primary);
font-size: 1rem;
padding: .6rem 1rem;
padding: .6rem .85rem;
cursor: pointer;
display: inline-flex;
align-items: center;
@ -67,7 +67,7 @@ button {
}
&[unrounded] {
border-radius: 0;
border-radius: 0 !important;
}
&[uppercase] {

View file

@ -1,12 +1,19 @@
import { differenceBy, orderBy, sampleSize, take } from 'lodash'
import { differenceBy, orderBy, sampleSize, take, throttle } from 'lodash'
import isMobile from 'ismobilejs'
import { computed, reactive, Ref, ref } from 'vue'
import { computed, provide, reactive, Ref, ref } from 'vue'
import { playbackService } from '@/services'
import { queueStore, songStore } from '@/stores'
import { eventBus, provideReadonly } from '@/utils'
import { useRouter } from '@/composables'
import { SelectedSongsKey, SongListConfigKey, SongListSortFieldKey, SongListSortOrderKey, SongsKey } from '@/symbols'
import {
SelectedSongsKey,
SongListConfigKey,
SongListSortFieldKey,
SongListSortOrderKey,
SongListFilterKeywordsKey,
SongsKey
} from '@/symbols'
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
import SongList from '@/components/song/SongList.vue'
@ -17,6 +24,7 @@ export const useSongList = (
songs: Ref<Song[]>,
config: Partial<SongListConfig> = { sortable: true, reorderable: true }
) => {
const filterKeywords = ref('')
config = reactive(config)
const { isCurrentScreen, go, onRouteChanged } = useRouter()
@ -53,6 +61,8 @@ export const useSongList = (
const playSelected = (shuffle: boolean) => playbackService.queueAndPlay(selectedSongs.value, shuffle)
const applyFilter = throttle((keywords: string) => (filterKeywords.value = keywords), 200)
const onPressEnter = async (event: KeyboardEvent) => {
if (selectedSongs.value.length === 1) {
queueStore.queueIfNotQueued(selectedSongs.value[0])
@ -110,6 +120,8 @@ export const useSongList = (
provideReadonly(SongListSortFieldKey, sortField)
provideReadonly(SongListSortOrderKey, sortOrder)
provide(SongListFilterKeywordsKey, filterKeywords)
return {
SongList,
SongListControls,
@ -128,6 +140,7 @@ export const useSongList = (
onPressEnter,
playAll,
playSelected,
applyFilter,
onScrollBreakpoint,
sort
}

View file

@ -4,16 +4,16 @@ export const useSongListControls = () => {
const { isCurrentScreen } = useRouter()
const getSongListControlsConfig = () => {
const config: SongListControlsConfig = {
play: true,
addTo: {
queue: true,
favorites: true,
},
clearQueue: true,
deletePlaylist: true,
refresh: true,
clearQueue: false,
deletePlaylist: false,
refresh: false,
filter: false
}
config.clearQueue = isCurrentScreen('Queue')
@ -22,6 +22,16 @@ export const useSongListControls = () => {
config.deletePlaylist = isCurrentScreen('Playlist')
config.refresh = isCurrentScreen('Playlist')
config.filter = isCurrentScreen(
'Queue',
'Artist',
'Album',
'Favorites',
'RecentlyPlayed',
'Playlist',
'Search.Songs'
)
return config
}

View file

@ -17,5 +17,6 @@ export const SelectedSongsKey: ReadonlyInjectionKey<Ref<Song[]>> = Symbol('Selec
export const SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> = Symbol('SongListConfig')
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
export const SongListSortOrderKey: ReadonlyInjectionKey<Ref<SortOrder>> = Symbol('SongListSortOrder')
export const SongListFilterKeywordsKey: InjectionKey<Ref<string>> = Symbol('SongListFilterKeywords')
export const ModalContextKey: InjectionKey<Ref<Record<string, any>>> = Symbol('ModalContext')

View file

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