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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import ModalWrapper from './ModalWrapper.vue'
new class extends UnitTestCase {
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],
['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined],
['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_ADD_USER_FORM', () => (activeModalName.value = 'add-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 = {
folder,
songs: songs ? arrayify(songs) : []
playables: playables ? arrayify(playables) : []
}
activeModalName.value = 'create-playlist-form'

View file

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

View file

@ -1,16 +1,16 @@
<template>
<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">
<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 -->
<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" />
</FooterBtn>
<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" />
</FooterBtn>
@ -31,7 +31,7 @@ import LikeButton from '@/components/song/SongLikeButton.vue'
import PlayButton from '@/components/ui/FooterPlayButton.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 playNext = async () => await playbackService.playNext()

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1
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="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="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-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-->
</div>

View file

@ -1,8 +1,8 @@
// 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="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="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path>
@ -11,9 +11,9 @@ exports[`renders with a current song 1`] = `
</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="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="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path>

View file

@ -1,15 +1,15 @@
// Vitest Snapshot v1
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">
<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>
`;
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-->
</div>
`;

View file

@ -1,5 +1,6 @@
<template>
<aside
v-if="playable"
: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"
>
@ -62,7 +63,7 @@
role="tabpanel"
tabindex="0"
>
<YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable as Song" />
<YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable" />
</div>
</div>
</aside>
@ -70,7 +71,7 @@
<script lang="ts" setup>
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 { useErrorHandler, useThirdPartyServices } from '@/composables'
import { isSong, requireInjection } from '@/utils'
@ -89,7 +90,7 @@ const ExtraDrawerTabHeader = defineAsyncComponent(() => import('./ExtraDrawerTab
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 artist = ref<Artist>()

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
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="">
<h1 data-v-886145d2="">Playlist Collaboration</h1>
</header>

View file

@ -54,7 +54,7 @@ import DOMPurify from 'dompurify'
import { orderBy } from 'lodash'
import { faBookmark, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, toRefs } from 'vue'
import { eventBus, secondsToHis } from '@/utils'
import { eventBus, secondsToHumanReadable } from '@/utils'
import { useDraggable } from '@/composables'
import { formatTimeAgo } from '@vueuse/core'
import { playbackService } from '@/services'
@ -84,9 +84,9 @@ const publicationDateForHumans = computed(() => {
const currentPosition = computed(() => podcast.value.state.progresses[episode.value.id] || 0)
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
return secondsLeft === 0 ? 0 : secondsToHis(secondsLeft)
return secondsLeft === 0 ? 0 : secondsToHumanReadable(secondsLeft)
})
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 {
protected test () {
it('renders a list of favorites', async () => {
favoriteStore.state.songs = factory('song', 13)
favoriteStore.state.playables = factory('song', 13)
await this.renderComponent()
await waitFor(() => {
@ -18,7 +18,7 @@ new class extends UnitTestCase {
})
it('shows empty state', async () => {
favoriteStore.state.songs = []
favoriteStore.state.playables = []
await this.renderComponent()
screen.getByTestId('screen-empty-state')

View file

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

View file

@ -50,7 +50,7 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
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 { genreStore, songStore } from '@/stores'
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')
playlistStore.init([playlist])
playlist.songs = songs
playlist.playables = songs
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)

View file

@ -121,7 +121,7 @@ const {
onScrollBreakpoint,
sort: baseSort,
config: listConfig
} = useSongList(ref<Song[] | CollaborativeSong[]>([]), { type: 'Playlist' })
} = useSongList(ref<Playable[] | CollaborativeSong[]>([]), { type: 'Playlist' })
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
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.
songs.value = playlist.value!.songs!
songs.value = playlist.value!.playables!
}
const onReorder = (target: Playable, type: MoveType) => {

View file

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

View file

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

View file

@ -14,19 +14,21 @@ exports[`renders 1`] = `
</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="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-->
</div>
<div class="context-menu p-0 hidden">
<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">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 songs to</p>
<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="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</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 class="context-menu p-0 hidden">
<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">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
<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="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</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>
@ -52,19 +54,21 @@ exports[`renders in Plus edition 1`] = `
</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="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-->
</div>
<div class="context-menu p-0 hidden">
<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">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 songs to</p>
<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="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</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 class="context-menu p-0 hidden">
<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">
<section data-v-42061e3e="" class="existing-playlists">
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
<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="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
</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><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 {
protected test () {
it('searches for prop query on created', () => {
const resetResultMock = this.mock(searchStore, 'resetSongResultState')
const searchMock = this.mock(searchStore, 'songSearch')
const resetResultMock = this.mock(searchStore, 'resetPlayableResultState')
const searchMock = this.mock(searchStore, 'playableSearch')
this.router.activateRoute({ path: 'search-songs', screen: 'Search.Songs' }, { q: 'search me' })
this.render(SearchSongResultsScreen)

View file

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

View file

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

View file

@ -7,9 +7,9 @@ import { screen, waitFor } from '@testing-library/vue'
import { downloadService, playbackService } from '@/services'
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
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 {
protected beforeEach () {
@ -17,15 +17,13 @@ new class extends UnitTestCase {
}
protected test () {
it('queues and plays', async () => {
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
it('plays', async () => {
const playMock = this.mock(playbackService, 'play')
const song = factory('song', { playback_state: 'Stopped' })
await this.renderComponent(song)
await this.user.click(screen.getByText('Play'))
expect(queueMock).toHaveBeenCalledWith(song)
expect(playMock).toHaveBeenCalledWith(song)
})
@ -49,20 +47,22 @@ new class extends UnitTestCase {
it('goes to album details screen', async () => {
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'))
expect(goMock).toHaveBeenCalledWith(`album/${songs[0].album_id}`)
expect(goMock).toHaveBeenCalledWith(`album/${song.album_id}`)
})
it('goes to artist details screen', async () => {
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'))
expect(goMock).toHaveBeenCalledWith(`artist/${songs[0].artist_id}`)
expect(goMock).toHaveBeenCalledWith(`artist/${song.artist_id}`)
})
it('downloads', async () => {
@ -71,7 +71,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Download'))
expect(downloadMock).toHaveBeenCalledWith(songs)
expect(downloadMock).toHaveBeenCalledWith(playables)
})
it('queues', async () => {
@ -80,17 +80,17 @@ new class extends UnitTestCase {
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()
const queueMock = this.mock(queueStore, 'queueAfterCurrent')
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 () => {
@ -100,7 +100,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Bottom of Queue'))
expect(queueMock).toHaveBeenCalledWith(songs)
expect(queueMock).toHaveBeenCalledWith(playables)
})
it('queues to top', async () => {
@ -110,7 +110,7 @@ new class extends UnitTestCase {
await this.user.click(screen.getByText('Top of Queue'))
expect(queueMock).toHaveBeenCalledWith(songs)
expect(queueMock).toHaveBeenCalledWith(playables)
})
it('removes from queue', async () => {
@ -126,7 +126,7 @@ new class extends UnitTestCase {
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 () => {
@ -148,7 +148,7 @@ new class extends UnitTestCase {
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 () => {
@ -157,7 +157,7 @@ new class extends UnitTestCase {
screen: 'Favorites'
})
this.renderComponent()
await this.renderComponent()
expect(screen.queryByText('Favorites')).toBeNull()
})
@ -174,7 +174,7 @@ new class extends UnitTestCase {
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 () => {
@ -187,7 +187,7 @@ new class extends UnitTestCase {
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 () => {
@ -210,14 +210,14 @@ new class extends UnitTestCase {
await this.renderComponent()
const removeSongsMock = this.mock(playlistStore, 'removeContent')
const removeContentMock = this.mock(playlistStore, 'removeContent')
const emitMock = this.mock(eventBus, 'emit')
await this.user.click(screen.getByText('Remove from Playlist'))
await waitFor(() => {
expect(removeSongsMock).toHaveBeenCalledWith(playlist, songs)
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_SONGS_REMOVED', playlist, songs)
expect(removeContentMock).toHaveBeenCalledWith(playlist, playables)
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_CONTENT_REMOVED', playlist, playables)
})
})
@ -239,7 +239,7 @@ new class extends UnitTestCase {
const emitMock = this.mock(eventBus, 'emit')
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 () => {
@ -278,9 +278,9 @@ new class extends UnitTestCase {
await waitFor(() => {
expect(confirmMock).toHaveBeenCalled()
expect(deleteMock).toHaveBeenCalledWith(songs)
expect(deleteMock).toHaveBeenCalledWith(playables)
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…'))
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 () => {
@ -382,11 +382,11 @@ new class extends UnitTestCase {
})
}
private async renderComponent (_songs?: Song | Song[]) {
songs = arrayify(_songs || factory('song', 5))
private async renderComponent (_playables?: MaybeArray<Playable>) {
playables = arrayify(_playables || factory('song', 5))
const rendered = this.render(PlayableContextMenu)
eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, songs)
const rendered = this.render(Component)
eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playables)
await this.tick(2)
return rendered

View file

@ -151,12 +151,12 @@ const contentType = computed(() => getPlayableCollectionContentType(playables.va
const visibilityActions = computed(() => {
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'
: 'private'
)))
))))
if (visibilities.size === 2) {
if (visibilities.length === 2) {
return [
{
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 viewArtist = (song: Song) => trigger(() => go(`artist/${song.artist_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 copyUrl = () => trigger(async () => {
await copyText(songStore.getShareableUrl(playables.value[0] as Song))
await copyText(songStore.getShareableUrl(playables.value[0]))
toastSuccess('URL copied to clipboard.')
})

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<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" />
</FooterExtraControlBtn>
</template>
@ -13,10 +13,10 @@ import { favoriteStore } from '@/stores'
import FooterExtraControlBtn from '@/components/layout/app-footer/FooterButton.vue'
const props = defineProps<{ song: Playable }>()
const { song } = toRefs(props)
const props = defineProps<{ playable: Playable }>()
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>

View file

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

View file

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

View file

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

View file

@ -4,37 +4,37 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { playbackService } from '@/services'
import { screen } from '@testing-library/vue'
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 {
protected test () {
it.each<[PlaybackState, string, MethodOf<typeof playbackService>]>([
['Stopped', 'Play', 'play'],
['Playing', 'Pause', 'pause'],
['Paused', 'Resume', 'resume']
])('if state is currently "%s", %ss', async (state, name, method) => {
it.each<[PlaybackState, MethodOf<typeof playbackService>]>([
['Stopped', 'play'],
['Playing', 'pause'],
['Paused', 'resume']
])('if state is currently "%s", %ss', async (state, method) => {
this.mock(queueStore, 'queueIfNotQueued')
const playbackMock = this.mock(playbackService, method)
this.renderComponent(state)
await this.user.click(screen.getByRole('button', { name }))
await this.user.click(screen.getByRole('button'))
expect(playbackMock).toHaveBeenCalled()
})
}
private renderComponent (playbackState: PlaybackState = 'Stopped') {
song = factory('song', {
playable = factory('song', {
playback_state: playbackState,
play_count: 10,
title: 'Foo bar'
})
return this.render(SongThumbnail, {
return this.render(Component, {
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
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" />
</span>
</button>
@ -29,19 +29,19 @@ import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { defaultCover, getPlayableProp } from '@/utils'
import { playbackService } from '@/services'
const props = defineProps<{ song: Playable }>()
const { song } = toRefs(props)
const props = defineProps<{ playable: Playable }>()
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(() => {
if (song.value.playback_state === 'Playing') {
if (playable.value.playback_state === 'Playing') {
return 'Pause'
}
if (song.value.playback_state === 'Paused') {
if (playable.value.playback_state === 'Paused') {
return 'Resume'
}
@ -49,9 +49,10 @@ const title = computed(() => {
})
const playOrPause = () => {
if (song.value.playback_state === 'Stopped') {
if (playable.value.playback_state === 'Stopped') {
// @todo play at the right playback position for Episodes
play()
} else if (song.value.playback_state === 'Paused') {
} else if (playable.value.playback_state === 'Paused') {
playbackService.resume()
} else {
playbackService.pause()

View file

@ -3,7 +3,7 @@
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">
<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">
<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>

View file

@ -2,8 +2,8 @@
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="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>
<!--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>
<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 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>
`;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
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>
<!--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>
<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"><br data-testid="Icon" icon="[object Object]"></button></span>
</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, {
global: {
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 () => {
if (libraryEmpty.value) return
let songs: Song[]
let playables: Playable[]
switch (getCurrentScreen()) {
case 'Album':
songs = await songStore.fetchForAlbum(parseInt(getRouteParam('id')!))
playables = await songStore.fetchForAlbum(parseInt(getRouteParam('id')!))
break
case 'Artist':
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
playables = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
break
case 'Playlist':
songs = await songStore.fetchForPlaylist(getRouteParam('id')!)
playables = await songStore.fetchForPlaylist(getRouteParam('id')!)
break
case 'Favorites':
songs = await favoriteStore.fetch()
playables = await favoriteStore.fetch()
break
case 'RecentlyPlayed':
songs = await recentlyPlayedStore.fetch()
playables = await recentlyPlayedStore.fetch()
break
default:
songs = await queueStore.fetchRandom()
playables = await queueStore.fetchRandom()
break
}
playbackService.queueAndPlay(songs)
playbackService.queueAndPlay(playables)
go('queue')
}
</script>

View file

@ -1,5 +1,5 @@
// 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
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 {
const type = getDragType(event)
if (!type) return <Playable[]>[]
@ -153,6 +153,6 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
acceptsDrop,
getDroppedData,
resolveDroppedValue,
resolveDroppedItems: resolveDroppedSongs
resolveDroppedItems
}
}

View file

@ -6,16 +6,14 @@ export const usePolicies = () => {
const { isPlus } = useKoelPlus()
const currentUserCan = {
editSong: (song: Song | Song[]) => {
editSong: (songs: MaybeArray<Song>) => {
if (isAdmin.value) return true
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,
uploadSongs: () => isAdmin.value || isPlus.value,
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_INVITE_USER_FORM: () => 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_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void
@ -52,9 +52,9 @@ export interface Events {
SOCKET_PLAY_PREV: () => void
SOCKET_PLAYBACK_STOPPED: () => 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_SONG: (song: Song) => void
SOCKET_SONG: (song: Playable) => void
SOCKET_SET_VOLUME: (volume: number) => void
SOCKET_VOLUME_CHANGED: (volume: number) => void
}

View file

@ -1,7 +1,7 @@
<template>
<div :class="{ 'standalone' : inStandaloneMode }" class="h-screen bg-k-bg-primary">
<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]">
<template v-if="connected">
@ -26,7 +26,7 @@
import { authService, socketService } from '@/services'
import { preferenceStore, userStore } from '@/stores'
import { defineAsyncComponent, onMounted, provide, reactive, ref, toRef } from 'vue'
import { logger } from '@/utils'
import { isSong, logger } from '@/utils'
import { RemoteState } from '@/remote/types'
const SongDetails = defineAsyncComponent(() => import('@/remote/components/SongDetails.vue'))
@ -50,7 +50,7 @@ const onUserLoggedIn = async () => {
const state = reactive<RemoteState>({
volume: 0,
song: null as Song | null
song: null as Playable | null
})
provide('state', state)
@ -64,7 +64,7 @@ const init = async () => {
.listen('SOCKET_SONG', song => (state.song = song))
.listen('SOCKET_PLAYBACK_STOPPED', () => state.song && (state.song.playback_state = 'Stopped'))
.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.song = data.song || null
connected.value = true

View file

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

View file

@ -1,26 +1,30 @@
<template>
<article class="flex-1 flex flex-col items-center justify-around">
<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
border-k-text-primary bg-center bg-cover bg-k-bg-secondary"
/>
<div class="w-full flex flex-col justify-around">
<div>
<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-[4vmin] opacity-50">{{ song.album_name }}</p>
<p class="text-[5vmin] mb-2 opacity-50">{{ artist }}</p>
<p class="text-[4vmin] opacity-50">{{ album }}</p>
</div>
</div>
</article>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue'
import { defaultCover } from '@/utils'
import { computed, toRefs } from 'vue'
import { defaultCover, getPlayableProp } from '@/utils'
const props = defineProps<{ song: Song }>()
const props = defineProps<{ song: Playable }>()
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>

View file

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

View file

@ -6,11 +6,11 @@ import { downloadService } from './downloadService'
new class extends UnitTestCase {
protected test () {
it('downloads songs', () => {
it('downloads playables', () => {
const mock = this.mock(downloadService, 'trigger')
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', () => {
@ -36,11 +36,11 @@ new class extends UnitTestCase {
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',
(songs, triggered) => {
const mock = this.mock(downloadService, 'trigger')
favoriteStore.state.songs = songs
favoriteStore.state.playables = songs
downloadService.fromFavorites()

View file

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

View file

@ -78,11 +78,11 @@ new class extends UnitTestCase {
it('scrobbles if current song ends', () => {
commonStore.state.uses_last_fm = true
userStore.state.current = reactive(factory('user', {
userStore.state.current = factory('user', {
preferences: {
lastfm_session_key: 'foo'
}
}))
})
playbackService.init(document.querySelector('.plyr')!)
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', {
playback_state: 'Playing'
}))

View file

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

View file

@ -6,7 +6,7 @@ import { favoriteStore } from '.'
new class extends UnitTestCase {
protected beforeEach () {
super.beforeEach(() => (favoriteStore.state.songs = []))
super.beforeEach(() => (favoriteStore.state.playables = []))
}
protected test () {
@ -31,18 +31,18 @@ new class extends UnitTestCase {
it('adds songs', () => {
const songs = factory('song', 3)
favoriteStore.add(songs)
expect(favoriteStore.state.songs).toEqual(songs)
expect(favoriteStore.state.playables).toEqual(songs)
// doesn't duplicate songs
favoriteStore.add(songs[0])
expect(favoriteStore.state.songs).toEqual(songs)
expect(favoriteStore.state.playables).toEqual(songs)
})
it('removes songs', () => {
const songs = factory('song', 3)
favoriteStore.state.songs = songs
favoriteStore.state.playables = songs
favoriteStore.remove(songs)
expect(favoriteStore.state.songs).toEqual([])
expect(favoriteStore.state.playables).toEqual([])
})
it('likes several songs', async () => {
@ -74,7 +74,7 @@ new class extends UnitTestCase {
await favoriteStore.fetch()
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'
export const favoriteStore = {
state: reactive({
songs: [] as Playable[]
state: reactive<{ playables: Playable[] }>({
playables: []
}),
async toggleOne (playable: Playable) {
@ -15,15 +15,15 @@ export const favoriteStore = {
playable.liked = !playable.liked
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>) {
this.state.songs = unionBy(this.state.songs, arrayify(songs), 'id')
this.state.playables = unionBy(this.state.playables, arrayify(songs), 'id')
},
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[]) {
@ -43,7 +43,7 @@ export const favoriteStore = {
},
async fetch () {
this.state.songs = songStore.syncWithVault(await http.get<Playable[]>('songs/favorite'))
return this.state.songs
this.state.playables = songStore.syncWithVault(await http.get<Playable[]>('songs/favorite'))
return this.state.playables
}
}

View file

@ -4,25 +4,26 @@ import { songStore } from '@/stores/songStore'
import { albumStore } from '@/stores/albumStore'
import { artistStore } from '@/stores/artistStore'
import { recentlyPlayedStore } from '@/stores'
import { isEpisode, isSong } from '@/utils'
export const overviewStore = {
state: reactive({
recentlyPlayed: [] as Song[],
recentlyPlayed: [] as Playable[],
recentlyAddedSongs: [] as Song[],
recentlyAddedAlbums: [] as Album[],
mostPlayedSongs: [] as Song[],
mostPlayedSongs: [] as Playable[],
mostPlayedAlbums: [] as Album[],
mostPlayedArtists: [] as Artist[]
}),
async fetch () {
const resource = await http.get<{
most_played_songs: Song[],
most_played_songs: Playable[],
most_played_albums: Album[],
most_played_artists: Artist[],
recently_added_songs: Song[],
recently_added_albums: Album[],
recently_played_songs: Song[],
recently_played_songs: Playable[],
}>('overview')
songStore.syncWithVault(resource.most_played_songs)
@ -31,7 +32,7 @@ export const overviewStore = {
this.state.mostPlayedAlbums = albumStore.syncWithVault(resource.most_played_albums)
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)
recentlyPlayedStore.excerptState.playables = songStore.syncWithVault(resource.recently_played_songs)
@ -41,8 +42,9 @@ export const overviewStore = {
refreshPlayStats () {
this.state.mostPlayedSongs = songStore.getMostPlayed(7)
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.playables.filter(
({ deleted, play_count }) => !deleted && play_count > 0
)
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.playables.filter(playable => {
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 = {
name: Playlist['name']
songs: Song['id'][]
songs: Playable['id'][]
rules?: SmartPlaylistRuleGroup[]
folder_id?: PlaylistFolder['name']
own_songs_only?: boolean
@ -95,11 +95,11 @@ export const playlistStore = {
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)
})
songStore.syncWithVault(updatedSongs)
songStore.syncWithVault(updatedPlayables)
cache.remove(['playlist.songs', playlist.id])
return playlist
@ -166,10 +166,14 @@ export const playlistStore = {
},
moveItemsInPlaylist: async (playlist: Playlist, songs: MaybeArray<Playable>, target: Playable, type: MoveType) => {
const orderHash = JSON.stringify(playlist.songs?.map(({ id }) => id))
playlist.songs?.splice(0, playlist.songs.length, ...moveItemsInList(playlist.songs, songs, target, type))
const orderHash = JSON.stringify(playlist.playables?.map(({ id }) => id))
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`, {
songs: arrayify(songs).map(({ id }) => id),
target: target.id,

View file

@ -1,26 +1,26 @@
import factory from '@/__tests__/factory'
import { reactive } from 'vue'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import factory from 'factoria'
import { http } from '@/services'
import { queueStore, songStore } from '.'
let songs: Song[]
let playables: Playable[]
new class extends UnitTestCase {
protected beforeEach () {
super.beforeEach(() => {
songs = factory('song', 3)
queueStore.state.playables = reactive(songs)
playables = factory('song', 3)
queueStore.state.playables = reactive(playables)
})
}
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', () => {
const song = factory('song')
@ -53,17 +53,17 @@ new class extends UnitTestCase {
it('removes a song from queue', () => {
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) })
})
it('removes multiple songs from queue', () => {
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) })
})

View file

@ -82,7 +82,7 @@ export const queueStore = {
this.all = head.concat(reactive(playables), this.all)
},
unqueue (playables: Playable | Playable[]) {
unqueue (playables: MaybeArray<Playable>) {
playables = arrayify(playables)
playables.forEach(song => (song.playback_state = 'Stopped'))
this.all = differenceBy(this.all, playables, 'id')
@ -91,7 +91,7 @@ export const queueStore = {
/**
* 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.saveState()
},

View file

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

View file

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

View file

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

View file

@ -45,11 +45,11 @@ export interface GenreSongListPaginateParams extends Record<string, any> {
export const songStore = {
vault: new Map<Playable['id'], Playable>(),
state: reactive({
songs: [] as Playable[]
state: reactive<{ songs: Playable[] }>({
songs: []
}),
getFormattedLength: (songs: Playable | Playable[]) => secondsToHumanReadable(sumBy(arrayify(songs), 'length')),
getFormattedLength: (playables: MaybeArray<Playable>) => secondsToHumanReadable(sumBy(arrayify(playables), 'length')),
byId (id: string) {
const song = this.vault.get(id)
@ -70,17 +70,17 @@ export const songStore = {
},
async resolve (id: string) {
let song = this.byId(id)
let playable = this.byId(id)
if (!song) {
if (!playable) {
try {
song = this.syncWithVault(await http.get<Song>(`songs/${id}`))[0]
playable = this.syncWithVault(await http.get<Playable>(`songs/${id}`))[0]
} catch (error: unknown) {
logger.error(error)
}
}
return song
return playable
},
/**
@ -146,9 +146,9 @@ export const songStore = {
: `${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 => {
let local = this.byId(song.id)
@ -169,7 +169,7 @@ export const songStore = {
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) {
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`))
))
playlistStore.byId(id)!.songs = songs
playlistStore.byId(id)!.playables = songs
return songs
},
async fetchForPlaylistFolder (folder: PlaylistFolder) {
const songs: Song[] = []
const playables: Playable[] = []
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) {

View file

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