feat(design): add thumbnails to song list (#1555)

This commit is contained in:
Phan An 2022-10-26 14:34:32 +02:00 committed by GitHub
parent 36e49338a6
commit c1847b2584
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 211 additions and 114 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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