mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: allow filtering (applicable) song lists (#1635)
This commit is contained in:
parent
63c155ceaf
commit
bfd2bd4fcd
18 changed files with 188 additions and 17 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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[]>([]))
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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="">
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
16
resources/assets/js/components/song/SongListFilter.spec.ts
Normal file
16
resources/assets/js/components/song/SongListFilter.spec.ts
Normal 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']])
|
||||
})
|
||||
}
|
||||
}
|
77
resources/assets/js/components/song/SongListFilter.vue
Normal file
77
resources/assets/js/components/song/SongListFilter.vue
Normal 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>
|
|
@ -64,6 +64,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.albums-pane) {
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
1
resources/assets/js/types.d.ts
vendored
1
resources/assets/js/types.d.ts
vendored
|
@ -325,6 +325,7 @@ interface SongListControlsConfig {
|
|||
clearQueue: boolean
|
||||
deletePlaylist: boolean
|
||||
refresh: boolean
|
||||
filter: boolean
|
||||
}
|
||||
|
||||
type ThemeableProperty = '--color-text-primary'
|
||||
|
|
Loading…
Reference in a new issue