mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
feat(design): add thumbnails to song list (#1555)
This commit is contained in:
parent
36e49338a6
commit
c1847b2584
11 changed files with 211 additions and 114 deletions
|
@ -128,7 +128,7 @@ const {
|
||||||
playAll,
|
playAll,
|
||||||
playSelected,
|
playSelected,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(songs, 'Artist', { columns: ['track', 'title', 'album', 'length'] })
|
} = useSongList(songs, 'Artist', { columns: ['track', 'thumbnail', 'title', 'album', 'length'] })
|
||||||
|
|
||||||
const { useLastfm } = useThirdPartyServices()
|
const { useLastfm } = useThirdPartyServices()
|
||||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||||
|
|
|
@ -20,12 +20,24 @@ new class extends UnitTestCase {
|
||||||
props: {
|
props: {
|
||||||
song,
|
song,
|
||||||
topPlayCount: 42
|
topPlayCount: 42
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
SongThumbnail: this.stub('thumbnail'),
|
||||||
|
LikeButton: this.stub('like-button')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('queues and plays', async () => {
|
it('has a thumbnail and a like button', () => {
|
||||||
|
const { getByTestId } = this.renderComponent()
|
||||||
|
getByTestId('thumbnail')
|
||||||
|
getByTestId('like-button')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues and plays on double-click', async () => {
|
||||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
||||||
const playMock = this.mock(playbackService, 'play')
|
const playMock = this.mock(playbackService, 'play')
|
||||||
const { getByTestId } = this.renderComponent()
|
const { getByTestId } = this.renderComponent()
|
||||||
|
@ -35,18 +47,5 @@ new class extends UnitTestCase {
|
||||||
expect(queueMock).toHaveBeenCalledWith(song)
|
expect(queueMock).toHaveBeenCalledWith(song)
|
||||||
expect(playMock).toHaveBeenCalledWith(song)
|
expect(playMock).toHaveBeenCalledWith(song)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each<[PlaybackState, MethodOf<typeof playbackService>]>([
|
|
||||||
['Stopped', 'play'],
|
|
||||||
['Playing', 'pause'],
|
|
||||||
['Paused', 'resume']
|
|
||||||
])('if state is currently "%s", %ss', async (state: PlaybackState, method: MethodOf<typeof playbackService>) => {
|
|
||||||
const mock = this.mock(playbackService, method)
|
|
||||||
const { getByTestId } = this.renderComponent(state)
|
|
||||||
|
|
||||||
await fireEvent.click(getByTestId('play-control'))
|
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,11 +8,7 @@
|
||||||
@contextmenu.prevent="requestContextMenu"
|
@contextmenu.prevent="requestContextMenu"
|
||||||
@dblclick.prevent="play"
|
@dblclick.prevent="play"
|
||||||
>
|
>
|
||||||
<aside :style="{ backgroundImage: `url(${song.album_cover ?? ''}), url(${defaultCover})` }" class="cover">
|
<SongThumbnail :song="song"/>
|
||||||
<a class="control" @click.prevent="changeSongState" data-testid="play-control">
|
|
||||||
<icon :icon="song.playback_state === 'Playing' ? faPause : faPlay" class="text-highlight"/>
|
|
||||||
</a>
|
|
||||||
</aside>
|
|
||||||
<main>
|
<main>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<h3>{{ song.title }}</h3>
|
<h3>{{ song.title }}</h3>
|
||||||
|
@ -27,13 +23,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { toRefs } from 'vue'
|
import { toRefs } from 'vue'
|
||||||
import { defaultCover, eventBus, pluralize } from '@/utils'
|
import { eventBus, pluralize } from '@/utils'
|
||||||
import { queueStore } from '@/stores'
|
import { queueStore } from '@/stores'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { useDraggable } from '@/composables'
|
import { useDraggable } from '@/composables'
|
||||||
|
|
||||||
|
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
|
|
||||||
const props = defineProps<{ song: Song }>()
|
const props = defineProps<{ song: Song }>()
|
||||||
|
@ -48,16 +44,6 @@ const play = () => {
|
||||||
queueStore.queueIfNotQueued(song.value)
|
queueStore.queueIfNotQueued(song.value)
|
||||||
playbackService.play(song.value)
|
playbackService.play(song.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeSongState = () => {
|
|
||||||
if (song.value.playback_state === 'Stopped') {
|
|
||||||
play()
|
|
||||||
} else if (song.value.playback_state === 'Paused') {
|
|
||||||
playbackService.resume()
|
|
||||||
} else {
|
|
||||||
playbackService.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -93,9 +79,8 @@ article {
|
||||||
button {
|
button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .cover, &:focus .cover {
|
::v-deep(.cover) {
|
||||||
.control {
|
.control {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
@ -104,54 +89,19 @@ article {
|
||||||
opacity: .7;
|
opacity: .7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cover {
|
// show the thumbnail's playback control on the whole card focus and hover
|
||||||
width: 48px;
|
&:hover ::v-deep(.cover), &:focus ::v-deep(.cover) {
|
||||||
min-width: 48px;
|
.control {
|
||||||
aspect-ratio: 1/1;
|
|
||||||
background-size: cover;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: " ";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
background: #000;
|
|
||||||
opacity: 0;
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
opacity: .7;
|
opacity: .7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.control {
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: rgba(0, 0, 0, .5);
|
|
||||||
font-size: 1rem;
|
|
||||||
z-index: 1;
|
|
||||||
display: none;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
transition: .3s;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
@ -70,12 +70,11 @@
|
||||||
<icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
<icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="favorite"></span>
|
<span class="favorite"></span>
|
||||||
<span class="play"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VirtualScroller
|
<VirtualScroller
|
||||||
v-slot="{ item }"
|
v-slot="{ item }"
|
||||||
:item-height="35"
|
:item-height="64"
|
||||||
:items="songRows"
|
:items="songRows"
|
||||||
@scroll="onScroll"
|
@scroll="onScroll"
|
||||||
@scrolled-to-end="$emit('scrolled-to-end')"
|
@scrolled-to-end="$emit('scrolled-to-end')"
|
||||||
|
@ -139,7 +138,7 @@ watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected
|
||||||
const config = computed((): SongListConfig => {
|
const config = computed((): SongListConfig => {
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
columns: ['track', 'title', 'artist', 'album', 'length']
|
columns: ['track', 'thumbnail', 'title', 'artist', 'album', 'length']
|
||||||
}, injectedConfig)
|
}, injectedConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -334,8 +333,8 @@ onMounted(() => render())
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.time {
|
&.time {
|
||||||
flex-basis: 96px;
|
flex-basis: 64px;
|
||||||
padding-right: 24px;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.track-number {
|
&.track-number {
|
||||||
|
@ -344,7 +343,7 @@ onMounted(() => render())
|
||||||
}
|
}
|
||||||
|
|
||||||
&.artist {
|
&.artist {
|
||||||
flex-basis: 23%;
|
flex-basis: 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.album {
|
&.album {
|
||||||
|
@ -437,6 +436,11 @@ onMounted(() => render())
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
&.thumbnail {
|
||||||
|
display: block;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
&.artist, &.title {
|
&.artist, &.title {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,9 @@
|
||||||
<SoundBars v-if="song.playback_state === 'Playing'"/>
|
<SoundBars v-if="song.playback_state === 'Playing'"/>
|
||||||
<span class="text-secondary" v-else>{{ song.track || '' }}</span>
|
<span class="text-secondary" v-else>{{ song.track || '' }}</span>
|
||||||
</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('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('artist')" class="artist">{{ song.artist_name }}</span>
|
||||||
<span v-if="columns.includes('album')" class="album">{{ song.album_name }}</span>
|
<span v-if="columns.includes('album')" class="album">{{ song.album_name }}</span>
|
||||||
|
@ -17,15 +20,10 @@
|
||||||
<span class="favorite">
|
<span class="favorite">
|
||||||
<LikeButton :song="song"/>
|
<LikeButton :song="song"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="play" data-testid="song-item-play" role="button" @click.stop="doPlayback">
|
|
||||||
<icon v-if="song.playback_state === 'Playing'" :icon="faCirclePause"/>
|
|
||||||
<icon v-else :icon="faCirclePlay"/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faCirclePause, faCirclePlay } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed, toRefs } from 'vue'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { queueStore } from '@/stores'
|
import { queueStore } from '@/stores'
|
||||||
|
@ -33,6 +31,7 @@ import { secondsToHis } from '@/utils'
|
||||||
|
|
||||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
import SoundBars from '@/components/ui/SoundBars.vue'
|
import SoundBars from '@/components/ui/SoundBars.vue'
|
||||||
|
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||||
|
|
||||||
const props = defineProps<{ item: SongRow, columns: SongListColumn[] }>()
|
const props = defineProps<{ item: SongRow, columns: SongListColumn[] }>()
|
||||||
const { item, columns } = toRefs(props)
|
const { item, columns } = toRefs(props)
|
||||||
|
@ -45,22 +44,6 @@ const play = () => {
|
||||||
queueStore.queueIfNotQueued(song.value)
|
queueStore.queueIfNotQueued(song.value)
|
||||||
playbackService.play(song.value)
|
playbackService.play(song.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doPlayback = () => {
|
|
||||||
switch (song.value.playback_state) {
|
|
||||||
case 'Playing':
|
|
||||||
playbackService.pause()
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Paused':
|
|
||||||
playbackService.resume()
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
play()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@ -68,14 +51,27 @@ const doPlayback = () => {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-bottom: 1px solid var(--color-bg-secondary);
|
border-bottom: 1px solid var(--color-bg-secondary);
|
||||||
max-width: 100% !important; // overriding .item
|
max-width: 100% !important; // overriding .item
|
||||||
height: 35px;
|
height: 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
&:focus, &:focus-within {
|
&:focus, &:focus-within {
|
||||||
box-shadow: 0 0 1px 1px var(--color-accent) inset;
|
box-shadow: 0 0 1px 1px var(--color-accent) inset;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.cover {
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, .05);
|
background: rgba(255, 255, 255, .05);
|
||||||
}
|
}
|
||||||
|
|
39
resources/assets/js/components/song/SongThumbnail.spec.ts
Normal file
39
resources/assets/js/components/song/SongThumbnail.spec.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
|
import { fireEvent } from '@testing-library/vue'
|
||||||
|
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||||
|
|
||||||
|
let song: Song
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
private renderComponent (playbackState: PlaybackState = 'Stopped') {
|
||||||
|
song = factory<Song>('song', {
|
||||||
|
playback_state: playbackState,
|
||||||
|
play_count: 10,
|
||||||
|
title: 'Foo bar'
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.render(SongThumbnail, {
|
||||||
|
props: {
|
||||||
|
song
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected test () {
|
||||||
|
it.each<[PlaybackState, MethodOf<typeof playbackService>]>([
|
||||||
|
['Stopped', 'play'],
|
||||||
|
['Playing', 'pause'],
|
||||||
|
['Paused', 'resume']
|
||||||
|
])('if state is currently "%s", %ss', async (state: PlaybackState, method: MethodOf<typeof playbackService>) => {
|
||||||
|
const mock = this.mock(playbackService, method)
|
||||||
|
const { getByTestId } = this.renderComponent(state)
|
||||||
|
|
||||||
|
await fireEvent.click(getByTestId('play-control'))
|
||||||
|
|
||||||
|
expect(mock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
82
resources/assets/js/components/song/SongThumbnail.vue
Normal file
82
resources/assets/js/components/song/SongThumbnail.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<template>
|
||||||
|
<div :style="{ backgroundImage: `url(${song.album_cover ?? ''}), url(${defaultCover})` }" class="cover">
|
||||||
|
<a class="control" @click.prevent="changeSongState" data-testid="play-control">
|
||||||
|
<icon :icon="song.playback_state === 'Playing' ? faPause : faPlay" class="text-highlight"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { toRefs } from 'vue'
|
||||||
|
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { defaultCover } from '@/utils'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
|
import { queueStore } from '@/stores'
|
||||||
|
|
||||||
|
const props = defineProps<{ song: Song }>()
|
||||||
|
const { song } = toRefs(props)
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
queueStore.queueIfNotQueued(song.value)
|
||||||
|
playbackService.play(song.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeSongState = () => {
|
||||||
|
if (song.value.playback_state === 'Stopped') {
|
||||||
|
play()
|
||||||
|
} else if (song.value.playback_state === 'Paused') {
|
||||||
|
playbackService.resume()
|
||||||
|
} else {
|
||||||
|
playbackService.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.cover {
|
||||||
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
background-size: cover;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #000;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, .5);
|
||||||
|
font-size: 1rem;
|
||||||
|
z-index: 1;
|
||||||
|
display: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: .3s;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div class="song-list-wrap" data-testid="song-list" tabindex="0">
|
<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><span class="play"></span></div><br data-testid="virtual-scroller" item-height="35" 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" 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
<span class="text pulse"/>
|
<span class="text pulse"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="title">
|
<span class="title">
|
||||||
|
<span class="thumbnail pulse"/>
|
||||||
<span class="text pulse"/>
|
<span class="text pulse"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="artist">
|
<span class="artist">
|
||||||
|
@ -52,7 +53,7 @@
|
||||||
.song-item {
|
.song-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--color-bg-secondary);
|
border-bottom: 1px solid var(--color-bg-secondary);
|
||||||
height: 35px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-list-header span span, .pulse {
|
.song-list-header span span, .pulse {
|
||||||
|
@ -77,8 +78,18 @@
|
||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
display: block;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist {
|
.artist {
|
||||||
|
@ -91,12 +102,28 @@
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
flex-basis: 96px;
|
flex-basis: 96px;
|
||||||
padding-right: 24px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorite {
|
.favorite {
|
||||||
flex-basis: 36px;
|
flex-basis: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title, .artist {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist .text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list-header, .song-item {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -103,7 +103,7 @@ export const tooltip: Directive = {
|
||||||
mounted: init,
|
mounted: init,
|
||||||
updated: init,
|
updated: init,
|
||||||
|
|
||||||
unmounted: (el: ElementWithTooltip, binding) => {
|
beforeUnmount: (el: ElementWithTooltip, binding) => {
|
||||||
el.$cleanup && el.$cleanup()
|
el.$cleanup && el.$cleanup()
|
||||||
el.$tooltip && document.removeChild(el.$tooltip)
|
el.$tooltip && document.removeChild(el.$tooltip)
|
||||||
}
|
}
|
||||||
|
|
2
resources/assets/js/types.d.ts
vendored
2
resources/assets/js/types.d.ts
vendored
|
@ -346,7 +346,7 @@ type ArtistAlbumViewMode = 'list' | 'thumbnails'
|
||||||
|
|
||||||
type RepeatMode = 'NO_REPEAT' | 'REPEAT_ALL' | 'REPEAT_ONE'
|
type RepeatMode = 'NO_REPEAT' | 'REPEAT_ALL' | 'REPEAT_ONE'
|
||||||
|
|
||||||
type SongListColumn = 'track' | 'title' | 'album' | 'artist' | 'length'
|
type SongListColumn = 'track' | 'thumbnail' | 'title' | 'album' | 'artist' | 'length'
|
||||||
|
|
||||||
interface SongListConfig {
|
interface SongListConfig {
|
||||||
sortable: boolean
|
sortable: boolean
|
||||||
|
|
Loading…
Reference in a new issue