feat: group title and artist into one column (#1583)

This commit is contained in:
Phan An 2022-11-12 22:38:31 +01:00 committed by GitHub
parent 3d34a4c2c7
commit 2d912039bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 345 additions and 132 deletions

View file

@ -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')

View file

@ -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)

View file

@ -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')

View file

@ -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')

View file

@ -67,7 +67,7 @@ const {
onPressEnter,
playSelected,
onScrollBreakpoint
} = useSongList(ref<Song[]>([]), 'Playlist')
} = useSongList(ref<Song[]>([]))
const router = requireInjection(RouterKey)
const dialog = requireInjection(DialogBoxKey)

View file

@ -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)

View file

@ -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)

View file

@ -65,7 +65,7 @@ const {
playAll,
playSelected,
onScrollBreakpoint
} = useSongList(recentlyPlayedSongs, 'RecentlyPlayed', { sortable: false })
} = useSongList(recentlyPlayedSongs)
let initialized = false
let loading = ref(false)

View file

@ -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

View file

@ -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()
})
}
}

View file

@ -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;
}
}
}
}

View file

@ -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()
})
}
}

View file

@ -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;
}

View 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>

View file

@ -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>
`;

View file

@ -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>`;

View file

@ -10,3 +10,4 @@ export * from './useUpload'
export * from './useScreen'
export * from './usePlaylistManagement'
export * from './useNetworkStatus'
export * from './useFloatingUi'

View 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()
}
}

View file

@ -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))

View file

@ -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'>

View file

@ -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;

View file

@ -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;

View file

@ -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;