mirror of
https://github.com/koel/koel
synced 2024-11-24 05:03:05 +00:00
fix(tests): broken FE tests after Podcast feature
This commit is contained in:
parent
7d3215a323
commit
0f67ce2478
72 changed files with 377 additions and 356 deletions
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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')],
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -6,8 +6,6 @@ import { eventBus } from '@/utils'
|
|||
import Sidebar from './Sidebar.vue'
|
||||
|
||||
const standardItems = [
|
||||
'Home',
|
||||
'Current Queue',
|
||||
'All Songs',
|
||||
'Albums',
|
||||
'Artists',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.`)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.')
|
||||
})
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)],
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export interface RemoteState {
|
||||
song: Song | null
|
||||
song: Playable | null
|
||||
volume: number
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ export const downloadService = {
|
|||
},
|
||||
|
||||
fromFavorites () {
|
||||
if (favoriteStore.state.songs.length) {
|
||||
if (favoriteStore.state.playables.length) {
|
||||
this.trigger('favorites')
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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'
|
||||
}))
|
||||
|
|
|
@ -30,7 +30,7 @@ const initialState = {
|
|||
song_length: 0,
|
||||
queue_state: {
|
||||
type: 'queue-states',
|
||||
songs: [],
|
||||
playables: [],
|
||||
current_song: null,
|
||||
playback_position: 0
|
||||
} as QueueState
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) })
|
||||
})
|
||||
|
||||
|
|
|
@ -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()
|
||||
},
|
||||
|
|
|
@ -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([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
15
resources/assets/js/types.d.ts
vendored
15
resources/assets/js/types.d.ts
vendored
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue