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 { screen } from '@testing-library/vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { queueStore, songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { PlayablesKey } from '@/symbols'
|
import { PlayablesKey } from '@/symbols'
|
||||||
|
@ -14,15 +14,13 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('plays', async () => {
|
it('plays', async () => {
|
||||||
const matchedSong = factory('song')
|
const matchedSong = factory('song')
|
||||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
|
||||||
const playMock = this.mock(playbackService, 'play')
|
const playMock = this.mock(playbackService, 'play')
|
||||||
|
|
||||||
this.renderComponent(matchedSong)
|
this.renderComponent(matchedSong)
|
||||||
|
|
||||||
await this.user.click(screen.getByTitle('Click to play'))
|
await this.user.click(screen.getByTitle('Click to play'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenNthCalledWith(1, matchedSong)
|
expect(playMock).toHaveBeenCalledWith(matchedSong)
|
||||||
expect(playMock).toHaveBeenNthCalledWith(1, matchedSong)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<article data-v-2487c4e3="" class="full relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin">
|
<article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="IV by Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="http://loremflickr.com/640/480" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>
|
||||||
<div data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="IV" src="http://loremflickr.com/640/480" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>
|
|
||||||
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
||||||
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/album/42" class="font-medium" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>
|
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/album/42" class="font-medium" data-testid="name">IV</a><a href="#/artist/17">Led Zeppelin</a></div>
|
||||||
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs in the album IV" role="button"> Shuffle </a><a title="Download all songs in the album IV" role="button"> Download </a></p>
|
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs in the album IV" role="button"> Shuffle </a><a title="Download all songs in the album IV" role="button"> Download </a></p>
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<nav data-v-0408531a="" class="album-menu menu context-menu select-none" tabindex="0" data-testid="album-context-menu">
|
<div data-v-6396fbf0="" data-testid="album-context-menu">
|
||||||
<ul data-v-0408531a="">
|
<nav data-v-6396fbf0="" class="album-menu menu context-menu select-none shadow" tabindex="0">
|
||||||
<li>Play All</li>
|
<ul data-v-6396fbf0="">
|
||||||
<li>Shuffle All</li>
|
<li>Play All</li>
|
||||||
<li class="separator"></li>
|
<li>Shuffle All</li>
|
||||||
<li>Go to Album</li>
|
<li class="separator"></li>
|
||||||
<li>Go to Artist</li>
|
<li>Go to Album</li>
|
||||||
<li class="separator"></li>
|
<li>Go to Artist</li>
|
||||||
<li>Download</li>
|
<li class="separator"></li>
|
||||||
</ul>
|
<li>Download</li>
|
||||||
</nav>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<article data-v-2487c4e3="" class="full relative flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin">
|
<article data-v-2487c4e3="" class="full relative group flex max-w-full md:max-w-[256px] border p-5 rounded-lg flex-col gap-5 transition border-color duration-200" data-testid="artist-album-card" draggable="true" tabindex="0" title="Led Zeppelin"><button data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="foo.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>
|
||||||
<div data-v-40f79232="" data-v-2487c4e3="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Led Zeppelin" src="foo.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>
|
|
||||||
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
<footer data-v-2487c4e3="" class="flex flex-1 flex-col gap-1.5 overflow-hidden">
|
||||||
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/artist/42" class="font-medium" data-testid="name">Led Zeppelin</a></div>
|
<div data-v-2487c4e3="" class="name flex flex-col gap-2 whitespace-nowrap"><a href="#/artist/42" class="font-medium" data-testid="name">Led Zeppelin</a></div>
|
||||||
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs by Led Zeppelin" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" role="button"> Download </a></p>
|
<p data-v-2487c4e3="" class="meta text-[0.9rem] flex gap-1.5 opacity-70 hover:opacity-100"><a title="Shuffle all songs by Led Zeppelin" role="button"> Shuffle </a><a title="Download all songs by Led Zeppelin" role="button"> Download </a></p>
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<nav data-v-0408531a="" class="artist-menu menu context-menu select-none" tabindex="0" data-testid="artist-context-menu">
|
<div data-v-6396fbf0="" data-testid="artist-context-menu">
|
||||||
<ul data-v-0408531a="">
|
<nav data-v-6396fbf0="" class="artist-menu menu context-menu select-none shadow" tabindex="0">
|
||||||
<li>Play All</li>
|
<ul data-v-6396fbf0="">
|
||||||
<li>Shuffle All</li>
|
<li>Play All</li>
|
||||||
<li class="separator"></li>
|
<li>Shuffle All</li>
|
||||||
<li>Go to Artist</li>
|
<li class="separator"></li>
|
||||||
<li class="separator"></li>
|
<li>Go to Artist</li>
|
||||||
<li>Download</li>
|
<li class="separator"></li>
|
||||||
</ul>
|
<li>Download</li>
|
||||||
</nav>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -8,7 +8,7 @@ import ModalWrapper from './ModalWrapper.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it.each<[string, keyof Events, User | Song[] | Playlist | PlaylistFolder | undefined]>([
|
it.each<[string, keyof Events, User | Playable[] | Playlist | PlaylistFolder | undefined]>([
|
||||||
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
|
['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined],
|
||||||
['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined],
|
['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined],
|
||||||
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],
|
['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')],
|
||||||
|
|
|
@ -50,10 +50,10 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
|
||||||
.on('MODAL_SHOW_KOEL_PLUS', () => (activeModalName.value = 'koel-plus'))
|
.on('MODAL_SHOW_KOEL_PLUS', () => (activeModalName.value = 'koel-plus'))
|
||||||
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
|
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
|
||||||
.on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form'))
|
.on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form'))
|
||||||
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, songs) => {
|
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, playables?) => {
|
||||||
context.value = {
|
context.value = {
|
||||||
folder,
|
folder,
|
||||||
songs: songs ? arrayify(songs) : []
|
playables: playables ? arrayify(playables) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
activeModalName.value = 'create-playlist-form'
|
activeModalName.value = 'create-playlist-form'
|
||||||
|
|
|
@ -5,35 +5,35 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { CurrentPlayableKey } from '@/symbols'
|
import { CurrentPlayableKey } from '@/symbols'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import FooterPlaybackControls from './FooterPlaybackControls.vue'
|
import Component from './FooterPlaybackControls.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('renders without a current song', () => expect(this.renderComponent(null).html()).toMatchSnapshot())
|
it('renders without a current playable', () => expect(this.renderComponent(null).html()).toMatchSnapshot())
|
||||||
it('renders with a current song', () => expect(this.renderComponent().html()).toMatchSnapshot())
|
it('renders with a current playable', () => expect(this.renderComponent().html()).toMatchSnapshot())
|
||||||
|
|
||||||
it('plays the previous song', async () => {
|
it('plays the previous song', async () => {
|
||||||
const playMock = this.mock(playbackService, 'playPrev')
|
const playMock = this.mock(playbackService, 'playPrev')
|
||||||
this.renderComponent()
|
this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name: 'Play previous song' }))
|
await this.user.click(screen.getByRole('button', { name: 'Play previous in queue' }))
|
||||||
|
|
||||||
expect(playMock).toHaveBeenCalled()
|
expect(playMock).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('plays the next song', async () => {
|
it('plays the next playable', async () => {
|
||||||
const playMock = this.mock(playbackService, 'playNext')
|
const playMock = this.mock(playbackService, 'playNext')
|
||||||
this.renderComponent()
|
this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name: 'Play next song' }))
|
await this.user.click(screen.getByRole('button', { name: 'Play next in queue' }))
|
||||||
|
|
||||||
expect(playMock).toHaveBeenCalled()
|
expect(playMock).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (song?: Song | null) {
|
private renderComponent (playable?: Playable | null) {
|
||||||
if (song === undefined) {
|
if (playable === undefined) {
|
||||||
song = factory('song', {
|
playable = factory('song', {
|
||||||
id: '00000000-0000-0000-0000-000000000000',
|
id: '00000000-0000-0000-0000-000000000000',
|
||||||
title: 'Fahrstuhl to Heaven',
|
title: 'Fahrstuhl to Heaven',
|
||||||
artist_name: 'Led Zeppelin',
|
artist_name: 'Led Zeppelin',
|
||||||
|
@ -44,13 +44,13 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.render(FooterPlaybackControls, {
|
return this.render(Component, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
PlayButton: this.stub('PlayButton')
|
PlayButton: this.stub('PlayButton')
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>CurrentPlayableKey]: ref(song)
|
[<symbol>CurrentPlayableKey]: ref(playable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="playback-controls flex flex-1 flex-col justify-center">
|
<div class="playback-controls flex flex-1 flex-col justify-center">
|
||||||
<div class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0">
|
<div class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0">
|
||||||
<LikeButton v-if="song" :song="song" class="text-base" />
|
<LikeButton v-if="playable" :playable="playable" class="text-base" />
|
||||||
<button v-else type="button" /> <!-- a placeholder to maintain the asymmetric layout -->
|
<button v-else type="button" /> <!-- a placeholder to maintain the asymmetric layout -->
|
||||||
|
|
||||||
<FooterBtn class="text-2xl" title="Play previous song" @click.prevent="playPrev">
|
<FooterBtn class="text-2xl" title="Play previous in queue" @click.prevent="playPrev">
|
||||||
<Icon :icon="faStepBackward" />
|
<Icon :icon="faStepBackward" />
|
||||||
</FooterBtn>
|
</FooterBtn>
|
||||||
|
|
||||||
<PlayButton />
|
<PlayButton />
|
||||||
|
|
||||||
<FooterBtn class="text-2xl" title="Play next song" @click.prevent="playNext">
|
<FooterBtn class="text-2xl" title="Play next in queue" @click.prevent="playNext">
|
||||||
<Icon :icon="faStepForward" />
|
<Icon :icon="faStepForward" />
|
||||||
</FooterBtn>
|
</FooterBtn>
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
import PlayButton from '@/components/ui/FooterPlayButton.vue'
|
import PlayButton from '@/components/ui/FooterPlayButton.vue'
|
||||||
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
|
import FooterBtn from '@/components/layout/app-footer/FooterButton.vue'
|
||||||
|
|
||||||
const song = requireInjection(CurrentPlayableKey, ref())
|
const playable = requireInjection(CurrentPlayableKey, ref())
|
||||||
|
|
||||||
const playPrev = async () => await playbackService.playPrev()
|
const playPrev = async () => await playbackService.playPrev()
|
||||||
const playNext = async () => await playbackService.playNext()
|
const playNext = async () => await playbackService.playNext()
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div data-v-8bf5fe81="" class="extra-controls flex justify-end relative md:w-[320px] px-6 md:px-8 py-0">
|
<div data-v-8bf5fe81="" class="extra-controls flex justify-end relative md:w-[420px] px-6 md:px-8 py-0">
|
||||||
<div data-v-8bf5fe81="" class="flex justify-end items-center gap-6"><button data-v-8bf5fe81="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary visualizer-btn hidden md:!block" type="button" data-testid="toggle-visualizer-btn" title="Toggle visualizer"><br data-v-8bf5fe81="" data-testid="Icon" icon="[object Object]"></button>
|
<div data-v-8bf5fe81="" class="flex justify-end items-center gap-6"><button data-v-b0dbb31b="" data-v-8bf5fe81="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary queue-btn" type="button" title="Queue (Q)"><br data-v-b0dbb31b="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-8bf5fe81="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary visualizer-btn hidden md:!block" type="button" data-testid="toggle-visualizer-btn" title="Toggle visualizer"><br data-v-8bf5fe81="" data-testid="Icon" icon="[object Object]" fixed-width=""></button>
|
||||||
<!--v-if--><span data-v-c7afcfc4="" data-v-8bf5fe81="" id="volume" class="muted hidden md:flex relative items-center gap-2"><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Unmute"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Mute" style="display: none;"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><input data-v-c7afcfc4="" class="plyr__volume !w-[120px] before:absolute before:left-0 before:right-0 before:top-[-12px] before:bottom-[-12px]" max="10" role="slider" step="0.1" title="Volume" type="range"></span>
|
<!--v-if--><span data-v-c7afcfc4="" data-v-8bf5fe81="" id="volume" class="muted hidden md:flex relative items-center gap-2"><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Unmute"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><button data-v-c7afcfc4="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" tabindex="0" title="Mute" style="display: none;"><br data-v-c7afcfc4="" data-testid="Icon" icon="[object Object]" fixed-width=""></button><input data-v-c7afcfc4="" class="plyr__volume !w-[120px] before:absolute before:left-0 before:right-0 before:top-[-12px] before:bottom-[-12px]" max="10" role="slider" step="0.1" title="Volume" type="range"></span>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders with a current song 1`] = `
|
exports[`renders with a current playable 1`] = `
|
||||||
<div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center">
|
<div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center">
|
||||||
<div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-base" type="button" title="Unlike Fahrstuhl to Heaven by Led Zeppelin"><br data-testid="Icon" icon="[object Object]"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
|
<div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-base" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
|
||||||
<path d="m17 2 4 4-4 4"></path>
|
<path d="m17 2 4 4-4 4"></path>
|
||||||
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
|
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
|
||||||
<path d="m7 22-4-4 4-4"></path>
|
<path d="m7 22-4-4 4-4"></path>
|
||||||
|
@ -11,9 +11,9 @@ exports[`renders with a current song 1`] = `
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`renders without a current song 1`] = `
|
exports[`renders without a current playable 1`] = `
|
||||||
<div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center">
|
<div data-v-2e8b419d="" class="playback-controls flex flex-1 flex-col justify-center">
|
||||||
<div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" type="button"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next song"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
|
<div data-v-2e8b419d="" class="flex items-center justify-between md:justify-center gap-5 md:gap-12 px-4 md:px-0"><button data-v-2e8b419d="" type="button"></button><!-- a placeholder to maintain the asymmetric layout --><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play previous in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><br data-v-2e8b419d="" data-testid="PlayButton"><button data-v-2e8b419d="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary text-2xl" type="button" title="Play next in queue"><br data-v-2e8b419d="" data-testid="Icon" icon="[object Object]"></button><button data-v-cab48a7c="" data-v-2e8b419d="" class="opacity-30 text-base" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button"><svg data-v-cab48a7c="" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="16" class="lucide lucide-repeat-icon">
|
||||||
<path d="m17 2 4 4-4 4"></path>
|
<path d="m17 2 4 4-4 4"></path>
|
||||||
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
|
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
|
||||||
<path d="m7 22-4-4 4-4"></path>
|
<path d="m7 22-4-4 4-4"></path>
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders with current song 1`] = `
|
exports[`renders with current song 1`] = `
|
||||||
<div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
|
<div data-v-91ed60f7="" class="playing song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5" draggable="true"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
|
||||||
<div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block">
|
<div data-v-91ed60f7="" class="meta overflow-hidden hidden md:block">
|
||||||
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="/#/artist/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a>
|
<h3 data-v-91ed60f7="" class="title text-ellipsis overflow-hidden whitespace-nowrap">Fahrstuhl zum Mond</h3><a data-v-91ed60f7="" href="#/artist/10" class="artist text-ellipsis overflow-hidden whitespace-nowrap block text-[0.9rem] !text-k-text-secondary hover:!text-k-accent">Led Zeppelin</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`renders with no current song 1`] = `
|
exports[`renders with no current song 1`] = `
|
||||||
<div data-v-91ed60f7="" class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-80 gap-5" draggable="false"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
|
<div data-v-91ed60f7="" class="song-info px-6 py-0 flex items-center content-start w-[84px] md:w-[420px] gap-5" draggable="false"><span data-v-91ed60f7="" class="album-thumb block h-[55%] md:h-3/4 aspect-square rounded-full bg-cover"></span>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
|
v-if="playable"
|
||||||
:class="{ 'showing-pane': activeTab }"
|
:class="{ 'showing-pane': activeTab }"
|
||||||
class="fixed sm:relative top-0 w-screen md:w-auto flex flex-col md:flex-row-reverse z-[2] text-k-text-secondary"
|
class="fixed sm:relative top-0 w-screen md:w-auto flex flex-col md:flex-row-reverse z-[2] text-k-text-secondary"
|
||||||
>
|
>
|
||||||
|
@ -62,7 +63,7 @@
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable as Song" />
|
<YouTubeVideoList v-if="shouldShowYouTubeTab" :song="playable" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
@ -70,7 +71,7 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import isMobile from 'ismobilejs'
|
import isMobile from 'ismobilejs'
|
||||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref, Ref, watch } from 'vue'
|
||||||
import { albumStore, artistStore, preferenceStore } from '@/stores'
|
import { albumStore, artistStore, preferenceStore } from '@/stores'
|
||||||
import { useErrorHandler, useThirdPartyServices } from '@/composables'
|
import { useErrorHandler, useThirdPartyServices } from '@/composables'
|
||||||
import { isSong, requireInjection } from '@/utils'
|
import { isSong, requireInjection } from '@/utils'
|
||||||
|
@ -89,7 +90,7 @@ const ExtraDrawerTabHeader = defineAsyncComponent(() => import('./ExtraDrawerTab
|
||||||
|
|
||||||
const { useYouTube } = useThirdPartyServices()
|
const { useYouTube } = useThirdPartyServices()
|
||||||
|
|
||||||
const playable = requireInjection(CurrentPlayableKey, ref(undefined))
|
const playable = requireInjection(CurrentPlayableKey, ref(undefined)) as Ref<Song | undefined>
|
||||||
const activeTab = ref<ExtraPanelTab | null>(null)
|
const activeTab = ref<ExtraPanelTab | null>(null)
|
||||||
|
|
||||||
const artist = ref<Artist>()
|
const artist = ref<Artist>()
|
||||||
|
|
|
@ -6,8 +6,6 @@ import { eventBus } from '@/utils'
|
||||||
import Sidebar from './Sidebar.vue'
|
import Sidebar from './Sidebar.vue'
|
||||||
|
|
||||||
const standardItems = [
|
const standardItems = [
|
||||||
'Home',
|
|
||||||
'Current Queue',
|
|
||||||
'All Songs',
|
'All Songs',
|
||||||
'Albums',
|
'Albums',
|
||||||
'Artists',
|
'Artists',
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
</SidebarSectionHeader>
|
</SidebarSectionHeader>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<PlaylistSidebarItem :list="{ name: 'Favorites', songs: favorites }" />
|
<PlaylistSidebarItem :list="{ name: 'Favorites', playables: favorites }" />
|
||||||
<PlaylistSidebarItem :list="{ name: 'Recently Played', songs: [] }" />
|
<PlaylistSidebarItem :list="{ name: 'Recently Played', playables: [] }" />
|
||||||
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder" />
|
<PlaylistFolderSidebarItem v-for="folder in folders" :key="folder.id" :folder="folder" />
|
||||||
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist" />
|
<PlaylistSidebarItem v-for="playlist in orphanPlaylists" :key="playlist.id" :list="playlist" />
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -26,7 +26,7 @@ import SidebarSection from '@/components/layout/main-wrapper/sidebar/SidebarSect
|
||||||
|
|
||||||
const folders = toRef(playlistFolderStore.state, 'folders')
|
const folders = toRef(playlistFolderStore.state, 'folders')
|
||||||
const playlists = toRef(playlistStore.state, 'playlists')
|
const playlists = toRef(playlistStore.state, 'playlists')
|
||||||
const favorites = toRef(favoriteStore.state, 'songs')
|
const favorites = toRef(favoriteStore.state, 'playables')
|
||||||
|
|
||||||
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
|
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => {
|
||||||
if (folder_id === null) return true
|
if (folder_id === null) return true
|
||||||
|
|
|
@ -9,7 +9,7 @@ import CreatePlaylistForm from './CreatePlaylistForm.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('creates playlist with no songs', async () => {
|
it('creates playlist with no playables', async () => {
|
||||||
const folder = factory('playlist-folder')
|
const folder = factory('playlist-folder')
|
||||||
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist'))
|
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist'))
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.queryByTestId('from-songs')).toBeNull()
|
expect(screen.queryByTestId('from-playables')).toBeNull()
|
||||||
|
|
||||||
await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist')
|
await this.type(screen.getByPlaceholderText('Playlist name'), 'My playlist')
|
||||||
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
@ -31,15 +31,15 @@ new class extends UnitTestCase {
|
||||||
}, [])
|
}, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates playlist with songs', async () => {
|
it('creates playlist with playables', async () => {
|
||||||
const songs = factory('song', 3)
|
const playables = factory('song', 3)
|
||||||
const folder = factory('playlist-folder')
|
const folder = factory('playlist-folder')
|
||||||
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist'))
|
const storeMock = this.mock(playlistStore, 'store').mockResolvedValue(factory('playlist'))
|
||||||
|
|
||||||
this.render(CreatePlaylistForm, {
|
this.render(CreatePlaylistForm, {
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>ModalContextKey]: [ref({ folder, songs })]
|
[<symbol>ModalContextKey]: [ref({ folder, playables })]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -51,7 +51,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
expect(storeMock).toHaveBeenCalledWith('My playlist', {
|
||||||
folder_id: folder.id
|
folder_id: folder.id
|
||||||
}, songs)
|
}, playables)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
<header>
|
<header>
|
||||||
<h1>
|
<h1>
|
||||||
New Playlist
|
New Playlist
|
||||||
<span v-if="songs.length" class="text-k-text-secondary" data-testid="from-songs">
|
<span v-if="playables.length" class="text-k-text-secondary" data-testid="from-playables">
|
||||||
from {{ pluralize(songs, 'item') }}
|
from {{ pluralize(playables, noun) }}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
@ -33,9 +33,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, toRef } from 'vue'
|
import { computed, ref, toRef } from 'vue'
|
||||||
import { playlistFolderStore, playlistStore } from '@/stores'
|
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||||
import { pluralize } from '@/utils'
|
import { getPlayableCollectionContentType, pluralize } from '@/utils'
|
||||||
import { useDialogBox, useErrorHandler, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables'
|
import { useDialogBox, useErrorHandler, useMessageToaster, useModal, useOverlay, useRouter } from '@/composables'
|
||||||
|
|
||||||
import Btn from '@/components/ui/form/Btn.vue'
|
import Btn from '@/components/ui/form/Btn.vue'
|
||||||
|
@ -50,7 +50,7 @@ const { go } = useRouter()
|
||||||
const { getFromContext } = useModal()
|
const { getFromContext } = useModal()
|
||||||
|
|
||||||
const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null
|
const targetFolder = getFromContext<PlaylistFolder | null>('folder') ?? null
|
||||||
const songs = getFromContext<Song[]>('songs') ?? []
|
const playables = getFromContext<Playable[]>('playables') ?? []
|
||||||
|
|
||||||
const folderId = ref(targetFolder?.id)
|
const folderId = ref(targetFolder?.id)
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
|
@ -59,13 +59,24 @@ const folders = toRef(playlistFolderStore.state, 'folders')
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
const close = () => emit('close')
|
const close = () => emit('close')
|
||||||
|
|
||||||
|
const noun = computed(() => {
|
||||||
|
switch (getPlayableCollectionContentType(playables)) {
|
||||||
|
case 'songs':
|
||||||
|
return 'song'
|
||||||
|
case 'episodes':
|
||||||
|
return 'song'
|
||||||
|
default:
|
||||||
|
return 'item'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
showOverlay()
|
showOverlay()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playlist = await playlistStore.store(name.value, {
|
const playlist = await playlistStore.store(name.value, {
|
||||||
folder_id: folderId.value
|
folder_id: folderId.value
|
||||||
}, songs)
|
}, playables)
|
||||||
|
|
||||||
close()
|
close()
|
||||||
toastSuccess(`Playlist "${playlist.name}" created.`)
|
toastSuccess(`Playlist "${playlist.name}" created.`)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders the modal 1`] = `
|
exports[`renders the modal 1`] = `
|
||||||
<div data-v-886145d2="" class="collaboration-modal max-w-[640px]" tabindex="0">
|
<div data-v-886145d2="" class="collaboration-modal max-w-[640px]" tabindex="0" data-testid="playlist-collaboration">
|
||||||
<header data-v-886145d2="">
|
<header data-v-886145d2="">
|
||||||
<h1 data-v-886145d2="">Playlist Collaboration</h1>
|
<h1 data-v-886145d2="">Playlist Collaboration</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -54,7 +54,7 @@ import DOMPurify from 'dompurify'
|
||||||
import { orderBy } from 'lodash'
|
import { orderBy } from 'lodash'
|
||||||
import { faBookmark, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
import { faBookmark, faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, defineAsyncComponent, toRefs } from 'vue'
|
import { computed, defineAsyncComponent, toRefs } from 'vue'
|
||||||
import { eventBus, secondsToHis } from '@/utils'
|
import { eventBus, secondsToHumanReadable } from '@/utils'
|
||||||
import { useDraggable } from '@/composables'
|
import { useDraggable } from '@/composables'
|
||||||
import { formatTimeAgo } from '@vueuse/core'
|
import { formatTimeAgo } from '@vueuse/core'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
|
@ -84,9 +84,9 @@ const publicationDateForHumans = computed(() => {
|
||||||
const currentPosition = computed(() => podcast.value.state.progresses[episode.value.id] || 0)
|
const currentPosition = computed(() => podcast.value.state.progresses[episode.value.id] || 0)
|
||||||
|
|
||||||
const timeLeft = computed(() => {
|
const timeLeft = computed(() => {
|
||||||
if (currentPosition.value === 0) return secondsToHis(episode.value.length)
|
if (currentPosition.value === 0) return secondsToHumanReadable(episode.value.length)
|
||||||
const secondsLeft = episode.value.length - currentPosition.value
|
const secondsLeft = episode.value.length - currentPosition.value
|
||||||
return secondsLeft === 0 ? 0 : secondsToHis(secondsLeft)
|
return secondsLeft === 0 ? 0 : secondsToHumanReadable(secondsLeft)
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldShowProgress = computed(() => timeLeft.value !== 0 && episode.value.length && currentPosition.value)
|
const shouldShowProgress = computed(() => timeLeft.value !== 0 && episode.value.length && currentPosition.value)
|
||||||
|
|
|
@ -8,7 +8,7 @@ import FavoritesScreen from './FavoritesScreen.vue'
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('renders a list of favorites', async () => {
|
it('renders a list of favorites', async () => {
|
||||||
favoriteStore.state.songs = factory('song', 13)
|
favoriteStore.state.playables = factory('song', 13)
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -18,7 +18,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty state', async () => {
|
it('shows empty state', async () => {
|
||||||
favoriteStore.state.songs = []
|
favoriteStore.state.playables = []
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
screen.getByTestId('screen-empty-state')
|
screen.getByTestId('screen-empty-state')
|
||||||
|
|
|
@ -93,7 +93,7 @@ const {
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint,
|
onScrollBreakpoint,
|
||||||
sort
|
sort
|
||||||
} = useSongList(toRef(favoriteStore.state, 'songs'), { type: 'Favorites' })
|
} = useSongList(toRef(favoriteStore.state, 'playables'), { type: 'Favorites' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Favorites')
|
const { SongListControls, config } = useSongListControls('Favorites')
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { faTags } from '@fortawesome/free-solid-svg-icons'
|
import { faTags } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { arrayify, eventBus, pluralize, secondsToHumanReadable } from '@/utils'
|
import { eventBus, pluralize, secondsToHumanReadable } from '@/utils'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { genreStore, songStore } from '@/stores'
|
import { genreStore, songStore } from '@/stores'
|
||||||
import { useErrorHandler, useRouter, useSongList, useSongListControls } from '@/composables'
|
import { useErrorHandler, useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
|
|
|
@ -57,10 +57,10 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async renderComponent (songs: Song[]) {
|
private async renderComponent (songs: Playable[]) {
|
||||||
playlist = playlist || factory('playlist')
|
playlist = playlist || factory('playlist')
|
||||||
playlistStore.init([playlist])
|
playlistStore.init([playlist])
|
||||||
playlist.songs = songs
|
playlist.playables = songs
|
||||||
|
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
||||||
|
|
||||||
|
|
|
@ -121,7 +121,7 @@ const {
|
||||||
onScrollBreakpoint,
|
onScrollBreakpoint,
|
||||||
sort: baseSort,
|
sort: baseSort,
|
||||||
config: listConfig
|
config: listConfig
|
||||||
} = useSongList(ref<Song[] | CollaborativeSong[]>([]), { type: 'Playlist' })
|
} = useSongList(ref<Playable[] | CollaborativeSong[]>([]), { type: 'Playlist' })
|
||||||
|
|
||||||
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
||||||
const { removeFromPlaylist } = usePlaylistManagement()
|
const { removeFromPlaylist } = usePlaylistManagement()
|
||||||
|
@ -163,7 +163,7 @@ const sort = (field: MaybeArray<PlayableListSortField> | null, order: SortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// To sort by position, we simply re-assign the songs array from the playlist, which maintains the original order.
|
// To sort by position, we simply re-assign the songs array from the playlist, which maintains the original order.
|
||||||
songs.value = playlist.value!.songs!
|
songs.value = playlist.value!.playables!
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReorder = (target: Playable, type: MoveType) => {
|
const onReorder = (target: Playable, type: MoveType) => {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { commonStore, queueStore } from '@/stores'
|
import { commonStore, queueStore } from '@/stores'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import QueueScreen from './QueueScreen.vue'
|
import Component from './QueueScreen.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
|
@ -46,10 +46,10 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (songs: Song[]) {
|
private renderComponent (playables: Playable[]) {
|
||||||
queueStore.state.playables = songs
|
queueStore.state.playables = playables
|
||||||
|
|
||||||
this.render(QueueScreen, {
|
this.render(Component, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
SongList: this.stub('song-list')
|
SongList: this.stub('song-list')
|
||||||
|
|
|
@ -22,8 +22,8 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async renderComponent (songs: Song[]) {
|
private async renderComponent (playables: Playable[]) {
|
||||||
recentlyPlayedStore.state.playables = songs
|
recentlyPlayedStore.state.playables = playables
|
||||||
const fetchMock = this.mock(recentlyPlayedStore, 'fetch')
|
const fetchMock = this.mock(recentlyPlayedStore, 'fetch')
|
||||||
|
|
||||||
this.render(RecentlyPlayedScreen, {
|
this.render(RecentlyPlayedScreen, {
|
||||||
|
|
|
@ -14,19 +14,21 @@ exports[`renders 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
|
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
|
||||||
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
|
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
|
||||||
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-block relative flex-nowrap" uppercased=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
|
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap" uppercase=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu p-0 hidden">
|
<div>
|
||||||
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
|
<div class="context-menu p-0 hidden">
|
||||||
<section data-v-42061e3e="" class="existing-playlists">
|
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
|
||||||
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 songs to</p>
|
<section data-v-42061e3e="" class="existing-playlists">
|
||||||
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
|
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
|
||||||
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
|
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
|
||||||
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
|
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
|
||||||
</ul>
|
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
|
||||||
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
|
</ul>
|
||||||
|
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,19 +54,21 @@ exports[`renders in Plus edition 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
|
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="controls w-full min-h-[32px] flex justify-between items-center gap-4">
|
||||||
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
|
<div data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative" data-testid="song-list-controls">
|
||||||
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-block relative flex-nowrap" uppercased=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
|
<div class="flex gap-2 flex-wrap"><span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap" uppercase=""><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer btn-shuffle-all" type="button" data-testid="btn-shuffle-all" highlight="" title="Shuffle all. Press Alt/⌥ to change mode."><br data-testid="Icon" icon="[object Object]" fixed-width=""> All </button><!--v-if--><!--v-if--><!--v-if--></span>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu p-0 hidden">
|
<div>
|
||||||
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
|
<div class="context-menu p-0 hidden">
|
||||||
<section data-v-42061e3e="" class="existing-playlists">
|
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0">
|
||||||
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 songs to</p>
|
<section data-v-42061e3e="" class="existing-playlists">
|
||||||
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
|
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 0 items to</p>
|
||||||
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
|
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
|
||||||
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
|
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
|
||||||
</ul>
|
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
|
||||||
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
|
</ul>
|
||||||
|
</section><button data-v-8943c846="" data-v-42061e3e="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer !w-full !border !border-solid !border-white/20" type="button" transparent=""> New Playlist… </button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div><label data-v-8ea4eaa5="" data-v-5691beb5-s="" class="text-k-text-secondary inline-flex items-center text-base"><input data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative align-bottom inline-block w-[32px] h-[20px] bg-gray-400 rounded-full shadow-inner cursor-pointer transition-all duration-200 ease-in-out mr-2 after:h-[16px] after:aspect-square after:absolute after:bg-white after:top-[2px] after:left-[2px] after:rounded-full after:transition-left after:duration-200 after:ease-in-out checked:bg-k-highlight checked:after:left-[14px]" type="checkbox"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">Own songs only</span></label>
|
</div><label data-v-8ea4eaa5="" data-v-5691beb5-s="" class="text-k-text-secondary inline-flex items-center text-base"><input data-v-8ea4eaa5="" data-v-5691beb5-s="" class="relative align-bottom inline-block w-[32px] h-[20px] bg-gray-400 rounded-full shadow-inner cursor-pointer transition-all duration-200 ease-in-out mr-2 after:h-[16px] after:aspect-square after:absolute after:bg-white after:top-[2px] after:left-[2px] after:rounded-full after:transition-left after:duration-200 after:ease-in-out checked:bg-k-highlight checked:after:left-[14px]" type="checkbox"><span data-v-8ea4eaa5="" data-v-5691beb5-s="">Own songs only</span></label>
|
||||||
|
|
|
@ -6,8 +6,8 @@ import SearchSongResultsScreen from './SearchSongResultsScreen.vue'
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('searches for prop query on created', () => {
|
it('searches for prop query on created', () => {
|
||||||
const resetResultMock = this.mock(searchStore, 'resetSongResultState')
|
const resetResultMock = this.mock(searchStore, 'resetPlayableResultState')
|
||||||
const searchMock = this.mock(searchStore, 'songSearch')
|
const searchMock = this.mock(searchStore, 'playableSearch')
|
||||||
|
|
||||||
this.router.activateRoute({ path: 'search-songs', screen: 'Search.Songs' }, { q: 'search me' })
|
this.router.activateRoute({ path: 'search-songs', screen: 'Search.Songs' }, { q: 'search me' })
|
||||||
this.render(SearchSongResultsScreen)
|
this.render(SearchSongResultsScreen)
|
||||||
|
|
|
@ -68,20 +68,20 @@ const {
|
||||||
applyFilter,
|
applyFilter,
|
||||||
sort,
|
sort,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(toRef(searchStore.state, 'songs'), { type: 'Search.Songs' })
|
} = useSongList(toRef(searchStore.state, 'playables'), { type: 'Search.Songs' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Search.Songs')
|
const { SongListControls, config } = useSongListControls('Search.Songs')
|
||||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
searchStore.resetSongResultState()
|
searchStore.resetPlayableResultState()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
q.value = getRouteParam('q') || ''
|
q.value = getRouteParam('q') || ''
|
||||||
if (!q.value) return
|
if (!q.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await searchStore.songSearch(q.value)
|
await searchStore.playableSearch(q.value)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { arrayify, eventBus } from '@/utils'
|
||||||
import Btn from '@/components/ui/form/Btn.vue'
|
import Btn from '@/components/ui/form/Btn.vue'
|
||||||
import AddToMenu from './AddToMenu.vue'
|
import AddToMenu from './AddToMenu.vue'
|
||||||
|
|
||||||
let songs: Song[]
|
let playables: Playable[]
|
||||||
|
|
||||||
const config: AddToMenuConfig = {
|
const config: AddToMenuConfig = {
|
||||||
queue: true,
|
queue: true,
|
||||||
|
@ -48,7 +48,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByTestId(testId))
|
await this.user.click(screen.getByTestId(testId))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(songs)
|
expect(mock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds songs to Favorites', async () => {
|
it('adds songs to Favorites', async () => {
|
||||||
|
@ -57,7 +57,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByTestId('add-to-favorites'))
|
await this.user.click(screen.getByTestId('add-to-favorites'))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(songs)
|
expect(mock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds songs to existing playlist', async () => {
|
it('adds songs to existing playlist', async () => {
|
||||||
|
@ -67,7 +67,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getAllByTestId('add-to-playlist')[1])
|
await this.user.click(screen.getAllByTestId('add-to-playlist')[1])
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], songs)
|
expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates playlist from selected songs', async () => {
|
it('creates playlist from selected songs', async () => {
|
||||||
|
@ -76,16 +76,16 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('New Playlist…'))
|
await this.user.click(screen.getByText('New Playlist…'))
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs)
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, playables)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (customConfig: Partial<AddToMenuConfig> = {}) {
|
private renderComponent (customConfig: Partial<AddToMenuConfig> = {}) {
|
||||||
songs = factory('song', 5)
|
playables = factory('song', 5)
|
||||||
|
|
||||||
return this.render(AddToMenu, {
|
return this.render(AddToMenu, {
|
||||||
props: {
|
props: {
|
||||||
songs,
|
playables,
|
||||||
config: Object.assign(clone(config), customConfig),
|
config: Object.assign(clone(config), customConfig),
|
||||||
showing: true
|
showing: true
|
||||||
},
|
},
|
||||||
|
|
|
@ -124,7 +124,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async renderComponent (_songs: Song | Song[], initialTab: EditSongFormTabName = 'details') {
|
private async renderComponent (_songs: MaybeArray<Song>, initialTab: EditSongFormTabName = 'details') {
|
||||||
songs = arrayify(_songs)
|
songs = arrayify(_songs)
|
||||||
|
|
||||||
const rendered = this.render(EditSongForm, {
|
const rendered = this.render(EditSongForm, {
|
||||||
|
|
|
@ -7,9 +7,9 @@ import { screen, waitFor } from '@testing-library/vue'
|
||||||
import { downloadService, playbackService } from '@/services'
|
import { downloadService, playbackService } from '@/services'
|
||||||
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
|
import { favoriteStore, playlistStore, queueStore, songStore } from '@/stores'
|
||||||
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
|
import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs'
|
||||||
import PlayableContextMenu from './PlayableContextMenu.vue'
|
import Component from './PlayableContextMenu.vue'
|
||||||
|
|
||||||
let songs: Song[]
|
let playables: Playable[]
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected beforeEach () {
|
protected beforeEach () {
|
||||||
|
@ -17,15 +17,13 @@ new class extends UnitTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('queues and plays', async () => {
|
it('plays', async () => {
|
||||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
|
||||||
const playMock = this.mock(playbackService, 'play')
|
const playMock = this.mock(playbackService, 'play')
|
||||||
const song = factory('song', { playback_state: 'Stopped' })
|
const song = factory('song', { playback_state: 'Stopped' })
|
||||||
await this.renderComponent(song)
|
await this.renderComponent(song)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Play'))
|
await this.user.click(screen.getByText('Play'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenCalledWith(song)
|
|
||||||
expect(playMock).toHaveBeenCalledWith(song)
|
expect(playMock).toHaveBeenCalledWith(song)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -49,20 +47,22 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('goes to album details screen', async () => {
|
it('goes to album details screen', async () => {
|
||||||
const goMock = this.mock(Router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(factory('song'))
|
const song = factory('song')
|
||||||
|
await this.renderComponent(song)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Album'))
|
await this.user.click(screen.getByText('Go to Album'))
|
||||||
|
|
||||||
expect(goMock).toHaveBeenCalledWith(`album/${songs[0].album_id}`)
|
expect(goMock).toHaveBeenCalledWith(`album/${song.album_id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to artist details screen', async () => {
|
it('goes to artist details screen', async () => {
|
||||||
const goMock = this.mock(Router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(factory('song'))
|
const song = factory('song')
|
||||||
|
await this.renderComponent(song)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Artist'))
|
await this.user.click(screen.getByText('Go to Artist'))
|
||||||
|
|
||||||
expect(goMock).toHaveBeenCalledWith(`artist/${songs[0].artist_id}`)
|
expect(goMock).toHaveBeenCalledWith(`artist/${song.artist_id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('downloads', async () => {
|
it('downloads', async () => {
|
||||||
|
@ -71,7 +71,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Download'))
|
await this.user.click(screen.getByText('Download'))
|
||||||
|
|
||||||
expect(downloadMock).toHaveBeenCalledWith(songs)
|
expect(downloadMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queues', async () => {
|
it('queues', async () => {
|
||||||
|
@ -80,17 +80,17 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Queue'))
|
await this.user.click(screen.getByText('Queue'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs)
|
expect(queueMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queues after current song', async () => {
|
it('queues after current', async () => {
|
||||||
this.fillQueue()
|
this.fillQueue()
|
||||||
const queueMock = this.mock(queueStore, 'queueAfterCurrent')
|
const queueMock = this.mock(queueStore, 'queueAfterCurrent')
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByText('After Current Song'))
|
await this.user.click(screen.getByText('After Current'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs)
|
expect(queueMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queues to bottom', async () => {
|
it('queues to bottom', async () => {
|
||||||
|
@ -100,7 +100,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Bottom of Queue'))
|
await this.user.click(screen.getByText('Bottom of Queue'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs)
|
expect(queueMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queues to top', async () => {
|
it('queues to top', async () => {
|
||||||
|
@ -110,7 +110,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Top of Queue'))
|
await this.user.click(screen.getByText('Top of Queue'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenCalledWith(songs)
|
expect(queueMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes from queue', async () => {
|
it('removes from queue', async () => {
|
||||||
|
@ -126,7 +126,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Remove from Queue'))
|
await this.user.click(screen.getByText('Remove from Queue'))
|
||||||
|
|
||||||
expect(removeMock).toHaveBeenCalledWith(songs)
|
expect(removeMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show "Remove from Queue" when not on Queue screen', async () => {
|
it('does not show "Remove from Queue" when not on Queue screen', async () => {
|
||||||
|
@ -148,7 +148,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Favorites'))
|
await this.user.click(screen.getByText('Favorites'))
|
||||||
|
|
||||||
expect(likeMock).toHaveBeenCalledWith(songs)
|
expect(likeMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not have an option to add to favorites for Favorites screen', async () => {
|
it('does not have an option to add to favorites for Favorites screen', async () => {
|
||||||
|
@ -157,7 +157,7 @@ new class extends UnitTestCase {
|
||||||
screen: 'Favorites'
|
screen: 'Favorites'
|
||||||
})
|
})
|
||||||
|
|
||||||
this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
expect(screen.queryByText('Favorites')).toBeNull()
|
expect(screen.queryByText('Favorites')).toBeNull()
|
||||||
})
|
})
|
||||||
|
@ -174,7 +174,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Remove from Favorites'))
|
await this.user.click(screen.getByText('Remove from Favorites'))
|
||||||
|
|
||||||
expect(unlikeMock).toHaveBeenCalledWith(songs)
|
expect(unlikeMock).toHaveBeenCalledWith(playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists and adds to existing playlist', async () => {
|
it('lists and adds to existing playlist', async () => {
|
||||||
|
@ -187,7 +187,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText(playlistStore.state.playlists[0].name))
|
await this.user.click(screen.getByText(playlistStore.state.playlists[0].name))
|
||||||
|
|
||||||
expect(addMock).toHaveBeenCalledWith(playlistStore.state.playlists[0], songs)
|
expect(addMock).toHaveBeenCalledWith(playlistStore.state.playlists[0], playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not list smart playlists', async () => {
|
it('does not list smart playlists', async () => {
|
||||||
|
@ -210,14 +210,14 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
const removeSongsMock = this.mock(playlistStore, 'removeContent')
|
const removeContentMock = this.mock(playlistStore, 'removeContent')
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Remove from Playlist'))
|
await this.user.click(screen.getByText('Remove from Playlist'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(removeSongsMock).toHaveBeenCalledWith(playlist, songs)
|
expect(removeContentMock).toHaveBeenCalledWith(playlist, playables)
|
||||||
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_SONGS_REMOVED', playlist, songs)
|
expect(emitMock).toHaveBeenCalledWith('PLAYLIST_CONTENT_REMOVED', playlist, playables)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -239,7 +239,7 @@ new class extends UnitTestCase {
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
await this.user.click(screen.getByText('Edit…'))
|
await this.user.click(screen.getByText('Edit…'))
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', songs)
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not allow edit songs if current user is not admin', async () => {
|
it('does not allow edit songs if current user is not admin', async () => {
|
||||||
|
@ -278,9 +278,9 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(confirmMock).toHaveBeenCalled()
|
expect(confirmMock).toHaveBeenCalled()
|
||||||
expect(deleteMock).toHaveBeenCalledWith(songs)
|
expect(deleteMock).toHaveBeenCalledWith(playables)
|
||||||
expect(toasterMock).toHaveBeenCalledWith('Deleted 5 songs from the filesystem.')
|
expect(toasterMock).toHaveBeenCalledWith('Deleted 5 songs from the filesystem.')
|
||||||
expect(emitMock).toHaveBeenCalledWith('SONGS_DELETED', songs)
|
expect(emitMock).toHaveBeenCalledWith('SONGS_DELETED', playables)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -297,7 +297,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
await this.user.click(screen.getByText('New Playlist…'))
|
await this.user.click(screen.getByText('New Playlist…'))
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, songs)
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_CREATE_PLAYLIST_FORM', null, playables)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('makes songs private', async () => {
|
it('makes songs private', async () => {
|
||||||
|
@ -382,11 +382,11 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async renderComponent (_songs?: Song | Song[]) {
|
private async renderComponent (_playables?: MaybeArray<Playable>) {
|
||||||
songs = arrayify(_songs || factory('song', 5))
|
playables = arrayify(_playables || factory('song', 5))
|
||||||
|
|
||||||
const rendered = this.render(PlayableContextMenu)
|
const rendered = this.render(Component)
|
||||||
eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, songs)
|
eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playables)
|
||||||
await this.tick(2)
|
await this.tick(2)
|
||||||
|
|
||||||
return rendered
|
return rendered
|
||||||
|
|
|
@ -151,12 +151,12 @@ const contentType = computed(() => getPlayableCollectionContentType(playables.va
|
||||||
const visibilityActions = computed(() => {
|
const visibilityActions = computed(() => {
|
||||||
if (contentType.value !== 'songs' || !canEditSongs.value) return []
|
if (contentType.value !== 'songs' || !canEditSongs.value) return []
|
||||||
|
|
||||||
const visibilities = new Set(playables.value.map((song => (song as Song).is_public
|
const visibilities = Array.from(new Set((playables.value as Song[]).map((song => song.is_public
|
||||||
? 'public'
|
? 'public'
|
||||||
: 'private'
|
: 'private'
|
||||||
)))
|
))))
|
||||||
|
|
||||||
if (visibilities.size === 2) {
|
if (visibilities.length === 2) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Unmark as Private',
|
label: 'Unmark as Private',
|
||||||
|
@ -201,7 +201,12 @@ const doPlayback = () => trigger(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const openEditForm = () => trigger(() => playables.value.length && eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', playables.value))
|
const openEditForm = () => trigger(() =>
|
||||||
|
playables.value.length
|
||||||
|
&& contentType.value === 'songs'
|
||||||
|
&& eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', playables.value as Song[])
|
||||||
|
)
|
||||||
|
|
||||||
const viewAlbum = (song: Song) => trigger(() => go(`album/${song.album_id}`))
|
const viewAlbum = (song: Song) => trigger(() => go(`album/${song.album_id}`))
|
||||||
const viewArtist = (song: Song) => trigger(() => go(`artist/${song.artist_id}`))
|
const viewArtist = (song: Song) => trigger(() => go(`artist/${song.artist_id}`))
|
||||||
const viewPodcast = (episode: Episode) => trigger(() => go(`podcasts/${episode.podcast_id}`))
|
const viewPodcast = (episode: Episode) => trigger(() => go(`podcasts/${episode.podcast_id}`))
|
||||||
|
@ -219,7 +224,7 @@ const removeFromQueue = () => trigger(() => queueStore.unqueue(playables.value))
|
||||||
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(playables.value))
|
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(playables.value))
|
||||||
|
|
||||||
const copyUrl = () => trigger(async () => {
|
const copyUrl = () => trigger(async () => {
|
||||||
await copyText(songStore.getShareableUrl(playables.value[0] as Song))
|
await copyText(songStore.getShareableUrl(playables.value[0]))
|
||||||
toastSuccess('URL copied to clipboard.')
|
toastSuccess('URL copied to clipboard.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { queueStore } from '@/stores'
|
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import SongCard from './SongCard.vue'
|
import SongCard from './SongCard.vue'
|
||||||
|
|
||||||
let song: Song
|
let playable: Playable
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
|
@ -17,19 +16,17 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queues and plays on double-click', async () => {
|
it('queues and plays on double-click', async () => {
|
||||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
|
||||||
const playMock = this.mock(playbackService, 'play')
|
const playMock = this.mock(playbackService, 'play')
|
||||||
this.renderComponent()
|
this.renderComponent()
|
||||||
|
|
||||||
await this.user.dblClick(screen.getByRole('article'))
|
await this.user.dblClick(screen.getByRole('article'))
|
||||||
|
|
||||||
expect(queueMock).toHaveBeenCalledWith(song)
|
expect(playMock).toHaveBeenCalledWith(playable)
|
||||||
expect(playMock).toHaveBeenCalledWith(song)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (playbackState: PlaybackState = 'Stopped') {
|
private renderComponent (playbackState: PlaybackState = 'Stopped') {
|
||||||
song = factory('song', {
|
playable = factory('song', {
|
||||||
playback_state: playbackState,
|
playback_state: playbackState,
|
||||||
play_count: 10,
|
play_count: 10,
|
||||||
title: 'Foo bar'
|
title: 'Foo bar'
|
||||||
|
@ -37,8 +34,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
return this.render(SongCard, {
|
return this.render(SongCard, {
|
||||||
props: {
|
props: {
|
||||||
song,
|
playable
|
||||||
topPlayCount: 42
|
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
@dblclick.prevent="play"
|
@dblclick.prevent="play"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<SongThumbnail :song="playable" />
|
<SongThumbnail :playable="playable" />
|
||||||
</span>
|
</span>
|
||||||
<main class="flex-1 flex items-start overflow-hidden gap-2">
|
<main class="flex-1 flex items-start overflow-hidden gap-2">
|
||||||
<div class="flex-1 space-y-1 overflow-hidden">
|
<div class="flex-1 space-y-1 overflow-hidden">
|
||||||
|
@ -39,7 +39,7 @@
|
||||||
- {{ pluralize(playable.play_count, 'play') }}
|
- {{ pluralize(playable.play_count, 'play') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<LikeButton :song="playable" class="opacity-0 text-k-text-secondary group-hover:opacity-100" />
|
<LikeButton :playable="playable" class="opacity-0 text-k-text-secondary group-hover:opacity-100" />
|
||||||
</main>
|
</main>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
@ -47,7 +47,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed, toRefs } from 'vue'
|
||||||
import { eventBus, isEpisode, isSong, pluralize } from '@/utils'
|
import { eventBus, isEpisode, isSong, pluralize } from '@/utils'
|
||||||
import { queueStore } from '@/stores'
|
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { useAuthorization, useDraggable, useKoelPlus } from '@/composables'
|
import { useAuthorization, useDraggable, useKoelPlus } from '@/composables'
|
||||||
|
|
||||||
|
|
|
@ -3,30 +3,28 @@ import factory from '@/__tests__/factory'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import { favoriteStore } from '@/stores'
|
import { favoriteStore } from '@/stores'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import SongLikeButton from './SongLikeButton.vue'
|
import Component from './SongLikeButton.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it.each<[string, boolean]>([
|
it.each<[string, boolean]>([['Unlike', true], ['Like', false]])('%s', async (name, liked) => {
|
||||||
['Unlike Foo by Bar', true],
|
|
||||||
['Like Foo by Bar', false]
|
|
||||||
])('%s', async (name: string, liked: boolean) => {
|
|
||||||
const mock = this.mock(favoriteStore, 'toggleOne')
|
const mock = this.mock(favoriteStore, 'toggleOne')
|
||||||
const song = factory('song', {
|
|
||||||
|
const playable = factory('song', {
|
||||||
liked,
|
liked,
|
||||||
title: 'Foo',
|
title: 'Foo',
|
||||||
artist_name: 'Bar'
|
artist_name: 'Bar'
|
||||||
})
|
})
|
||||||
|
|
||||||
this.render(SongLikeButton, {
|
this.render(Component, {
|
||||||
props: {
|
props: {
|
||||||
song
|
playable
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name }))
|
await this.user.click(screen.getByRole('button', { name }))
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith(song)
|
expect(mock).toHaveBeenCalledWith(playable)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<FooterExtraControlBtn :title="title" @click.stop="toggleLike">
|
<FooterExtraControlBtn :title="title" @click.stop="toggleLike">
|
||||||
<Icon v-if="song.liked" :icon="faHeart" />
|
<Icon v-if="playable.liked" :icon="faHeart" />
|
||||||
<Icon v-else :icon="faEmptyHeart" />
|
<Icon v-else :icon="faEmptyHeart" />
|
||||||
</FooterExtraControlBtn>
|
</FooterExtraControlBtn>
|
||||||
</template>
|
</template>
|
||||||
|
@ -13,10 +13,10 @@ import { favoriteStore } from '@/stores'
|
||||||
|
|
||||||
import FooterExtraControlBtn from '@/components/layout/app-footer/FooterButton.vue'
|
import FooterExtraControlBtn from '@/components/layout/app-footer/FooterButton.vue'
|
||||||
|
|
||||||
const props = defineProps<{ song: Playable }>()
|
const props = defineProps<{ playable: Playable }>()
|
||||||
const { song } = toRefs(props)
|
const { playable } = toRefs(props)
|
||||||
|
|
||||||
const title = computed(() => song.value.liked ? 'Unlike' : 'Like')
|
const title = computed(() => playable.value.liked ? 'Unlike' : 'Like')
|
||||||
|
|
||||||
const toggleLike = () => favoriteStore.toggleOne(song.value)
|
const toggleLike = () => favoriteStore.toggleOne(playable.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -80,7 +80,7 @@ new class extends UnitTestCase {
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>PlayablesKey]: [ref(songs)],
|
[<symbol>PlayablesKey]: [ref(songs)],
|
||||||
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Song[]) => (selectedPlayables = value)],
|
[<symbol>SelectedPlayablesKey]: [ref(selectedPlayables), (value: Playable[]) => (selectedPlayables = value)],
|
||||||
[<symbol>PlayableListConfigKey]: [config],
|
[<symbol>PlayableListConfigKey]: [config],
|
||||||
[<symbol>PlayableListContextKey]: [context],
|
[<symbol>PlayableListContextKey]: [context],
|
||||||
[<symbol>PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)],
|
[<symbol>PlayableListSortFieldKey]: [sortFieldRef, (value: PlayableListSortField) => (sortFieldRef.value = value)],
|
||||||
|
|
|
@ -20,7 +20,7 @@ new class extends UnitTestCase {
|
||||||
liked: true
|
liked: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const { html } = await this.renderComponent(song)
|
const { html } = this.renderComponent(song)
|
||||||
expect(html()).toMatchSnapshot()
|
expect(html()).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -31,11 +31,11 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (song?: Song) {
|
private renderComponent (playable?: Playable) {
|
||||||
song = song ?? factory('song')
|
playable = playable ?? factory('song')
|
||||||
|
|
||||||
row = {
|
row = {
|
||||||
playable: song,
|
playable,
|
||||||
selected: false
|
selected: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,19 +11,19 @@
|
||||||
@dblclick.prevent.stop="play"
|
@dblclick.prevent.stop="play"
|
||||||
>
|
>
|
||||||
<span class="track-number">
|
<span class="track-number">
|
||||||
<SoundBars v-if="song.playback_state === 'Playing'" />
|
<SoundBars v-if="playable.playback_state === 'Playing'" />
|
||||||
<span v-else class="text-k-text-secondary">
|
<span v-else class="text-k-text-secondary">
|
||||||
<Icon :icon="faPodcast" v-if="isEpisode(song)" />
|
<template v-if="isSong(playable)">{{ playable.track || '' }}</template>
|
||||||
<template v-else>{{ song.track || '' }}</template>
|
<Icon :icon="faPodcast" v-else />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="thumbnail leading-none">
|
<span class="thumbnail leading-none">
|
||||||
<SongThumbnail :song="song" />
|
<SongThumbnail :playable="playable" />
|
||||||
</span>
|
</span>
|
||||||
<span class="title-artist flex flex-col gap-2 overflow-hidden">
|
<span class="title-artist flex flex-col gap-2 overflow-hidden">
|
||||||
<span class="title text-k-text-primary !flex gap-2 items-center">
|
<span class="title text-k-text-primary !flex gap-2 items-center">
|
||||||
<ExternalMark v-if="external" class="!inline-block" />
|
<ExternalMark v-if="external" class="!inline-block" />
|
||||||
{{ song.title }}
|
{{ playable.title }}
|
||||||
</span>
|
</span>
|
||||||
<span class="artist">{{ artist }}</span>
|
<span class="artist">{{ artist }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -32,11 +32,11 @@
|
||||||
<span class="collaborator">
|
<span class="collaborator">
|
||||||
<UserAvatar :user="collaborator" width="24" />
|
<UserAvatar :user="collaborator" width="24" />
|
||||||
</span>
|
</span>
|
||||||
<span :title="song.collaboration.added_at" class="added-at">{{ song.collaboration.fmt_added_at }}</span>
|
<span :title="playable.collaboration.added_at" class="added-at">{{ playable.collaboration.fmt_added_at }}</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="time">{{ fmtLength }}</span>
|
<span class="time">{{ fmtLength }}</span>
|
||||||
<span class="extra">
|
<span class="extra">
|
||||||
<LikeButton :song="song" />
|
<LikeButton :playable="playable" />
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
@ -64,21 +64,23 @@ const { item } = toRefs(props)
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'play', playable: Playable): void }>()
|
const emit = defineEmits<{ (e: 'play', playable: Playable): void }>()
|
||||||
|
|
||||||
const song = computed<Playable | CollaborativeSong>(() => item.value.playable)
|
const playable = computed<Playable | CollaborativeSong>(() => item.value.playable)
|
||||||
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
const playing = computed(() => ['Playing', 'Paused'].includes(playable.value.playback_state!))
|
||||||
|
|
||||||
const external = computed(() => {
|
const external = computed(() => {
|
||||||
if (!isSong(song.value)) return false
|
if (!isSong(playable.value)) return false
|
||||||
return isPlus.value && song.value.owner_id !== currentUser.value?.id
|
return isPlus.value && playable.value.owner_id !== currentUser.value?.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const fmtLength = secondsToHis(song.value.length)
|
const fmtLength = secondsToHis(playable.value.length)
|
||||||
const artist = computed(() => getPlayableProp(song.value, 'artist_name', 'podcast_author'))
|
const artist = computed(() => getPlayableProp(playable.value, 'artist_name', 'podcast_author'))
|
||||||
const album = computed(() => getPlayableProp(song.value, 'album_name', 'podcast_title'))
|
const album = computed(() => getPlayableProp(playable.value, 'album_name', 'podcast_title'))
|
||||||
|
|
||||||
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => (song.value as CollaborativeSong).collaboration.user)
|
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(
|
||||||
|
() => (playable.value as CollaborativeSong).collaboration.user
|
||||||
|
)
|
||||||
|
|
||||||
const play = () => emit('play', song.value)
|
const play = () => emit('play', playable.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|
|
@ -4,37 +4,37 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import { queueStore } from '@/stores'
|
import { queueStore } from '@/stores'
|
||||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
import Component from './SongThumbnail.vue'
|
||||||
|
|
||||||
let song: Song
|
let playable: Playable
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it.each<[PlaybackState, string, MethodOf<typeof playbackService>]>([
|
it.each<[PlaybackState, MethodOf<typeof playbackService>]>([
|
||||||
['Stopped', 'Play', 'play'],
|
['Stopped', 'play'],
|
||||||
['Playing', 'Pause', 'pause'],
|
['Playing', 'pause'],
|
||||||
['Paused', 'Resume', 'resume']
|
['Paused', 'resume']
|
||||||
])('if state is currently "%s", %ss', async (state, name, method) => {
|
])('if state is currently "%s", %ss', async (state, method) => {
|
||||||
this.mock(queueStore, 'queueIfNotQueued')
|
this.mock(queueStore, 'queueIfNotQueued')
|
||||||
const playbackMock = this.mock(playbackService, method)
|
const playbackMock = this.mock(playbackService, method)
|
||||||
this.renderComponent(state)
|
this.renderComponent(state)
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name }))
|
await this.user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
expect(playbackMock).toHaveBeenCalled()
|
expect(playbackMock).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (playbackState: PlaybackState = 'Stopped') {
|
private renderComponent (playbackState: PlaybackState = 'Stopped') {
|
||||||
song = factory('song', {
|
playable = factory('song', {
|
||||||
playback_state: playbackState,
|
playback_state: playbackState,
|
||||||
play_count: 10,
|
play_count: 10,
|
||||||
title: 'Foo bar'
|
title: 'Foo bar'
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.render(SongThumbnail, {
|
return this.render(Component, {
|
||||||
props: {
|
props: {
|
||||||
song
|
playable
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
class="absolute flex opacity-0 items-center justify-center w-[24px] aspect-square rounded-full top-1/2
|
class="absolute flex opacity-0 items-center justify-center w-[24px] aspect-square rounded-full top-1/2
|
||||||
left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"
|
left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"
|
||||||
>
|
>
|
||||||
<Icon v-if="song.playback_state === 'Playing'" :icon="faPause" class="text-white" />
|
<Icon v-if="playable.playback_state === 'Playing'" :icon="faPause" class="text-white" />
|
||||||
<Icon v-else :icon="faPlay" class="text-white ml-0.5" />
|
<Icon v-else :icon="faPlay" class="text-white ml-0.5" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -29,19 +29,19 @@ import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { defaultCover, getPlayableProp } from '@/utils'
|
import { defaultCover, getPlayableProp } from '@/utils'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
|
|
||||||
const props = defineProps<{ song: Playable }>()
|
const props = defineProps<{ playable: Playable }>()
|
||||||
const { song } = toRefs(props)
|
const { playable } = toRefs(props)
|
||||||
|
|
||||||
const src = computed(() => getPlayableProp<string>(song.value, 'album_cover', 'episode_image'))
|
const src = computed(() => getPlayableProp<string>(playable.value, 'album_cover', 'episode_image'))
|
||||||
|
|
||||||
const play = () => playbackService.play(song.value)
|
const play = () => playbackService.play(playable.value)
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (song.value.playback_state === 'Playing') {
|
if (playable.value.playback_state === 'Playing') {
|
||||||
return 'Pause'
|
return 'Pause'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (song.value.playback_state === 'Paused') {
|
if (playable.value.playback_state === 'Paused') {
|
||||||
return 'Resume'
|
return 'Resume'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,9 +49,10 @@ const title = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const playOrPause = () => {
|
const playOrPause = () => {
|
||||||
if (song.value.playback_state === 'Stopped') {
|
if (playable.value.playback_state === 'Stopped') {
|
||||||
|
// @todo play at the right playback position for Episodes
|
||||||
play()
|
play()
|
||||||
} else if (song.value.playback_state === 'Paused') {
|
} else if (playable.value.playback_state === 'Paused') {
|
||||||
playbackService.resume()
|
playbackService.resume()
|
||||||
} else {
|
} else {
|
||||||
playbackService.pause()
|
playbackService.pause()
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0" showing="true">
|
<div data-v-42061e3e="" class="add-to w-full max-w-[256px] min-w-[200px] p-3 space-y-3" data-testid="add-to-menu" tabindex="0" showing="true">
|
||||||
<section data-v-42061e3e="" class="existing-playlists">
|
<section data-v-42061e3e="" class="existing-playlists">
|
||||||
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 5 songs to</p>
|
<p data-v-42061e3e="" class="mb-2 text-[0.9rem]">Add 5 items to</p>
|
||||||
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
|
<ul data-v-42061e3e="" class="relative max-h-48 overflow-y-scroll space-y-1.5">
|
||||||
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
|
<li data-v-42061e3e="" data-testid="queue" tabindex="0">Queue</li>
|
||||||
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
|
<li data-v-42061e3e="" class="favorites" data-testid="add-to-favorites" tabindex="0"> Favorites </li>
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0">
|
<div class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0" data-testid="song-list" tabindex="0">
|
||||||
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-k-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span>
|
<div class="sortable song-list-header flex z-[2] bg-k-bg-secondary"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-k-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album">Album<span class="ml-2"><!--v-if--><!--v-if--></span></span>
|
||||||
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc"></span>
|
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc" content-type="songs"></span>
|
||||||
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<article data-v-9a89d9b9="" class="playing song-item text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-9a89d9b9="" class="track-number"><i data-v-47e95701="" data-v-9a89d9b9="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-9a89d9b9="" class="thumbnail"><div data-v-9a89d9b9="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="song-thumbnail group w-[48px] min-w-[48px] aspect-square bg-cover relative rounded overflow-hidden flex items-center justify-center before:absolute before:w-full before:h-full before:pointer-events-none before:z-[1] before:left-0 before:top-0 before:bg-black before:opacity-0 hover:before:opacity-70"><img alt="Test Album" src="https://example.com/cover.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none" loading="lazy"><a title="Pause" class="w-7 h-7 text-base z-[1] text-k-text-primary duration-300 justify-center items-center rounded-full bg-black/50 pl-0.5 flex opacity-0 group-hover:opacity-100" role="button"><br data-testid="Icon" icon="[object Object]" class="text-k-highlight"></a></div></span><span data-v-9a89d9b9="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-9a89d9b9="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-9a89d9b9="" class="artist">Test Artist</span></span><span data-v-9a89d9b9="" class="album">Test Album</span>
|
<article data-v-9a89d9b9="" class="playing song-item group text-k-text-secondary border-b border-k-border !max-w-full h-[64px] flex items-center transition-[background-color,_box-shadow] ease-in-out duration-200 focus:rounded-md focus focus-within:rounded-md focus:ring-inset focus:ring-1 focus:!ring-k-accent focus-within:ring-inset focus-within:ring-1 focus-within:!ring-k-accent hover:bg-white/5 hover:ring-inset hover:ring-1 hover:ring-white/10 hover:rounded-md" data-testid="song-item" tabindex="0"><span data-v-9a89d9b9="" class="track-number"><i data-v-47e95701="" data-v-9a89d9b9="" class="relative flex gap-1 content-between w-[13px] aspect-square"><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span data-v-9a89d9b9="" class="thumbnail leading-none"><button data-v-9a89d9b9="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" title="Pause" class="song-thumbnail w-[48px] aspect-square bg-cover relative rounded overflow-hidden active:scale-95"><img alt="Cover image" src="https://example.com/cover.jpg" class="w-full aspect-square object-cover" loading="lazy"><span class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span class="absolute flex opacity-0 items-center justify-center w-[24px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-testid="Icon" icon="[object Object]" class="text-white"></span></button></span><span data-v-9a89d9b9="" class="title-artist flex flex-col gap-2 overflow-hidden"><span data-v-9a89d9b9="" class="title text-k-text-primary !flex gap-2 items-center"><!--v-if--> Test Song</span><span data-v-9a89d9b9="" class="artist">Test Artist</span></span><span data-v-9a89d9b9="" class="album">Test Album</span>
|
||||||
<!--v-if--><span data-v-9a89d9b9="" class="time">16:40</span><span data-v-9a89d9b9="" class="extra"><button data-v-9a89d9b9="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike Test Song by Test Artist"><br data-testid="Icon" icon="[object Object]"></button></span>
|
<!--v-if--><span data-v-9a89d9b9="" class="time">16:40</span><span data-v-9a89d9b9="" class="extra"><button data-v-9a89d9b9="" class="transition-[color] duration-200 ease-in-out text-k-text-secondary hover:text-k-text-primary" type="button" title="Unlike"><br data-testid="Icon" icon="[object Object]"></button></span>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -118,11 +118,11 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderComponent (currentSong: Song | null = null) {
|
private renderComponent (currentPlayable: Playable | null = null) {
|
||||||
return this.render(FooterPlayButton, {
|
return this.render(FooterPlayButton, {
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>CurrentPlayableKey]: ref(currentSong)
|
[<symbol>CurrentPlayableKey]: ref(currentPlayable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -31,30 +31,30 @@ const toggle = async () => song.value ? playbackService.toggle() : initiatePlayb
|
||||||
const initiatePlayback = async () => {
|
const initiatePlayback = async () => {
|
||||||
if (libraryEmpty.value) return
|
if (libraryEmpty.value) return
|
||||||
|
|
||||||
let songs: Song[]
|
let playables: Playable[]
|
||||||
|
|
||||||
switch (getCurrentScreen()) {
|
switch (getCurrentScreen()) {
|
||||||
case 'Album':
|
case 'Album':
|
||||||
songs = await songStore.fetchForAlbum(parseInt(getRouteParam('id')!))
|
playables = await songStore.fetchForAlbum(parseInt(getRouteParam('id')!))
|
||||||
break
|
break
|
||||||
case 'Artist':
|
case 'Artist':
|
||||||
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
|
playables = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
|
||||||
break
|
break
|
||||||
case 'Playlist':
|
case 'Playlist':
|
||||||
songs = await songStore.fetchForPlaylist(getRouteParam('id')!)
|
playables = await songStore.fetchForPlaylist(getRouteParam('id')!)
|
||||||
break
|
break
|
||||||
case 'Favorites':
|
case 'Favorites':
|
||||||
songs = await favoriteStore.fetch()
|
playables = await favoriteStore.fetch()
|
||||||
break
|
break
|
||||||
case 'RecentlyPlayed':
|
case 'RecentlyPlayed':
|
||||||
songs = await recentlyPlayedStore.fetch()
|
playables = await recentlyPlayedStore.fetch()
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
songs = await queueStore.fetchRandom()
|
playables = await queueStore.fetchRandom()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackService.queueAndPlay(songs)
|
playbackService.queueAndPlay(playables)
|
||||||
go('queue')
|
go('queue')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders for album 1`] = `<div data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="IV" src="https://test/album.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>`;
|
exports[`renders for album 1`] = `<button data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="https://test/album.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs in the album IV</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>`;
|
||||||
|
|
||||||
exports[`renders for artist 1`] = `<div data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md after:block after:pt-[100%]" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Led Zeppelin" src="https://test/blimp.jpg" class="w-full h-full object-cover absolute left-0 top-0 pointer-events-none before:absolute before:w-full before:h-full before:opacity-0 before:z-[1] before-top-0" loading="lazy"><a data-v-40f79232="" class="control control-play h-full w-full absolute flex justify-center items-center" role="button"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="icon opacity-0 w-1/2 h-1/2 flex justify-center items-center pointer-events-none pl-[4%] rounded-full after:w-full after:h-full"></span></a></div>`;
|
exports[`renders for artist 1`] = `<button data-v-40f79232="" class="thumbnail relative w-full aspect-square bg-no-repeat bg-cover bg-center overflow-hidden rounded-md active:scale-95" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-40f79232="" alt="Thumbnail" src="https://test/blimp.jpg" class="w-full aspect-square object-cover" loading="lazy"><span data-v-40f79232="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-40f79232="" class="absolute top-0 left-0 w-full h-full group-hover:bg-black/40 z-10"></span><span data-v-40f79232="" class="play-icon absolute flex opacity-0 items-center justify-center w-[32px] aspect-square rounded-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-k-highlight group-hover:opacity-100 duration-500 transition z-20"><br data-v-40f79232="" data-testid="Icon" icon="[object Object]" class="ml-1 text-white" size="lg"></span></button>`;
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `<span data-v-cf9b67d8="" class="btn-group inline-block relative flex-nowrap"><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Green</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Orange</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Blue</button></span>`;
|
exports[`renders 1`] = `<span data-v-cf9b67d8="" class="btn-group inline-flex relative flex-nowrap"><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Green</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Orange</button><button data-v-8943c846="" class="text-base text-k-text-primary bg-k-primary px-4 py-2.5 rounded cursor-pointer" type="button">Blue</button></span>`;
|
||||||
|
|
|
@ -119,7 +119,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveDroppedSongs = async (event: DragEvent) => {
|
const resolveDroppedItems = async (event: DragEvent) => {
|
||||||
try {
|
try {
|
||||||
const type = getDragType(event)
|
const type = getDragType(event)
|
||||||
if (!type) return <Playable[]>[]
|
if (!type) return <Playable[]>[]
|
||||||
|
@ -153,6 +153,6 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
||||||
acceptsDrop,
|
acceptsDrop,
|
||||||
getDroppedData,
|
getDroppedData,
|
||||||
resolveDroppedValue,
|
resolveDroppedValue,
|
||||||
resolveDroppedItems: resolveDroppedSongs
|
resolveDroppedItems
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,16 +6,14 @@ export const usePolicies = () => {
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
|
|
||||||
const currentUserCan = {
|
const currentUserCan = {
|
||||||
editSong: (song: Song | Song[]) => {
|
editSong: (songs: MaybeArray<Song>) => {
|
||||||
if (isAdmin.value) return true
|
if (isAdmin.value) return true
|
||||||
if (!isPlus.value) return false
|
if (!isPlus.value) return false
|
||||||
return arrayify(song).every(s => s.owner_id === currentUser.value.id)
|
return arrayify(songs).every(song => song.owner_id === currentUser.value.id)
|
||||||
},
|
},
|
||||||
|
|
||||||
editPlaylist: (playlist: Playlist) => playlist.user_id === currentUser.value.id,
|
editPlaylist: (playlist: Playlist) => playlist.user_id === currentUser.value.id,
|
||||||
|
|
||||||
uploadSongs: () => isAdmin.value || isPlus.value,
|
uploadSongs: () => isAdmin.value || isPlus.value,
|
||||||
|
|
||||||
changeAlbumOrArtistThumbnails: () => isAdmin.value || isPlus.value // for Plus, the logic is handled in the backend
|
changeAlbumOrArtistThumbnails: () => isAdmin.value || isPlus.value // for Plus, the logic is handled in the backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ export interface Events {
|
||||||
MODAL_SHOW_ADD_USER_FORM: () => void
|
MODAL_SHOW_ADD_USER_FORM: () => void
|
||||||
MODAL_SHOW_INVITE_USER_FORM: () => void
|
MODAL_SHOW_INVITE_USER_FORM: () => void
|
||||||
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
|
MODAL_SHOW_EDIT_USER_FORM: (user: User) => void
|
||||||
MODAL_SHOW_EDIT_SONG_FORM: (songs: Song | Song[], initialTab?: EditSongFormTabName) => void
|
MODAL_SHOW_EDIT_SONG_FORM: (songs: MaybeArray<Song>, initialTab?: EditSongFormTabName) => void
|
||||||
MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder?: PlaylistFolder | null, playables?: MaybeArray<Playable>) => void
|
MODAL_SHOW_CREATE_PLAYLIST_FORM: (folder?: PlaylistFolder | null, playables?: MaybeArray<Playable>) => void
|
||||||
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
|
MODAL_SHOW_EDIT_PLAYLIST_FORM: (playlist: Playlist) => void
|
||||||
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void
|
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void
|
||||||
|
@ -52,9 +52,9 @@ export interface Events {
|
||||||
SOCKET_PLAY_PREV: () => void
|
SOCKET_PLAY_PREV: () => void
|
||||||
SOCKET_PLAYBACK_STOPPED: () => void
|
SOCKET_PLAYBACK_STOPPED: () => void
|
||||||
SOCKET_GET_STATUS: () => void
|
SOCKET_GET_STATUS: () => void
|
||||||
SOCKET_STATUS: (data: { song?: Song, volume: number }) => void
|
SOCKET_STATUS: (data: { song?: Playable, volume: number }) => void
|
||||||
SOCKET_GET_CURRENT_SONG: () => void
|
SOCKET_GET_CURRENT_SONG: () => void
|
||||||
SOCKET_SONG: (song: Song) => void
|
SOCKET_SONG: (song: Playable) => void
|
||||||
SOCKET_SET_VOLUME: (volume: number) => void
|
SOCKET_SET_VOLUME: (volume: number) => void
|
||||||
SOCKET_VOLUME_CHANGED: (volume: number) => void
|
SOCKET_VOLUME_CHANGED: (volume: number) => void
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class="{ 'standalone' : inStandaloneMode }" class="h-screen bg-k-bg-primary">
|
<div :class="{ 'standalone' : inStandaloneMode }" class="h-screen bg-k-bg-primary">
|
||||||
<template v-if="authenticated">
|
<template v-if="authenticated">
|
||||||
<AlbumArtOverlay v-if="showAlbumArtOverlay && state.song" :album="state.song.album_id" />
|
<AlbumArtOverlay v-if="showAlbumArtOverlay && state.song && isSong(state.song)" :album="state.song.album_id" />
|
||||||
|
|
||||||
<main class="h-screen flex flex-col items-center justify-between text-center relative z-[1]">
|
<main class="h-screen flex flex-col items-center justify-between text-center relative z-[1]">
|
||||||
<template v-if="connected">
|
<template v-if="connected">
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
import { authService, socketService } from '@/services'
|
import { authService, socketService } from '@/services'
|
||||||
import { preferenceStore, userStore } from '@/stores'
|
import { preferenceStore, userStore } from '@/stores'
|
||||||
import { defineAsyncComponent, onMounted, provide, reactive, ref, toRef } from 'vue'
|
import { defineAsyncComponent, onMounted, provide, reactive, ref, toRef } from 'vue'
|
||||||
import { logger } from '@/utils'
|
import { isSong, logger } from '@/utils'
|
||||||
import { RemoteState } from '@/remote/types'
|
import { RemoteState } from '@/remote/types'
|
||||||
|
|
||||||
const SongDetails = defineAsyncComponent(() => import('@/remote/components/SongDetails.vue'))
|
const SongDetails = defineAsyncComponent(() => import('@/remote/components/SongDetails.vue'))
|
||||||
|
@ -50,7 +50,7 @@ const onUserLoggedIn = async () => {
|
||||||
|
|
||||||
const state = reactive<RemoteState>({
|
const state = reactive<RemoteState>({
|
||||||
volume: 0,
|
volume: 0,
|
||||||
song: null as Song | null
|
song: null as Playable | null
|
||||||
})
|
})
|
||||||
|
|
||||||
provide('state', state)
|
provide('state', state)
|
||||||
|
@ -64,7 +64,7 @@ const init = async () => {
|
||||||
.listen('SOCKET_SONG', song => (state.song = song))
|
.listen('SOCKET_SONG', song => (state.song = song))
|
||||||
.listen('SOCKET_PLAYBACK_STOPPED', () => state.song && (state.song.playback_state = 'Stopped'))
|
.listen('SOCKET_PLAYBACK_STOPPED', () => state.song && (state.song.playback_state = 'Stopped'))
|
||||||
.listen('SOCKET_VOLUME_CHANGED', (volume: number) => state.volume = volume)
|
.listen('SOCKET_VOLUME_CHANGED', (volume: number) => state.volume = volume)
|
||||||
.listen('SOCKET_STATUS', (data: { song?: Song, volume: number }) => {
|
.listen('SOCKET_STATUS', (data: { song?: Playable, volume: number }) => {
|
||||||
state.volume = data.volume || 0
|
state.volume = data.volume || 0
|
||||||
state.song = data.song || null
|
state.song = data.song || null
|
||||||
connected.value = true
|
connected.value = true
|
||||||
|
|
|
@ -34,7 +34,7 @@ import { socketService } from '@/services'
|
||||||
|
|
||||||
import VolumeControl from '@/remote/components/VolumeControl.vue'
|
import VolumeControl from '@/remote/components/VolumeControl.vue'
|
||||||
|
|
||||||
const props = defineProps<{ song: Song }>()
|
const props = defineProps<{ song: Playable }>()
|
||||||
const { song } = toRefs(props)
|
const { song } = toRefs(props)
|
||||||
|
|
||||||
const toggleFavorite = () => {
|
const toggleFavorite = () => {
|
||||||
|
|
|
@ -1,26 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<article class="flex-1 flex flex-col items-center justify-around">
|
<article class="flex-1 flex flex-col items-center justify-around">
|
||||||
<div
|
<div
|
||||||
:style="{ backgroundImage: `url(${song.album_cover || defaultCover})` }"
|
:style="{ backgroundImage: `url(${image || defaultCover})` }"
|
||||||
class="cover my-0 mx-auto w-[calc(70vw_+_4px)] aspect-square rounded-full border-2 border-solid
|
class="cover my-0 mx-auto w-[calc(70vw_+_4px)] aspect-square rounded-full border-2 border-solid
|
||||||
border-k-text-primary bg-center bg-cover bg-k-bg-secondary"
|
border-k-text-primary bg-center bg-cover bg-k-bg-secondary"
|
||||||
/>
|
/>
|
||||||
<div class="w-full flex flex-col justify-around">
|
<div class="w-full flex flex-col justify-around">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[6vmin] font-bold mx-auto mb-4">{{ song.title }}</p>
|
<p class="text-[6vmin] font-bold mx-auto mb-4">{{ song.title }}</p>
|
||||||
<p class="text-[5vmin] mb-2 opacity-50">{{ song.artist_name }}</p>
|
<p class="text-[5vmin] mb-2 opacity-50">{{ artist }}</p>
|
||||||
<p class="text-[4vmin] opacity-50">{{ song.album_name }}</p>
|
<p class="text-[4vmin] opacity-50">{{ album }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toRefs } from 'vue'
|
import { computed, toRefs } from 'vue'
|
||||||
import { defaultCover } from '@/utils'
|
import { defaultCover, getPlayableProp } from '@/utils'
|
||||||
|
|
||||||
const props = defineProps<{ song: Song }>()
|
const props = defineProps<{ song: Playable }>()
|
||||||
const { song } = toRefs(props)
|
const { song } = toRefs(props)
|
||||||
|
|
||||||
|
const image = computed(() => getPlayableProp(song.value, 'album_cover', 'episode_image'))
|
||||||
|
const artist = computed(() => getPlayableProp(song.value, 'artist_name', 'podcast_author'))
|
||||||
|
const album = computed(() => getPlayableProp(song.value, 'album_name', 'podcast_title'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export interface RemoteState {
|
export interface RemoteState {
|
||||||
song: Song | null
|
song: Playable | null
|
||||||
volume: number
|
volume: number
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,11 @@ import { downloadService } from './downloadService'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('downloads songs', () => {
|
it('downloads playables', () => {
|
||||||
const mock = this.mock(downloadService, 'trigger')
|
const mock = this.mock(downloadService, 'trigger')
|
||||||
downloadService.fromPlayables([factory('song', { id: 'bar' })])
|
downloadService.fromPlayables([factory('song', { id: 'bar' })])
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('songs?songs[]=bar&songs[]=foo&')
|
expect(mock).toHaveBeenCalledWith('songs?songs[]=bar&')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('downloads all by artist', () => {
|
it('downloads all by artist', () => {
|
||||||
|
@ -36,11 +36,11 @@ new class extends UnitTestCase {
|
||||||
expect(mock).toHaveBeenCalledWith(`playlist/${playlist.id}`)
|
expect(mock).toHaveBeenCalledWith(`playlist/${playlist.id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each<[Song[], boolean]>([[[], false], [factory('song', 5), true]])(
|
it.each<[Playable[], boolean]>([[[], false], [factory('song', 5), true]])(
|
||||||
'downloads favorites if available',
|
'downloads favorites if available',
|
||||||
(songs, triggered) => {
|
(songs, triggered) => {
|
||||||
const mock = this.mock(downloadService, 'trigger')
|
const mock = this.mock(downloadService, 'trigger')
|
||||||
favoriteStore.state.songs = songs
|
favoriteStore.state.playables = songs
|
||||||
|
|
||||||
downloadService.fromFavorites()
|
downloadService.fromFavorites()
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const downloadService = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromFavorites () {
|
fromFavorites () {
|
||||||
if (favoriteStore.state.songs.length) {
|
if (favoriteStore.state.playables.length) {
|
||||||
this.trigger('favorites')
|
this.trigger('favorites')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -78,11 +78,11 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('scrobbles if current song ends', () => {
|
it('scrobbles if current song ends', () => {
|
||||||
commonStore.state.uses_last_fm = true
|
commonStore.state.uses_last_fm = true
|
||||||
userStore.state.current = reactive(factory('user', {
|
userStore.state.current = factory('user', {
|
||||||
preferences: {
|
preferences: {
|
||||||
lastfm_session_key: 'foo'
|
lastfm_session_key: 'foo'
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
|
||||||
playbackService.init(document.querySelector('.plyr')!)
|
playbackService.init(document.querySelector('.plyr')!)
|
||||||
const scrobbleMock = this.mock(songStore, 'scrobble')
|
const scrobbleMock = this.mock(songStore, 'scrobble')
|
||||||
|
@ -399,7 +399,7 @@ new class extends UnitTestCase {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCurrentSong (song?: Song) {
|
private setCurrentSong (song?: Playable) {
|
||||||
song = reactive(song || factory('song', {
|
song = reactive(song || factory('song', {
|
||||||
playback_state: 'Playing'
|
playback_state: 'Playing'
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -30,7 +30,7 @@ const initialState = {
|
||||||
song_length: 0,
|
song_length: 0,
|
||||||
queue_state: {
|
queue_state: {
|
||||||
type: 'queue-states',
|
type: 'queue-states',
|
||||||
songs: [],
|
playables: [],
|
||||||
current_song: null,
|
current_song: null,
|
||||||
playback_position: 0
|
playback_position: 0
|
||||||
} as QueueState
|
} as QueueState
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { favoriteStore } from '.'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected beforeEach () {
|
protected beforeEach () {
|
||||||
super.beforeEach(() => (favoriteStore.state.songs = []))
|
super.beforeEach(() => (favoriteStore.state.playables = []))
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
|
@ -31,18 +31,18 @@ new class extends UnitTestCase {
|
||||||
it('adds songs', () => {
|
it('adds songs', () => {
|
||||||
const songs = factory('song', 3)
|
const songs = factory('song', 3)
|
||||||
favoriteStore.add(songs)
|
favoriteStore.add(songs)
|
||||||
expect(favoriteStore.state.songs).toEqual(songs)
|
expect(favoriteStore.state.playables).toEqual(songs)
|
||||||
|
|
||||||
// doesn't duplicate songs
|
// doesn't duplicate songs
|
||||||
favoriteStore.add(songs[0])
|
favoriteStore.add(songs[0])
|
||||||
expect(favoriteStore.state.songs).toEqual(songs)
|
expect(favoriteStore.state.playables).toEqual(songs)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes songs', () => {
|
it('removes songs', () => {
|
||||||
const songs = factory('song', 3)
|
const songs = factory('song', 3)
|
||||||
favoriteStore.state.songs = songs
|
favoriteStore.state.playables = songs
|
||||||
favoriteStore.remove(songs)
|
favoriteStore.remove(songs)
|
||||||
expect(favoriteStore.state.songs).toEqual([])
|
expect(favoriteStore.state.playables).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('likes several songs', async () => {
|
it('likes several songs', async () => {
|
||||||
|
@ -74,7 +74,7 @@ new class extends UnitTestCase {
|
||||||
await favoriteStore.fetch()
|
await favoriteStore.fetch()
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('songs/favorite')
|
expect(getMock).toHaveBeenCalledWith('songs/favorite')
|
||||||
expect(favoriteStore.state.songs).toEqual(songs)
|
expect(favoriteStore.state.playables).toEqual(songs)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,8 @@ import { arrayify } from '@/utils'
|
||||||
import { songStore } from '@/stores'
|
import { songStore } from '@/stores'
|
||||||
|
|
||||||
export const favoriteStore = {
|
export const favoriteStore = {
|
||||||
state: reactive({
|
state: reactive<{ playables: Playable[] }>({
|
||||||
songs: [] as Playable[]
|
playables: []
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async toggleOne (playable: Playable) {
|
async toggleOne (playable: Playable) {
|
||||||
|
@ -15,15 +15,15 @@ export const favoriteStore = {
|
||||||
playable.liked = !playable.liked
|
playable.liked = !playable.liked
|
||||||
playable.liked ? this.add(playable) : this.remove(playable)
|
playable.liked ? this.add(playable) : this.remove(playable)
|
||||||
|
|
||||||
await http.post<Song>('interaction/like', { song: playable.id })
|
await http.post<Playable>('interaction/like', { song: playable.id })
|
||||||
},
|
},
|
||||||
|
|
||||||
add (songs: MaybeArray<Playable>) {
|
add (songs: MaybeArray<Playable>) {
|
||||||
this.state.songs = unionBy(this.state.songs, arrayify(songs), 'id')
|
this.state.playables = unionBy(this.state.playables, arrayify(songs), 'id')
|
||||||
},
|
},
|
||||||
|
|
||||||
remove (songs: MaybeArray<Playable>) {
|
remove (songs: MaybeArray<Playable>) {
|
||||||
this.state.songs = differenceBy(this.state.songs, arrayify(songs), 'id')
|
this.state.playables = differenceBy(this.state.playables, arrayify(songs), 'id')
|
||||||
},
|
},
|
||||||
|
|
||||||
async like (songs: Playable[]) {
|
async like (songs: Playable[]) {
|
||||||
|
@ -43,7 +43,7 @@ export const favoriteStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
this.state.songs = songStore.syncWithVault(await http.get<Playable[]>('songs/favorite'))
|
this.state.playables = songStore.syncWithVault(await http.get<Playable[]>('songs/favorite'))
|
||||||
return this.state.songs
|
return this.state.playables
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,25 +4,26 @@ import { songStore } from '@/stores/songStore'
|
||||||
import { albumStore } from '@/stores/albumStore'
|
import { albumStore } from '@/stores/albumStore'
|
||||||
import { artistStore } from '@/stores/artistStore'
|
import { artistStore } from '@/stores/artistStore'
|
||||||
import { recentlyPlayedStore } from '@/stores'
|
import { recentlyPlayedStore } from '@/stores'
|
||||||
|
import { isEpisode, isSong } from '@/utils'
|
||||||
|
|
||||||
export const overviewStore = {
|
export const overviewStore = {
|
||||||
state: reactive({
|
state: reactive({
|
||||||
recentlyPlayed: [] as Song[],
|
recentlyPlayed: [] as Playable[],
|
||||||
recentlyAddedSongs: [] as Song[],
|
recentlyAddedSongs: [] as Song[],
|
||||||
recentlyAddedAlbums: [] as Album[],
|
recentlyAddedAlbums: [] as Album[],
|
||||||
mostPlayedSongs: [] as Song[],
|
mostPlayedSongs: [] as Playable[],
|
||||||
mostPlayedAlbums: [] as Album[],
|
mostPlayedAlbums: [] as Album[],
|
||||||
mostPlayedArtists: [] as Artist[]
|
mostPlayedArtists: [] as Artist[]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
const resource = await http.get<{
|
const resource = await http.get<{
|
||||||
most_played_songs: Song[],
|
most_played_songs: Playable[],
|
||||||
most_played_albums: Album[],
|
most_played_albums: Album[],
|
||||||
most_played_artists: Artist[],
|
most_played_artists: Artist[],
|
||||||
recently_added_songs: Song[],
|
recently_added_songs: Song[],
|
||||||
recently_added_albums: Album[],
|
recently_added_albums: Album[],
|
||||||
recently_played_songs: Song[],
|
recently_played_songs: Playable[],
|
||||||
}>('overview')
|
}>('overview')
|
||||||
|
|
||||||
songStore.syncWithVault(resource.most_played_songs)
|
songStore.syncWithVault(resource.most_played_songs)
|
||||||
|
@ -31,7 +32,7 @@ export const overviewStore = {
|
||||||
|
|
||||||
this.state.mostPlayedAlbums = albumStore.syncWithVault(resource.most_played_albums)
|
this.state.mostPlayedAlbums = albumStore.syncWithVault(resource.most_played_albums)
|
||||||
this.state.mostPlayedArtists = artistStore.syncWithVault(resource.most_played_artists)
|
this.state.mostPlayedArtists = artistStore.syncWithVault(resource.most_played_artists)
|
||||||
this.state.recentlyAddedSongs = songStore.syncWithVault(resource.recently_added_songs)
|
this.state.recentlyAddedSongs = songStore.syncWithVault(resource.recently_added_songs) as Song[]
|
||||||
this.state.recentlyAddedAlbums = albumStore.syncWithVault(resource.recently_added_albums)
|
this.state.recentlyAddedAlbums = albumStore.syncWithVault(resource.recently_added_albums)
|
||||||
|
|
||||||
recentlyPlayedStore.excerptState.playables = songStore.syncWithVault(resource.recently_played_songs)
|
recentlyPlayedStore.excerptState.playables = songStore.syncWithVault(resource.recently_played_songs)
|
||||||
|
@ -41,8 +42,9 @@ export const overviewStore = {
|
||||||
|
|
||||||
refreshPlayStats () {
|
refreshPlayStats () {
|
||||||
this.state.mostPlayedSongs = songStore.getMostPlayed(7)
|
this.state.mostPlayedSongs = songStore.getMostPlayed(7)
|
||||||
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.playables.filter(
|
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.playables.filter(playable => {
|
||||||
({ deleted, play_count }) => !deleted && play_count > 0
|
if (isSong(playable) && playable.deleted) return false
|
||||||
)
|
return playable.play_count > 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { songStore } from '@/stores/songStore'
|
||||||
|
|
||||||
type CreatePlaylistRequestData = {
|
type CreatePlaylistRequestData = {
|
||||||
name: Playlist['name']
|
name: Playlist['name']
|
||||||
songs: Song['id'][]
|
songs: Playable['id'][]
|
||||||
rules?: SmartPlaylistRuleGroup[]
|
rules?: SmartPlaylistRuleGroup[]
|
||||||
folder_id?: PlaylistFolder['name']
|
folder_id?: PlaylistFolder['name']
|
||||||
own_songs_only?: boolean
|
own_songs_only?: boolean
|
||||||
|
@ -95,11 +95,11 @@ export const playlistStore = {
|
||||||
return playlist
|
return playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSongs = await http.post<Song[]>(`playlists/${playlist.id}/songs`, {
|
const updatedPlayables = await http.post<Playable[]>(`playlists/${playlist.id}/songs`, {
|
||||||
songs: playables.map(song => song.id)
|
songs: playables.map(song => song.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
songStore.syncWithVault(updatedSongs)
|
songStore.syncWithVault(updatedPlayables)
|
||||||
cache.remove(['playlist.songs', playlist.id])
|
cache.remove(['playlist.songs', playlist.id])
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
|
@ -166,10 +166,14 @@ export const playlistStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
moveItemsInPlaylist: async (playlist: Playlist, songs: MaybeArray<Playable>, target: Playable, type: MoveType) => {
|
moveItemsInPlaylist: async (playlist: Playlist, songs: MaybeArray<Playable>, target: Playable, type: MoveType) => {
|
||||||
const orderHash = JSON.stringify(playlist.songs?.map(({ id }) => id))
|
const orderHash = JSON.stringify(playlist.playables?.map(({ id }) => id))
|
||||||
playlist.songs?.splice(0, playlist.songs.length, ...moveItemsInList(playlist.songs, songs, target, type))
|
playlist.playables?.splice(
|
||||||
|
0,
|
||||||
|
playlist.playables.length,
|
||||||
|
...moveItemsInList(playlist.playables, songs, target, type)
|
||||||
|
)
|
||||||
|
|
||||||
if (orderHash !== JSON.stringify(playlist.songs?.map(({ id }) => id))) {
|
if (orderHash !== JSON.stringify(playlist.playables?.map(({ id }) => id))) {
|
||||||
await http.silently.post(`playlists/${playlist.id}/songs/move`, {
|
await http.silently.post(`playlists/${playlist.id}/songs/move`, {
|
||||||
songs: arrayify(songs).map(({ id }) => id),
|
songs: arrayify(songs).map(({ id }) => id),
|
||||||
target: target.id,
|
target: target.id,
|
||||||
|
|
|
@ -1,26 +1,26 @@
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from 'factoria'
|
|
||||||
import { http } from '@/services'
|
import { http } from '@/services'
|
||||||
import { queueStore, songStore } from '.'
|
import { queueStore, songStore } from '.'
|
||||||
|
|
||||||
let songs: Song[]
|
let playables: Playable[]
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected beforeEach () {
|
protected beforeEach () {
|
||||||
super.beforeEach(() => {
|
super.beforeEach(() => {
|
||||||
songs = factory('song', 3)
|
playables = factory('song', 3)
|
||||||
queueStore.state.playables = reactive(songs)
|
queueStore.state.playables = reactive(playables)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('returns all queued songs', () => expect(queueStore.all).toEqual(songs))
|
it('returns all queued songs', () => expect(queueStore.all).toEqual(playables))
|
||||||
|
|
||||||
it('returns the first queued song', () => expect(queueStore.first).toEqual(songs[0]))
|
it('returns the first queued song', () => expect(queueStore.first).toEqual(playables[0]))
|
||||||
|
|
||||||
it('returns the last queued song', () => expect(queueStore.last).toEqual(songs[2]))
|
it('returns the last queued song', () => expect(queueStore.last).toEqual(playables[2]))
|
||||||
|
|
||||||
it('queues to bottom', () => {
|
it('queues to bottom', () => {
|
||||||
const song = factory('song')
|
const song = factory('song')
|
||||||
|
@ -53,17 +53,17 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('removes a song from queue', () => {
|
it('removes a song from queue', () => {
|
||||||
const putMock = this.mock(http, 'put')
|
const putMock = this.mock(http, 'put')
|
||||||
queueStore.unqueue(songs[1])
|
queueStore.unqueue(playables[1])
|
||||||
|
|
||||||
expect(queueStore.all).toEqual([songs[0], songs[2]])
|
expect(queueStore.all).toEqual([playables[0], playables[2]])
|
||||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes multiple songs from queue', () => {
|
it('removes multiple songs from queue', () => {
|
||||||
const putMock = this.mock(http, 'put')
|
const putMock = this.mock(http, 'put')
|
||||||
queueStore.unqueue([songs[1], songs[0]])
|
queueStore.unqueue([playables[1], playables[0]])
|
||||||
|
|
||||||
expect(queueStore.all).toEqual([songs[2]])
|
expect(queueStore.all).toEqual([playables[2]])
|
||||||
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
expect(putMock).toHaveBeenCalledWith('queue/state', { songs: queueStore.all.map(song => song.id) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -82,7 +82,7 @@ export const queueStore = {
|
||||||
this.all = head.concat(reactive(playables), this.all)
|
this.all = head.concat(reactive(playables), this.all)
|
||||||
},
|
},
|
||||||
|
|
||||||
unqueue (playables: Playable | Playable[]) {
|
unqueue (playables: MaybeArray<Playable>) {
|
||||||
playables = arrayify(playables)
|
playables = arrayify(playables)
|
||||||
playables.forEach(song => (song.playback_state = 'Stopped'))
|
playables.forEach(song => (song.playback_state = 'Stopped'))
|
||||||
this.all = differenceBy(this.all, playables, 'id')
|
this.all = differenceBy(this.all, playables, 'id')
|
||||||
|
@ -91,7 +91,7 @@ export const queueStore = {
|
||||||
/**
|
/**
|
||||||
* Move some songs to after a target.
|
* Move some songs to after a target.
|
||||||
*/
|
*/
|
||||||
move (playables: Playable | Playable[], target: Playable, type: MoveType) {
|
move (playables: MaybeArray<Playable>, target: Playable, type: MoveType) {
|
||||||
this.state.playables = moveItemsInList(this.state.playables, playables, target, type)
|
this.state.playables = moveItemsInList(this.state.playables, playables, target, type)
|
||||||
this.saveState()
|
this.saveState()
|
||||||
},
|
},
|
||||||
|
|
|
@ -50,18 +50,18 @@ new class extends UnitTestCase {
|
||||||
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
const getMock = this.mock(http, 'get').mockResolvedValue(songs)
|
||||||
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
const syncMock = this.mock(songStore, 'syncWithVault', songs)
|
||||||
|
|
||||||
await searchStore.songSearch('test')
|
await searchStore.playableSearch('test')
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('search/songs?q=test')
|
expect(getMock).toHaveBeenCalledWith('search/songs?q=test')
|
||||||
expect(syncMock).toHaveBeenCalledWith(songs)
|
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||||
|
|
||||||
expect(searchStore.state.songs).toEqual(songs)
|
expect(searchStore.state.playables).toEqual(songs)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resets the song result state', () => {
|
it('resets the song result state', () => {
|
||||||
searchStore.state.songs = factory('song', 3)
|
searchStore.state.playables = factory('song', 3)
|
||||||
searchStore.resetSongResultState()
|
searchStore.resetPlayableResultState()
|
||||||
expect(searchStore.state.songs).toEqual([])
|
expect(searchStore.state.playables).toEqual([])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { http } from '@/services'
|
import { http } from '@/services'
|
||||||
import { albumStore, artistStore, podcastStore, songStore } from '@/stores'
|
import { albumStore, artistStore, songStore } from '@/stores'
|
||||||
|
|
||||||
type ExcerptState = {
|
type ExcerptState = {
|
||||||
playables: Playable[]
|
playables: Playable[]
|
||||||
|
@ -24,7 +24,7 @@ export const searchStore = {
|
||||||
artists: [],
|
artists: [],
|
||||||
podcasts: []
|
podcasts: []
|
||||||
} as ExcerptState,
|
} as ExcerptState,
|
||||||
songs: [] as Playable[]
|
playables: [] as Playable[]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
async excerptSearch (q: string) {
|
async excerptSearch (q: string) {
|
||||||
|
@ -36,11 +36,11 @@ export const searchStore = {
|
||||||
this.state.excerpt.podcasts = result.podcasts
|
this.state.excerpt.podcasts = result.podcasts
|
||||||
},
|
},
|
||||||
|
|
||||||
async songSearch (q: string) {
|
async playableSearch (q: string) {
|
||||||
this.state.songs = songStore.syncWithVault(await http.get<Song[]>(`search/songs?q=${q}`))
|
this.state.playables = songStore.syncWithVault(await http.get<Playable[]>(`search/songs?q=${q}`))
|
||||||
},
|
},
|
||||||
|
|
||||||
resetSongResultState () {
|
resetPlayableResultState () {
|
||||||
this.state.songs = []
|
this.state.playables = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -241,7 +241,7 @@ new class extends UnitTestCase {
|
||||||
expect(getMock).toHaveBeenCalledWith('playlists/966268ea-935d-4f63-a84e-180385376a78/songs')
|
expect(getMock).toHaveBeenCalledWith('playlists/966268ea-935d-4f63-a84e-180385376a78/songs')
|
||||||
expect(syncMock).toHaveBeenCalledWith(songs)
|
expect(syncMock).toHaveBeenCalledWith(songs)
|
||||||
expect(fetched).toEqual(songs)
|
expect(fetched).toEqual(songs)
|
||||||
expect(playlist.songs).toEqual(songs)
|
expect(playlist.playables).toEqual(songs)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches for playlist with cache', async () => {
|
it('fetches for playlist with cache', async () => {
|
||||||
|
@ -256,7 +256,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
expect(getMock).not.toHaveBeenCalled()
|
expect(getMock).not.toHaveBeenCalled()
|
||||||
expect(fetched).toEqual(songs)
|
expect(fetched).toEqual(songs)
|
||||||
expect(playlist.songs).toEqual(songs)
|
expect(playlist.playables).toEqual(songs)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches for playlist discarding cache', async () => {
|
it('fetches for playlist discarding cache', async () => {
|
||||||
|
@ -271,7 +271,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalled()
|
expect(getMock).toHaveBeenCalled()
|
||||||
expect(cache.get(['playlist.songs', playlist.id])).toEqual([])
|
expect(cache.get(['playlist.songs', playlist.id])).toEqual([])
|
||||||
expect(playlist.songs).toEqual([])
|
expect(playlist.playables).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('paginates', async () => {
|
it('paginates', async () => {
|
||||||
|
|
|
@ -45,11 +45,11 @@ export interface GenreSongListPaginateParams extends Record<string, any> {
|
||||||
export const songStore = {
|
export const songStore = {
|
||||||
vault: new Map<Playable['id'], Playable>(),
|
vault: new Map<Playable['id'], Playable>(),
|
||||||
|
|
||||||
state: reactive({
|
state: reactive<{ songs: Playable[] }>({
|
||||||
songs: [] as Playable[]
|
songs: []
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getFormattedLength: (songs: Playable | Playable[]) => secondsToHumanReadable(sumBy(arrayify(songs), 'length')),
|
getFormattedLength: (playables: MaybeArray<Playable>) => secondsToHumanReadable(sumBy(arrayify(playables), 'length')),
|
||||||
|
|
||||||
byId (id: string) {
|
byId (id: string) {
|
||||||
const song = this.vault.get(id)
|
const song = this.vault.get(id)
|
||||||
|
@ -70,17 +70,17 @@ export const songStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolve (id: string) {
|
async resolve (id: string) {
|
||||||
let song = this.byId(id)
|
let playable = this.byId(id)
|
||||||
|
|
||||||
if (!song) {
|
if (!playable) {
|
||||||
try {
|
try {
|
||||||
song = this.syncWithVault(await http.get<Song>(`songs/${id}`))[0]
|
playable = this.syncWithVault(await http.get<Playable>(`songs/${id}`))[0]
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return song
|
return playable
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -146,9 +146,9 @@ export const songStore = {
|
||||||
: `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}`
|
: `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}`
|
||||||
},
|
},
|
||||||
|
|
||||||
getShareableUrl: (song: Song) => `${window.BASE_URL}#/song/${song.id}`,
|
getShareableUrl: (song: Playable) => `${window.BASE_URL}#/song/${song.id}`,
|
||||||
|
|
||||||
syncWithVault (playables: Playable | Playable[]) {
|
syncWithVault (playables: MaybeArray<Playable>) {
|
||||||
return arrayify(playables).map(song => {
|
return arrayify(playables).map(song => {
|
||||||
let local = this.byId(song.id)
|
let local = this.byId(song.id)
|
||||||
|
|
||||||
|
@ -169,7 +169,7 @@ export const songStore = {
|
||||||
watch(() => playable.play_count, () => overviewStore.refreshPlayStats())
|
watch(() => playable.play_count, () => overviewStore.refreshPlayStats())
|
||||||
},
|
},
|
||||||
|
|
||||||
ensureNotDeleted: (songs: Song | Song[]) => arrayify(songs).filter(({ deleted }) => !deleted),
|
ensureNotDeleted: (songs: MaybeArray<Song>) => arrayify(songs).filter(({ deleted }) => !deleted),
|
||||||
|
|
||||||
async fetchForAlbum (album: Album | number) {
|
async fetchForAlbum (album: Album | number) {
|
||||||
const id = typeof album === 'number' ? album : album.id
|
const id = typeof album === 'number' ? album : album.id
|
||||||
|
@ -201,19 +201,19 @@ export const songStore = {
|
||||||
async () => this.syncWithVault(await http.get<Song[]>(`playlists/${id}/songs`))
|
async () => this.syncWithVault(await http.get<Song[]>(`playlists/${id}/songs`))
|
||||||
))
|
))
|
||||||
|
|
||||||
playlistStore.byId(id)!.songs = songs
|
playlistStore.byId(id)!.playables = songs
|
||||||
|
|
||||||
return songs
|
return songs
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchForPlaylistFolder (folder: PlaylistFolder) {
|
async fetchForPlaylistFolder (folder: PlaylistFolder) {
|
||||||
const songs: Song[] = []
|
const playables: Playable[] = []
|
||||||
|
|
||||||
for await (const playlist of playlistStore.byFolder(folder)) {
|
for await (const playlist of playlistStore.byFolder(folder)) {
|
||||||
songs.push(...await songStore.fetchForPlaylist(playlist))
|
playables.push(...await songStore.fetchForPlaylist(playlist))
|
||||||
}
|
}
|
||||||
|
|
||||||
return uniqBy(songs, 'id')
|
return uniqBy(playables, 'id')
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchForPodcast (podcast: Podcast | string, refresh = false) {
|
async fetchForPodcast (podcast: Podcast | string, refresh = false) {
|
||||||
|
|
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
|
podcast_author: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollaborativeSong extends Song {
|
interface CollaborativeSong extends Playable {
|
||||||
collaboration: {
|
collaboration: {
|
||||||
user: PlaylistCollaborator
|
user: PlaylistCollaborator
|
||||||
added_at: string | null
|
added_at: string | null
|
||||||
|
@ -243,12 +243,12 @@ type SmartPlaylistInputTypes = Record<SmartPlaylistModel['type'], SmartPlaylistO
|
||||||
|
|
||||||
type FavoriteList = {
|
type FavoriteList = {
|
||||||
name: 'Favorites'
|
name: 'Favorites'
|
||||||
songs: Song[]
|
playables: Playable[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecentlyPlayedList = {
|
type RecentlyPlayedList = {
|
||||||
name: 'Recently Played'
|
name: 'Recently Played'
|
||||||
songs: Song[]
|
playables: Playable[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlaylistFolder {
|
interface PlaylistFolder {
|
||||||
|
@ -273,7 +273,7 @@ interface Playlist {
|
||||||
rules: SmartPlaylistRuleGroup[]
|
rules: SmartPlaylistRuleGroup[]
|
||||||
own_songs_only: boolean
|
own_songs_only: boolean
|
||||||
cover: string | null
|
cover: string | null
|
||||||
songs?: Playable[]
|
playables?: Playable[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
|
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
|
||||||
|
@ -289,8 +289,8 @@ interface Podcast {
|
||||||
readonly author: string
|
readonly author: string
|
||||||
readonly subscribed_at: string
|
readonly subscribed_at: string
|
||||||
readonly state: {
|
readonly state: {
|
||||||
current_episode: Song['id'] | null
|
current_episode: Playable['id'] | null
|
||||||
progresses: Record<Song['id'], number>
|
progresses: Record<Playable['id'], number>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -327,6 +327,7 @@ interface UserPreferences extends Record<string, any> {
|
||||||
visualizer?: Visualizer['id'] | null
|
visualizer?: Visualizer['id'] | null
|
||||||
active_extra_panel_tab: ExtraPanelTab | null
|
active_extra_panel_tab: ExtraPanelTab | null
|
||||||
make_uploads_public: boolean
|
make_uploads_public: boolean
|
||||||
|
lastfm_session_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
@ -350,7 +351,7 @@ interface Settings {
|
||||||
interface Interaction {
|
interface Interaction {
|
||||||
type: 'interactions'
|
type: 'interactions'
|
||||||
readonly id: number
|
readonly id: number
|
||||||
readonly song_id: Song['id']
|
readonly song_id: Playable['id']
|
||||||
liked: boolean
|
liked: boolean
|
||||||
play_count: number
|
play_count: number
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
import { pluralize } from '@/utils/formatters'
|
|
||||||
import { arrayify } from '@/utils/helpers'
|
|
||||||
|
|
||||||
export function isSong (playable: Playable): playable is Song {
|
export function isSong (playable: Playable): playable is Song {
|
||||||
return playable.type === 'songs'
|
return playable.type === 'songs'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue