fix(tests): broken FE tests after Podcast feature

This commit is contained in:
Phan An 2024-06-03 01:15:31 +08:00
parent 7d3215a323
commit 0f67ce2478
72 changed files with 377 additions and 356 deletions

View file

@ -1,7 +1,7 @@
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { queueStore, songStore } from '@/stores' import { songStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { PlayablesKey } from '@/symbols' import { PlayablesKey } from '@/symbols'
@ -14,15 +14,13 @@ new class extends UnitTestCase {
it('plays', async () => { it('plays', async () => {
const matchedSong = factory('song') const matchedSong = factory('song')
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
const playMock = this.mock(playbackService, 'play') const playMock = this.mock(playbackService, 'play')
this.renderComponent(matchedSong) this.renderComponent(matchedSong)
await this.user.click(screen.getByTitle('Click to play')) await this.user.click(screen.getByTitle('Click to play'))
expect(queueMock).toHaveBeenNthCalledWith(1, matchedSong) expect(playMock).toHaveBeenCalledWith(matchedSong)
expect(playMock).toHaveBeenNthCalledWith(1, matchedSong)
}) })
} }

View file

@ -1,8 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<article data-v-2487c4e3="" class="full relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin"> <article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="http://loremflickr.com/640/480" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>
<div data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="IV" src="http://loremflickr.com/640/480" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden"> <footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/album/42" class="font-medium" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div> <div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/album/42" class="font-medium" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs in the album IV" role="button"> Shuffle </a><a title="Download all songs in the album IV" role="button"> Download </a></p> <p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs in the album IV" role="button"> Shuffle </a><a title="Download all songs in the album IV" role="button"> Download </a></p>

View file

@ -1,15 +1,17 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<nav data-v-0408531a="" class="album-menu menu context-menu select-none" tabindex="0" data-testid="album-context-menu"> <div data-v-6396fbf0="" data-testid="album-context-menu">
<ul data-v-0408531a=""> <nav data-v-6396fbf0="" class="album-menu menu context-menu select-none shadow" tabindex="0">
<li>Play All</li> <ul data-v-6396fbf0="">
<li>Shuffle All</li> <li>Play All</li>
<li class="separator"></li> <li>Shuffle All</li>
<li>Go to Album</li> <li class="separator"></li>
<li>Go to Artist</li> <li>Go to Album</li>
<li class="separator"></li> <li>Go to Artist</li>
<li>Download</li> <li class="separator"></li>
</ul> <li>Download</li>
</nav> </ul>
</nav>
</div>
`; `;

View file

@ -1,8 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<article data-v-2487c4e3="" class="full relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin"> <article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="foo.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>
<div data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Led Zeppelin" src="foo.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden"> <footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/artist/42" class="font-medium" data-testid="name">Led Zeppelin</a></div> <div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/artist/42" class="font-medium" data-testid="name">Led Zeppelin</a></div>
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs by Led Zeppelin" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" role="button"> Download </a></p> <p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs by Led Zeppelin" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" role="button"> Download </a></p>

View file

@ -1,14 +1,16 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<nav data-v-0408531a="" class="artist-menu menu context-menu select-none" tabindex="0" data-testid="artist-context-menu"> <div data-v-6396fbf0="" data-testid="artist-context-menu">
<ul data-v-0408531a=""> <nav data-v-6396fbf0="" class="artist-menu menu context-menu select-none shadow" tabindex="0">
<li>Play All</li> <ul data-v-6396fbf0="">
<li>Shuffle All</li> <li>Play All</li>
<li class="separator"></li> <li>Shuffle All</li>
<li>Go to Artist</li> <li class="separator"></li>
<li class="separator"></li> <li>Go to Artist</li>
<li>Download</li> <li class="separator"></li>
</ul> <li>Download</li>
</nav> </ul>
</nav>
</div>
`; `;

View file

@ -8,7 +8,7 @@ import ModalWrapper from './ModalWrapper.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it.each<[string, keyof Events, User | Song[] | Playlist | PlaylistFolder | undefined]>([ it.each<[string, keyof Events, User | Playable[] | Playlist | PlaylistFolder | undefined]>([
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined], ['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined], ['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined],
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')], ['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],

View file

@ -50,10 +50,10 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
.on('MODAL_SHOW_KOEL_PLUS', () => (activeModalName.value = 'koel-plus')) .on('MODAL_SHOW_KOEL_PLUS', () => (activeModalName.value = 'koel-plus'))
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form')) .on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
.on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form')) .on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form'))
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, songs) => { .on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, playables?) => {
context.value = { context.value = {
folder, folder,
songs: songs ? arrayify(songs) : [] playables: playables ? arrayify(playables) : []
} }
activeModalName.value = 'create-playlist-form' activeModalName.value = 'create-playlist-form'

View file

@ -5,35 +5,35 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentPlayableKey } from '@/symbols' import { CurrentPlayableKey } from '@/symbols'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import FooterPlaybackControls from './FooterPlaybackControls.vue' import Component from './FooterPlaybackControls.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it('renders without a current song', () => expect(this.renderComponent(null).html()).toMatchSnapshot()) it('renders without a current playable', () => expect(this.renderComponent(null).html()).toMatchSnapshot())
it('renders with a current song', () => expect(this.renderComponent().html()).toMatchSnapshot()) it('renders with a current playable', () => expect(this.renderComponent().html()).toMatchSnapshot())
it('plays the previous song', async () => { it('plays the previous song', async () => {
const playMock = this.mock(playbackService, 'playPrev') const playMock = this.mock(playbackService, 'playPrev')
this.renderComponent() this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Play previous song' })) await this.user.click(screen.getByRole('button', { name: 'Play previous in queue' }))
expect(playMock).toHaveBeenCalled() expect(playMock).toHaveBeenCalled()
}) })
it('plays the next song', async () => { it('plays the next playable', async () => {
const playMock = this.mock(playbackService, 'playNext') const playMock = this.mock(playbackService, 'playNext')
this.renderComponent() this.renderComponent()
await this.user.click(screen.getByRole('button', { name: 'Play next song' })) await this.user.click(screen.getByRole('button', { name: 'Play next in queue' }))
expect(playMock).toHaveBeenCalled() expect(playMock).toHaveBeenCalled()
}) })
} }
private renderComponent (song?: Song | null) { private renderComponent (playable?: Playable | null) {
if (song === undefined) { if (playable === undefined) {
song = factory('song', { playable = factory('song', {
id: '00000000-0000-0000-0000-000000000000', id: '00000000-0000-0000-0000-000000000000',
title: 'Fahrstuhl to Heaven', title: 'Fahrstuhl to Heaven',
artist_name: 'Led Zeppelin', artist_name: 'Led Zeppelin',
@ -44,13 +44,13 @@ new class extends UnitTestCase {
}) })
} }
return this.render(FooterPlaybackControls, { return this.render(Component, {
global: { global: {
stubs: { stubs: {
PlayButton: this.stub('PlayButton') PlayButton: this.stub('PlayButton')
}, },
provide: { provide: {
[<symbol>CurrentPlayableKey]: ref(song) [<symbol>CurrentPlayableKey]: ref(playable)
} }
} }
}) })

View file

@ -1,16 +1,16 @@
<template> <template>
<div class="playback-controls flex flex-1 flex-col justify-center"> <div class="playback-controls flex flex-1 flex-col justify-center">
<div class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"> <div class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0">
<LikeButton v-if="song" :song="song" class="text-base" /> <LikeButton v-if="playable" :playable="playable" class="text-base" />
<button v-else type="button" /> <!-- a placeholder to maintain the asymmetric layout --> <button v-else type="button" /> <!-- a placeholder to maintain the asymmetric layout -->
<FooterBtn class="text-2xl" title="Play previous song" @click.prevent="playPrev"> <FooterBtn class="text-2xl" title="Play previous in queue" @click.prevent="playPrev">
<Icon :icon="faStepBackward" /> <Icon :icon="faStepBackward" />
</FooterBtn> </FooterBtn>
<PlayButton /> <PlayButton />
<FooterBtn class="text-2xl" title="Play next song" @click.prevent="playNext"> <FooterBtn class="text-2xl" title="Play next in queue" @click.prevent="playNext">
<Icon :icon="faStepForward" /> <Icon :icon="faStepForward" />
</FooterBtn> </FooterBtn>
@ -31,7 +31,7 @@ import LikeButton from '@/components/song/SongLikeButton.vue'
import PlayButton from '@/components/ui/FooterPlayButton.vue' import PlayButton from '@/components/ui/FooterPlayButton.vue'
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue' import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
const song = requireInjection(CurrentPlayableKey, ref()) const playable = requireInjection(CurrentPlayableKey, ref())
const playPrev = async () => await playbackService.playPrev() const playPrev = async () => await playbackService.playPrev()
const playNext = async () => await playbackService.playNext() const playNext = async () => await playbackService.playNext()

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<div data-v-8bf5fe81="" class="extra-controls flex justify-end relative md:w-[320px] px-6 md:px-8 py-0"> <div data-v-8bf5fe81="" class="extra-controls flex justify-end relative md:w-[420px] px-6 md:px-8 py-0">
<div data-v-8bf5fe81="" class="flex justify-end items-center gap-6"><button data-v-8bf5fe81="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary visualizer-btn hidden md:!block" type="button" data-testid="toggle-visualizer-btn" title="Toggle visualizer"><br data-v-8bf5fe81="" data-testid="Icon" icon="[object Object]"></button> <div data-v-8bf5fe81="" class="flex justify-end items-center gap-6"><button data-v-b0dbb31b="" data-v-8bf5fe81="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary queue-btn" type="button" title="Queue (Q)"><br data-v-b0dbb31b="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-8bf5fe81="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary visualizer-btn hidden md:!block" type="button" data-testid="toggle-visualizer-btn" title="Toggle visualizer"><br data-v-8bf5fe81="" data-testid="Icon" icon="[object Object]" fixed-width=""></button>
<!--v-if--><span data-v-c7afcfc4="" data-v-8bf5fe81="" id="volume" class="muted hidden md:flex relative items-center gap-2"><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Unmute"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Mute" style="display: none;"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><input data-v-c7afcfc4="" class="plyr__volume !w-[120px] before:absolute before:left-0 before:right-0 before:top-[-12px] before:bottom-[-12px]" max="10" role="slider" step="0.1" title="Volume" type="range"></span> <!--v-if--><span data-v-c7afcfc4="" data-v-8bf5fe81="" id="volume" class="muted hidden md:flex relative items-center gap-2"><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Unmute"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Mute" style="display: none;"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><input data-v-c7afcfc4="" class="plyr__volume !w-[120px] before:absolute before:left-0 before:right-0 before:top-[-12px] before:bottom-[-12px]" max="10" role="slider" step="0.1" title="Volume" type="range"></span>
<!--v-if--> <!--v-if-->
</div> </div>

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders with a current song 1`] = ` exports[`renders with a current playable 1`] = `
<div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center"> <div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center">
<div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-base" type="button" title="Unlike Fahrstuhl to Heaven by Led Zeppelin"><br data-testid="Icon" icon="[object Object]"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon"> <div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-base" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
<path d="m17 2 4 4-4 4"></path> <path d="m17 2 4 4-4 4"></path>
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path> <path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path> <path d="m7 22-4-4 4-4"></path>
@ -11,9 +11,9 @@ exports[`renders with a current song 1`] = `
</div> </div>
`; `;
exports[`renders without a current song 1`] = ` exports[`renders without a current playable 1`] = `
<div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center"> <div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center">
<div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" type="button"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon"> <div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" type="button"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
<path d="m17 2 4 4-4 4"></path> <path d="m17 2 4 4-4 4"></path>
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path> <path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path> <path d="m7 22-4-4 4-4"></path>

View file

@ -1,15 +1,15 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders with current song 1`] = ` exports[`renders with current song 1`] = `
<div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span> <div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
<div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block"> <div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block">
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="/#/artist/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a> <h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="#/artist/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a>
</div> </div>
</div> </div>
`; `;
exports[`renders with no current song 1`] = ` exports[`renders with no current song 1`] = `
<div data-v-91ed60f7="" class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5" draggable="false"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span> <div data-v-91ed60f7="" class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5" draggable="false"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
<!--v-if--> <!--v-if-->
</div> </div>
`; `;

View file

@ -1,5 +1,6 @@
<template> <template>
<aside <aside
v-if="playable"
:class="{ 'showing-pane': activeTab }" :class="{ 'showing-pane': activeTab }"
class="fixed sm:relative top-0 w-screen md:w-auto flex flex-col md:flex-row-reverse z-[2] text-k-text-secondary" class="fixed sm:relative top-0 w-screen md:w-auto flex flex-col md:flex-row-reverse z-[2] text-k-text-secondary"
> >
@ -62,7 +63,7 @@
role="tabpanel" role="tabpanel"
tabindex="0" tabindex="0"
> >
<YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable as Song" /> <YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable" />
</div> </div>
</div> </div>
</aside> </aside>
@ -70,7 +71,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import isMobile from 'ismobilejs' import isMobile from 'ismobilejs'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue' import { computed, defineAsyncComponent, onMounted, ref, Ref, watch } from 'vue'
import { albumStore, artistStore, preferenceStore } from '@/stores' import { albumStore, artistStore, preferenceStore } from '@/stores'
import { useErrorHandler, useThirdPartyServices } from '@/composables' import { useErrorHandler, useThirdPartyServices } from '@/composables'
import { isSong, requireInjection } from '@/utils' import { isSong, requireInjection } from '@/utils'
@ -89,7 +90,7 @@ const ExtraDrawerTabHeader = defineAsyncComponent(() => import('./ExtraDrawerTab
const { useYouTube } = useThirdPartyServices() const { useYouTube } = useThirdPartyServices()
const playable = requireInjection(CurrentPlayableKey, ref(undefined)) const playable = requireInjection(CurrentPlayableKey, ref(undefined)) as Ref<Song | undefined>
const activeTab = ref<ExtraPanelTab | null>(null) const activeTab = ref<ExtraPanelTab | null>(null)
const artist = ref<Artist>() const artist = ref<Artist>()

View file

@ -6,8 +6,6 @@ import { eventBus } from '@/utils'
import Sidebar from './Sidebar.vue' import Sidebar from './Sidebar.vue'
const standardItems = [ const standardItems = [
'Home',
'Current Queue',
'All Songs', 'All Songs',
'Albums', 'Albums',
'Artists', 'Artists',

View file

@ -6,8 +6,8 @@
</SidebarSectionHeader> </SidebarSectionHeader>
<ul> <ul>
<PlaylistSidebarItem :list="{ name: 'Favorites', songs: favorites }" /> <PlaylistSidebarItem :list="{ name: 'Favorites', playables: favorites }" />
<PlaylistSidebarItem :list="{ name: 'Recently Played', songs: [] }" /> <PlaylistSidebarItem :list="{ name: 'Recently Played', playables: [] }" />
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder" /> <PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder" />
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist" /> <PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist" />
</ul> </ul>
@ -26,7 +26,7 @@ import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSect
const folders = toRef(playlistFolderStore.state, 'folders') const folders = toRef(playlistFolderStore.state, 'folders')
const playlists = toRef(playlistStore.state, 'playlists') const playlists = toRef(playlistStore.state, 'playlists')
const favorites = toRef(favoriteStore.state, 'songs') const favorites = toRef(favoriteStore.state, 'playables')
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => { const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
if (folder_id === null) return true if (folder_id === null) return true

View file

@ -9,7 +9,7 @@ import CreatePlaylistForm from './CreatePlaylistForm.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it('creates playlist with no songs', async () => { it('creates playlist with no playables', async () => {
const folder = factory('playlist-folder') const folder = factory('playlist-folder')
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist')) const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist'))
@ -21,7 +21,7 @@ new class extends UnitTestCase {
} }
}) })
expect(screen.queryByTestId('from-songs')).toBeNull() expect(screen.queryByTestId('from-playables')).toBeNull()
await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist') await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist')
await this.user.click(screen.getByRole('button', { name: 'Save' })) await this.user.click(screen.getByRole('button', { name: 'Save' }))
@ -31,15 +31,15 @@ new class extends UnitTestCase {
}, []) }, [])
}) })
it('creates playlist with songs', async () => { it('creates playlist with playables', async () => {
const songs = factory('song', 3) const playables = factory('song', 3)
const folder = factory('playlist-folder') const folder = factory('playlist-folder')
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist')) const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist'))
this.render(CreatePlaylistForm, { this.render(CreatePlaylistForm, {
global: { global: {
provide: { provide: {
[<symbol>ModalContextKey]: [ref({ folder, songs })] [<symbol>ModalContextKey]: [ref({ folder, playables })]
} }
} }
}) })
@ -51,7 +51,7 @@ new class extends UnitTestCase {
expect(storeMock).toHaveBeenCalledWith('My playlist', { expect(storeMock).toHaveBeenCalledWith('My playlist', {
folder_id: folder.id folder_id: folder.id
}, songs) }, playables)
}) })
} }
} }

View file

@ -3,8 +3,8 @@
<header> <header>
<h1> <h1>
New Playlist New Playlist
<span v-if="songs.length" class="text-k-text-secondary" data-testid="from-songs"> <span v-if="playables.length" class="text-k-text-secondary" data-testid="from-playables">
from {{ pluralize(songs, 'item') }} from {{ pluralize(playables, noun) }}
</span> </span>
</h1> </h1>
</header> </header>
@ -33,9 +33,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, toRef } from 'vue' import { computed, ref, toRef } from 'vue'
import { playlistFolderStore, playlistStore } from '@/stores' import { playlistFolderStore, playlistStore } from '@/stores'
import { pluralize } from '@/utils' import { getPlayableCollectionContentType, pluralize } from '@/utils'
import { useDialogBox, useErrorHandler, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables' import { useDialogBox, useErrorHandler, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables'
import Btn from '@/components/ui/form/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
@ -50,7 +50,7 @@ const { go } = useRouter()
const { getFromContext } = useModal() const { getFromContext } = useModal()
const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null
const songs = getFromContext<Song[]>('songs') ?? [] const playables = getFromContext<Playable[]>('playables') ?? []
const folderId = ref(targetFolder?.id) const folderId = ref(targetFolder?.id)
const name = ref('') const name = ref('')
@ -59,13 +59,24 @@ const folders = toRef(playlistFolderStore.state, 'folders')
const emit = defineEmits<{ (e: 'close'): void }>() const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close') const close = () => emit('close')
const noun = computed(() => {
switch (getPlayableCollectionContentType(playables)) {
case 'songs':
return 'song'
case 'episodes':
return 'song'
default:
return 'item'
}
})
const submit = async () => { const submit = async () => {
showOverlay() showOverlay()
try { try {
const playlist = await playlistStore.store(name.value, { const playlist = await playlistStore.store(name.value, {
folder_id: folderId.value folder_id: folderId.value
}, songs) }, playables)
close() close()
toastSuccess(`Playlist "${playlist.name}" created.`) toastSuccess(`Playlist "${playlist.name}" created.`)

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders the modal 1`] = ` exports[`renders the modal 1`] = `
<div data-v-886145d2="" class="collaboration-modal max-w-[640px]" tabindex="0"> <div data-v-886145d2="" class="collaboration-modal max-w-[640px]" tabindex="0" data-testid="playlist-collaboration">
<header data-v-886145d2=""> <header data-v-886145d2="">
<h1 data-v-886145d2="">Playlist Collaboration</h1> <h1 data-v-886145d2="">Playlist Collaboration</h1>
</header> </header>

View file

@ -54,7 +54,7 @@ import DOMPurify from 'dompurify'
import { orderBy } from 'lodash' import { orderBy } from 'lodash'
import { faBookmark, faPause, faPlay } from '@fortawesome/free-solid-svg-icons' import { faBookmark, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, toRefs } from 'vue' import { computed, defineAsyncComponent, toRefs } from 'vue'
import { eventBus, secondsToHis } from '@/utils' import { eventBus, secondsToHumanReadable } from '@/utils'
import { useDraggable } from '@/composables' import { useDraggable } from '@/composables'
import { formatTimeAgo } from '@vueuse/core' import { formatTimeAgo } from '@vueuse/core'
import { playbackService } from '@/services' import { playbackService } from '@/services'
@ -84,9 +84,9 @@ const publicationDateForHumans = computed(() => {
const currentPosition = computed(() => podcast.value.state.progresses[episode.value.id] || 0) const currentPosition = computed(() => podcast.value.state.progresses[episode.value.id] || 0)
const timeLeft = computed(() => { const timeLeft = computed(() => {
if (currentPosition.value === 0) return secondsToHis(episode.value.length) if (currentPosition.value === 0) return secondsToHumanReadable(episode.value.length)
const secondsLeft = episode.value.length - currentPosition.value const secondsLeft = episode.value.length - currentPosition.value
return secondsLeft === 0 ? 0 : secondsToHis(secondsLeft) return secondsLeft === 0 ? 0 : secondsToHumanReadable(secondsLeft)
}) })
const shouldShowProgress = computed(() => timeLeft.value !== 0 && episode.value.length && currentPosition.value) const shouldShowProgress = computed(() => timeLeft.value !== 0 && episode.value.length && currentPosition.value)

View file

@ -8,7 +8,7 @@ import FavoritesScreen from './FavoritesScreen.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it('renders a list of favorites', async () => { it('renders a list of favorites', async () => {
favoriteStore.state.songs = factory('song', 13) favoriteStore.state.playables = factory('song', 13)
await this.renderComponent() await this.renderComponent()
await waitFor(() => { await waitFor(() => {
@ -18,7 +18,7 @@ new class extends UnitTestCase {
}) })
it('shows empty state', async () => { it('shows empty state', async () => {
favoriteStore.state.songs = [] favoriteStore.state.playables = []
await this.renderComponent() await this.renderComponent()
screen.getByTestId('screen-empty-state') screen.getByTestId('screen-empty-state')

View file

@ -93,7 +93,7 @@ const {
applyFilter, applyFilter,
onScrollBreakpoint, onScrollBreakpoint,
sort sort
} = useSongList(toRef(favoriteStore.state, 'songs'), { type: 'Favorites' }) } = useSongList(toRef(favoriteStore.state, 'playables'), { type: 'Favorites' })
const { SongListControls, config } = useSongListControls('Favorites') const { SongListControls, config } = useSongListControls('Favorites')

View file

@ -50,7 +50,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { faTags } from '@fortawesome/free-solid-svg-icons' import { faTags } from '@fortawesome/free-solid-svg-icons'
import { arrayify, eventBus, pluralize, secondsToHumanReadable } from '@/utils' import { eventBus, pluralize, secondsToHumanReadable } from '@/utils'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { genreStore, songStore } from '@/stores' import { genreStore, songStore } from '@/stores'
import { useErrorHandler, useRouter, useSongList, useSongListControls } from '@/composables' import { useErrorHandler, useRouter, useSongList, useSongListControls } from '@/composables'

View file

@ -57,10 +57,10 @@ new class extends UnitTestCase {
}) })
} }
private async renderComponent (songs: Song[]) { private async renderComponent (songs: Playable[]) {
playlist = playlist || factory('playlist') playlist = playlist || factory('playlist')
playlistStore.init([playlist]) playlistStore.init([playlist])
playlist.songs = songs playlist.playables = songs
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs) const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)

View file

@ -121,7 +121,7 @@ const {
onScrollBreakpoint, onScrollBreakpoint,
sort: baseSort, sort: baseSort,
config: listConfig config: listConfig
} = useSongList(ref<Song[] | CollaborativeSong[]>([]), { type: 'Playlist' }) } = useSongList(ref<Playable[] | CollaborativeSong[]>([]), { type: 'Playlist' })
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist') const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
const { removeFromPlaylist } = usePlaylistManagement() const { removeFromPlaylist } = usePlaylistManagement()
@ -163,7 +163,7 @@ const sort = (field: MaybeArray<PlayableListSortField> | null, order: SortOrder)
} }
// To sort by position, we simply re-assign the songs array from the playlist, which maintains the original order. // To sort by position, we simply re-assign the songs array from the playlist, which maintains the original order.
songs.value = playlist.value!.songs! songs.value = playlist.value!.playables!
} }
const onReorder = (target: Playable, type: MoveType) => { const onReorder = (target: Playable, type: MoveType) => {

View file

@ -4,7 +4,7 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { commonStore, queueStore } from '@/stores' import { commonStore, queueStore } from '@/stores'
import { screen, waitFor } from '@testing-library/vue' import { screen, waitFor } from '@testing-library/vue'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import QueueScreen from './QueueScreen.vue' import Component from './QueueScreen.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
@ -46,10 +46,10 @@ new class extends UnitTestCase {
}) })
} }
private renderComponent (songs: Song[]) { private renderComponent (playables: Playable[]) {
queueStore.state.playables = songs queueStore.state.playables = playables
this.render(QueueScreen, { this.render(Component, {
global: { global: {
stubs: { stubs: {
SongList: this.stub('song-list') SongList: this.stub('song-list')

View file

@ -22,8 +22,8 @@ new class extends UnitTestCase {
}) })
} }
private async renderComponent (songs: Song[]) { private async renderComponent (playables: Playable[]) {
recentlyPlayedStore.state.playables = songs recentlyPlayedStore.state.playables = playables
const fetchMock = this.mock(recentlyPlayedStore, 'fetch') const fetchMock = this.mock(recentlyPlayedStore, 'fetch')
this.render(RecentlyPlayedScreen, { this.render(RecentlyPlayedScreen, {

View file

@ -14,19 +14,21 @@ exports[`renders 1`] = `
</div> </div>
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4"> <div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls"> <div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-block relative flex-nowrap" uppercased=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span> <div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap" uppercase=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
</div> </div>
<div class="context-menu p-0 hidden"> <div>
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0"> <div class="context-menu p-0 hidden">
<section data-v-42061e3e="" class="existing-playlists"> <div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 songs to</p> <section data-v-42061e3e="" class="existing-playlists">
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5"> <p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li> <ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li> <li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
</ul> <li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button> </ul>
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -52,19 +54,21 @@ exports[`renders in Plus edition 1`] = `
</div> </div>
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4"> <div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls"> <div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-block relative flex-nowrap" uppercased=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span> <div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap" uppercase=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
</div> </div>
<div class="context-menu p-0 hidden"> <div>
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0"> <div class="context-menu p-0 hidden">
<section data-v-42061e3e="" class="existing-playlists"> <div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 songs to</p> <section data-v-42061e3e="" class="existing-playlists">
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5"> <p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li> <ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li> <li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
</ul> <li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button> </ul>
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
</div>
</div> </div>
</div> </div>
</div><label data-v-8ea4eaa5="" data-v-5691beb5-s="" class="text-k-text-secondary inline-flex items-center text-base"><input data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative align-bottom inline-block w-[32px] h-[20px] bg-gray-400 rounded-full shadow-inner cursor-pointer transition-all duration-200 ease-in-out mr-2 after:h-[16px] after:aspect-square after:absolute after:bg-white after:top-[2px] after:left-[2px] after:rounded-full after:transition-left after:duration-200 after:ease-in-out checked:bg-k-highlight checked:after:left-[14px]" type="checkbox"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">Own songs only</span></label> </div><label data-v-8ea4eaa5="" data-v-5691beb5-s="" class="text-k-text-secondary inline-flex items-center text-base"><input data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative align-bottom inline-block w-[32px] h-[20px] bg-gray-400 rounded-full shadow-inner cursor-pointer transition-all duration-200 ease-in-out mr-2 after:h-[16px] after:aspect-square after:absolute after:bg-white after:top-[2px] after:left-[2px] after:rounded-full after:transition-left after:duration-200 after:ease-in-out checked:bg-k-highlight checked:after:left-[14px]" type="checkbox"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">Own songs only</span></label>

View file

@ -6,8 +6,8 @@ import SearchSongResultsScreen from './SearchSongResultsScreen.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it('searches for prop query on created', () => { it('searches for prop query on created', () => {
const resetResultMock = this.mock(searchStore, 'resetSongResultState') const resetResultMock = this.mock(searchStore, 'resetPlayableResultState')
const searchMock = this.mock(searchStore, 'songSearch') const searchMock = this.mock(searchStore, 'playableSearch')
this.router.activateRoute({ path: 'search-songs', screen: 'Search.Songs' }, { q: 'search me' }) this.router.activateRoute({ path: 'search-songs', screen: 'Search.Songs' }, { q: 'search me' })
this.render(SearchSongResultsScreen) this.render(SearchSongResultsScreen)

View file

@ -68,20 +68,20 @@ const {
applyFilter, applyFilter,
sort, sort,
onScrollBreakpoint onScrollBreakpoint
} = useSongList(toRef(searchStore.state, 'songs'), { type: 'Search.Songs' }) } = useSongList(toRef(searchStore.state, 'playables'), { type: 'Search.Songs' })
const { SongListControls, config } = useSongListControls('Search.Songs') const { SongListControls, config } = useSongListControls('Search.Songs')
const decodedQ = computed(() => decodeURIComponent(q.value)) const decodedQ = computed(() => decodeURIComponent(q.value))
const loading = ref(false) const loading = ref(false)
searchStore.resetSongResultState() searchStore.resetPlayableResultState()
onMounted(async () => { onMounted(async () => {
q.value = getRouteParam('q') || '' q.value = getRouteParam('q') || ''
if (!q.value) return if (!q.value) return
loading.value = true loading.value = true
await searchStore.songSearch(q.value) await searchStore.playableSearch(q.value)
loading.value = false loading.value = false
}) })
</script> </script>

View file

@ -8,7 +8,7 @@ import { arrayify, eventBus } from '@/utils'
import Btn from '@/components/ui/form/Btn.vue' import Btn from '@/components/ui/form/Btn.vue'
import AddToMenu from './AddToMenu.vue' import AddToMenu from './AddToMenu.vue'
let songs: Song[] let playables: Playable[]
const config: AddToMenuConfig = { const config: AddToMenuConfig = {
queue: true, queue: true,
@ -48,7 +48,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByTestId(testId)) await this.user.click(screen.getByTestId(testId))
expect(mock).toHaveBeenCalledWith(songs) expect(mock).toHaveBeenCalledWith(playables)
}) })
it('adds songs to Favorites', async () => { it('adds songs to Favorites', async () => {
@ -57,7 +57,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByTestId('add-to-favorites')) await this.user.click(screen.getByTestId('add-to-favorites'))
expect(mock).toHaveBeenCalledWith(songs) expect(mock).toHaveBeenCalledWith(playables)
}) })
it('adds songs to existing playlist', async () => { it('adds songs to existing playlist', async () => {
@ -67,7 +67,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getAllByTestId('add-to-playlist')[1]) await this.user.click(screen.getAllByTestId('add-to-playlist')[1])
expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], songs) expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], playables)
}) })
it('creates playlist from selected songs', async () => { it('creates playlist from selected songs', async () => {
@ -76,16 +76,16 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('New Playlist…')) await this.user.click(screen.getByText('New Playlist…'))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs) expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, playables)
}) })
} }
private renderComponent (customConfig: Partial<AddToMenuConfig> = {}) { private renderComponent (customConfig: Partial<AddToMenuConfig> = {}) {
songs = factory('song', 5) playables = factory('song', 5)
return this.render(AddToMenu, { return this.render(AddToMenu, {
props: { props: {
songs, playables,
config: Object.assign(clone(config), customConfig), config: Object.assign(clone(config), customConfig),
showing: true showing: true
}, },

View file

@ -124,7 +124,7 @@ new class extends UnitTestCase {
}) })
} }
private async renderComponent (_songs: Song | Song[], initialTab: EditSongFormTabName = 'details') { private async renderComponent (_songs: MaybeArray<Song>, initialTab: EditSongFormTabName = 'details') {
songs = arrayify(_songs) songs = arrayify(_songs)
const rendered = this.render(EditSongForm, { const rendered = this.render(EditSongForm, {

View file

@ -7,9 +7,9 @@ import { screen, waitFor } from '@testing-library/vue'
import { downloadService, playbackService } from '@/services' import { downloadService, playbackService } from '@/services'
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores' import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs' import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
import PlayableContextMenu from './PlayableContextMenu.vue' import Component from './PlayableContextMenu.vue'
let songs: Song[] let playables: Playable[]
new class extends UnitTestCase { new class extends UnitTestCase {
protected beforeEach () { protected beforeEach () {
@ -17,15 +17,13 @@ new class extends UnitTestCase {
} }
protected test () { protected test () {
it('queues and plays', async () => { it('plays', async () => {
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
const playMock = this.mock(playbackService, 'play') const playMock = this.mock(playbackService, 'play')
const song = factory('song', { playback_state: 'Stopped' }) const song = factory('song', { playback_state: 'Stopped' })
await this.renderComponent(song) await this.renderComponent(song)
await this.user.click(screen.getByText('Play')) await this.user.click(screen.getByText('Play'))
expect(queueMock).toHaveBeenCalledWith(song)
expect(playMock).toHaveBeenCalledWith(song) expect(playMock).toHaveBeenCalledWith(song)
}) })
@ -49,20 +47,22 @@ new class extends UnitTestCase {
it('goes to album details screen', async () => { it('goes to album details screen', async () => {
const goMock = this.mock(Router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(factory('song')) const song = factory('song')
await this.renderComponent(song)
await this.user.click(screen.getByText('Go to Album')) await this.user.click(screen.getByText('Go to Album'))
expect(goMock).toHaveBeenCalledWith(`album/${songs[0].album_id}`) expect(goMock).toHaveBeenCalledWith(`album/${song.album_id}`)
}) })
it('goes to artist details screen', async () => { it('goes to artist details screen', async () => {
const goMock = this.mock(Router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(factory('song')) const song = factory('song')
await this.renderComponent(song)
await this.user.click(screen.getByText('Go to Artist')) await this.user.click(screen.getByText('Go to Artist'))
expect(goMock).toHaveBeenCalledWith(`artist/${songs[0].artist_id}`) expect(goMock).toHaveBeenCalledWith(`artist/${song.artist_id}`)
}) })
it('downloads', async () => { it('downloads', async () => {
@ -71,7 +71,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Download')) await this.user.click(screen.getByText('Download'))
expect(downloadMock).toHaveBeenCalledWith(songs) expect(downloadMock).toHaveBeenCalledWith(playables)
}) })
it('queues', async () => { it('queues', async () => {
@ -80,17 +80,17 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Queue')) await this.user.click(screen.getByText('Queue'))
expect(queueMock).toHaveBeenCalledWith(songs) expect(queueMock).toHaveBeenCalledWith(playables)
}) })
it('queues after current song', async () => { it('queues after current', async () => {
this.fillQueue() this.fillQueue()
const queueMock = this.mock(queueStore, 'queueAfterCurrent') const queueMock = this.mock(queueStore, 'queueAfterCurrent')
await this.renderComponent() await this.renderComponent()
await this.user.click(screen.getByText('After Current Song')) await this.user.click(screen.getByText('After Current'))
expect(queueMock).toHaveBeenCalledWith(songs) expect(queueMock).toHaveBeenCalledWith(playables)
}) })
it('queues to bottom', async () => { it('queues to bottom', async () => {
@ -100,7 +100,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Bottom of Queue')) await this.user.click(screen.getByText('Bottom of Queue'))
expect(queueMock).toHaveBeenCalledWith(songs) expect(queueMock).toHaveBeenCalledWith(playables)
}) })
it('queues to top', async () => { it('queues to top', async () => {
@ -110,7 +110,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Top of Queue')) await this.user.click(screen.getByText('Top of Queue'))
expect(queueMock).toHaveBeenCalledWith(songs) expect(queueMock).toHaveBeenCalledWith(playables)
}) })
it('removes from queue', async () => { it('removes from queue', async () => {
@ -126,7 +126,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Remove from Queue')) await this.user.click(screen.getByText('Remove from Queue'))
expect(removeMock).toHaveBeenCalledWith(songs) expect(removeMock).toHaveBeenCalledWith(playables)
}) })
it('does not show "Remove from Queue" when not on Queue screen', async () => { it('does not show "Remove from Queue" when not on Queue screen', async () => {
@ -148,7 +148,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Favorites')) await this.user.click(screen.getByText('Favorites'))
expect(likeMock).toHaveBeenCalledWith(songs) expect(likeMock).toHaveBeenCalledWith(playables)
}) })
it('does not have an option to add to favorites for Favorites screen', async () => { it('does not have an option to add to favorites for Favorites screen', async () => {
@ -157,7 +157,7 @@ new class extends UnitTestCase {
screen: 'Favorites' screen: 'Favorites'
}) })
this.renderComponent() await this.renderComponent()
expect(screen.queryByText('Favorites')).toBeNull() expect(screen.queryByText('Favorites')).toBeNull()
}) })
@ -174,7 +174,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Remove from Favorites')) await this.user.click(screen.getByText('Remove from Favorites'))
expect(unlikeMock).toHaveBeenCalledWith(songs) expect(unlikeMock).toHaveBeenCalledWith(playables)
}) })
it('lists and adds to existing playlist', async () => { it('lists and adds to existing playlist', async () => {
@ -187,7 +187,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText(playlistStore.state.playlists[0].name)) await this.user.click(screen.getByText(playlistStore.state.playlists[0].name))
expect(addMock).toHaveBeenCalledWith(playlistStore.state.playlists[0], songs) expect(addMock).toHaveBeenCalledWith(playlistStore.state.playlists[0], playables)
}) })
it('does not list smart playlists', async () => { it('does not list smart playlists', async () => {
@ -210,14 +210,14 @@ new class extends UnitTestCase {
await this.renderComponent() await this.renderComponent()
const removeSongsMock = this.mock(playlistStore, 'removeContent') const removeContentMock = this.mock(playlistStore, 'removeContent')
const emitMock = this.mock(eventBus, 'emit') const emitMock = this.mock(eventBus, 'emit')
await this.user.click(screen.getByText('Remove from Playlist')) await this.user.click(screen.getByText('Remove from Playlist'))
await waitFor(() => { await waitFor(() => {
expect(removeSongsMock).toHaveBeenCalledWith(playlist, songs) expect(removeContentMock).toHaveBeenCalledWith(playlist, playables)
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_SONGS_REMOVED', playlist, songs) expect(emitMock).toHaveBeenCalledWith('PLAYLIST_CONTENT_REMOVED', playlist, playables)
}) })
}) })
@ -239,7 +239,7 @@ new class extends UnitTestCase {
const emitMock = this.mock(eventBus, 'emit') const emitMock = this.mock(eventBus, 'emit')
await this.user.click(screen.getByText('Edit…')) await this.user.click(screen.getByText('Edit…'))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', songs) expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', playables)
}) })
it('does not allow edit songs if current user is not admin', async () => { it('does not allow edit songs if current user is not admin', async () => {
@ -278,9 +278,9 @@ new class extends UnitTestCase {
await waitFor(() => { await waitFor(() => {
expect(confirmMock).toHaveBeenCalled() expect(confirmMock).toHaveBeenCalled()
expect(deleteMock).toHaveBeenCalledWith(songs) expect(deleteMock).toHaveBeenCalledWith(playables)
expect(toasterMock).toHaveBeenCalledWith('Deleted 5 songs from the filesystem.') expect(toasterMock).toHaveBeenCalledWith('Deleted 5 songs from the filesystem.')
expect(emitMock).toHaveBeenCalledWith('SONGS_DELETED', songs) expect(emitMock).toHaveBeenCalledWith('SONGS_DELETED', playables)
}) })
}) })
@ -297,7 +297,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('New Playlist…')) await this.user.click(screen.getByText('New Playlist…'))
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs) expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, playables)
}) })
it('makes songs private', async () => { it('makes songs private', async () => {
@ -382,11 +382,11 @@ new class extends UnitTestCase {
}) })
} }
private async renderComponent (_songs?: Song | Song[]) { private async renderComponent (_playables?: MaybeArray<Playable>) {
songs = arrayify(_songs || factory('song', 5)) playables = arrayify(_playables || factory('song', 5))
const rendered = this.render(PlayableContextMenu) const rendered = this.render(Component)
eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, songs) eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playables)
await this.tick(2) await this.tick(2)
return rendered return rendered

View file

@ -151,12 +151,12 @@ const contentType = computed(() => getPlayableCollectionContentType(playables.va
const visibilityActions = computed(() => { const visibilityActions = computed(() => {
if (contentType.value !== 'songs' || !canEditSongs.value) return [] if (contentType.value !== 'songs' || !canEditSongs.value) return []
const visibilities = new Set(playables.value.map((song => (song as Song).is_public const visibilities = Array.from(new Set((playables.value as Song[]).map((song => song.is_public
? 'public' ? 'public'
: 'private' : 'private'
))) ))))
if (visibilities.size === 2) { if (visibilities.length === 2) {
return [ return [
{ {
label: 'Unmark as Private', label: 'Unmark as Private',
@ -201,7 +201,12 @@ const doPlayback = () => trigger(() => {
} }
}) })
const openEditForm = () => trigger(() => playables.value.length && eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', playables.value)) const openEditForm = () => trigger(() =>
playables.value.length
&& contentType.value === 'songs'
&& eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', playables.value as Song[])
)
const viewAlbum = (song: Song) => trigger(() => go(`album/${song.album_id}`)) const viewAlbum = (song: Song) => trigger(() => go(`album/${song.album_id}`))
const viewArtist = (song: Song) => trigger(() => go(`artist/${song.artist_id}`)) const viewArtist = (song: Song) => trigger(() => go(`artist/${song.artist_id}`))
const viewPodcast = (episode: Episode) => trigger(() => go(`podcasts/${episode.podcast_id}`)) const viewPodcast = (episode: Episode) => trigger(() => go(`podcasts/${episode.podcast_id}`))
@ -219,7 +224,7 @@ const removeFromQueue = () => trigger(() => queueStore.unqueue(playables.value))
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(playables.value)) const removeFromFavorites = () => trigger(() => favoriteStore.unlike(playables.value))
const copyUrl = () => trigger(async () => { const copyUrl = () => trigger(async () => {
await copyText(songStore.getShareableUrl(playables.value[0] as Song)) await copyText(songStore.getShareableUrl(playables.value[0]))
toastSuccess('URL copied to clipboard.') toastSuccess('URL copied to clipboard.')
}) })

View file

@ -1,12 +1,11 @@
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { queueStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import SongCard from './SongCard.vue' import SongCard from './SongCard.vue'
let song: Song let playable: Playable
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
@ -17,19 +16,17 @@ new class extends UnitTestCase {
}) })
it('queues and plays on double-click', async () => { it('queues and plays on double-click', async () => {
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
const playMock = this.mock(playbackService, 'play') const playMock = this.mock(playbackService, 'play')
this.renderComponent() this.renderComponent()
await this.user.dblClick(screen.getByRole('article')) await this.user.dblClick(screen.getByRole('article'))
expect(queueMock).toHaveBeenCalledWith(song) expect(playMock).toHaveBeenCalledWith(playable)
expect(playMock).toHaveBeenCalledWith(song)
}) })
} }
private renderComponent (playbackState: PlaybackState = 'Stopped') { private renderComponent (playbackState: PlaybackState = 'Stopped') {
song = factory('song', { playable = factory('song', {
playback_state: playbackState, playback_state: playbackState,
play_count: 10, play_count: 10,
title: 'Foo bar' title: 'Foo bar'
@ -37,8 +34,7 @@ new class extends UnitTestCase {
return this.render(SongCard, { return this.render(SongCard, {
props: { props: {
song, playable
topPlayCount: 42
}, },
global: { global: {
stubs: { stubs: {

View file

@ -11,7 +11,7 @@
@dblclick.prevent="play" @dblclick.prevent="play"
> >
<span> <span>
<SongThumbnail :song="playable" /> <SongThumbnail :playable="playable" />
</span> </span>
<main class="flex-1 flex items-start overflow-hidden gap-2"> <main class="flex-1 flex items-start overflow-hidden gap-2">
<div class="flex-1 space-y-1 overflow-hidden"> <div class="flex-1 space-y-1 overflow-hidden">
@ -39,7 +39,7 @@
- {{ pluralize(playable.play_count, 'play') }} - {{ pluralize(playable.play_count, 'play') }}
</p> </p>
</div> </div>
<LikeButton :song="playable" class="opacity-0 text-k-text-secondary group-hover:opacity-100" /> <LikeButton :playable="playable" class="opacity-0 text-k-text-secondary group-hover:opacity-100" />
</main> </main>
</article> </article>
</template> </template>
@ -47,7 +47,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, toRefs } from 'vue' import { computed, toRefs } from 'vue'
import { eventBus, isEpisode, isSong, pluralize } from '@/utils' import { eventBus, isEpisode, isSong, pluralize } from '@/utils'
import { queueStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { useAuthorization, useDraggable, useKoelPlus } from '@/composables' import { useAuthorization, useDraggable, useKoelPlus } from '@/composables'

View file

@ -3,30 +3,28 @@ import factory from '@/__tests__/factory'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import { favoriteStore } from '@/stores' import { favoriteStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import SongLikeButton from './SongLikeButton.vue' import Component from './SongLikeButton.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it.each<[string, boolean]>([ it.each<[string, boolean]>([['Unlike', true], ['Like', false]])('%s', async (name, liked) => {
['Unlike Foo by Bar', true],
['Like Foo by Bar', false]
])('%s', async (name: string, liked: boolean) => {
const mock = this.mock(favoriteStore, 'toggleOne') const mock = this.mock(favoriteStore, 'toggleOne')
const song = factory('song', {
const playable = factory('song', {
liked, liked,
title: 'Foo', title: 'Foo',
artist_name: 'Bar' artist_name: 'Bar'
}) })
this.render(SongLikeButton, { this.render(Component, {
props: { props: {
song playable
} }
}) })
await this.user.click(screen.getByRole('button', { name })) await this.user.click(screen.getByRole('button', { name }))
expect(mock).toHaveBeenCalledWith(song) expect(mock).toHaveBeenCalledWith(playable)
}) })
} }
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<FooterExtraControlBtn :title="title" @click.stop="toggleLike"> <FooterExtraControlBtn :title="title" @click.stop="toggleLike">
<Icon v-if="song.liked" :icon="faHeart" /> <Icon v-if="playable.liked" :icon="faHeart" />
<Icon v-else :icon="faEmptyHeart" /> <Icon v-else :icon="faEmptyHeart" />
</FooterExtraControlBtn> </FooterExtraControlBtn>
</template> </template>
@ -13,10 +13,10 @@ import { favoriteStore } from '@/stores'
import FooterExtraControlBtn from '@/components/layout/app-footer/FooterButton.vue' import FooterExtraControlBtn from '@/components/layout/app-footer/FooterButton.vue'
const props = defineProps<{ song: Playable }>() const props = defineProps<{ playable: Playable }>()
const { song } = toRefs(props) const { playable } = toRefs(props)
const title = computed(() => song.value.liked ? 'Unlike' : 'Like') const title = computed(() => playable.value.liked ? 'Unlike' : 'Like')
const toggleLike = () => favoriteStore.toggleOne(song.value) const toggleLike = () => favoriteStore.toggleOne(playable.value)
</script> </script>

View file

@ -80,7 +80,7 @@ new class extends UnitTestCase {
}, },
provide: { provide: {
[<symbol>PlayablesKey]: [ref(songs)], [<symbol>PlayablesKey]: [ref(songs)],
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Song[]) => (selectedPlayables = value)], [<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
[<symbol>PlayableListConfigKey]: [config], [<symbol>PlayableListConfigKey]: [config],
[<symbol>PlayableListContextKey]: [context], [<symbol>PlayableListContextKey]: [context],
[<symbol>PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)], [<symbol>PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)],

View file

@ -20,7 +20,7 @@ new class extends UnitTestCase {
liked: true liked: true
}) })
const { html } = await this.renderComponent(song) const { html } = this.renderComponent(song)
expect(html()).toMatchSnapshot() expect(html()).toMatchSnapshot()
}) })
@ -31,11 +31,11 @@ new class extends UnitTestCase {
}) })
} }
private renderComponent (song?: Song) { private renderComponent (playable?: Playable) {
song = song ?? factory('song') playable = playable ?? factory('song')
row = { row = {
playable: song, playable,
selected: false selected: false
} }

View file

@ -11,19 +11,19 @@
@dblclick.prevent.stop="play" @dblclick.prevent.stop="play"
> >
<span class="track-number"> <span class="track-number">
<SoundBars v-if="song.playback_state === 'Playing'" /> <SoundBars v-if="playable.playback_state === 'Playing'" />
<span v-else class="text-k-text-secondary"> <span v-else class="text-k-text-secondary">
<Icon :icon="faPodcast" v-if="isEpisode(song)" /> <template v-if="isSong(playable)">{{ playable.track || '' }}</template>
<template v-else>{{ song.track || '' }}</template> <Icon :icon="faPodcast" v-else />
</span> </span>
</span> </span>
<span class="thumbnail leading-none"> <span class="thumbnail leading-none">
<SongThumbnail :song="song" /> <SongThumbnail :playable="playable" />
</span> </span>
<span class="title-artist flex flex-col gap-2 overflow-hidden"> <span class="title-artist flex flex-col gap-2 overflow-hidden">
<span class="title text-k-text-primary !flex gap-2 items-center"> <span class="title text-k-text-primary !flex gap-2 items-center">
<ExternalMark v-if="external" class="!inline-block" /> <ExternalMark v-if="external" class="!inline-block" />
{{ song.title }} {{ playable.title }}
</span> </span>
<span class="artist">{{ artist }}</span> <span class="artist">{{ artist }}</span>
</span> </span>
@ -32,11 +32,11 @@
<span class="collaborator"> <span class="collaborator">
<UserAvatar :user="collaborator" width="24" /> <UserAvatar :user="collaborator" width="24" />
</span> </span>
<span :title="song.collaboration.added_at" class="added-at">{{ song.collaboration.fmt_added_at }}</span> <span :title="playable.collaboration.added_at" class="added-at">{{ playable.collaboration.fmt_added_at }}</span>
</template> </template>
<span class="time">{{ fmtLength }}</span> <span class="time">{{ fmtLength }}</span>
<span class="extra"> <span class="extra">
<LikeButton :song="song" /> <LikeButton :playable="playable" />
</span> </span>
</article> </article>
</template> </template>
@ -64,21 +64,23 @@ const { item } = toRefs(props)
const emit = defineEmits<{ (e: 'play', playable: Playable): void }>() const emit = defineEmits<{ (e: 'play', playable: Playable): void }>()
const song = computed<Playable | CollaborativeSong>(() => item.value.playable) const playable = computed<Playable | CollaborativeSong>(() => item.value.playable)
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!)) const playing = computed(() => ['Playing', 'Paused'].includes(playable.value.playback_state!))
const external = computed(() => { const external = computed(() => {
if (!isSong(song.value)) return false if (!isSong(playable.value)) return false
return isPlus.value && song.value.owner_id !== currentUser.value?.id return isPlus.value && playable.value.owner_id !== currentUser.value?.id
}) })
const fmtLength = secondsToHis(song.value.length) const fmtLength = secondsToHis(playable.value.length)
const artist = computed(() => getPlayableProp(song.value, 'artist_name', 'podcast_author')) const artist = computed(() => getPlayableProp(playable.value, 'artist_name', 'podcast_author'))
const album = computed(() => getPlayableProp(song.value, 'album_name', 'podcast_title')) const album = computed(() => getPlayableProp(playable.value, 'album_name', 'podcast_title'))
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => (song.value as CollaborativeSong).collaboration.user) const collaborator = computed<Pick<User, 'name' | 'avatar'>>(
() => (playable.value as CollaborativeSong).collaboration.user
)
const play = () => emit('play', song.value) const play = () => emit('play', playable.value)
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View file

@ -4,37 +4,37 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import { queueStore } from '@/stores' import { queueStore } from '@/stores'
import SongThumbnail from '@/components/song/SongThumbnail.vue' import Component from './SongThumbnail.vue'
let song: Song let playable: Playable
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it.each<[PlaybackState, string, MethodOf<typeof playbackService>]>([ it.each<[PlaybackState, MethodOf<typeof playbackService>]>([
['Stopped', 'Play', 'play'], ['Stopped', 'play'],
['Playing', 'Pause', 'pause'], ['Playing', 'pause'],
['Paused', 'Resume', 'resume'] ['Paused', 'resume']
])('if state is currently "%s", %ss', async (state, name, method) => { ])('if state is currently "%s", %ss', async (state, method) => {
this.mock(queueStore, 'queueIfNotQueued') this.mock(queueStore, 'queueIfNotQueued')
const playbackMock = this.mock(playbackService, method) const playbackMock = this.mock(playbackService, method)
this.renderComponent(state) this.renderComponent(state)
await this.user.click(screen.getByRole('button', { name })) await this.user.click(screen.getByRole('button'))
expect(playbackMock).toHaveBeenCalled() expect(playbackMock).toHaveBeenCalled()
}) })
} }
private renderComponent (playbackState: PlaybackState = 'Stopped') { private renderComponent (playbackState: PlaybackState = 'Stopped') {
song = factory('song', { playable = factory('song', {
playback_state: playbackState, playback_state: playbackState,
play_count: 10, play_count: 10,
title: 'Foo bar' title: 'Foo bar'
}) })
return this.render(SongThumbnail, { return this.render(Component, {
props: { props: {
song playable
} }
}) })
} }

View file

@ -17,7 +17,7 @@
class="absolute flex opacity-0 items-center justify-center w-[24px] aspect-square rounded-full top-1/2 class="absolute flex opacity-0 items-center justify-center w-[24px] aspect-square rounded-full top-1/2
left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20" left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"
> >
<Icon v-if="song.playback_state === 'Playing'" :icon="faPause" class="text-white" /> <Icon v-if="playable.playback_state === 'Playing'" :icon="faPause" class="text-white" />
<Icon v-else :icon="faPlay" class="text-white ml-0.5" /> <Icon v-else :icon="faPlay" class="text-white ml-0.5" />
</span> </span>
</button> </button>
@ -29,19 +29,19 @@ import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { defaultCover, getPlayableProp } from '@/utils' import { defaultCover, getPlayableProp } from '@/utils'
import { playbackService } from '@/services' import { playbackService } from '@/services'
const props = defineProps<{ song: Playable }>() const props = defineProps<{ playable: Playable }>()
const { song } = toRefs(props) const { playable } = toRefs(props)
const src = computed(() => getPlayableProp<string>(song.value, 'album_cover', 'episode_image')) const src = computed(() => getPlayableProp<string>(playable.value, 'album_cover', 'episode_image'))
const play = () => playbackService.play(song.value) const play = () => playbackService.play(playable.value)
const title = computed(() => { const title = computed(() => {
if (song.value.playback_state === 'Playing') { if (playable.value.playback_state === 'Playing') {
return 'Pause' return 'Pause'
} }
if (song.value.playback_state === 'Paused') { if (playable.value.playback_state === 'Paused') {
return 'Resume' return 'Resume'
} }
@ -49,9 +49,10 @@ const title = computed(() => {
}) })
const playOrPause = () => { const playOrPause = () => {
if (song.value.playback_state === 'Stopped') { if (playable.value.playback_state === 'Stopped') {
// @todo play at the right playback position for Episodes
play() play()
} else if (song.value.playback_state === 'Paused') { } else if (playable.value.playback_state === 'Paused') {
playbackService.resume() playbackService.resume()
} else { } else {
playbackService.pause() playbackService.pause()

View file

@ -3,7 +3,7 @@
exports[`renders 1`] = ` exports[`renders 1`] = `
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0" showing="true"> <div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0" showing="true">
<section data-v-42061e3e="" class="existing-playlists"> <section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 5 songs to</p> <p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 5 items to</p>
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5"> <ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li> <li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li> <li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>

View file

@ -2,8 +2,8 @@
exports[`renders 1`] = ` exports[`renders 1`] = `
<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0"> <div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0">
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary"><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-k-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span> <div class="sortable song-list-header flex z-[2] bg-k-bg-secondary"><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-k-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album">Album<span class="ml-2"><!--v-if--><!--v-if--></span></span>
<!--v-if--><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> <!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc" content-type="songs"></span>
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]"> </div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
</div> </div>
`; `;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = ` exports[`renders 1`] = `
<article data-v-9a89d9b9="" class="playing song-item text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-9a89d9b9="" class="track-number"><i data-v-47e95701="" data-v-9a89d9b9="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-9a89d9b9="" class="thumbnail"><div data-v-9a89d9b9="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="song-thumbnail group w-[48px] min-w-[48px] aspect-square bg-cover relative rounded overflow-hidden flex items-center justify-center before:absolute before:w-full before:h-full before:pointer-events-none before:z-[1] before:left-0 before:top-0 before:bg-black before:opacity-0 hover:before:opacity-70"><img alt="Test Album" src="https://example.com/cover.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none" loading="lazy"><a title="Pause" class="w-7 h-7 text-base z-[1] text-k-text-primary duration-300 justify-center items-center rounded-full bg-black/50 pl-0.5 flex opacity-0 group-hover:opacity-100" role="button"><br data-testid="Icon" icon="[object Object]" class="text-k-highlight"></a></div></span><span data-v-9a89d9b9="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-9a89d9b9="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-9a89d9b9="" class="artist">Test Artist</span></span><span data-v-9a89d9b9="" class="album">Test Album</span> <article data-v-9a89d9b9="" class="playing song-item group text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-9a89d9b9="" class="track-number"><i data-v-47e95701="" data-v-9a89d9b9="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-9a89d9b9="" class="thumbnail leading-none"><button data-v-9a89d9b9="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" title="Pause" class="song-thumbnail w-[48px] aspect-square bg-cover relative rounded overflow-hidden active:scale-95"><img alt="Cover image" src="https://example.com/cover.jpg" class="w-full aspect-square object-cover" loading="lazy"><span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span class="absolute flex opacity-0 items-center justify-center w-[24px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-testid="Icon" icon="[object Object]" class="text-white"></span></button></span><span data-v-9a89d9b9="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-9a89d9b9="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-9a89d9b9="" class="artist">Test Artist</span></span><span data-v-9a89d9b9="" class="album">Test Album</span>
<!--v-if--><span data-v-9a89d9b9="" class="time">16:40</span><span data-v-9a89d9b9="" class="extra"><button data-v-9a89d9b9="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike Test Song by Test Artist"><br data-testid="Icon" icon="[object Object]"></button></span> <!--v-if--><span data-v-9a89d9b9="" class="time">16:40</span><span data-v-9a89d9b9="" class="extra"><button data-v-9a89d9b9="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button></span>
</article> </article>
`; `;

View file

@ -118,11 +118,11 @@ new class extends UnitTestCase {
}) })
} }
private renderComponent (currentSong: Song | null = null) { private renderComponent (currentPlayable: Playable | null = null) {
return this.render(FooterPlayButton, { return this.render(FooterPlayButton, {
global: { global: {
provide: { provide: {
[<symbol>CurrentPlayableKey]: ref(currentSong) [<symbol>CurrentPlayableKey]: ref(currentPlayable)
} }
} }
}) })

View file

@ -31,30 +31,30 @@ const toggle = async () => song.value ? playbackService.toggle() : initiatePlayb
const initiatePlayback = async () => { const initiatePlayback = async () => {
if (libraryEmpty.value) return if (libraryEmpty.value) return
let songs: Song[] let playables: Playable[]
switch (getCurrentScreen()) { switch (getCurrentScreen()) {
case 'Album': case 'Album':
songs = await songStore.fetchForAlbum(parseInt(getRouteParam('id')!)) playables = await songStore.fetchForAlbum(parseInt(getRouteParam('id')!))
break break
case 'Artist': case 'Artist':
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!)) playables = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
break break
case 'Playlist': case 'Playlist':
songs = await songStore.fetchForPlaylist(getRouteParam('id')!) playables = await songStore.fetchForPlaylist(getRouteParam('id')!)
break break
case 'Favorites': case 'Favorites':
songs = await favoriteStore.fetch() playables = await favoriteStore.fetch()
break break
case 'RecentlyPlayed': case 'RecentlyPlayed':
songs = await recentlyPlayedStore.fetch() playables = await recentlyPlayedStore.fetch()
break break
default: default:
songs = await queueStore.fetchRandom() playables = await queueStore.fetchRandom()
break break
} }
playbackService.queueAndPlay(songs) playbackService.queueAndPlay(playables)
go('queue') go('queue')
} }
</script> </script>

View file

@ -1,5 +1,5 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders for album 1`] = `<div data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="IV" src="https://test/album.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>`; exports[`renders for album 1`] = `<button data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="https://test/album.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>`;
exports[`renders for artist 1`] = `<div data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Led Zeppelin" src="https://test/blimp.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>`; exports[`renders for artist 1`] = `<button data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="https://test/blimp.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>`;

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = `<span data-v-cf9b67d8="" class="btn-group inline-block relative flex-nowrap"><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Green</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Orange</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Blue</button></span>`; exports[`renders 1`] = `<span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap"><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Green</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Orange</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Blue</button></span>`;

View file

@ -119,7 +119,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
} }
} }
const resolveDroppedSongs = async (event: DragEvent) => { const resolveDroppedItems = async (event: DragEvent) => {
try { try {
const type = getDragType(event) const type = getDragType(event)
if (!type) return <Playable[]>[] if (!type) return <Playable[]>[]
@ -153,6 +153,6 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
acceptsDrop, acceptsDrop,
getDroppedData, getDroppedData,
resolveDroppedValue, resolveDroppedValue,
resolveDroppedItems: resolveDroppedSongs resolveDroppedItems
} }
} }

View file

@ -6,16 +6,14 @@ export const usePolicies = () => {
const { isPlus } = useKoelPlus() const { isPlus } = useKoelPlus()
const currentUserCan = { const currentUserCan = {
editSong: (song: Song | Song[]) => { editSong: (songs: MaybeArray<Song>) => {
if (isAdmin.value) return true if (isAdmin.value) return true
if (!isPlus.value) return false if (!isPlus.value) return false
return arrayify(song).every(s => s.owner_id === currentUser.value.id) return arrayify(songs).every(song => song.owner_id === currentUser.value.id)
}, },
editPlaylist: (playlist: Playlist) => playlist.user_id === currentUser.value.id, editPlaylist: (playlist: Playlist) => playlist.user_id === currentUser.value.id,
uploadSongs: () => isAdmin.value || isPlus.value, uploadSongs: () => isAdmin.value || isPlus.value,
changeAlbumOrArtistThumbnails: () => isAdmin.value || isPlus.value // for Plus, the logic is handled in the backend changeAlbumOrArtistThumbnails: () => isAdmin.value || isPlus.value // for Plus, the logic is handled in the backend
} }

View file

@ -22,7 +22,7 @@ export interface Events {
MODAL_SHOW_ADD_USER_FORM: () => void MODAL_SHOW_ADD_USER_FORM: () => void
MODAL_SHOW_INVITE_USER_FORM: () => void MODAL_SHOW_INVITE_USER_FORM: () => void
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab?: EditSongFormTabName) => void MODAL_SHOW_EDIT_SONG_FORM: (songs: MaybeArray<Song>, initialTab?: EditSongFormTabName) => void
MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder?: PlaylistFolder | null, playables?: MaybeArray<Playable>) => void MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder?: PlaylistFolder | null, playables?: MaybeArray<Playable>) => void
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void
@ -52,9 +52,9 @@ export interface Events {
SOCKET_PLAY_PREV: () => void SOCKET_PLAY_PREV: () => void
SOCKET_PLAYBACK_STOPPED: () => void SOCKET_PLAYBACK_STOPPED: () => void
SOCKET_GET_STATUS: () => void SOCKET_GET_STATUS: () => void
SOCKET_STATUS: (data: { song?: Song, volume: number }) => void SOCKET_STATUS: (data: { song?: Playable, volume: number }) => void
SOCKET_GET_CURRENT_SONG: () => void SOCKET_GET_CURRENT_SONG: () => void
SOCKET_SONG: (song: Song) => void SOCKET_SONG: (song: Playable) => void
SOCKET_SET_VOLUME: (volume: number) => void SOCKET_SET_VOLUME: (volume: number) => void
SOCKET_VOLUME_CHANGED: (volume: number) => void SOCKET_VOLUME_CHANGED: (volume: number) => void
} }

View file

@ -1,7 +1,7 @@
<template> <template>
<div :class="{ 'standalone' : inStandaloneMode }" class="h-screen bg-k-bg-primary"> <div :class="{ 'standalone' : inStandaloneMode }" class="h-screen bg-k-bg-primary">
<template v-if="authenticated"> <template v-if="authenticated">
<AlbumArtOverlay v-if="showAlbumArtOverlay && state.song" :album="state.song.album_id" /> <AlbumArtOverlay v-if="showAlbumArtOverlay && state.song && isSong(state.song)" :album="state.song.album_id" />
<main class="h-screen flex flex-col items-center justify-between text-center relative z-[1]"> <main class="h-screen flex flex-col items-center justify-between text-center relative z-[1]">
<template v-if="connected"> <template v-if="connected">
@ -26,7 +26,7 @@
import { authService, socketService } from '@/services' import { authService, socketService } from '@/services'
import { preferenceStore, userStore } from '@/stores' import { preferenceStore, userStore } from '@/stores'
import { defineAsyncComponent, onMounted, provide, reactive, ref, toRef } from 'vue' import { defineAsyncComponent, onMounted, provide, reactive, ref, toRef } from 'vue'
import { logger } from '@/utils' import { isSong, logger } from '@/utils'
import { RemoteState } from '@/remote/types' import { RemoteState } from '@/remote/types'
const SongDetails = defineAsyncComponent(() => import('@/remote/components/SongDetails.vue')) const SongDetails = defineAsyncComponent(() => import('@/remote/components/SongDetails.vue'))
@ -50,7 +50,7 @@ const onUserLoggedIn = async () => {
const state = reactive<RemoteState>({ const state = reactive<RemoteState>({
volume: 0, volume: 0,
song: null as Song | null song: null as Playable | null
}) })
provide('state', state) provide('state', state)
@ -64,7 +64,7 @@ const init = async () => {
.listen('SOCKET_SONG', song => (state.song = song)) .listen('SOCKET_SONG', song => (state.song = song))
.listen('SOCKET_PLAYBACK_STOPPED', () => state.song && (state.song.playback_state = 'Stopped')) .listen('SOCKET_PLAYBACK_STOPPED', () => state.song && (state.song.playback_state = 'Stopped'))
.listen('SOCKET_VOLUME_CHANGED', (volume: number) => state.volume = volume) .listen('SOCKET_VOLUME_CHANGED', (volume: number) => state.volume = volume)
.listen('SOCKET_STATUS', (data: { song?: Song, volume: number }) => { .listen('SOCKET_STATUS', (data: { song?: Playable, volume: number }) => {
state.volume = data.volume || 0 state.volume = data.volume || 0
state.song = data.song || null state.song = data.song || null
connected.value = true connected.value = true

View file

@ -34,7 +34,7 @@ import { socketService } from '@/services'
import VolumeControl from '@/remote/components/VolumeControl.vue' import VolumeControl from '@/remote/components/VolumeControl.vue'
const props = defineProps<{ song: Song }>() const props = defineProps<{ song: Playable }>()
const { song } = toRefs(props) const { song } = toRefs(props)
const toggleFavorite = () => { const toggleFavorite = () => {

View file

@ -1,26 +1,30 @@
<template> <template>
<article class="flex-1 flex flex-col items-center justify-around"> <article class="flex-1 flex flex-col items-center justify-around">
<div <div
:style="{ backgroundImage: `url(${song.album_cover || defaultCover})` }" :style="{ backgroundImage: `url(${image || defaultCover})` }"
class="cover my-0 mx-auto w-[calc(70vw_+_4px)] aspect-square rounded-full border-2 border-solid class="cover my-0 mx-auto w-[calc(70vw_+_4px)] aspect-square rounded-full border-2 border-solid
border-k-text-primary bg-center bg-cover bg-k-bg-secondary" border-k-text-primary bg-center bg-cover bg-k-bg-secondary"
/> />
<div class="w-full flex flex-col justify-around"> <div class="w-full flex flex-col justify-around">
<div> <div>
<p class="text-[6vmin] font-bold mx-auto mb-4">{{ song.title }}</p> <p class="text-[6vmin] font-bold mx-auto mb-4">{{ song.title }}</p>
<p class="text-[5vmin] mb-2 opacity-50">{{ song.artist_name }}</p> <p class="text-[5vmin] mb-2 opacity-50">{{ artist }}</p>
<p class="text-[4vmin] opacity-50">{{ song.album_name }}</p> <p class="text-[4vmin] opacity-50">{{ album }}</p>
</div> </div>
</div> </div>
</article> </article>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { toRefs } from 'vue' import { computed, toRefs } from 'vue'
import { defaultCover } from '@/utils' import { defaultCover, getPlayableProp } from '@/utils'
const props = defineProps<{ song: Song }>() const props = defineProps<{ song: Playable }>()
const { song } = toRefs(props) const { song } = toRefs(props)
const image = computed(() => getPlayableProp(song.value, 'album_cover', 'episode_image'))
const artist = computed(() => getPlayableProp(song.value, 'artist_name', 'podcast_author'))
const album = computed(() => getPlayableProp(song.value, 'album_name', 'podcast_title'))
</script> </script>

View file

@ -1,4 +1,4 @@
export interface RemoteState { export interface RemoteState {
song: Song | null song: Playable | null
volume: number volume: number
} }

View file

@ -6,11 +6,11 @@ import { downloadService } from './downloadService'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
it('downloads songs', () => { it('downloads playables', () => {
const mock = this.mock(downloadService, 'trigger') const mock = this.mock(downloadService, 'trigger')
downloadService.fromPlayables([factory('song', { id: 'bar' })]) downloadService.fromPlayables([factory('song', { id: 'bar' })])
expect(mock).toHaveBeenCalledWith('songs?songs[]=bar&songs[]=foo&') expect(mock).toHaveBeenCalledWith('songs?songs[]=bar&')
}) })
it('downloads all by artist', () => { it('downloads all by artist', () => {
@ -36,11 +36,11 @@ new class extends UnitTestCase {
expect(mock).toHaveBeenCalledWith(`playlist/${playlist.id}`) expect(mock).toHaveBeenCalledWith(`playlist/${playlist.id}`)
}) })
it.each<[Song[], boolean]>([[[], false], [factory('song', 5), true]])( it.each<[Playable[], boolean]>([[[], false], [factory('song', 5), true]])(
'downloads favorites if available', 'downloads favorites if available',
(songs, triggered) => { (songs, triggered) => {
const mock = this.mock(downloadService, 'trigger') const mock = this.mock(downloadService, 'trigger')
favoriteStore.state.songs = songs favoriteStore.state.playables = songs
downloadService.fromFavorites() downloadService.fromFavorites()

View file

@ -21,7 +21,7 @@ export const downloadService = {
}, },
fromFavorites () { fromFavorites () {
if (favoriteStore.state.songs.length) { if (favoriteStore.state.playables.length) {
this.trigger('favorites') this.trigger('favorites')
} }
}, },

View file

@ -78,11 +78,11 @@ new class extends UnitTestCase {
it('scrobbles if current song ends', () => { it('scrobbles if current song ends', () => {
commonStore.state.uses_last_fm = true commonStore.state.uses_last_fm = true
userStore.state.current = reactive(factory('user', { userStore.state.current = factory('user', {
preferences: { preferences: {
lastfm_session_key: 'foo' lastfm_session_key: 'foo'
} }
})) })
playbackService.init(document.querySelector('.plyr')!) playbackService.init(document.querySelector('.plyr')!)
const scrobbleMock = this.mock(songStore, 'scrobble') const scrobbleMock = this.mock(songStore, 'scrobble')
@ -399,7 +399,7 @@ new class extends UnitTestCase {
})) }))
} }
private setCurrentSong (song?: Song) { private setCurrentSong (song?: Playable) {
song = reactive(song || factory('song', { song = reactive(song || factory('song', {
playback_state: 'Playing' playback_state: 'Playing'
})) }))

View file

@ -30,7 +30,7 @@ const initialState = {
song_length: 0, song_length: 0,
queue_state: { queue_state: {
type: 'queue-states', type: 'queue-states',
songs: [], playables: [],
current_song: null, current_song: null,
playback_position: 0 playback_position: 0
} as QueueState } as QueueState

View file

@ -6,7 +6,7 @@ import { favoriteStore } from '.'
new class extends UnitTestCase { new class extends UnitTestCase {
protected beforeEach () { protected beforeEach () {
super.beforeEach(() => (favoriteStore.state.songs = [])) super.beforeEach(() => (favoriteStore.state.playables = []))
} }
protected test () { protected test () {
@ -31,18 +31,18 @@ new class extends UnitTestCase {
it('adds songs', () => { it('adds songs', () => {
const songs = factory('song', 3) const songs = factory('song', 3)
favoriteStore.add(songs) favoriteStore.add(songs)
expect(favoriteStore.state.songs).toEqual(songs) expect(favoriteStore.state.playables).toEqual(songs)
// doesn't duplicate songs // doesn't duplicate songs
favoriteStore.add(songs[0]) favoriteStore.add(songs[0])
expect(favoriteStore.state.songs).toEqual(songs) expect(favoriteStore.state.playables).toEqual(songs)
}) })
it('removes songs', () => { it('removes songs', () => {
const songs = factory('song', 3) const songs = factory('song', 3)
favoriteStore.state.songs = songs favoriteStore.state.playables = songs
favoriteStore.remove(songs) favoriteStore.remove(songs)
expect(favoriteStore.state.songs).toEqual([]) expect(favoriteStore.state.playables).toEqual([])
}) })
it('likes several songs', async () => { it('likes several songs', async () => {
@ -74,7 +74,7 @@ new class extends UnitTestCase {
await favoriteStore.fetch() await favoriteStore.fetch()
expect(getMock).toHaveBeenCalledWith('songs/favorite') expect(getMock).toHaveBeenCalledWith('songs/favorite')
expect(favoriteStore.state.songs).toEqual(songs) expect(favoriteStore.state.playables).toEqual(songs)
}) })
} }
} }

View file

@ -5,8 +5,8 @@ import { arrayify } from '@/utils'
import { songStore } from '@/stores' import { songStore } from '@/stores'
export const favoriteStore = { export const favoriteStore = {
state: reactive({ state: reactive<{ playables: Playable[] }>({
songs: [] as Playable[] playables: []
}), }),
async toggleOne (playable: Playable) { async toggleOne (playable: Playable) {
@ -15,15 +15,15 @@ export const favoriteStore = {
playable.liked = !playable.liked playable.liked = !playable.liked
playable.liked ? this.add(playable) : this.remove(playable) playable.liked ? this.add(playable) : this.remove(playable)
await http.post<Song>('interaction/like', { song: playable.id }) await http.post<Playable>('interaction/like', { song: playable.id })
}, },
add (songs: MaybeArray<Playable>) { add (songs: MaybeArray<Playable>) {
this.state.songs = unionBy(this.state.songs, arrayify(songs), 'id') this.state.playables = unionBy(this.state.playables, arrayify(songs), 'id')
}, },
remove (songs: MaybeArray<Playable>) { remove (songs: MaybeArray<Playable>) {
this.state.songs = differenceBy(this.state.songs, arrayify(songs), 'id') this.state.playables = differenceBy(this.state.playables, arrayify(songs), 'id')
}, },
async like (songs: Playable[]) { async like (songs: Playable[]) {
@ -43,7 +43,7 @@ export const favoriteStore = {
}, },
async fetch () { async fetch () {
this.state.songs = songStore.syncWithVault(await http.get<Playable[]>('songs/favorite')) this.state.playables = songStore.syncWithVault(await http.get<Playable[]>('songs/favorite'))
return this.state.songs return this.state.playables
} }
} }

View file

@ -4,25 +4,26 @@ import { songStore } from '@/stores/songStore'
import { albumStore } from '@/stores/albumStore' import { albumStore } from '@/stores/albumStore'
import { artistStore } from '@/stores/artistStore' import { artistStore } from '@/stores/artistStore'
import { recentlyPlayedStore } from '@/stores' import { recentlyPlayedStore } from '@/stores'
import { isEpisode, isSong } from '@/utils'
export const overviewStore = { export const overviewStore = {
state: reactive({ state: reactive({
recentlyPlayed: [] as Song[], recentlyPlayed: [] as Playable[],
recentlyAddedSongs: [] as Song[], recentlyAddedSongs: [] as Song[],
recentlyAddedAlbums: [] as Album[], recentlyAddedAlbums: [] as Album[],
mostPlayedSongs: [] as Song[], mostPlayedSongs: [] as Playable[],
mostPlayedAlbums: [] as Album[], mostPlayedAlbums: [] as Album[],
mostPlayedArtists: [] as Artist[] mostPlayedArtists: [] as Artist[]
}), }),
async fetch () { async fetch () {
const resource = await http.get<{ const resource = await http.get<{
most_played_songs: Song[], most_played_songs: Playable[],
most_played_albums: Album[], most_played_albums: Album[],
most_played_artists: Artist[], most_played_artists: Artist[],
recently_added_songs: Song[], recently_added_songs: Song[],
recently_added_albums: Album[], recently_added_albums: Album[],
recently_played_songs: Song[], recently_played_songs: Playable[],
}>('overview') }>('overview')
songStore.syncWithVault(resource.most_played_songs) songStore.syncWithVault(resource.most_played_songs)
@ -31,7 +32,7 @@ export const overviewStore = {
this.state.mostPlayedAlbums = albumStore.syncWithVault(resource.most_played_albums) this.state.mostPlayedAlbums = albumStore.syncWithVault(resource.most_played_albums)
this.state.mostPlayedArtists = artistStore.syncWithVault(resource.most_played_artists) this.state.mostPlayedArtists = artistStore.syncWithVault(resource.most_played_artists)
this.state.recentlyAddedSongs = songStore.syncWithVault(resource.recently_added_songs) this.state.recentlyAddedSongs = songStore.syncWithVault(resource.recently_added_songs) as Song[]
this.state.recentlyAddedAlbums = albumStore.syncWithVault(resource.recently_added_albums) this.state.recentlyAddedAlbums = albumStore.syncWithVault(resource.recently_added_albums)
recentlyPlayedStore.excerptState.playables = songStore.syncWithVault(resource.recently_played_songs) recentlyPlayedStore.excerptState.playables = songStore.syncWithVault(resource.recently_played_songs)
@ -41,8 +42,9 @@ export const overviewStore = {
refreshPlayStats () { refreshPlayStats () {
this.state.mostPlayedSongs = songStore.getMostPlayed(7) this.state.mostPlayedSongs = songStore.getMostPlayed(7)
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.playables.filter( this.state.recentlyPlayed = recentlyPlayedStore.excerptState.playables.filter(playable => {
({ deleted, play_count }) => !deleted && play_count > 0 if (isSong(playable) && playable.deleted) return false
) return playable.play_count > 0
})
} }
} }

View file

@ -8,7 +8,7 @@ import { songStore } from '@/stores/songStore'
type CreatePlaylistRequestData = { type CreatePlaylistRequestData = {
name: Playlist['name'] name: Playlist['name']
songs: Song['id'][] songs: Playable['id'][]
rules?: SmartPlaylistRuleGroup[] rules?: SmartPlaylistRuleGroup[]
folder_id?: PlaylistFolder['name'] folder_id?: PlaylistFolder['name']
own_songs_only?: boolean own_songs_only?: boolean
@ -95,11 +95,11 @@ export const playlistStore = {
return playlist return playlist
} }
const updatedSongs = await http.post<Song[]>(`playlists/${playlist.id}/songs`, { const updatedPlayables = await http.post<Playable[]>(`playlists/${playlist.id}/songs`, {
songs: playables.map(song => song.id) songs: playables.map(song => song.id)
}) })
songStore.syncWithVault(updatedSongs) songStore.syncWithVault(updatedPlayables)
cache.remove(['playlist.songs', playlist.id]) cache.remove(['playlist.songs', playlist.id])
return playlist return playlist
@ -166,10 +166,14 @@ export const playlistStore = {
}, },
moveItemsInPlaylist: async (playlist: Playlist, songs: MaybeArray<Playable>, target: Playable, type: MoveType) => { moveItemsInPlaylist: async (playlist: Playlist, songs: MaybeArray<Playable>, target: Playable, type: MoveType) => {
const orderHash = JSON.stringify(playlist.songs?.map(({ id }) => id)) const orderHash = JSON.stringify(playlist.playables?.map(({ id }) => id))
playlist.songs?.splice(0, playlist.songs.length, ...moveItemsInList(playlist.songs, songs, target, type)) playlist.playables?.splice(
0,
playlist.playables.length,
...moveItemsInList(playlist.playables, songs, target, type)
)
if (orderHash !== JSON.stringify(playlist.songs?.map(({ id }) => id))) { if (orderHash !== JSON.stringify(playlist.playables?.map(({ id }) => id))) {
await http.silently.post(`playlists/${playlist.id}/songs/move`, { await http.silently.post(`playlists/${playlist.id}/songs/move`, {
songs: arrayify(songs).map(({ id }) => id), songs: arrayify(songs).map(({ id }) => id),
target: target.id, target: target.id,

View file

@ -1,26 +1,26 @@
import factory from '@/__tests__/factory'
import { reactive } from 'vue' import { reactive } from 'vue'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from 'factoria'
import { http } from '@/services' import { http } from '@/services'
import { queueStore, songStore } from '.' import { queueStore, songStore } from '.'
let songs: Song[] let playables: Playable[]
new class extends UnitTestCase { new class extends UnitTestCase {
protected beforeEach () { protected beforeEach () {
super.beforeEach(() => { super.beforeEach(() => {
songs = factory('song', 3) playables = factory('song', 3)
queueStore.state.playables = reactive(songs) queueStore.state.playables = reactive(playables)
}) })
} }
protected test () { protected test () {
it('returns all queued songs', () => expect(queueStore.all).toEqual(songs)) it('returns all queued songs', () => expect(queueStore.all).toEqual(playables))
it('returns the first queued song', () => expect(queueStore.first).toEqual(songs[0])) it('returns the first queued song', () => expect(queueStore.first).toEqual(playables[0]))
it('returns the last queued song', () => expect(queueStore.last).toEqual(songs[2])) it('returns the last queued song', () => expect(queueStore.last).toEqual(playables[2]))
it('queues to bottom', () => { it('queues to bottom', () => {
const song = factory('song') const song = factory('song')
@ -53,17 +53,17 @@ new class extends UnitTestCase {
it('removes a song from queue', () => { it('removes a song from queue', () => {
const putMock = this.mock(http, 'put') const putMock = this.mock(http, 'put')
queueStore.unqueue(songs[1]) queueStore.unqueue(playables[1])
expect(queueStore.all).toEqual([songs[0], songs[2]]) expect(queueStore.all).toEqual([playables[0], playables[2]])
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) }) expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
}) })
it('removes multiple songs from queue', () => { it('removes multiple songs from queue', () => {
const putMock = this.mock(http, 'put') const putMock = this.mock(http, 'put')
queueStore.unqueue([songs[1], songs[0]]) queueStore.unqueue([playables[1], playables[0]])
expect(queueStore.all).toEqual([songs[2]]) expect(queueStore.all).toEqual([playables[2]])
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) }) expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
}) })

View file

@ -82,7 +82,7 @@ export const queueStore = {
this.all = head.concat(reactive(playables), this.all) this.all = head.concat(reactive(playables), this.all)
}, },
unqueue (playables: Playable | Playable[]) { unqueue (playables: MaybeArray<Playable>) {
playables = arrayify(playables) playables = arrayify(playables)
playables.forEach(song => (song.playback_state = 'Stopped')) playables.forEach(song => (song.playback_state = 'Stopped'))
this.all = differenceBy(this.all, playables, 'id') this.all = differenceBy(this.all, playables, 'id')
@ -91,7 +91,7 @@ export const queueStore = {
/** /**
* Move some songs to after a target. * Move some songs to after a target.
*/ */
move (playables: Playable | Playable[], target: Playable, type: MoveType) { move (playables: MaybeArray<Playable>, target: Playable, type: MoveType) {
this.state.playables = moveItemsInList(this.state.playables, playables, target, type) this.state.playables = moveItemsInList(this.state.playables, playables, target, type)
this.saveState() this.saveState()
}, },

View file

@ -50,18 +50,18 @@ new class extends UnitTestCase {
const getMock = this.mock(http, 'get').mockResolvedValue(songs) const getMock = this.mock(http, 'get').mockResolvedValue(songs)
const syncMock = this.mock(songStore, 'syncWithVault', songs) const syncMock = this.mock(songStore, 'syncWithVault', songs)
await searchStore.songSearch('test') await searchStore.playableSearch('test')
expect(getMock).toHaveBeenCalledWith('search/songs?q=test') expect(getMock).toHaveBeenCalledWith('search/songs?q=test')
expect(syncMock).toHaveBeenCalledWith(songs) expect(syncMock).toHaveBeenCalledWith(songs)
expect(searchStore.state.songs).toEqual(songs) expect(searchStore.state.playables).toEqual(songs)
}) })
it('resets the song result state', () => { it('resets the song result state', () => {
searchStore.state.songs = factory('song', 3) searchStore.state.playables = factory('song', 3)
searchStore.resetSongResultState() searchStore.resetPlayableResultState()
expect(searchStore.state.songs).toEqual([]) expect(searchStore.state.playables).toEqual([])
}) })
} }
} }

View file

@ -1,6 +1,6 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { http } from '@/services' import { http } from '@/services'
import { albumStore, artistStore, podcastStore, songStore } from '@/stores' import { albumStore, artistStore, songStore } from '@/stores'
type ExcerptState = { type ExcerptState = {
playables: Playable[] playables: Playable[]
@ -24,7 +24,7 @@ export const searchStore = {
artists: [], artists: [],
podcasts: [] podcasts: []
} as ExcerptState, } as ExcerptState,
songs: [] as Playable[] playables: [] as Playable[]
}), }),
async excerptSearch (q: string) { async excerptSearch (q: string) {
@ -36,11 +36,11 @@ export const searchStore = {
this.state.excerpt.podcasts = result.podcasts this.state.excerpt.podcasts = result.podcasts
}, },
async songSearch (q: string) { async playableSearch (q: string) {
this.state.songs = songStore.syncWithVault(await http.get<Song[]>(`search/songs?q=${q}`)) this.state.playables = songStore.syncWithVault(await http.get<Playable[]>(`search/songs?q=${q}`))
}, },
resetSongResultState () { resetPlayableResultState () {
this.state.songs = [] this.state.playables = []
} }
} }

View file

@ -241,7 +241,7 @@ new class extends UnitTestCase {
expect(getMock).toHaveBeenCalledWith('playlists/966268ea-935d-4f63-a84e-180385376a78/songs') expect(getMock).toHaveBeenCalledWith('playlists/966268ea-935d-4f63-a84e-180385376a78/songs')
expect(syncMock).toHaveBeenCalledWith(songs) expect(syncMock).toHaveBeenCalledWith(songs)
expect(fetched).toEqual(songs) expect(fetched).toEqual(songs)
expect(playlist.songs).toEqual(songs) expect(playlist.playables).toEqual(songs)
}) })
it('fetches for playlist with cache', async () => { it('fetches for playlist with cache', async () => {
@ -256,7 +256,7 @@ new class extends UnitTestCase {
expect(getMock).not.toHaveBeenCalled() expect(getMock).not.toHaveBeenCalled()
expect(fetched).toEqual(songs) expect(fetched).toEqual(songs)
expect(playlist.songs).toEqual(songs) expect(playlist.playables).toEqual(songs)
}) })
it('fetches for playlist discarding cache', async () => { it('fetches for playlist discarding cache', async () => {
@ -271,7 +271,7 @@ new class extends UnitTestCase {
expect(getMock).toHaveBeenCalled() expect(getMock).toHaveBeenCalled()
expect(cache.get(['playlist.songs', playlist.id])).toEqual([]) expect(cache.get(['playlist.songs', playlist.id])).toEqual([])
expect(playlist.songs).toEqual([]) expect(playlist.playables).toEqual([])
}) })
it('paginates', async () => { it('paginates', async () => {

View file

@ -45,11 +45,11 @@ export interface GenreSongListPaginateParams extends Record<string, any> {
export const songStore = { export const songStore = {
vault: new Map<Playable['id'], Playable>(), vault: new Map<Playable['id'], Playable>(),
state: reactive({ state: reactive<{ songs: Playable[] }>({
songs: [] as Playable[] songs: []
}), }),
getFormattedLength: (songs: Playable | Playable[]) => secondsToHumanReadable(sumBy(arrayify(songs), 'length')), getFormattedLength: (playables: MaybeArray<Playable>) => secondsToHumanReadable(sumBy(arrayify(playables), 'length')),
byId (id: string) { byId (id: string) {
const song = this.vault.get(id) const song = this.vault.get(id)
@ -70,17 +70,17 @@ export const songStore = {
}, },
async resolve (id: string) { async resolve (id: string) {
let song = this.byId(id) let playable = this.byId(id)
if (!song) { if (!playable) {
try { try {
song = this.syncWithVault(await http.get<Song>(`songs/${id}`))[0] playable = this.syncWithVault(await http.get<Playable>(`songs/${id}`))[0]
} catch (error: unknown) { } catch (error: unknown) {
logger.error(error) logger.error(error)
} }
} }
return song return playable
}, },
/** /**
@ -146,9 +146,9 @@ export const songStore = {
: `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}` : `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}`
}, },
getShareableUrl: (song: Song) => `${window.BASE_URL}#/song/${song.id}`, getShareableUrl: (song: Playable) => `${window.BASE_URL}#/song/${song.id}`,
syncWithVault (playables: Playable | Playable[]) { syncWithVault (playables: MaybeArray<Playable>) {
return arrayify(playables).map(song => { return arrayify(playables).map(song => {
let local = this.byId(song.id) let local = this.byId(song.id)
@ -169,7 +169,7 @@ export const songStore = {
watch(() => playable.play_count, () => overviewStore.refreshPlayStats()) watch(() => playable.play_count, () => overviewStore.refreshPlayStats())
}, },
ensureNotDeleted: (songs: Song | Song[]) => arrayify(songs).filter(({ deleted }) => !deleted), ensureNotDeleted: (songs: MaybeArray<Song>) => arrayify(songs).filter(({ deleted }) => !deleted),
async fetchForAlbum (album: Album | number) { async fetchForAlbum (album: Album | number) {
const id = typeof album === 'number' ? album : album.id const id = typeof album === 'number' ? album : album.id
@ -201,19 +201,19 @@ export const songStore = {
async () => this.syncWithVault(await http.get<Song[]>(`playlists/${id}/songs`)) async () => this.syncWithVault(await http.get<Song[]>(`playlists/${id}/songs`))
)) ))
playlistStore.byId(id)!.songs = songs playlistStore.byId(id)!.playables = songs
return songs return songs
}, },
async fetchForPlaylistFolder (folder: PlaylistFolder) { async fetchForPlaylistFolder (folder: PlaylistFolder) {
const songs: Song[] = [] const playables: Playable[] = []
for await (const playlist of playlistStore.byFolder(folder)) { for await (const playlist of playlistStore.byFolder(folder)) {
songs.push(...await songStore.fetchForPlaylist(playlist)) playables.push(...await songStore.fetchForPlaylist(playlist))
} }
return uniqBy(songs, 'id') return uniqBy(playables, 'id')
}, },
async fetchForPodcast (podcast: Podcast | string, refresh = false) { async fetchForPodcast (podcast: Podcast | string, refresh = false) {

View file

@ -190,7 +190,7 @@ interface Episode extends Playable {
podcast_author: string podcast_author: string
} }
interface CollaborativeSong extends Song { interface CollaborativeSong extends Playable {
collaboration: { collaboration: {
user: PlaylistCollaborator user: PlaylistCollaborator
added_at: string | null added_at: string | null
@ -243,12 +243,12 @@ type SmartPlaylistInputTypes = Record<SmartPlaylistModel['type'], SmartPlaylistO
type FavoriteList = { type FavoriteList = {
name: 'Favorites' name: 'Favorites'
songs: Song[] playables: Playable[]
} }
type RecentlyPlayedList = { type RecentlyPlayedList = {
name: 'Recently Played' name: 'Recently Played'
songs: Song[] playables: Playable[]
} }
interface PlaylistFolder { interface PlaylistFolder {
@ -273,7 +273,7 @@ interface Playlist {
rules: SmartPlaylistRuleGroup[] rules: SmartPlaylistRuleGroup[]
own_songs_only: boolean own_songs_only: boolean
cover: string | null cover: string | null
songs?: Playable[] playables?: Playable[]
} }
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
@ -289,8 +289,8 @@ interface Podcast {
readonly author: string readonly author: string
readonly subscribed_at: string readonly subscribed_at: string
readonly state: { readonly state: {
current_episode: Song['id'] | null current_episode: Playable['id'] | null
progresses: Record<Song['id'], number> progresses: Record<Playable['id'], number>
} }
} }
@ -327,6 +327,7 @@ interface UserPreferences extends Record<string, any> {
visualizer?: Visualizer['id'] | null visualizer?: Visualizer['id'] | null
active_extra_panel_tab: ExtraPanelTab | null active_extra_panel_tab: ExtraPanelTab | null
make_uploads_public: boolean make_uploads_public: boolean
lastfm_session_key?: string
} }
interface User { interface User {
@ -350,7 +351,7 @@ interface Settings {
interface Interaction { interface Interaction {
type: 'interactions' type: 'interactions'
readonly id: number readonly id: number
readonly song_id: Song['id'] readonly song_id: Playable['id']
liked: boolean liked: boolean
play_count: number play_count: number
} }

View file

@ -1,6 +1,3 @@
import { pluralize } from '@/utils/formatters'
import { arrayify } from '@/utils/helpers'
export function isSong (playable: Playable): playable is Song { export function isSong (playable: Playable): playable is Song {
return playable.type === 'songs' return playable.type === 'songs'
} }