mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: group title and artist into one column (#1583)
This commit is contained in:
parent
3d34a4c2c7
commit
2d912039bc
23 changed files with 345 additions and 132 deletions
|
@ -131,7 +131,7 @@ const {
|
|||
playAll,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(songs, 'Album', { columns: ['track', 'title', 'artist', 'length'] })
|
||||
} = useSongList(songs)
|
||||
|
||||
const useLastfm = toRef(commonStore.state, 'use_last_fm')
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
|
|
@ -63,7 +63,7 @@ const {
|
|||
onPressEnter,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(toRef(songStore.state, 'songs'), 'Songs')
|
||||
} = useSongList(toRef(songStore.state, 'songs'))
|
||||
|
||||
const toaster = requireInjection(MessageToasterKey)
|
||||
const router = requireInjection(RouterKey)
|
||||
|
|
|
@ -128,7 +128,7 @@ const {
|
|||
playAll,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(songs, 'Artist', { columns: ['track', 'thumbnail', 'title', 'album', 'length'] })
|
||||
} = useSongList(songs)
|
||||
|
||||
const { useLastfm } = useThirdPartyServices()
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
|
|
@ -88,7 +88,7 @@ const {
|
|||
playSelected,
|
||||
onScrollBreakpoint,
|
||||
sort
|
||||
} = useSongList(toRef(favoriteStore.state, 'songs'), 'Favorites')
|
||||
} = useSongList(toRef(favoriteStore.state, 'songs'))
|
||||
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@ const {
|
|||
onPressEnter,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(ref<Song[]>([]), 'Playlist')
|
||||
} = useSongList(ref<Song[]>([]))
|
||||
|
||||
const router = requireInjection(RouterKey)
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
|
|
|
@ -107,7 +107,7 @@ const {
|
|||
playSelected,
|
||||
onScrollBreakpoint,
|
||||
sort
|
||||
} = useSongList(ref<Song[]>([]), 'Playlist')
|
||||
} = useSongList(ref<Song[]>([]))
|
||||
|
||||
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
||||
|
||||
|
@ -126,7 +126,7 @@ const fetchSongs = async (refresh = false) => {
|
|||
sort()
|
||||
}
|
||||
|
||||
watch(playlistId, async (id) => {
|
||||
watch(playlistId, async id => {
|
||||
if (!id) return
|
||||
|
||||
playlist.value = playlistStore.byId(id)
|
||||
|
|
|
@ -81,7 +81,7 @@ const {
|
|||
isPhone,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(toRef(queueStore.state, 'songs'), 'Queue', { sortable: false })
|
||||
} = useSongList(toRef(queueStore.state, 'songs'))
|
||||
|
||||
const loading = ref(false)
|
||||
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
||||
|
|
|
@ -65,7 +65,7 @@ const {
|
|||
playAll,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(recentlyPlayedSongs, 'RecentlyPlayed', { sortable: false })
|
||||
} = useSongList(recentlyPlayedSongs)
|
||||
|
||||
let initialized = false
|
||||
let loading = ref(false)
|
||||
|
|
|
@ -57,7 +57,7 @@ const {
|
|||
playSelected,
|
||||
sort,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(toRef(searchStore.state, 'songs'), 'Search.Songs')
|
||||
} = useSongList(toRef(searchStore.state, 'songs'))
|
||||
|
||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||
const loading = ref(false)
|
||||
|
@ -65,7 +65,7 @@ const loading = ref(false)
|
|||
searchStore.resetSongResultState()
|
||||
|
||||
onMounted(async () => {
|
||||
q.value = router.$currentRoute.value?.params?.q || ''
|
||||
q.value = router.$currentRoute.value.params?.q || ''
|
||||
if (!q.value) return
|
||||
|
||||
loading.value = true
|
||||
|
|
|
@ -4,23 +4,18 @@ import { fireEvent } from '@testing-library/vue'
|
|||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { arrayify } from '@/utils'
|
||||
import {
|
||||
ScreenNameKey,
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListSortFieldKey,
|
||||
SongListSortOrderKey,
|
||||
SongsKey
|
||||
} from '@/symbols'
|
||||
import { SelectedSongsKey, SongListConfigKey, SongListSortFieldKey, SongListSortOrderKey, SongsKey } from '@/symbols'
|
||||
import SongList from './SongList.vue'
|
||||
|
||||
let songs: Song[]
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent (
|
||||
private async renderComponent (
|
||||
_songs: Song | Song[],
|
||||
screen: ScreenName = 'Songs',
|
||||
config: Partial<SongListConfig> = {},
|
||||
config: Partial<SongListConfig> = {
|
||||
sortable: true,
|
||||
reorderable: true
|
||||
},
|
||||
selectedSongs: Song[] = [],
|
||||
sortField: SongListSortField = 'title',
|
||||
sortOrder: SortOrder = 'asc'
|
||||
|
@ -30,15 +25,20 @@ new class extends UnitTestCase {
|
|||
const sortFieldRef = ref(sortField)
|
||||
const sortOrderRef = ref(sortOrder)
|
||||
|
||||
await this.router.activateRoute({
|
||||
screen: 'Songs',
|
||||
path: '/songs'
|
||||
})
|
||||
|
||||
return this.render(SongList, {
|
||||
global: {
|
||||
stubs: {
|
||||
VirtualScroller: this.stub('virtual-scroller')
|
||||
VirtualScroller: this.stub('virtual-scroller'),
|
||||
SongListSorter: this.stub('song-list-sorter')
|
||||
},
|
||||
provide: {
|
||||
[<symbol>SongsKey]: [ref(songs)],
|
||||
[<symbol>SelectedSongsKey]: [ref(selectedSongs), value => (selectedSongs = value)],
|
||||
[<symbol>ScreenNameKey]: [ref(screen)],
|
||||
[<symbol>SongListConfigKey]: [config],
|
||||
[<symbol>SongListSortFieldKey]: [sortFieldRef, value => (sortFieldRef.value = value)],
|
||||
[<symbol>SongListSortOrderKey]: [sortOrderRef, value => (sortOrderRef.value = value)]
|
||||
|
@ -49,7 +49,7 @@ new class extends UnitTestCase {
|
|||
|
||||
protected test () {
|
||||
it('renders', async () => {
|
||||
const { html } = this.renderComponent(factory<Song>('song', 5))
|
||||
const { html } = await this.renderComponent(factory<Song>('song', 5))
|
||||
expect(html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
|
@ -57,10 +57,9 @@ new class extends UnitTestCase {
|
|||
['track', 'header-track-number'],
|
||||
['title', 'header-title'],
|
||||
['album_name', 'header-album'],
|
||||
['length', 'header-length'],
|
||||
['artist_name', 'header-artist']
|
||||
['length', 'header-length']
|
||||
])('sorts by %s upon %s clicked', async (field, testId) => {
|
||||
const { getByTestId, emitted } = this.renderComponent(factory<Song>('song', 5))
|
||||
const { getByTestId, emitted } = await this.renderComponent(factory<Song>('song', 5))
|
||||
|
||||
await fireEvent.click(getByTestId(testId))
|
||||
expect(emitted().sort[0]).toEqual([field, 'desc'])
|
||||
|
@ -68,5 +67,15 @@ new class extends UnitTestCase {
|
|||
await fireEvent.click(getByTestId(testId))
|
||||
expect(emitted().sort[1]).toEqual([field, 'asc'])
|
||||
})
|
||||
|
||||
it('cannot be sorted if configured so', async () => {
|
||||
const { getByTestId, emitted } = await this.renderComponent(factory<Song>('song', 5), {
|
||||
sortable: false,
|
||||
reorderable: true
|
||||
})
|
||||
|
||||
await fireEvent.click(getByTestId('header-track-number'))
|
||||
expect(emitted().sort).toBeUndefined()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
>
|
||||
<div :class="config.sortable ? 'sortable' : 'unsortable'" class="song-list-header">
|
||||
<span
|
||||
v-if="config.columns.includes('track')"
|
||||
class="track-number"
|
||||
data-testid="header-track-number"
|
||||
role="button"
|
||||
|
@ -22,8 +21,7 @@
|
|||
<icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||
</span>
|
||||
<span
|
||||
v-if="config.columns.includes('title')"
|
||||
class="title"
|
||||
class="title-artist"
|
||||
data-testid="header-title"
|
||||
role="button"
|
||||
title="Sort by title"
|
||||
|
@ -34,19 +32,6 @@
|
|||
<icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||
</span>
|
||||
<span
|
||||
v-if="config.columns.includes('artist')"
|
||||
class="artist"
|
||||
data-testid="header-artist"
|
||||
role="button"
|
||||
title="Sort by artist"
|
||||
@click="sort('artist_name')"
|
||||
>
|
||||
Artist
|
||||
<icon v-if="sortField === 'artist_name' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
|
||||
<icon v-if="sortField === 'artist_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||
</span>
|
||||
<span
|
||||
v-if="config.columns.includes('album')"
|
||||
class="album"
|
||||
data-testid="header-album"
|
||||
role="button"
|
||||
|
@ -58,7 +43,6 @@
|
|||
<icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||
</span>
|
||||
<span
|
||||
v-if="config.columns.includes('length')"
|
||||
class="time"
|
||||
data-testid="header-length"
|
||||
role="button"
|
||||
|
@ -69,7 +53,9 @@
|
|||
<icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
|
||||
<icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||
</span>
|
||||
<span class="favorite"></span>
|
||||
<span class="extra">
|
||||
<SongListSorter :field="sortField" :order="sortOrder" @sort="sort"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<VirtualScroller
|
||||
|
@ -81,7 +67,6 @@
|
|||
>
|
||||
<SongListItem
|
||||
:key="item.song.id"
|
||||
:columns="config.columns"
|
||||
:item="item"
|
||||
draggable="true"
|
||||
@click="rowClicked(item, $event)"
|
||||
|
@ -100,11 +85,11 @@
|
|||
import { findIndex } from 'lodash'
|
||||
import isMobile from 'ismobilejs'
|
||||
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, nextTick, onMounted, Ref, ref, watch } from 'vue'
|
||||
import { nextTick, onMounted, Ref, ref, watch } from 'vue'
|
||||
import { eventBus, requireInjection } from '@/utils'
|
||||
import { useDraggable, useDroppable } from '@/composables'
|
||||
import {
|
||||
ScreenNameKey,
|
||||
RouterKey,
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListSortFieldKey,
|
||||
|
@ -114,6 +99,7 @@ import {
|
|||
|
||||
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
|
||||
import SongListItem from '@/components/song/SongListItem.vue'
|
||||
import SongListSorter from '@/components/song/SongListSorter.vue'
|
||||
|
||||
const { startDragging } = useDraggable('songs')
|
||||
const { getDroppedData, acceptsDrop } = useDroppable(['songs'])
|
||||
|
@ -121,27 +107,20 @@ const { getDroppedData, acceptsDrop } = useDroppable(['songs'])
|
|||
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
|
||||
|
||||
const [items] = requireInjection<[Ref<Song[]>]>(SongsKey)
|
||||
const [screen] = requireInjection<[ScreenName]>(ScreenNameKey)
|
||||
const [selectedSongs, setSelectedSongs] = requireInjection<[Ref<Song[]>, Closure]>(SelectedSongsKey)
|
||||
const [sortField, setSortField] = requireInjection<[Ref<SongListSortField>, Closure]>(SongListSortFieldKey)
|
||||
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
||||
const [injectedConfig] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
|
||||
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
|
||||
|
||||
const router = requireInjection(RouterKey)
|
||||
|
||||
const screen = router.$currentRoute.value.screen
|
||||
const lastSelectedRow = ref<SongRow>()
|
||||
const sortFields = ref<SongListSortField[]>([])
|
||||
const songRows = ref<SongRow[]>([])
|
||||
|
||||
const allowReordering = screen === 'Queue'
|
||||
|
||||
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
|
||||
|
||||
const config = computed((): SongListConfig => {
|
||||
return Object.assign({
|
||||
sortable: true,
|
||||
columns: ['track', 'thumbnail', 'title', 'artist', 'album', 'length']
|
||||
}, injectedConfig)
|
||||
})
|
||||
|
||||
let lastScrollTop = 0
|
||||
|
||||
const onScroll = e => {
|
||||
|
@ -166,7 +145,7 @@ const generateSongRows = () => {
|
|||
// selected songs manually.
|
||||
const selectedSongIds = selectedSongs.value.map(song => song.id)
|
||||
|
||||
return items.value.map(song => ({
|
||||
return items.value.map<SongRow>(song => ({
|
||||
song,
|
||||
selected: selectedSongIds.includes(song.id)
|
||||
}))
|
||||
|
@ -174,7 +153,7 @@ const generateSongRows = () => {
|
|||
|
||||
const sort = (field: SongListSortField) => {
|
||||
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
|
||||
if (!config.value.sortable) {
|
||||
if (!config.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -185,7 +164,7 @@ const sort = (field: SongListSortField) => {
|
|||
}
|
||||
|
||||
const render = () => {
|
||||
config.value.sortable || (sortFields.value = [])
|
||||
config.sortable || (sortFields.value = [])
|
||||
songRows.value = generateSongRows()
|
||||
}
|
||||
|
||||
|
@ -259,7 +238,7 @@ const onDragStart = (row: SongRow, event: DragEvent) => {
|
|||
}
|
||||
|
||||
const onDragEnter = (event: DragEvent) => {
|
||||
if (!allowReordering) return
|
||||
if (!config.reorderable) return
|
||||
|
||||
if (acceptsDrop(event)) {
|
||||
(event.target as Element).parentElement?.classList.add('droppable')
|
||||
|
@ -270,7 +249,7 @@ const onDragEnter = (event: DragEvent) => {
|
|||
}
|
||||
|
||||
const onDrop = (item: SongRow, event: DragEvent) => {
|
||||
if (!allowReordering || !getDroppedData(event) || !selectedSongs.value.length) {
|
||||
if (!config.reorderable || !getDroppedData(event) || !selectedSongs.value.length) {
|
||||
return onDragLeave(event)
|
||||
}
|
||||
|
||||
|
@ -315,8 +294,8 @@ onMounted(() => render())
|
|||
|
||||
.song-list-header {
|
||||
background: var(--color-bg-secondary);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
z-index: 2; // fix stack-context related issue when e.g., footer would cover the sort context menu
|
||||
}
|
||||
|
||||
div.droppable {
|
||||
|
@ -342,15 +321,11 @@ onMounted(() => render())
|
|||
padding-left: 24px;
|
||||
}
|
||||
|
||||
&.artist {
|
||||
flex-basis: 20%;
|
||||
}
|
||||
|
||||
&.album {
|
||||
flex-basis: 27%;
|
||||
}
|
||||
|
||||
&.favorite {
|
||||
&.extra {
|
||||
flex-basis: 36px;
|
||||
}
|
||||
|
||||
|
@ -362,9 +337,13 @@ onMounted(() => render())
|
|||
}
|
||||
}
|
||||
|
||||
&.title {
|
||||
&.title-artist {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.extra {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.song-list-header {
|
||||
|
@ -373,9 +352,9 @@ onMounted(() => render())
|
|||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
|
||||
i:not(.duration-header) {
|
||||
color: var(--color-highlight);
|
||||
font-size: 1.2rem;
|
||||
.extra {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -406,10 +385,6 @@ onMounted(() => render())
|
|||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.song-list-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
top: 0;
|
||||
|
||||
|
@ -426,30 +401,22 @@ onMounted(() => render())
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text-secondary);
|
||||
width: 200%;
|
||||
}
|
||||
|
||||
.song-item span {
|
||||
.song-item :is(.track-number, .album, .time),
|
||||
.song-list-header :is(.track-number, .album, .time) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song-item span {
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&.thumbnail {
|
||||
display: block;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
&.artist, &.title {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&.artist {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,46 @@
|
|||
import { without } from 'lodash'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { queueStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import { fireEvent } from '@testing-library/vue'
|
||||
import SongListItem from './SongListItem.vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import SongListItem from './SongListItem.vue'
|
||||
|
||||
let row: SongRow
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent (columns: SongListColumn[] = ['track', 'title', 'artist', 'album', 'length']) {
|
||||
private renderComponent (song?: Song) {
|
||||
song = song ?? factory<Song>('song')
|
||||
|
||||
row = {
|
||||
song: factory<Song>('song'),
|
||||
song,
|
||||
selected: false
|
||||
}
|
||||
|
||||
return this.render(SongListItem, {
|
||||
props: {
|
||||
item: row,
|
||||
columns
|
||||
item: row
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected test () {
|
||||
it('renders', async () => {
|
||||
const song = factory<Song>('song', {
|
||||
title: 'Test Song',
|
||||
album_name: 'Test Album',
|
||||
artist_name: 'Test Artist',
|
||||
length: 1000,
|
||||
playback_state: 'Playing',
|
||||
track: 12,
|
||||
album_cover: 'https://example.com/cover.jpg',
|
||||
liked: true
|
||||
})
|
||||
|
||||
const { html } = await this.renderComponent(song)
|
||||
expect(html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('plays on double click', async () => {
|
||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
||||
const playMock = this.mock(playbackService, 'play')
|
||||
|
@ -35,16 +51,5 @@ new class extends UnitTestCase {
|
|||
expect(queueMock).toHaveBeenCalledWith(row.song)
|
||||
expect(playMock).toHaveBeenCalledWith(row.song)
|
||||
})
|
||||
|
||||
it.each<[SongListColumn, string]>([
|
||||
['track', '.track-number'],
|
||||
['title', '.title'],
|
||||
['artist', '.artist'],
|
||||
['album', '.album'],
|
||||
['length', '.time']
|
||||
])('does not render %s if so configure', async (column: SongListColumn, selector: string) => {
|
||||
const { container } = this.renderComponent(without(['track', 'title', 'artist', 'album', 'length'], column))
|
||||
expect(container.querySelector(selector)).toBeNull()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,21 +3,25 @@
|
|||
:class="{ playing, selected: item.selected }"
|
||||
class="song-item"
|
||||
data-testid="song-item"
|
||||
@dblclick.prevent.stop="play"
|
||||
tabindex="0"
|
||||
@dblclick.prevent.stop="play"
|
||||
>
|
||||
<span v-if="columns.includes('track')" class="track-number">
|
||||
<span class="track-number">
|
||||
<SoundBars v-if="song.playback_state === 'Playing'"/>
|
||||
<span class="text-secondary" v-else>{{ song.track || '' }}</span>
|
||||
<span v-else class="text-secondary">{{ song.track || '' }}</span>
|
||||
</span>
|
||||
<span class="thumbnail">
|
||||
<SongThumbnail :song="song"/>
|
||||
</span>
|
||||
<span v-if="columns.includes('title')" class="title text-primary">{{ song.title }}</span>
|
||||
<span v-if="columns.includes('artist')" class="artist">{{ song.artist_name }}</span>
|
||||
<span v-if="columns.includes('album')" class="album">{{ song.album_name }}</span>
|
||||
<span v-if="columns.includes('length')" class="time">{{ fmtLength }}</span>
|
||||
<span class="favorite">
|
||||
<span class="title-artist">
|
||||
<span class="title text-primary">{{ song.title }}</span>
|
||||
<span class="artist">
|
||||
{{ song.artist_name }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="album">{{ song.album_name }}</span>
|
||||
<span class="time">{{ fmtLength }}</span>
|
||||
<span class="extra">
|
||||
<LikeButton :song="song"/>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -33,8 +37,8 @@ import LikeButton from '@/components/song/SongLikeButton.vue'
|
|||
import SoundBars from '@/components/ui/SoundBars.vue'
|
||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||
|
||||
const props = defineProps<{ item: SongRow, columns: SongListColumn[] }>()
|
||||
const { item, columns } = toRefs(props)
|
||||
const props = defineProps<{ item: SongRow }>()
|
||||
const { item } = toRefs(props)
|
||||
|
||||
const song = computed(() => item.value.song)
|
||||
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
||||
|
@ -81,13 +85,25 @@ const play = () => {
|
|||
}
|
||||
|
||||
&.playing {
|
||||
color: var(--color-accent);
|
||||
|
||||
.title {
|
||||
.title, .track-number, .favorite {
|
||||
color: var(--color-accent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.title-artist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
color: currentColor;
|
||||
}
|
||||
|
|
102
resources/assets/js/components/song/SongListSorter.vue
Normal file
102
resources/assets/js/components/song/SongListSorter.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<div>
|
||||
<button ref="button" title="Sort" @click.stop="trigger">
|
||||
<icon :icon="faSort"/>
|
||||
</button>
|
||||
<menu ref="menu" v-koel-clickaway="hide">
|
||||
<li v-for="item in menuItems" :class="item.field === field && 'active'" @click="sort(item.field)">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="icon">
|
||||
<icon v-if="order === 'asc'" :icon="faArrowDown"/>
|
||||
<icon v-else :icon="faArrowUp"/>
|
||||
</span>
|
||||
</li>
|
||||
</menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faArrowDown, faArrowUp, faSort } from '@fortawesome/free-solid-svg-icons'
|
||||
import { onBeforeUnmount, onMounted, ref, toRefs } from 'vue'
|
||||
import { useFloatingUi } from '@/composables'
|
||||
|
||||
const props = defineProps<{ field?: SongListSortField, order?: SortOrder }>()
|
||||
const { field, order } = toRefs(props)
|
||||
|
||||
const emit = defineEmits<{ (e: 'sort', payload: SongListSortField): void }>()
|
||||
|
||||
const button = ref<HTMLButtonElement>()
|
||||
const menu = ref<HTMLDivElement>()
|
||||
|
||||
const menuItems: { label: string, field: SongListSortField }[] = [
|
||||
{
|
||||
label: 'Title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
label: 'Artist',
|
||||
field: 'artist_name'
|
||||
},
|
||||
{
|
||||
label: 'Album',
|
||||
field: 'album_name'
|
||||
},
|
||||
{
|
||||
label: 'Track & Disc',
|
||||
field: 'track'
|
||||
},
|
||||
{
|
||||
label: 'Time',
|
||||
field: 'length'
|
||||
}
|
||||
]
|
||||
|
||||
const { setup, teardown, trigger, hide } = useFloatingUi(button, menu, {
|
||||
placement: 'bottom-end',
|
||||
useArrow: false,
|
||||
autoTrigger: false
|
||||
})
|
||||
|
||||
const sort = (field: SongListSortField) => {
|
||||
emit('sort', field)
|
||||
hide()
|
||||
}
|
||||
|
||||
onMounted(() => menu.value && setup())
|
||||
onBeforeUnmount(() => teardown())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
button {
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
menu {
|
||||
width: max-content;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-highlight);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
exports[`renders 1`] = `
|
||||
<div class="song-list-wrap" data-testid="song-list" tabindex="0">
|
||||
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="artist" data-testid="header-artist" role="button" title="Sort by artist"> Artist <!--v-if--><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="favorite"></span></div><br data-testid="virtual-scroller" item-height="64" items="">
|
||||
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc"></span></div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover" data-v-a2b2e00f=""><img alt="Test Album" src="https://example.com/cover.jpg" loading="lazy" data-v-a2b2e00f=""><a class="control" data-testid="play-control" data-v-a2b2e00f=""><br data-testid="icon" icon="[object Object]" class="text-highlight" data-v-a2b2e00f=""></a></div></span><span class="title-artist"><span class="title text-primary">Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" data-testid="like-btn" type="button"><br data-testid="btn-like-liked" icon="[object Object]"></button></span></div>`;
|
|
@ -10,3 +10,4 @@ export * from './useUpload'
|
|||
export * from './useScreen'
|
||||
export * from './usePlaylistManagement'
|
||||
export * from './useNetworkStatus'
|
||||
export * from './useFloatingUi'
|
||||
|
|
106
resources/assets/js/composables/useFloatingUi.ts
Normal file
106
resources/assets/js/composables/useFloatingUi.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { isRef, Ref } from 'vue'
|
||||
import { arrow as arrowMiddleware, autoUpdate, computePosition, flip, offset, Placement } from '@floating-ui/dom'
|
||||
|
||||
export type Config = {
|
||||
placement: Placement,
|
||||
useArrow: boolean,
|
||||
autoTrigger: boolean,
|
||||
}
|
||||
|
||||
export const useFloatingUi = (
|
||||
reference: HTMLElement | Ref<HTMLElement>,
|
||||
floating: HTMLElement | Ref<HTMLElement>,
|
||||
config: Partial<Config> = {}
|
||||
) => {
|
||||
const mergedConfig: Config = Object.assign({
|
||||
placement: 'bottom',
|
||||
useArrow: true,
|
||||
autoTrigger: true
|
||||
}, config)
|
||||
|
||||
let _cleanUp: Closure
|
||||
let _show: Closure
|
||||
let _hide: Closure
|
||||
let _trigger: Closure
|
||||
|
||||
const setup = () => {
|
||||
reference = isRef(reference) ? reference.value : reference
|
||||
floating = isRef(floating) ? floating.value : floating
|
||||
|
||||
floating.style.display = 'none'
|
||||
|
||||
let arrow: HTMLElement | null = null
|
||||
|
||||
if (mergedConfig.useArrow) {
|
||||
arrow = document.createElement('div')
|
||||
arrow.className = 'arrow'
|
||||
floating.appendChild(arrow)
|
||||
}
|
||||
|
||||
const middleware = [
|
||||
flip(),
|
||||
offset(6)
|
||||
]
|
||||
|
||||
if (arrow) {
|
||||
middleware.push(arrowMiddleware({
|
||||
element: arrow,
|
||||
padding: 6
|
||||
}))
|
||||
}
|
||||
|
||||
const update = async () => {
|
||||
const { x, y, placement: _, middlewareData } = await computePosition(reference, floating, {
|
||||
placement: mergedConfig.placement,
|
||||
middleware
|
||||
})
|
||||
|
||||
floating.style.left = `${x}px`
|
||||
floating.style.top = `${y}px`
|
||||
|
||||
if (arrow) {
|
||||
const { x: arrowX, y: arrowY } = middlewareData.arrow
|
||||
|
||||
const staticSide = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right'
|
||||
}[mergedConfig.placement.split('-')[0]]
|
||||
|
||||
Object.assign(arrow.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : '',
|
||||
top: arrowY != null ? `${arrowY}px` : '',
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticSide]: '-4px'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_cleanUp = autoUpdate(reference, floating, update)
|
||||
|
||||
_show = async () => {
|
||||
floating.style.display = 'block'
|
||||
await update()
|
||||
}
|
||||
|
||||
_hide = () => (floating.style.display = 'none')
|
||||
_trigger = () => floating.style.display === 'none' ? _show() : _hide()
|
||||
|
||||
if (mergedConfig.autoTrigger) {
|
||||
reference.addEventListener('mouseenter', _show)
|
||||
reference.addEventListener('focus', _show)
|
||||
reference.addEventListener('mouseleave', _hide)
|
||||
reference.addEventListener('blur', _hide)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setup,
|
||||
teardown: () => _cleanUp(),
|
||||
show: () => _show(),
|
||||
hide: () => _hide(),
|
||||
trigger: () => _trigger()
|
||||
}
|
||||
}
|
|
@ -7,7 +7,6 @@ import { eventBus, provideReadonly, requireInjection } from '@/utils'
|
|||
|
||||
import {
|
||||
RouterKey,
|
||||
ScreenNameKey,
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListSortFieldKey,
|
||||
|
@ -20,8 +19,9 @@ import SongList from '@/components/song/SongList.vue'
|
|||
import SongListControls from '@/components/song/SongListControls.vue'
|
||||
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||
|
||||
export const useSongList = (songs: Ref<Song[]>, screen: ScreenName, config: Partial<SongListConfig> = {}) => {
|
||||
export const useSongList = (songs: Ref<Song[]>, config: Partial<SongListConfig> = {}) => {
|
||||
const router = requireInjection(RouterKey)
|
||||
const screen = router.$currentRoute.value.screen
|
||||
|
||||
const songList = ref<InstanceType<typeof SongList>>()
|
||||
|
||||
|
@ -34,6 +34,9 @@ export const useSongList = (songs: Ref<Song[]>, screen: ScreenName, config: Part
|
|||
headerLayout.value = direction === 'down' ? 'collapsed' : 'expanded'
|
||||
}
|
||||
|
||||
config.reorderable = screen !== 'Queue'
|
||||
config.sortable = !['Queue', 'RecentlyPlayed', 'Search.Songs'].includes(screen)
|
||||
|
||||
const duration = computed(() => songStore.getFormattedLength(songs.value))
|
||||
|
||||
const thumbnails = computed(() => {
|
||||
|
@ -72,14 +75,16 @@ export const useSongList = (songs: Ref<Song[]>, screen: ScreenName, config: Part
|
|||
}
|
||||
|
||||
const sortField = ref<SongListSortField | null>(((): SongListSortField | null => {
|
||||
if (!config.sortable) return null
|
||||
if (screen === 'Album' || screen === 'Artist') return 'track'
|
||||
if (screen === 'Search.Songs') return null
|
||||
return config.sortable ? 'title' : null
|
||||
return 'title'
|
||||
})())
|
||||
|
||||
const sortOrder = ref<SortOrder>('asc')
|
||||
|
||||
const sort = (by: SongListSortField | null = sortField.value, order: SortOrder = sortOrder.value) => {
|
||||
if (!config.sortable) return
|
||||
if (!by) return
|
||||
|
||||
sortField.value = by
|
||||
|
@ -102,7 +107,6 @@ export const useSongList = (songs: Ref<Song[]>, screen: ScreenName, config: Part
|
|||
songs.value = differenceBy(songs.value, deletedSongs, 'id')
|
||||
})
|
||||
|
||||
provideReadonly(ScreenNameKey, screen)
|
||||
provideReadonly(SongsKey, songs, false)
|
||||
provideReadonly(SelectedSongsKey, selectedSongs, false)
|
||||
provideReadonly(SongListConfigKey, reactive(config))
|
||||
|
|
4
resources/assets/js/types.d.ts
vendored
4
resources/assets/js/types.d.ts
vendored
|
@ -350,11 +350,9 @@ type ArtistAlbumViewMode = 'list' | 'thumbnails'
|
|||
|
||||
type RepeatMode = 'NO_REPEAT' | 'REPEAT_ALL' | 'REPEAT_ONE'
|
||||
|
||||
type SongListColumn = 'track' | 'thumbnail' | 'title' | 'album' | 'artist' | 'length'
|
||||
|
||||
interface SongListConfig {
|
||||
sortable: boolean
|
||||
columns: SongListColumn[]
|
||||
reorderable: boolean
|
||||
}
|
||||
|
||||
type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name'>
|
||||
|
|
|
@ -160,7 +160,7 @@
|
|||
@mixin context-menu() {
|
||||
padding: .4rem 0;
|
||||
min-width: 144px;
|
||||
background-color: var(--color-bg-primary);
|
||||
background-color: var(--color-bg-context-menu);
|
||||
position: fixed;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
|
|
@ -294,11 +294,12 @@ label {
|
|||
}
|
||||
}
|
||||
|
||||
.context-menu, .submenu {
|
||||
.context-menu, .submenu, menu {
|
||||
@include context-menu();
|
||||
position: fixed;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
padding: 4px 12px;
|
||||
cursor: default;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
--color-bg-secondary: rgba(255, 255, 255, .025);
|
||||
--color-highlight: #ff7d2e;
|
||||
--color-accent: var(--color-highlight);
|
||||
--color-bg-context-menu: var(--color-bg-primary);
|
||||
|
||||
--bg-image: none;
|
||||
--bg-position: center;
|
||||
|
|
Loading…
Reference in a new issue