mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +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,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(songs, 'Artist', { columns: ['track', 'title', 'album', 'length'] })
|
||||
} = useSongList(songs, 'Artist', { columns: ['track', 'thumbnail', 'title', 'album', 'length'] })
|
||||
|
||||
const { useLastfm } = useThirdPartyServices()
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
|
|
@ -20,12 +20,24 @@ new class extends UnitTestCase {
|
|||
props: {
|
||||
song,
|
||||
topPlayCount: 42
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SongThumbnail: this.stub('thumbnail'),
|
||||
LikeButton: this.stub('like-button')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 playMock = this.mock(playbackService, 'play')
|
||||
const { getByTestId } = this.renderComponent()
|
||||
|
@ -35,18 +47,5 @@ new class extends UnitTestCase {
|
|||
expect(queueMock).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"
|
||||
@dblclick.prevent="play"
|
||||
>
|
||||
<aside :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>
|
||||
</aside>
|
||||
<SongThumbnail :song="song"/>
|
||||
<main>
|
||||
<div class="details">
|
||||
<h3>{{ song.title }}</h3>
|
||||
|
@ -27,13 +23,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { toRefs } from 'vue'
|
||||
import { defaultCover, eventBus, pluralize } from '@/utils'
|
||||
import { eventBus, pluralize } from '@/utils'
|
||||
import { queueStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import { useDraggable } from '@/composables'
|
||||
|
||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||
|
||||
const props = defineProps<{ song: Song }>()
|
||||
|
@ -48,16 +44,6 @@ 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>
|
||||
|
@ -93,9 +79,20 @@ article {
|
|||
button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::v-deep(.cover) {
|
||||
.control {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: .7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .cover, &:focus .cover {
|
||||
// show the thumbnail's playback control on the whole card focus and hover
|
||||
&:hover ::v-deep(.cover), &:focus ::v-deep(.cover) {
|
||||
.control {
|
||||
display: flex;
|
||||
}
|
||||
|
@ -105,53 +102,6 @@ article {
|
|||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
|
|
@ -70,12 +70,11 @@
|
|||
<icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
|
||||
</span>
|
||||
<span class="favorite"></span>
|
||||
<span class="play"></span>
|
||||
</div>
|
||||
|
||||
<VirtualScroller
|
||||
v-slot="{ item }"
|
||||
:item-height="35"
|
||||
:item-height="64"
|
||||
:items="songRows"
|
||||
@scroll="onScroll"
|
||||
@scrolled-to-end="$emit('scrolled-to-end')"
|
||||
|
@ -139,7 +138,7 @@ watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected
|
|||
const config = computed((): SongListConfig => {
|
||||
return Object.assign({
|
||||
sortable: true,
|
||||
columns: ['track', 'title', 'artist', 'album', 'length']
|
||||
columns: ['track', 'thumbnail', 'title', 'artist', 'album', 'length']
|
||||
}, injectedConfig)
|
||||
})
|
||||
|
||||
|
@ -334,8 +333,8 @@ onMounted(() => render())
|
|||
white-space: nowrap;
|
||||
|
||||
&.time {
|
||||
flex-basis: 96px;
|
||||
padding-right: 24px;
|
||||
flex-basis: 64px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&.track-number {
|
||||
|
@ -344,7 +343,7 @@ onMounted(() => render())
|
|||
}
|
||||
|
||||
&.artist {
|
||||
flex-basis: 23%;
|
||||
flex-basis: 20%;
|
||||
}
|
||||
|
||||
&.album {
|
||||
|
@ -437,6 +436,11 @@ onMounted(() => render())
|
|||
vertical-align: bottom;
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&.thumbnail {
|
||||
display: block;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
&.artist, &.title {
|
||||
display: inline;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,9 @@
|
|||
<SoundBars v-if="song.playback_state === 'Playing'"/>
|
||||
<span class="text-secondary" v-else>{{ 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>
|
||||
|
@ -17,15 +20,10 @@
|
|||
<span class="favorite">
|
||||
<LikeButton :song="song"/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faCirclePause, faCirclePlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { playbackService } from '@/services'
|
||||
import { queueStore } from '@/stores'
|
||||
|
@ -33,6 +31,7 @@ import { secondsToHis } from '@/utils'
|
|||
|
||||
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)
|
||||
|
@ -45,22 +44,6 @@ const play = () => {
|
|||
queueStore.queueIfNotQueued(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>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -68,14 +51,27 @@ const doPlayback = () => {
|
|||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-bg-secondary);
|
||||
max-width: 100% !important; // overriding .item
|
||||
height: 35px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:focus, &:focus-within {
|
||||
box-shadow: 0 0 1px 1px var(--color-accent) inset;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.cover {
|
||||
.control {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: .7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
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`] = `
|
||||
<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>
|
||||
`;
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="title">
|
||||
<span class="thumbnail pulse"/>
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="artist">
|
||||
|
@ -52,7 +53,7 @@
|
|||
.song-item {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-bg-secondary);
|
||||
height: 35px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.song-list-header span span, .pulse {
|
||||
|
@ -77,8 +78,18 @@
|
|||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.artist {
|
||||
|
@ -91,12 +102,28 @@
|
|||
|
||||
.time {
|
||||
flex-basis: 96px;
|
||||
padding-right: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.favorite {
|
||||
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>
|
||||
|
|
|
@ -103,7 +103,7 @@ export const tooltip: Directive = {
|
|||
mounted: init,
|
||||
updated: init,
|
||||
|
||||
unmounted: (el: ElementWithTooltip, binding) => {
|
||||
beforeUnmount: (el: ElementWithTooltip, binding) => {
|
||||
el.$cleanup && el.$cleanup()
|
||||
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 SongListColumn = 'track' | 'title' | 'album' | 'artist' | 'length'
|
||||
type SongListColumn = 'track' | 'thumbnail' | 'title' | 'album' | 'artist' | 'length'
|
||||
|
||||
interface SongListConfig {
|
||||
sortable: boolean
|
||||
|
|
Loading…
Reference in a new issue