mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
feat(design): revamp the layout
This commit is contained in:
parent
0b85ff18b9
commit
a028dc03d0
68 changed files with 1201 additions and 1042 deletions
|
@ -13,10 +13,6 @@ use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class SmartPlaylistService
|
class SmartPlaylistService
|
||||||
{
|
{
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection|array<array-key, Song> */
|
/** @return Collection|array<array-key, Song> */
|
||||||
public function getSongs(Playlist $playlist, ?User $user = null): Collection
|
public function getSongs(Playlist $playlist, ?User $user = null): Collection
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
<div v-if="authenticated" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop">
|
<div v-if="authenticated" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop">
|
||||||
<Hotkeys/>
|
<Hotkeys/>
|
||||||
<AppHeader/>
|
|
||||||
<MainWrapper/>
|
<MainWrapper/>
|
||||||
<AppFooter/>
|
<AppFooter/>
|
||||||
<SupportKoel/>
|
<SupportKoel/>
|
||||||
|
@ -25,11 +24,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, nextTick, onMounted, provide, ref } from 'vue'
|
import { defineAsyncComponent, nextTick, onMounted, provide, ref, watch } from 'vue'
|
||||||
import { eventBus, hideOverlay, requireInjection, showOverlay } from '@/utils'
|
import { eventBus, hideOverlay, requireInjection, showOverlay } from '@/utils'
|
||||||
import { commonStore, preferenceStore as preferences } from '@/stores'
|
import { commonStore, preferenceStore as preferences, queueStore } from '@/stores'
|
||||||
import { authService, playbackService, socketListener, socketService, uploadService } from '@/services'
|
import { authService, playbackService, socketListener, socketService, uploadService } from '@/services'
|
||||||
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
|
import { CurrentSongKey, DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
|
||||||
|
|
||||||
import DialogBox from '@/components/ui/DialogBox.vue'
|
import DialogBox from '@/components/ui/DialogBox.vue'
|
||||||
import MessageToaster from '@/components/ui/MessageToaster.vue'
|
import MessageToaster from '@/components/ui/MessageToaster.vue'
|
||||||
|
@ -42,7 +41,6 @@ import AppFooter from '@/components/layout/app-footer/index.vue'
|
||||||
// GlobalEventListener must NOT be lazy-loaded, so that it can handle LOG_OUT event properly.
|
// GlobalEventListener must NOT be lazy-loaded, so that it can handle LOG_OUT event properly.
|
||||||
import GlobalEventListeners from '@/components/utils/GlobalEventListeners.vue'
|
import GlobalEventListeners from '@/components/utils/GlobalEventListeners.vue'
|
||||||
|
|
||||||
const AppHeader = defineAsyncComponent(() => import('@/components/layout/AppHeader.vue'))
|
|
||||||
const Hotkeys = defineAsyncComponent(() => import('@/components/utils/HotkeyListener.vue'))
|
const Hotkeys = defineAsyncComponent(() => import('@/components/utils/HotkeyListener.vue'))
|
||||||
const LoginForm = defineAsyncComponent(() => import('@/components/auth/LoginForm.vue'))
|
const LoginForm = defineAsyncComponent(() => import('@/components/auth/LoginForm.vue'))
|
||||||
const MainWrapper = defineAsyncComponent(() => import('@/components/layout/main-wrapper/index.vue'))
|
const MainWrapper = defineAsyncComponent(() => import('@/components/layout/main-wrapper/index.vue'))
|
||||||
|
@ -57,6 +55,7 @@ const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZ
|
||||||
|
|
||||||
const dialog = ref<InstanceType<typeof DialogBox>>()
|
const dialog = ref<InstanceType<typeof DialogBox>>()
|
||||||
const toaster = ref<InstanceType<typeof MessageToaster>>()
|
const toaster = ref<InstanceType<typeof MessageToaster>>()
|
||||||
|
const currentSong = ref<Song | null>(null)
|
||||||
const authenticated = ref(false)
|
const authenticated = ref(false)
|
||||||
const showDropZone = ref(false)
|
const showDropZone = ref(false)
|
||||||
|
|
||||||
|
@ -121,11 +120,14 @@ const onDragOver = (e: DragEvent) => {
|
||||||
showDropZone.value = Boolean(e.dataTransfer?.types.includes('Files')) && router.$currentRoute.value.screen !== 'Upload'
|
showDropZone.value = Boolean(e.dataTransfer?.types.includes('Files')) && router.$currentRoute.value.screen !== 'Upload'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => queueStore.current, song => (currentSong.value = song))
|
||||||
|
|
||||||
const onDragEnd = () => (showDropZone.value = false)
|
const onDragEnd = () => (showDropZone.value = false)
|
||||||
const onDrop = () => (showDropZone.value = false)
|
const onDrop = () => (showDropZone.value = false)
|
||||||
|
|
||||||
provide(DialogBoxKey, dialog)
|
provide(DialogBoxKey, dialog)
|
||||||
provide(MessageToasterKey, toaster)
|
provide(MessageToasterKey, toaster)
|
||||||
|
provide(CurrentSongKey, currentSong)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@ -169,6 +171,12 @@ provide(MessageToasterKey, toaster)
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#main {
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
padding-top: var(--header-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.login-wrapper {
|
.login-wrapper {
|
||||||
@include vertical-center();
|
@include vertical-center();
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<nav class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu">
|
<nav class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu" data-v-0408531a="">
|
||||||
<ul>
|
<ul data-v-0408531a="">
|
||||||
<li data-testid="play">Play All</li>
|
<li data-testid="play">Play All</li>
|
||||||
<li data-testid="shuffle">Shuffle All</li>
|
<li data-testid="shuffle">Shuffle All</li>
|
||||||
<li class="separator"></li>
|
<li class="separator"></li>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
|
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu" data-v-0408531a="">
|
||||||
<ul>
|
<ul data-v-0408531a="">
|
||||||
<li data-testid="play">Play All</li>
|
<li data-testid="play">Play All</li>
|
||||||
<li data-testid="shuffle">Shuffle All</li>
|
<li data-testid="shuffle">Shuffle All</li>
|
||||||
<li class="separator"></li>
|
<li class="separator"></li>
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
import isMobile from 'ismobilejs'
|
|
||||||
import { expect, it } from 'vitest'
|
|
||||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
|
||||||
import { eventBus } from '@/utils'
|
|
||||||
import compareVersions from 'compare-versions'
|
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
|
||||||
import AppHeader from './AppHeader.vue'
|
|
||||||
import SearchForm from '@/components/ui/SearchForm.vue'
|
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
|
||||||
protected test () {
|
|
||||||
it('toggles sidebar (mobile only)', async () => {
|
|
||||||
isMobile.any = true
|
|
||||||
const { getByTitle } = this.render(AppHeader)
|
|
||||||
const mock = this.mock(eventBus, 'emit')
|
|
||||||
|
|
||||||
await fireEvent.click(getByTitle('Show or hide the sidebar'))
|
|
||||||
|
|
||||||
expect(mock).toHaveBeenCalledWith('TOGGLE_SIDEBAR')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggles search form (mobile only)', async () => {
|
|
||||||
isMobile.any = true
|
|
||||||
|
|
||||||
const { getByTitle, getByRole, queryByRole } = this.render(AppHeader, {
|
|
||||||
global: {
|
|
||||||
stubs: {
|
|
||||||
SearchForm
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await queryByRole('search')).toBeNull()
|
|
||||||
|
|
||||||
await fireEvent.click(getByTitle('Show or hide the search form'))
|
|
||||||
await waitFor(() => getByRole('search'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([[true, true, true], [false, true, false], [true, false, false], [false, false, false]])(
|
|
||||||
'announces a new version has new version: %s, is admin: %s, should announce: %s',
|
|
||||||
async (hasNewVersion, isAdmin, announcing) => {
|
|
||||||
this.mock(compareVersions, 'compare', hasNewVersion)
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
this.actingAsAdmin()
|
|
||||||
} else {
|
|
||||||
this.actingAs()
|
|
||||||
}
|
|
||||||
|
|
||||||
const { queryAllByTestId } = this.render(AppHeader)
|
|
||||||
|
|
||||||
expect(queryAllByTestId('new-version')).toHaveLength(announcing ? 1 : 0)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
<template>
|
|
||||||
<header id="mainHeader">
|
|
||||||
<h1 class="brand text-thin">Koel</h1>
|
|
||||||
<span class="hamburger" role="button" title="Show or hide the sidebar" @click="toggleSidebar">
|
|
||||||
<icon :icon="faBars"/>
|
|
||||||
</span>
|
|
||||||
<span class="magnifier" role="button" title="Show or hide the search form" @click="toggleSearchForm">
|
|
||||||
<icon :icon="faSearch"/>
|
|
||||||
</span>
|
|
||||||
<SearchForm v-if="showSearchForm"/>
|
|
||||||
<div class="header-right">
|
|
||||||
<UserBadge/>
|
|
||||||
<button
|
|
||||||
class="about control"
|
|
||||||
data-testid="about-btn"
|
|
||||||
title="About Koel"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="showAboutDialog"
|
|
||||||
>
|
|
||||||
<span v-if="shouldNotifyNewVersion" class="new-version" data-testid="new-version">
|
|
||||||
{{ latestVersion }} available!
|
|
||||||
</span>
|
|
||||||
<icon v-else :icon="faInfoCircle"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { faBars, faInfoCircle, faSearch } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import isMobile from 'ismobilejs'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { eventBus } from '@/utils'
|
|
||||||
import { useNewVersionNotification } from '@/composables'
|
|
||||||
|
|
||||||
import SearchForm from '@/components/ui/SearchForm.vue'
|
|
||||||
import UserBadge from '@/components/user/UserBadge.vue'
|
|
||||||
|
|
||||||
const showSearchForm = ref(!isMobile.any)
|
|
||||||
const { shouldNotifyNewVersion, latestVersion } = useNewVersionNotification()
|
|
||||||
|
|
||||||
const toggleSidebar = () => eventBus.emit('TOGGLE_SIDEBAR')
|
|
||||||
const toggleSearchForm = () => (showSearchForm.value = !showSearchForm.value)
|
|
||||||
const showAboutDialog = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
#mainHeader {
|
|
||||||
height: var(--header-height);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
display: flex;
|
|
||||||
box-shadow: 0 0 2px 0 rgba(0, 0, 0, .4);
|
|
||||||
|
|
||||||
h1.brand {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 1.7rem;
|
|
||||||
opacity: 0;
|
|
||||||
line-height: var(--header-height);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger, .magnifier {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
flex: 0 0 48px;
|
|
||||||
order: -1;
|
|
||||||
line-height: var(--header-height);
|
|
||||||
text-align: center;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
.about {
|
|
||||||
height: 100%;
|
|
||||||
@include vertical-center();
|
|
||||||
padding: 16px;
|
|
||||||
border-left: 1px solid rgba(255, 255, 255, .1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 667px) {
|
|
||||||
display: flex;
|
|
||||||
align-content: stretch;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
.hamburger, .magnifier {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1.brand {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -96,12 +96,12 @@ eventBus.on({
|
||||||
form {
|
form {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 460px;
|
min-width: 460px;
|
||||||
max-width: calc(100% - 24px);
|
max-width: calc(100vw - 24px);
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
@media only screen and (max-width: 667px) {
|
@media screen and (max-width: 667px) {
|
||||||
min-width: calc(100% - 24px);
|
min-width: calc(100vw - 24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
> header, > main, > footer {
|
> header, > main, > footer {
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
A very thin wrapper around Plyr, extracted as a standalone component for easier styling and to work better with HMR.
|
||||||
|
-->
|
||||||
|
<div class="plyr">
|
||||||
|
<audio controls crossorigin="anonymous"></audio>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup></script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// can't be scoped as it would be overridden by the plyr css
|
||||||
|
.plyr {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
|
||||||
|
.plyr__controls {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__progress--played[value] {
|
||||||
|
transition: .3s ease-in-out;
|
||||||
|
color: rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.plyr__progress--played[value] {
|
||||||
|
color: var(--color-highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(hover: none) {
|
||||||
|
.plyr__progress--played[value] {
|
||||||
|
color: var(--color-highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,30 +1,22 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { preferenceStore } from '@/stores'
|
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
import FooterExtraControls from './FooterExtraControls.vue'
|
import FooterExtraControls from './FooterExtraControls.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
it('renders', () => {
|
it('renders', () => {
|
||||||
preferenceStore.state.showExtraPanel = true
|
|
||||||
|
|
||||||
expect(this.render(FooterExtraControls, {
|
expect(this.render(FooterExtraControls, {
|
||||||
props: {
|
|
||||||
song: factory<Song>('song', {
|
|
||||||
playback_state: 'Playing',
|
|
||||||
title: 'Fahrstuhl to Heaven',
|
|
||||||
artist_name: 'Led Zeppelin',
|
|
||||||
artist_id: 3,
|
|
||||||
album_name: 'Led Zeppelin IV',
|
|
||||||
album_id: 4,
|
|
||||||
liked: false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
RepeatModeSwitch: this.stub('RepeatModeSwitch'),
|
Equalizer: this.stub('Equalizer'),
|
||||||
Volume: this.stub('Volume')
|
Volume: this.stub('Volume')
|
||||||
|
},
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: factory<Song>('song', {
|
||||||
|
playback_state: 'Playing'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).html()).toMatchSnapshot()
|
}).html()).toMatchSnapshot()
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="other-controls" data-testid="other-controls">
|
<div class="extra-controls" data-testid="other-controls">
|
||||||
<div v-koel-clickaway="closeEqualizer" class="wrapper">
|
<div v-koel-clickaway="closeEqualizer" class="wrapper">
|
||||||
<Equalizer v-if="useEqualizer" v-show="showEqualizer"/>
|
<Equalizer v-if="useEqualizer" v-show="showEqualizer"/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="song?.playback_state === 'Playing'"
|
v-if="song?.playback_state === 'Playing'"
|
||||||
class="control"
|
class="visualizer-btn"
|
||||||
data-testid="toggle-visualizer-btn"
|
data-testid="toggle-visualizer-btn"
|
||||||
title="Show/hide the visualizer"
|
title="Show/hide the visualizer"
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -14,24 +14,11 @@
|
||||||
<icon :icon="faBolt"/>
|
<icon :icon="faBolt"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<LikeButton v-if="song" :song="song" class="like"/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
:class="{ active: showExtraPanel }"
|
|
||||||
class="control text-uppercase"
|
|
||||||
data-testid="toggle-extra-panel-btn"
|
|
||||||
title="View song information"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="toggleExtraPanel"
|
|
||||||
>
|
|
||||||
Info
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="useEqualizer"
|
v-if="useEqualizer"
|
||||||
:class="{ active: showEqualizer }"
|
:class="{ active: showEqualizer }"
|
||||||
:title="`${ showEqualizer ? 'Hide' : 'Show'} equalizer`"
|
:title="`${ showEqualizer ? 'Hide' : 'Show'} equalizer`"
|
||||||
class="control equalizer"
|
class="equalizer"
|
||||||
data-testid="toggle-equalizer-btn"
|
data-testid="toggle-equalizer-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@click.prevent="toggleEqualizer"
|
@click.prevent="toggleEqualizer"
|
||||||
|
@ -39,91 +26,60 @@
|
||||||
<icon :icon="faSliders"/>
|
<icon :icon="faSliders"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a v-else :class="{ active: viewingQueue }" class="queue control" href="#/queue">
|
|
||||||
<icon :icon="faListOl"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<RepeatModeSwitch/>
|
|
||||||
<Volume/>
|
<Volume/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import isMobile from 'ismobilejs'
|
import { faBolt, faSliders } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faBolt, faListOl, faSliders } from '@fortawesome/free-solid-svg-icons'
|
import { ref } from 'vue'
|
||||||
import { ref, toRef, toRefs } from 'vue'
|
|
||||||
import { eventBus, isAudioContextSupported as useEqualizer, requireInjection } from '@/utils'
|
import { eventBus, isAudioContextSupported as useEqualizer, requireInjection } from '@/utils'
|
||||||
import { preferenceStore } from '@/stores'
|
import { CurrentSongKey } from '@/symbols'
|
||||||
import { RouterKey } from '@/symbols'
|
|
||||||
|
|
||||||
import Equalizer from '@/components/ui/Equalizer.vue'
|
import Equalizer from '@/components/ui/Equalizer.vue'
|
||||||
import Volume from '@/components/ui/Volume.vue'
|
import Volume from '@/components/ui/Volume.vue'
|
||||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
|
||||||
import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ song: Song }>()
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
const { song } = toRefs(props)
|
|
||||||
|
|
||||||
const showExtraPanel = toRef(preferenceStore.state, 'showExtraPanel')
|
|
||||||
const showEqualizer = ref(false)
|
const showEqualizer = ref(false)
|
||||||
const viewingQueue = ref(false)
|
|
||||||
|
|
||||||
const toggleExtraPanel = () => (preferenceStore.showExtraPanel = !showExtraPanel.value)
|
|
||||||
const toggleEqualizer = () => (showEqualizer.value = !showEqualizer.value)
|
const toggleEqualizer = () => (showEqualizer.value = !showEqualizer.value)
|
||||||
const closeEqualizer = () => (showEqualizer.value = false)
|
const closeEqualizer = () => (showEqualizer.value = false)
|
||||||
const toggleVisualizer = () => isMobile.any || eventBus.emit('TOGGLE_VISUALIZER')
|
const toggleVisualizer = () => eventBus.emit('TOGGLE_VISUALIZER')
|
||||||
|
|
||||||
const router = requireInjection(RouterKey)
|
|
||||||
|
|
||||||
router.onRouteChanged(route => (viewingQueue.value = route.screen === 'Queue'))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.other-controls {
|
.extra-controls {
|
||||||
@include vertical-center();
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 var(--extra-panel-width);
|
width: 320px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
padding: 0 2rem;
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
@include vertical-center();
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
> * + * {
|
align-items: center;
|
||||||
margin-left: 1rem;
|
gap: 1.5rem;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control {
|
button {
|
||||||
&.active {
|
color: currentColor;
|
||||||
color: var(--color-accent);
|
transition: color 0.2s ease-in-out;
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
&:hover {
|
||||||
padding-right: 0;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
@media only screen and (max-width: 768px) {
|
||||||
position: absolute !important;
|
width: auto;
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 188px;
|
|
||||||
padding-top: 12px; // leave space for the audio track
|
|
||||||
|
|
||||||
&::before {
|
.visualizer-btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> * + * {
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { expect, it } from 'vitest'
|
|
||||||
import factory from '@/__tests__/factory'
|
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
|
||||||
import FooterMiddlePane from './FooterMiddlePane.vue'
|
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
|
||||||
protected test () {
|
|
||||||
it('renders without a song', () => expect(this.render(FooterMiddlePane).html()).toMatchSnapshot())
|
|
||||||
|
|
||||||
it('renders with a song', () => {
|
|
||||||
expect(this.render(FooterMiddlePane, {
|
|
||||||
props: {
|
|
||||||
song: factory<Song>('song', {
|
|
||||||
title: 'Fahrstuhl to Heaven',
|
|
||||||
artist_name: 'Led Zeppelin',
|
|
||||||
artist_id: 3,
|
|
||||||
album_name: 'Led Zeppelin IV',
|
|
||||||
album_id: 4
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).html()).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="middle-pane" data-testid="footer-middle-pane">
|
|
||||||
<div id="progressPane" class="progress">
|
|
||||||
<template v-if="song">
|
|
||||||
<h3 class="title">{{ song.title }}</h3>
|
|
||||||
<p class="meta">
|
|
||||||
<a :href="`/#/artist/${song.artist_id}`" class="artist">{{ song.artist_name }}</a> –
|
|
||||||
<a :href="`/#/album/${song.album_id}`" class="album">{{ song.album_name }}</a>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="plyr">
|
|
||||||
<audio controls crossorigin="anonymous"></audio>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { toRefs } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ song?: Song }>()
|
|
||||||
const { song } = toRefs(props)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.middle-pane {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep(#progressPane) {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
font-size: .9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some little tweaks here and there
|
|
||||||
.plyr {
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__progress {
|
|
||||||
&--seek {
|
|
||||||
height: 11px; // increase click area
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&--left, &--right {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.meta, .title {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__progress {
|
|
||||||
&--seek {
|
|
||||||
border-bottom-color: var(--color-bg-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
import { fireEvent } from '@testing-library/vue'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
|
import FooterPlaybackControls from './FooterPlaybackControls.vue'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
private renderComponent (song?: Song | null) {
|
||||||
|
if (song === undefined) {
|
||||||
|
song = factory<Song>('song', {
|
||||||
|
id: 42,
|
||||||
|
title: 'Fahrstuhl to Heaven',
|
||||||
|
artist_name: 'Led Zeppelin',
|
||||||
|
artist_id: 3,
|
||||||
|
album_name: 'Led Zeppelin IV',
|
||||||
|
album_id: 4,
|
||||||
|
liked: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.render(FooterPlaybackControls, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PlayButton: this.stub('PlayButton')
|
||||||
|
},
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: ref(song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected test () {
|
||||||
|
it('renders without a current song', () => expect(this.renderComponent(null).html()).toMatchSnapshot())
|
||||||
|
it('renders with a current song', () => expect(this.renderComponent().html()).toMatchSnapshot())
|
||||||
|
|
||||||
|
it('plays the previous song', async () => {
|
||||||
|
const playMock = this.mock(playbackService, 'playPrev')
|
||||||
|
const { getByTitle } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByTitle('Play previous song'))
|
||||||
|
|
||||||
|
expect(playMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('plays the next song', async () => {
|
||||||
|
const playMock = this.mock(playbackService, 'playNext')
|
||||||
|
const { getByTitle } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByTitle('Play next song'))
|
||||||
|
|
||||||
|
expect(playMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
<template>
|
||||||
|
<div class="playback-controls" data-testid="footer-middle-pane">
|
||||||
|
<div class="buttons">
|
||||||
|
<LikeButton v-if="song" :song="song" class="like-btn"/>
|
||||||
|
<button type="button" v-else/> <!-- a placeholder to maintain the flex layout -->
|
||||||
|
|
||||||
|
<button type="button" title="Play previous song" @click.prevent="playPrev">
|
||||||
|
<icon :icon="faStepBackward"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<PlayButton/>
|
||||||
|
|
||||||
|
<button type="button" title="Play next song" @click.prevent="playNext">
|
||||||
|
<icon :icon="faStepForward"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<RepeatModeSwitch class="repeat-mode-btn"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { faStepBackward, faStepForward } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
|
import { defaultCover, requireInjection } from '@/utils'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
|
||||||
|
import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue'
|
||||||
|
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
|
import PlayButton from '@/components/ui/FooterPlayButton.vue'
|
||||||
|
|
||||||
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
|
const cover = computed(() => song.value?.album_cover || defaultCover)
|
||||||
|
|
||||||
|
const playPrev = async () => await playbackService.playPrev()
|
||||||
|
const playNext = async () => await playbackService.playNext()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.playback-controls {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
transition: all .2s ease-in-out;
|
||||||
|
transition-property: color, border, transform;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.like-btn, &.repeat-mode-btn {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,42 +0,0 @@
|
||||||
import { expect, it } from 'vitest'
|
|
||||||
import { fireEvent } from '@testing-library/vue'
|
|
||||||
import { playbackService } from '@/services'
|
|
||||||
import factory from '@/__tests__/factory'
|
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
|
||||||
import FooterPlayerControls from './FooterPlayerControls.vue'
|
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
|
||||||
protected test () {
|
|
||||||
it.each<[string, string, MethodOf<typeof playbackService>]>([
|
|
||||||
['plays next song', 'Play next song', 'playNext'],
|
|
||||||
['plays previous song', 'Play previous song', 'playPrev'],
|
|
||||||
['plays/resumes current song', 'Play or resume', 'toggle']
|
|
||||||
])('%s', async (_: string, title: string, playbackMethod: MethodOf<typeof playbackService>) => {
|
|
||||||
const mock = this.mock(playbackService, playbackMethod)
|
|
||||||
|
|
||||||
const { getByTitle } = this.render(FooterPlayerControls, {
|
|
||||||
props: {
|
|
||||||
song: factory<Song>('song')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await fireEvent.click(getByTitle(title))
|
|
||||||
expect(mock).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pauses the current song', async () => {
|
|
||||||
const mock = this.mock(playbackService, 'toggle')
|
|
||||||
|
|
||||||
const { getByTitle } = this.render(FooterPlayerControls, {
|
|
||||||
props: {
|
|
||||||
song: factory<Song>('song', {
|
|
||||||
playback_state: 'Playing'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await fireEvent.click(getByTitle('Pause'))
|
|
||||||
expect(mock).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,222 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="side player-controls" :class="{ playing }">
|
|
||||||
<icon
|
|
||||||
:icon="faStepBackward"
|
|
||||||
class="prev control"
|
|
||||||
data-testid="play-prev-btn"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
title="Play previous song"
|
|
||||||
@click.prevent="playPrev"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span class="album-thumb-wrapper">
|
|
||||||
<span :style="{ backgroundImage: `url('${cover}')` }" class="album-thumb"></span>
|
|
||||||
<span
|
|
||||||
v-if="shouldShowPlayButton"
|
|
||||||
class="play"
|
|
||||||
data-testid="play-btn"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
title="Play or resume"
|
|
||||||
@click.prevent="toggle"
|
|
||||||
>
|
|
||||||
<icon :icon="faPlay" size="lg"/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="pause"
|
|
||||||
data-testid="pause-btn"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
title="Pause"
|
|
||||||
@click.prevent="toggle"
|
|
||||||
>
|
|
||||||
<icon :icon="faPause" size="lg"/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<icon
|
|
||||||
:icon="faStepForward"
|
|
||||||
class="next control"
|
|
||||||
data-testid="play-next-btn"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
title="Play next song"
|
|
||||||
@click.prevent="playNext"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { faPause, faPlay, faStepBackward, faStepForward } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { computed, toRefs } from 'vue'
|
|
||||||
import { playbackService } from '@/services'
|
|
||||||
import { defaultCover } from '@/utils'
|
|
||||||
|
|
||||||
const props = defineProps<{ song: Song | null }>()
|
|
||||||
const { song } = toRefs(props)
|
|
||||||
|
|
||||||
const cover = computed(() => song.value?.album_cover ? song.value?.album_cover : defaultCover)
|
|
||||||
const shouldShowPlayButton = computed(() => !song || song.value?.playback_state !== 'Playing')
|
|
||||||
const playing = computed(() => song.value?.playback_state === 'Playing')
|
|
||||||
|
|
||||||
const playPrev = async () => await playbackService.playPrev()
|
|
||||||
const playNext = async () => await playbackService.playNext()
|
|
||||||
const toggle = async () => await playbackService.toggle()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.player-controls {
|
|
||||||
@include vertical-center();
|
|
||||||
flex: 0 0 256px;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.album-thumb-wrapper {
|
|
||||||
margin-left: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-thumb {
|
|
||||||
filter: brightness(.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev, .next {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play, .pause {
|
|
||||||
opacity: .7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-thumb-wrapper {
|
|
||||||
flex: 0 0 calc(var(--footer-height) + 30px);
|
|
||||||
height: calc(var(--footer-height) + 30px);
|
|
||||||
transition: .2s ease-out;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 0, 0, .2);
|
|
||||||
margin-left: -3rem;
|
|
||||||
margin-right: -3rem;
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
margin-left: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include vertical-center();
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.album-thumb {
|
|
||||||
&::after {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.play, .pause {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-thumb {
|
|
||||||
position: relative;
|
|
||||||
background-color: transparent;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-size: cover;
|
|
||||||
transition: .2s ease-out;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev, .next {
|
|
||||||
transition: .4s ease-out;
|
|
||||||
opacity: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
margin: -.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play, .pause {
|
|
||||||
@include inset-when-pressed();
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
transition: opacity .4s ease-out;
|
|
||||||
display: flex;
|
|
||||||
font-size: 2rem;
|
|
||||||
place-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
line-height: calc(var(--footer-height) + 30px);
|
|
||||||
text-align: center;
|
|
||||||
text-indent: 2px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
opacity: 0;
|
|
||||||
text-shadow: 0 0 1px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play {
|
|
||||||
margin-left: .2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enabled {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.playing .album-thumb {
|
|
||||||
animation: spin 30s linear infinite;
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion) {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-thumb-wrapper {
|
|
||||||
flex: 0 0 48px;
|
|
||||||
height: 48px;
|
|
||||||
box-shadow: 0 0 0 1px var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-thumb {
|
|
||||||
background-image: none !important;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.play, .pause {
|
|
||||||
line-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev, .next, .play, .pause {
|
|
||||||
opacity: 1;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import FooterSongInfo from './FooterSongInfo.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('renders with no current song', () => expect(this.render(FooterSongInfo).html()).toMatchSnapshot())
|
||||||
|
|
||||||
|
it('renders with current song', () => {
|
||||||
|
const song = factory<Song>('song', {
|
||||||
|
title: 'Fahrstuhl zum Mond',
|
||||||
|
album_cover: 'https://via.placeholder.com/150',
|
||||||
|
playback_state: 'Playing',
|
||||||
|
artist_id: 10,
|
||||||
|
artist_name: 'Led Zeppelin'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(this.render(FooterSongInfo, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: ref(song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).html()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<div class="song-info" :class="{ playing: song?.playback_state === 'Playing' }">
|
||||||
|
<span :style="{ backgroundImage: `url('${cover}')` }" class="album-thumb"/>
|
||||||
|
<div class="meta" v-if="song">
|
||||||
|
<h3 class="title">{{ song.title }}</h3>
|
||||||
|
<a :href="`/#/artist/${song.artist_id}`" class="artist">{{ song.artist_name }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defaultCover, requireInjection } from '@/utils'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
|
||||||
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
|
const cover = computed(() => song.value?.album_cover || defaultCover)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.song-info {
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 320px;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
width: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-thumb {
|
||||||
|
display: block;
|
||||||
|
height: 75%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-size: cover;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
height: 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
display: block;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing .album-thumb {
|
||||||
|
animation: spin 30s linear infinite;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,9 +1,10 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div class="other-controls" data-testid="other-controls" data-v-8bf5fe81="">
|
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
|
||||||
<div class="wrapper" data-v-8bf5fe81="">
|
<div class="wrapper" data-v-8bf5fe81="">
|
||||||
<!--v-if--><button class="control" data-testid="toggle-visualizer-btn" title="Show/hide the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button><button title="Like Fahrstuhl to Heaven by Led Zeppelin" class="text-secondary like" data-testid="like-btn" data-v-5fcb4e02="" data-v-8bf5fe81=""><br data-testid="btn-like-unliked" icon="[object Object]" data-v-5fcb4e02=""></button><button class="active control text-uppercase" data-testid="toggle-extra-panel-btn" title="View song information" type="button" data-v-8bf5fe81=""> Info </button><a class="queue control" href="#/queue" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></a><br data-testid="RepeatModeSwitch" data-v-8bf5fe81=""><br data-testid="Volume" data-v-8bf5fe81="">
|
<!--v-if--><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Show/hide the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button>
|
||||||
|
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
// Vitest Snapshot v1
|
|
||||||
|
|
||||||
exports[`renders with a song 1`] = `
|
|
||||||
<div class="middle-pane" data-testid="footer-middle-pane" data-v-77c679f2="">
|
|
||||||
<div id="progressPane" class="progress" data-v-77c679f2="">
|
|
||||||
<h3 class="title" data-v-77c679f2="">Fahrstuhl to Heaven</h3>
|
|
||||||
<p class="meta" data-v-77c679f2=""><a href="/#/artist/3" class="artist" data-v-77c679f2="">Led Zeppelin</a> – <a href="/#/album/4" class="album" data-v-77c679f2="">Led Zeppelin IV</a></p>
|
|
||||||
<div class="plyr" data-v-77c679f2=""><audio controls="" crossorigin="anonymous" data-v-77c679f2=""></audio></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`renders without a song 1`] = `
|
|
||||||
<div class="middle-pane" data-testid="footer-middle-pane" data-v-77c679f2="">
|
|
||||||
<div id="progressPane" class="progress" data-v-77c679f2="">
|
|
||||||
<!--v-if-->
|
|
||||||
<div class="plyr" data-v-77c679f2=""><audio controls="" crossorigin="anonymous" data-v-77c679f2=""></audio></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders with a current song 1`] = `
|
||||||
|
<div class="playback-controls" data-testid="footer-middle-pane" data-v-2e8b419d="">
|
||||||
|
<div class="buttons" data-v-2e8b419d=""><button title="Unlike Fahrstuhl to Heaven by Led Zeppelin" data-testid="like-btn" type="button" class="like-btn" data-v-2e8b419d=""><br data-testid="btn-like-liked" icon="[object Object]"></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current mode: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
|
||||||
|
<div class="fa-layers" data-v-cab48a7c=""><br data-testid="icon" icon="[object Object]" data-v-cab48a7c="">
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
</button></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`renders without a current song 1`] = `
|
||||||
|
<div class="playback-controls" data-testid="footer-middle-pane" data-v-2e8b419d="">
|
||||||
|
<div class="buttons" data-v-2e8b419d=""><button type="button" data-v-2e8b419d=""></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current mode: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
|
||||||
|
<div class="fa-layers" data-v-cab48a7c=""><br data-testid="icon" icon="[object Object]" data-v-cab48a7c="">
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
</button></div>
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders with current song 1`] = `
|
||||||
|
<div class="song-info playing" data-v-91ed60f7=""><span style="background-image: url(https://via.placeholder.com/150);" class="album-thumb" data-v-91ed60f7=""></span>
|
||||||
|
<div class="meta" data-v-91ed60f7="">
|
||||||
|
<h3 class="title" data-v-91ed60f7="">Fahrstuhl zum Mond</h3><a href="/#/artist/10" class="artist" data-v-91ed60f7="">Led Zeppelin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`renders with no current song 1`] = `
|
||||||
|
<div class="song-info" data-v-91ed60f7=""><span style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="album-thumb" data-v-91ed60f7=""></span>
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -1,29 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<footer id="mainFooter" @contextmenu.prevent="requestContextMenu">
|
<footer id="mainFooter" @contextmenu.prevent="requestContextMenu">
|
||||||
<PlayerControls :song="song"/>
|
<AudioPlayer/>
|
||||||
|
|
||||||
<div class="media-info-wrap">
|
<div class="wrapper">
|
||||||
<MiddlePane :song="song"/>
|
<SongInfo/>
|
||||||
<ExtraControls :song="song"/>
|
<PlaybackControls/>
|
||||||
|
<ExtraControls/>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus, requireInjection } from '@/utils'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
|
||||||
|
import AudioPlayer from '@/components/layout/app-footer/AudioPlayer.vue'
|
||||||
|
import SongInfo from '@/components/layout/app-footer/FooterSongInfo.vue'
|
||||||
import ExtraControls from '@/components/layout/app-footer/FooterExtraControls.vue'
|
import ExtraControls from '@/components/layout/app-footer/FooterExtraControls.vue'
|
||||||
import MiddlePane from '@/components/layout/app-footer/FooterMiddlePane.vue'
|
import PlaybackControls from '@/components/layout/app-footer/FooterPlaybackControls.vue'
|
||||||
import PlayerControls from '@/components/layout/app-footer/FooterPlayerControls.vue'
|
|
||||||
|
|
||||||
const song = ref<Song>()
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
const requestContextMenu = (event: MouseEvent) => {
|
const requestContextMenu = (event: MouseEvent) => {
|
||||||
song.value?.id && eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
song.value && eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
eventBus.on('SONG_STARTED', (newSong: Song) => (song.value = newSong))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -31,33 +32,14 @@ footer {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
height: var(--footer-height);
|
height: var(--footer-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2);
|
||||||
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 99;
|
z-index: 1;
|
||||||
|
|
||||||
.media-info-wrap {
|
.wrapper {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
flex: 1;
|
||||||
|
|
||||||
// Add a reverse gradient here to eliminate the "hard cut" feel.
|
|
||||||
&::before {
|
|
||||||
content: " ";
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: calc(2 * var(--footer-height) / 3);
|
|
||||||
bottom: var(--footer-height);
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
// Safari 8 won't recognize rgba(255, 255, 255, 0) and treat it as black.
|
|
||||||
// rgba(#000, 0) is a workaround.
|
|
||||||
background-image: linear-gradient(to bottom, rgba(#000, 0) 0%, rgba(#000, .1) 100%);
|
|
||||||
pointer-events: none; // click-through
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
@include themed-background();
|
|
||||||
height: var(--footer-height-mobile);
|
|
||||||
padding-top: 12px; // leave space for the audio track
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,44 +1,73 @@
|
||||||
|
import { ref, Ref } from 'vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { fireEvent } from '@testing-library/vue'
|
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { commonStore } from '@/stores'
|
import { albumStore, artistStore, commonStore } from '@/stores'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
import ExtraPanel from './ExtraPanel.vue'
|
import ExtraPanel from './ExtraPanel.vue'
|
||||||
|
import { eventBus } from '@/utils'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private renderComponent () {
|
private renderComponent (songRef: Ref<Song | null> = ref(null)) {
|
||||||
return this.render(ExtraPanel, {
|
return this.render(ExtraPanel, {
|
||||||
props: {
|
|
||||||
song: factory<Song>('song')
|
|
||||||
},
|
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
LyricsPane: this.stub(),
|
ProfileAvatar: this.stub(),
|
||||||
AlbumInfo: this.stub(),
|
LyricsPane: this.stub('lyrics'),
|
||||||
ArtistInfo: this.stub(),
|
AlbumInfo: this.stub('album-info'),
|
||||||
YouTubeVideoList: this.stub()
|
ArtistInfo: this.stub('artist-info'),
|
||||||
|
YouTubeVideoList: this.stub('youtube-video-list'),
|
||||||
|
ExtraPanelTabHeader: this.stub()
|
||||||
|
},
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: songRef
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('has a YouTube tab if using YouTube ', () => {
|
it('renders without a current song', () => expect(this.renderComponent().html()).toMatchSnapshot())
|
||||||
|
|
||||||
|
it('fetches info for the current song', async () => {
|
||||||
commonStore.state.use_you_tube = true
|
commonStore.state.use_you_tube = true
|
||||||
this.renderComponent().getByTestId('extra-tab-youtube')
|
const artist = factory<Artist>('artist')
|
||||||
|
const resolveArtistMock = this.mock(artistStore, 'resolve').mockResolvedValue(artist)
|
||||||
|
|
||||||
|
const album = factory<Album>('album')
|
||||||
|
const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album)
|
||||||
|
|
||||||
|
const song = factory<Song>('song')
|
||||||
|
|
||||||
|
const songRef = ref<Song | null>(null)
|
||||||
|
|
||||||
|
const { getByTestId } = this.renderComponent(songRef)
|
||||||
|
songRef.value = song
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(resolveArtistMock).toHaveBeenCalledWith(song.artist_id)
|
||||||
|
expect(resolveAlbumMock).toHaveBeenCalledWith(song.album_id)
|
||||||
|
;['lyrics', 'album-info', 'artist-info', 'youtube-video-list'].forEach(id => getByTestId(id))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not have a YouTube tab if not using YouTube', () => {
|
it('shows About Koel model', async () => {
|
||||||
commonStore.state.use_you_tube = false
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
expect(this.renderComponent().queryByTestId('extra-tab-youtube')).toBeNull()
|
const { getByTitle } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByTitle('About Koel'))
|
||||||
|
|
||||||
|
expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_ABOUT_KOEL')
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([['extra-tab-lyrics'], ['extra-tab-album'], ['extra-tab-artist']])('switches to "%s" tab', async (id) => {
|
it('logs out', async () => {
|
||||||
const { getByTestId, container } = this.renderComponent()
|
const emitMock = this.mock(eventBus, 'emit')
|
||||||
|
const { getByTitle } = this.renderComponent()
|
||||||
|
|
||||||
await fireEvent.click(getByTestId(id))
|
await fireEvent.click(getByTitle('Log out'))
|
||||||
|
|
||||||
expect(container.querySelector('[aria-selected=true]')).toBe(getByTestId(id))
|
expect(emitMock).toHaveBeenCalledWith('LOG_OUT')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,58 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<section id="extra" :class="{ showing }" class="text-secondary" data-testid="extra-panel">
|
<div id="extraPanel" :class="{ 'showing-pane': selectedTab }">
|
||||||
<div class="tabs">
|
<div class="controls">
|
||||||
<div class="clear" role="tablist">
|
<div class="top">
|
||||||
<button
|
<SidebarMenuToggleButton class="burger"/>
|
||||||
id="extraTabLyrics"
|
<ExtraPanelTabHeader v-if="song" v-model="selectedTab"/>
|
||||||
:aria-selected="currentTab === 'Lyrics'"
|
|
||||||
aria-controls="extraPanelLyrics"
|
|
||||||
data-testid="extra-tab-lyrics"
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="currentTab = 'Lyrics'"
|
|
||||||
>
|
|
||||||
Lyrics
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="extraTabArtist"
|
|
||||||
:aria-selected="currentTab === 'Artist'"
|
|
||||||
aria-controls="extraPanelArtist"
|
|
||||||
data-testid="extra-tab-artist"
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="currentTab = 'Artist'"
|
|
||||||
>
|
|
||||||
Artist
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="extraTabAlbum"
|
|
||||||
:aria-selected="currentTab === 'Album'"
|
|
||||||
aria-controls="extraPanelAlbum"
|
|
||||||
data-testid="extra-tab-album"
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="currentTab = 'Album'"
|
|
||||||
>
|
|
||||||
Album
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="useYouTube"
|
|
||||||
id="extraTabYouTube"
|
|
||||||
:aria-selected="currentTab === 'YouTube'"
|
|
||||||
aria-controls="extraPanelYouTube"
|
|
||||||
data-testid="extra-tab-youtube"
|
|
||||||
role="tab"
|
|
||||||
title="YouTube"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="currentTab = 'YouTube'"
|
|
||||||
>
|
|
||||||
<icon :icon="faYoutube"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panes">
|
<div class="bottom">
|
||||||
|
<button title="About Koel" type="button" @click.prevent="openAboutKoelModal">
|
||||||
|
<icon :icon="faInfoCircle"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button title="Log out" type="button" @click.prevent="logout">
|
||||||
|
<icon :icon="faArrowRightFromBracket"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ProfileAvatar @click="onProfileLinkClick"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panes" v-if="song" v-show="selectedTab">
|
||||||
<div
|
<div
|
||||||
v-show="currentTab === 'Lyrics'"
|
v-show="selectedTab === 'Lyrics'"
|
||||||
id="extraPanelLyrics"
|
id="extraPanelLyrics"
|
||||||
aria-labelledby="extraTabLyrics"
|
aria-labelledby="extraTabLyrics"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
|
@ -62,27 +31,29 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="currentTab === 'Artist'"
|
v-show="selectedTab === 'Artist'"
|
||||||
id="extraPanelArtist"
|
id="extraPanelArtist"
|
||||||
aria-labelledby="extraTabArtist"
|
aria-labelledby="extraTabArtist"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<ArtistInfo v-if="artist" :artist="artist" mode="aside"/>
|
<ArtistInfo v-if="artist" :artist="artist" mode="aside"/>
|
||||||
|
<span v-else>Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="currentTab === 'Album'"
|
v-show="selectedTab === 'Album'"
|
||||||
id="extraPanelAlbum"
|
id="extraPanelAlbum"
|
||||||
aria-labelledby="extraTabAlbum"
|
aria-labelledby="extraTabAlbum"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<AlbumInfo v-if="album" :album="album" mode="aside"/>
|
<AlbumInfo v-if="album" :album="album" mode="aside"/>
|
||||||
|
<span v-else>Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="currentTab === 'YouTube'"
|
v-show="selectedTab === 'YouTube'"
|
||||||
id="extraPanelYouTube"
|
id="extraPanelYouTube"
|
||||||
aria-labelledby="extraTabYouTube"
|
aria-labelledby="extraTabYouTube"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
|
@ -92,72 +63,81 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import isMobile from 'ismobilejs'
|
import isMobile from 'ismobilejs'
|
||||||
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
import { faArrowRightFromBracket, faInfoCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ref, toRef, watch } from 'vue'
|
import { defineAsyncComponent, ref, watch } from 'vue'
|
||||||
import { eventBus } from '@/utils'
|
import { albumStore, artistStore } from '@/stores'
|
||||||
import { albumStore, artistStore, preferenceStore as preferences } from '@/stores'
|
import { useAuthorization, useThirdPartyServices } from '@/composables'
|
||||||
import { useThirdPartyServices } from '@/composables'
|
import { eventBus, logger, requireInjection } from '@/utils'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
|
||||||
import LyricsPane from '@/components/ui/LyricsPane.vue'
|
import ProfileAvatar from '@/components/ui/ProfileAvatar.vue'
|
||||||
import ArtistInfo from '@/components/artist/ArtistInfo.vue'
|
import SidebarMenuToggleButton from '@/components/ui/SidebarMenuToggleButton.vue'
|
||||||
import AlbumInfo from '@/components/album/AlbumInfo.vue'
|
|
||||||
import YouTubeVideoList from '@/components/ui/YouTubeVideoList.vue'
|
|
||||||
|
|
||||||
type Tab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
const LyricsPane = defineAsyncComponent(() => import('@/components/ui/LyricsPane.vue'))
|
||||||
const defaultTab: Tab = 'Lyrics'
|
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
|
||||||
|
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
|
||||||
const song = ref<Song | null>(null)
|
const YouTubeVideoList = defineAsyncComponent(() => import('@/components/ui/YouTubeVideoList.vue'))
|
||||||
const showing = toRef(preferences.state, 'showExtraPanel')
|
const ExtraPanelTabHeader = defineAsyncComponent(() => import('@/components/ui/ExtraPanelTabHeader.vue'))
|
||||||
const currentTab = ref<Tab>(defaultTab)
|
|
||||||
|
|
||||||
|
const { currentUser } = useAuthorization()
|
||||||
const { useYouTube } = useThirdPartyServices()
|
const { useYouTube } = useThirdPartyServices()
|
||||||
|
|
||||||
const artist = ref<Artist>()
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
const album = ref<Album>()
|
const selectedTab = ref<ExtraPanelTab | undefined>(undefined)
|
||||||
|
|
||||||
watch(showing, (showingExtraPanel) => {
|
const artist = ref<Artist | null>(null)
|
||||||
if (showingExtraPanel && !isMobile.any) {
|
const album = ref<Album | null>(null)
|
||||||
document.documentElement.classList.add('with-extra-panel')
|
|
||||||
} else {
|
watch(song, song => song && fetchSongInfo(song))
|
||||||
document.documentElement.classList.remove('with-extra-panel')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchSongInfo = async (_song: Song) => {
|
const fetchSongInfo = async (_song: Song) => {
|
||||||
try {
|
|
||||||
song.value = _song
|
song.value = _song
|
||||||
|
artist.value = null
|
||||||
|
album.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
artist.value = await artistStore.resolve(_song.artist_id)
|
artist.value = await artistStore.resolve(_song.artist_id)
|
||||||
album.value = await albumStore.resolve(_song.album_id)
|
album.value = await albumStore.resolve(_song.album_id)
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
throw err
|
logger.log('Failed to fetch media information', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventBus.on({
|
const openAboutKoelModal = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
|
||||||
SONG_STARTED: async (song: Song) => await fetchSongInfo(song),
|
const onProfileLinkClick = () => isMobile.any && (selectedTab.value = undefined)
|
||||||
KOEL_READY: () => {
|
const logout = () => eventBus.emit('LOG_OUT')
|
||||||
// On ready, add 'with-extra-panel' class.
|
|
||||||
isMobile.any || document.documentElement.classList.add('with-extra-panel')
|
|
||||||
|
|
||||||
// Hide the extra panel if on mobile
|
|
||||||
isMobile.phone && (showing.value = false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
#extra {
|
#extraPanel {
|
||||||
flex: 0 0 var(--extra-panel-width);
|
display: flex;
|
||||||
padding-top: 2.3rem;
|
flex-direction: row-reverse;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
height: var(--header-height);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
@include themed-background();
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.showing-pane {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
width: var(--extra-panel-width);
|
||||||
|
padding: 2rem 1.7rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
display: none;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
// Enable scroll with momentum on touch devices
|
// Enable scroll with momentum on touch devices
|
||||||
|
@ -165,38 +145,79 @@ eventBus.on({
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.showing {
|
@media screen and (max-width: 768px) {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 64px;
|
||||||
|
padding: 1.6rem 0 1.2rem;
|
||||||
|
background-color: rgba(0, 0, 0, .05);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, .05);
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
z-index: 2;
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||||
|
box-shadow: 0 0 30px 0 rgba(0, 0, 0, .75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top, .bottom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep(button) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 42px;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
border-radius: 999rem;
|
||||||
|
background: rgba(0, 0, 0, .3);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: .7;
|
||||||
|
transition: opacity .2s ease-in-out;
|
||||||
|
color: currentColor;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &.active {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.burger {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-weight: var(--font-weight-thin);
|
|
||||||
font-size: 2.2rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
line-height: 2.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 1024px) {
|
|
||||||
position: fixed;
|
|
||||||
height: calc(100vh - var(--header-height));
|
|
||||||
width: var(--extra-panel-width);
|
|
||||||
z-index: 9;
|
|
||||||
top: var(--header-height);
|
|
||||||
right: -100%;
|
|
||||||
transition: right .3s ease-in;
|
|
||||||
|
|
||||||
&.showing {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 667px) {
|
|
||||||
@include themed-background();
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
[role=tabpanel] {
|
|
||||||
padding-bottom: calc(var(--footer-height-mobile) + 1rem)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
import { waitFor } from '@testing-library/vue'
|
import { waitFor } 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 { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import { albumStore, preferenceStore } from '@/stores'
|
import { albumStore, preferenceStore } from '@/stores'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
|
import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
|
||||||
import MainContent from './MainContent.vue'
|
import MainContent from './MainContent.vue'
|
||||||
|
|
||||||
|
@ -16,12 +18,13 @@ new class extends UnitTestCase {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
AlbumArtOverlay
|
AlbumArtOverlay
|
||||||
|
},
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: ref(factory<Song>('song'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
eventBus.emit('SONG_STARTED', factory<Song>('song'))
|
|
||||||
|
|
||||||
await waitFor(() => getByTestId('album-art-overlay'))
|
await waitFor(() => getByTestId('album-art-overlay'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -32,12 +35,13 @@ new class extends UnitTestCase {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
AlbumArtOverlay
|
AlbumArtOverlay
|
||||||
|
},
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: ref(factory<Song>('song'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
eventBus.emit('SONG_STARTED', factory<Song>('song'))
|
|
||||||
|
|
||||||
await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull())
|
await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
|
||||||
import { eventBus, requireInjection } from '@/utils'
|
import { eventBus, requireInjection } from '@/utils'
|
||||||
import { preferenceStore } from '@/stores'
|
import { preferenceStore } from '@/stores'
|
||||||
import { useThirdPartyServices } from '@/composables'
|
import { useThirdPartyServices } from '@/composables'
|
||||||
import { RouterKey } from '@/symbols'
|
import { CurrentSongKey, RouterKey } from '@/symbols'
|
||||||
|
|
||||||
import HomeScreen from '@/components/screens/HomeScreen.vue'
|
import HomeScreen from '@/components/screens/HomeScreen.vue'
|
||||||
import QueueScreen from '@/components/screens/QueueScreen.vue'
|
import QueueScreen from '@/components/screens/QueueScreen.vue'
|
||||||
|
@ -60,19 +60,17 @@ const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/N
|
||||||
const Visualizer = defineAsyncComponent(() => import('@/components/ui/Visualizer.vue'))
|
const Visualizer = defineAsyncComponent(() => import('@/components/ui/Visualizer.vue'))
|
||||||
|
|
||||||
const { useYouTube } = useThirdPartyServices()
|
const { useYouTube } = useThirdPartyServices()
|
||||||
|
|
||||||
const router = requireInjection(RouterKey)
|
const router = requireInjection(RouterKey)
|
||||||
|
const currentSong = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
|
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
|
||||||
const showingVisualizer = ref(false)
|
const showingVisualizer = ref(false)
|
||||||
const screen = ref<ScreenName>('Home')
|
const screen = ref<ScreenName>('Home')
|
||||||
const currentSong = ref<Song | null>(null)
|
|
||||||
|
|
||||||
router.onRouteChanged(route => (screen.value = route.screen))
|
router.onRouteChanged(route => (screen.value = route.screen))
|
||||||
|
|
||||||
eventBus.on({
|
eventBus.on('TOGGLE_VISUALIZER', () => (showingVisualizer.value = !showingVisualizer.value))
|
||||||
TOGGLE_VISUALIZER: () => (showingVisualizer.value = !showingVisualizer.value),
|
|
||||||
SONG_STARTED: (song: Song) => (currentSong.value = song)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => router.resolve())
|
onMounted(() => router.resolve())
|
||||||
</script>
|
</script>
|
||||||
|
@ -94,7 +92,7 @@ onMounted(() => router.resolve())
|
||||||
|
|
||||||
.main-scroll-wrap {
|
.main-scroll-wrap {
|
||||||
&:not(.song-list-wrap) {
|
&:not(.song-list-wrap) {
|
||||||
padding: 24px 24px 48px;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
|
@ -120,7 +118,7 @@ onMounted(() => router.resolve())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 375px) {
|
@media screen and (max-width: 768px) {
|
||||||
> section {
|
> section {
|
||||||
// Leave some space for the "Back to top" button
|
// Leave some space for the "Back to top" button
|
||||||
.main-scroll-wrap {
|
.main-scroll-wrap {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<nav id="sidebar" :class="{ showing }" class="side side-nav">
|
<nav id="sidebar" :class="{ showing: mobileShowing }" class="side side-nav" v-koel-clickaway="closeIfMobile">
|
||||||
|
<SearchForm/>
|
||||||
<section class="music">
|
<section class="music">
|
||||||
<h1>Your Music</h1>
|
<h1>Your Music</h1>
|
||||||
|
|
||||||
|
@ -78,7 +79,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import isMobile from 'ismobilejs'
|
|
||||||
import {
|
import {
|
||||||
faCompactDisc,
|
faCompactDisc,
|
||||||
faHome,
|
faHome,
|
||||||
|
@ -97,8 +97,9 @@ import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composa
|
||||||
import { RouterKey } from '@/symbols'
|
import { RouterKey } from '@/symbols'
|
||||||
|
|
||||||
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
|
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
|
||||||
|
import SearchForm from '@/components/ui/SearchForm.vue'
|
||||||
|
|
||||||
const showing = ref(!isMobile.phone)
|
const mobileShowing = ref(false)
|
||||||
const activeScreen = ref<ScreenName>()
|
const activeScreen = ref<ScreenName>()
|
||||||
const droppableToQueue = ref(false)
|
const droppableToQueue = ref(false)
|
||||||
|
|
||||||
|
@ -128,14 +129,12 @@ const onQueueDrop = async (event: DragEvent) => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeIfMobile = () => (mobileShowing.value = false)
|
||||||
|
|
||||||
const router = requireInjection(RouterKey)
|
const router = requireInjection(RouterKey)
|
||||||
|
|
||||||
router.onRouteChanged(route => {
|
router.onRouteChanged(route => {
|
||||||
// On mobile, hide the sidebar whenever a screen is activated.
|
mobileShowing.value = false
|
||||||
if (isMobile.phone) {
|
|
||||||
showing.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
activeScreen.value = route.screen
|
activeScreen.value = route.screen
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -144,15 +143,15 @@ eventBus.on({
|
||||||
* Listen to toggle sidebar event to show or hide the sidebar.
|
* Listen to toggle sidebar event to show or hide the sidebar.
|
||||||
* This should only be triggered on a mobile device.
|
* This should only be triggered on a mobile device.
|
||||||
*/
|
*/
|
||||||
TOGGLE_SIDEBAR: () => (showing.value = !showing.value)
|
TOGGLE_SIDEBAR: () => (mobileShowing.value = !mobileShowing.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
nav {
|
nav {
|
||||||
flex: 0 0 256px;
|
width: var(--sidebar-width);
|
||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
padding: 2.05rem 0;
|
padding: 2.05rem 1.5rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
@ -183,55 +182,66 @@ nav {
|
||||||
::v-deep(h1) {
|
::v-deep(h1) {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
padding: 0 16px;
|
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::v-deep(a svg) {
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
::v-deep(a) {
|
::v-deep(a) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .7rem;
|
gap: .7rem;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
padding: 0 16px 0 12px;
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
position: relative;
|
||||||
&.active, &:hover {
|
|
||||||
border-left-color: var(--color-highlight);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background: rgba(255, 255, 255, .05);
|
|
||||||
box-shadow: 0 1px 0 rgba(0, 0, 0, .1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: .5;
|
padding: 2px 0 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&.active, &:hover {
|
||||||
border-left-color: var(--color-highlight);
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 25%;
|
||||||
|
right: -1.5rem;
|
||||||
|
width: 4px;
|
||||||
|
height: 50%;
|
||||||
|
background-color: var(--color-highlight);
|
||||||
|
box-shadow: 0 0 40px 10px var(--color-highlight);
|
||||||
|
border-radius: 9999rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep(li li a) { // submenu items
|
::v-deep(li li a) { // submenu items
|
||||||
padding-left: 24px;
|
padding-left: 11px;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
padding: 2px 0 0 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 667px) {
|
@media screen and (max-width: 768px) {
|
||||||
@include themed-background();
|
@include themed-background();
|
||||||
|
transform: translateX(-100vw);
|
||||||
|
transition: transform .2s ease-in-out;
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
height: calc(100vh - var(--header-height) + var(--footer-height));
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
top: var(--header-height);
|
height: calc(100vh - var(--header-height));
|
||||||
left: -100%;
|
|
||||||
transition: left .3s ease-in;
|
|
||||||
|
|
||||||
&.showing {
|
&.showing {
|
||||||
left: 0;
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders without a current song 1`] = `
|
||||||
|
<div id="extraPanel" class="" data-v-10bd6447="">
|
||||||
|
<div class="controls" data-v-10bd6447="">
|
||||||
|
<div class="top" data-v-10bd6447=""><button class="burger" data-v-10bd6447=""><br data-testid="icon" icon="[object Object]"></button>
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
<div class="bottom" data-v-10bd6447=""><button title="About Koel" type="button" data-v-10bd6447=""><br data-testid="icon" icon="[object Object]" data-v-10bd6447=""></button><button title="Log out" type="button" data-v-10bd6447=""><br data-testid="icon" icon="[object Object]" data-v-10bd6447=""></button><br data-testid="stub" data-v-10bd6447=""></div>
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -8,7 +8,7 @@
|
||||||
</p>
|
</p>
|
||||||
<button data-testid="hide-support-koel" type="button" @click.prevent="close">Hide</button>
|
<button data-testid="hide-support-koel" type="button" @click.prevent="close">Hide</button>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button data-testid="stop-support-koel-bugging" @click.prevent="stopBugging" type="button">
|
<button data-testid="stop-support-koel-bugging" type="button" @click.prevent="stopBugging">
|
||||||
Don't bug me again
|
Don't bug me again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -132,7 +132,6 @@ router.onRouteChanged(route => {
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.playlist {
|
.playlist {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.droppable {
|
&.droppable {
|
||||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
@ -145,10 +144,5 @@ router.onRouteChanged(route => {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
|
||||||
width: calc(100% - 32px);
|
|
||||||
margin: 5px 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -101,7 +101,7 @@ const download = () => downloadService.fromAlbum(album.value!)
|
||||||
const showInfo = () => (showingInfo.value = true)
|
const showInfo = () => (showingInfo.value = true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const id = parseInt(router.$currentRoute.value?.params!.id)
|
const id = parseInt(router.$currentRoute.value.params!.id)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -102,7 +102,7 @@ const download = () => downloadService.fromArtist(artist.value!)
|
||||||
const showInfo = () => (showingInfo.value = true)
|
const showInfo = () => (showingInfo.value = true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const id = parseInt(router.$currentRoute.value!.params!.id)
|
const id = parseInt(router.$currentRoute.value.params!.id)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -111,7 +111,7 @@ useScreen('Home').onScreenActivated(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-scroll-wrap {
|
.main-scroll-wrap {
|
||||||
section {
|
section:not(:last-of-type) {
|
||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,10 +17,11 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
||||||
import createYouTubePlayer from 'youtube-player'
|
import createYouTubePlayer from 'youtube-player'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import type { YouTubePlayer } from 'youtube-player/dist/types'
|
import type { YouTubePlayer } from 'youtube-player/dist/types'
|
||||||
import { eventBus, use } from '@/utils'
|
import { eventBus, requireInjection, use } from '@/utils'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||||
|
@ -42,20 +43,20 @@ const getPlayer = () => {
|
||||||
return player
|
return player
|
||||||
}
|
}
|
||||||
|
|
||||||
eventBus.on({
|
const currentSong = requireInjection(CurrentSongKey)
|
||||||
PLAY_YOUTUBE_VIDEO (payload: { id: string, title: string }) {
|
|
||||||
|
/**
|
||||||
|
* Pause video playback when a song is played/resumed.
|
||||||
|
*/
|
||||||
|
watch(() => currentSong.value?.playback_state, state => state === 'Playing' && player?.pauseVideo())
|
||||||
|
|
||||||
|
eventBus.on('PLAY_YOUTUBE_VIDEO', (payload: { id: string, title: string }) => {
|
||||||
title.value = payload.title
|
title.value = payload.title
|
||||||
|
|
||||||
use(getPlayer(), player => {
|
use(getPlayer(), player => {
|
||||||
player.loadVideoById(payload.id)
|
player.loadVideoById(payload.id)
|
||||||
player.playVideo()
|
player.playVideo()
|
||||||
})
|
})
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop video playback when a song is played/resumed.
|
|
||||||
*/
|
|
||||||
SONG_STARTED: () => player && player.pauseVideo()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -79,6 +79,7 @@ article {
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<button @click.stop="toggleLike" :title="title" class="text-secondary" data-testid="like-btn">
|
<button :title="title" data-testid="like-btn" type="button" @click.stop="toggleLike">
|
||||||
<icon v-if="song.liked" :icon="faHeart" class="text-maroon" data-testid="btn-like-liked"/>
|
<icon v-if="song.liked" :icon="faHeart" data-testid="btn-like-liked"/>
|
||||||
<icon v-else :icon="faEmptyHeart" data-testid="btn-like-unliked"/>
|
<icon v-else :icon="faEmptyHeart" data-testid="btn-like-unliked"/>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
@ -18,11 +18,3 @@ const title = computed(() => `${song.value.liked ? 'Unlike' : 'Like'} ${song.val
|
||||||
|
|
||||||
const toggleLike = () => favoriteStore.toggleOne(song.value)
|
const toggleLike = () => favoriteStore.toggleOne(song.value)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
button {
|
|
||||||
&:hover .fa-heart {
|
|
||||||
color: var(--color-maroon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -308,6 +308,10 @@ onMounted(() => render())
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.song-list-header {
|
.song-list-header {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
@ -402,8 +406,6 @@ onMounted(() => render())
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
@media only screen and (max-width: 768px) {
|
||||||
padding: 12px;
|
|
||||||
|
|
||||||
.song-list-header {
|
.song-list-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,10 @@
|
||||||
<SoundBars v-if="song.playback_state === 'Playing'"/>
|
<SoundBars v-if="song.playback_state === 'Playing'"/>
|
||||||
<span class="text-secondary" v-else>{{ song.track || '' }}</span>
|
<span class="text-secondary" v-else>{{ song.track || '' }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="columns.includes('title')" class="title">{{ song.title }}</span>
|
<span v-if="columns.includes('title')" class="title text-primary">{{ song.title }}</span>
|
||||||
<span v-if="columns.includes('artist')" class="artist">{{ song.artist_name }}</span>
|
<span v-if="columns.includes('artist')" class="artist">{{ song.artist_name }}</span>
|
||||||
<span v-if="columns.includes('album')" class="album">{{ song.album_name }}</span>
|
<span v-if="columns.includes('album')" class="album">{{ song.album_name }}</span>
|
||||||
<span v-if="columns.includes('length')" class="time text-secondary">{{ fmtLength }}</span>
|
<span v-if="columns.includes('length')" class="time">{{ fmtLength }}</span>
|
||||||
<span class="favorite">
|
<span class="favorite">
|
||||||
<LikeButton :song="song"/>
|
<LikeButton :song="song"/>
|
||||||
</span>
|
</span>
|
||||||
|
@ -65,6 +65,7 @@ const doPlayback = () => {
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.song-item {
|
.song-item {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
border-bottom: 1px solid var(--color-bg-secondary);
|
border-bottom: 1px solid var(--color-bg-secondary);
|
||||||
max-width: 100% !important; // overriding .item
|
max-width: 100% !important; // overriding .item
|
||||||
height: 35px;
|
height: 35px;
|
||||||
|
@ -83,8 +84,16 @@ const doPlayback = () => {
|
||||||
background-color: rgba(255, 255, 255, .08);
|
background-color: rgba(255, 255, 255, .08);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.playing > span {
|
&.playing {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: currentColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -171,7 +171,6 @@ const onDrop = async (event: DragEvent) => {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-left: 4%; // to balance the play icon
|
padding-left: 4%; // to balance the play icon
|
||||||
z-index: 99;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
|
|
|
@ -31,7 +31,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(var(--footer-height-mobile) + 26px);
|
bottom: calc(var(--footer-height) + 26px);
|
||||||
right: 1.8rem;
|
right: 1.8rem;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
|
@ -97,3 +97,9 @@ eventBus.on('CONTEXT_MENU_OPENED', target => target === el || close())
|
||||||
|
|
||||||
defineExpose({ open, close, shown })
|
defineExpose({ open, close, shown })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
nav {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -185,6 +185,7 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
box-shadow: 0 0 50x 0 var(--color-bg-primary);
|
||||||
|
|
||||||
label {
|
label {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
@ -336,7 +337,7 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
|
||||||
max-width: 414px;
|
max-width: 414px;
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: calc(var(--footer-height-mobile) + 0px);
|
bottom: var(--footer-height);
|
||||||
display: block;
|
display: block;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import ExtraPanelTabHeader from './ExtraPanelTabHeader.vue'
|
||||||
|
import { commonStore } from '@/stores'
|
||||||
|
import { fireEvent } from '@testing-library/vue'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('renders tab headers', () => {
|
||||||
|
commonStore.state.use_you_tube = false
|
||||||
|
const { getByTitle, queryByTitle } = this.render(ExtraPanelTabHeader)
|
||||||
|
|
||||||
|
;['Lyrics', 'Artist information', 'Album information'].forEach(title => getByTitle(title))
|
||||||
|
expect(queryByTitle('Related YouTube videos')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a YouTube tab header if using YouTube', () => {
|
||||||
|
commonStore.state.use_you_tube = true
|
||||||
|
const { getByTitle } = this.render(ExtraPanelTabHeader)
|
||||||
|
|
||||||
|
getByTitle('Related YouTube videos')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the selected tab value', async () => {
|
||||||
|
const { getByTitle, emitted } = this.render(ExtraPanelTabHeader)
|
||||||
|
|
||||||
|
await fireEvent.click(getByTitle('Lyrics'))
|
||||||
|
|
||||||
|
expect(emitted()['update:modelValue']).toEqual([['Lyrics']])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
63
resources/assets/js/components/ui/ExtraPanelTabHeader.vue
Normal file
63
resources/assets/js/components/ui/ExtraPanelTabHeader.vue
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
id="extraTabLyrics"
|
||||||
|
:class="{ active: value === 'Lyrics' }"
|
||||||
|
title="Lyrics"
|
||||||
|
type="button"
|
||||||
|
@click.prevent="toggleTab('Lyrics')"
|
||||||
|
>
|
||||||
|
<icon :icon="faFileLines" fixed-width/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="extraTabArtist"
|
||||||
|
:class="{ active: value === 'Artist' }"
|
||||||
|
title="Artist information"
|
||||||
|
type="button"
|
||||||
|
@click.prevent="toggleTab('Artist')"
|
||||||
|
>
|
||||||
|
<icon :icon="faMicrophone" fixed-width/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="extraTabAlbum"
|
||||||
|
:class="{ active: value === 'Album' }"
|
||||||
|
title="Album information"
|
||||||
|
type="button"
|
||||||
|
@click.prevent="toggleTab('Album')"
|
||||||
|
>
|
||||||
|
<icon :icon="faCompactDisc" fixed-width/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="useYouTube"
|
||||||
|
id="extraTabYouTube"
|
||||||
|
:class="{ active: value === 'YouTube' }"
|
||||||
|
title="Related YouTube videos"
|
||||||
|
type="button"
|
||||||
|
@click.prevent="toggleTab('YouTube')"
|
||||||
|
>
|
||||||
|
<icon :icon="faYoutube" fixed-width/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { faCompactDisc, faFileLines, faMicrophone } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useThirdPartyServices } from '@/composables'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue?: ExtraPanelTab }>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const { useYouTube } = useThirdPartyServices()
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleTab = (tab: ExtraPanelTab) => (value.value = value.value === tab ? undefined : tab)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
125
resources/assets/js/components/ui/FooterPlayButton.spec.ts
Normal file
125
resources/assets/js/components/ui/FooterPlayButton.spec.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
|
import { fireEvent, getByRole, waitFor } from '@testing-library/vue'
|
||||||
|
import { CurrentSongKey } from '@/symbols'
|
||||||
|
import { commonStore, favoriteStore, queueStore, recentlyPlayedStore, songStore } from '@/stores'
|
||||||
|
import FooterPlayButton from './FooterPlayButton.vue'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
private renderComponent (currentSong: Song | null = null) {
|
||||||
|
return this.render(FooterPlayButton, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[CurrentSongKey]: ref(currentSong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected test () {
|
||||||
|
it('toggles the playback of current song', async () => {
|
||||||
|
const toggleMock = this.mock(playbackService, 'toggle')
|
||||||
|
const { getByRole } = this.renderComponent(factory<Song>('song'))
|
||||||
|
|
||||||
|
await fireEvent.click(getByRole('button'))
|
||||||
|
|
||||||
|
expect(toggleMock).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each<[ScreenName, MethodOf<typeof songStore>]>([
|
||||||
|
['Album', 'fetchForAlbum'],
|
||||||
|
['Artist', 'fetchForArtist'],
|
||||||
|
['Playlist', 'fetchForPlaylist']
|
||||||
|
])('initiates playback for %s screen', async (screen, fetchMethod) => {
|
||||||
|
commonStore.state.song_count = 10
|
||||||
|
const songs = factory<Song>('song', 3)
|
||||||
|
const fetchMock = this.mock(songStore, fetchMethod).mockResolvedValue(songs)
|
||||||
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
|
const goMock = this.mock(this.router, 'go')
|
||||||
|
|
||||||
|
this.router.activateRoute({
|
||||||
|
screen,
|
||||||
|
path: '_'
|
||||||
|
}, { id: '42' })
|
||||||
|
|
||||||
|
const { getByRole } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(42)
|
||||||
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
|
expect(goMock).toHaveBeenCalledWith('queue')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each<[ScreenName, object, string]>([
|
||||||
|
['Favorites', favoriteStore, 'fetch'],
|
||||||
|
['RecentlyPlayed', recentlyPlayedStore, 'fetch']
|
||||||
|
])('initiates playback for %s screen', async (screen, store, fetchMethod) => {
|
||||||
|
commonStore.state.song_count = 10
|
||||||
|
const songs = factory<Song>('song', 3)
|
||||||
|
const fetchMock = this.mock(store, fetchMethod).mockResolvedValue(songs)
|
||||||
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
|
const goMock = this.mock(this.router, 'go')
|
||||||
|
|
||||||
|
this.router.activateRoute({
|
||||||
|
screen,
|
||||||
|
path: '_'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByRole } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled()
|
||||||
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
|
expect(goMock).toHaveBeenCalledWith('queue')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each<[ScreenName]>([['Queue'], ['Songs'], ['Albums']])('initiates playback %s screen', async (screen) => {
|
||||||
|
commonStore.state.song_count = 10
|
||||||
|
const songs = factory<Song>('song', 3)
|
||||||
|
const fetchMock = this.mock(queueStore, 'fetchRandom').mockResolvedValue(songs)
|
||||||
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
|
const goMock = this.mock(this.router, 'go')
|
||||||
|
|
||||||
|
this.router.activateRoute({
|
||||||
|
screen,
|
||||||
|
path: '_'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByRole } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled()
|
||||||
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
|
expect(goMock).toHaveBeenCalledWith('queue')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing if there are no songs', async () => {
|
||||||
|
commonStore.state.song_count = 0
|
||||||
|
|
||||||
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
|
const goMock = this.mock(this.router, 'go')
|
||||||
|
|
||||||
|
this.router.activateRoute({
|
||||||
|
screen: 'Songs',
|
||||||
|
path: '_'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { getByRole } = this.renderComponent()
|
||||||
|
|
||||||
|
await fireEvent.click(getByRole('button'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(playMock).not.toHaveBeenCalled()
|
||||||
|
expect(goMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
70
resources/assets/js/components/ui/FooterPlayButton.vue
Normal file
70
resources/assets/js/components/ui/FooterPlayButton.vue
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<button type="button" :class="playing ? 'playing' : 'stopped'" title="Play or resume" @click.prevent="toggle">
|
||||||
|
<icon v-if="playing" :icon="faPause"/>
|
||||||
|
<icon v-else :icon="faPlay"/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
|
import { commonStore, favoriteStore, queueStore, recentlyPlayedStore, songStore } from '@/stores'
|
||||||
|
import { requireInjection } from '@/utils'
|
||||||
|
import { CurrentSongKey, RouterKey } from '@/symbols'
|
||||||
|
|
||||||
|
const router = requireInjection(RouterKey)
|
||||||
|
const song = requireInjection(CurrentSongKey, ref(null))
|
||||||
|
|
||||||
|
const libraryEmpty = computed(() => commonStore.state.song_count === 0)
|
||||||
|
const playing = computed(() => song.value?.playback_state === 'Playing')
|
||||||
|
|
||||||
|
const toggle = async () => song.value ? playbackService.toggle() : initiatePlayback()
|
||||||
|
|
||||||
|
const initiatePlayback = async () => {
|
||||||
|
if (libraryEmpty.value) return
|
||||||
|
|
||||||
|
let songs: Song[]
|
||||||
|
|
||||||
|
switch (router.$currentRoute.value.screen) {
|
||||||
|
case 'Album':
|
||||||
|
songs = await songStore.fetchForAlbum(parseInt(router.$currentRoute.value.params!.id))
|
||||||
|
break
|
||||||
|
case 'Artist':
|
||||||
|
songs = await songStore.fetchForArtist(parseInt(router.$currentRoute.value.params!.id))
|
||||||
|
break
|
||||||
|
case 'Playlist':
|
||||||
|
songs = await songStore.fetchForPlaylist(parseInt(router.$currentRoute.value.params!.id))
|
||||||
|
break
|
||||||
|
case 'Favorites':
|
||||||
|
songs = await favoriteStore.fetch()
|
||||||
|
break
|
||||||
|
case 'RecentlyPlayed':
|
||||||
|
songs = await recentlyPlayedStore.fetch()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
songs = await queueStore.fetchRandom()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await playbackService.queueAndPlay(songs)
|
||||||
|
router.go('queue')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
button {
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
|
||||||
|
&.stopped {
|
||||||
|
text-indent: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-text-primary) !important;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
17
resources/assets/js/components/ui/ProfileAvatar.spec.ts
Normal file
17
resources/assets/js/components/ui/ProfileAvatar.spec.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import ProfileAvatar from './ProfileAvatar.vue'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('renders', () => {
|
||||||
|
const user = factory<User>('user', {
|
||||||
|
name: 'John Doe',
|
||||||
|
avatar: 'https://example.com/avatar.jpg'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(this.actingAs(user).render(ProfileAvatar).html()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
31
resources/assets/js/components/ui/ProfileAvatar.vue
Normal file
31
resources/assets/js/components/ui/ProfileAvatar.vue
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<template>
|
||||||
|
<a class="view-profile" data-testid="view-profile-link" href="/#/profile" title="View/edit user profile">
|
||||||
|
<img :alt="`Avatar of ${currentUser.name}`" :src="currentUser.avatar"/>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useAuthorization } from '@/composables'
|
||||||
|
|
||||||
|
const { currentUser } = useAuthorization()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 39px;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
transition: border .2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(255, 255, 255, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,7 +2,6 @@
|
||||||
<button
|
<button
|
||||||
:class="{ active: mode !== 'NO_REPEAT' }"
|
:class="{ active: mode !== 'NO_REPEAT' }"
|
||||||
:title="`Change repeat mode (current mode: ${readableMode})`"
|
:title="`Change repeat mode (current mode: ${readableMode})`"
|
||||||
class="control"
|
|
||||||
data-testid="repeat-mode-switch"
|
data-testid="repeat-mode-switch"
|
||||||
type="button"
|
type="button"
|
||||||
@click.prevent="changeMode"
|
@click.prevent="changeMode"
|
||||||
|
@ -39,11 +38,15 @@ const changeMode = () => playbackService.changeRepeatMode()
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
color: var(--color-accent);
|
color: currentColor;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
opacity: .3;
|
||||||
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: var(--color-highlight);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,26 +1,33 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="searchForm" class="side search" role="search">
|
<form id="searchForm" role="search" @submit.prevent="onSubmit">
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="q"
|
v-model="q"
|
||||||
:class="{ dirty: q }"
|
:class="{ dirty: q }"
|
||||||
autocorrect="false"
|
autocorrect="false"
|
||||||
name="q"
|
name="q"
|
||||||
placeholder="Press F to search"
|
:placeholder="placeholder"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
type="search"
|
type="search"
|
||||||
@focus="goToSearchScreen"
|
@focus="maybeGoToSearchScreen"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
>
|
>
|
||||||
</div>
|
<button type="submit" title="Search">
|
||||||
|
<icon :icon="faSearch"/>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import isMobile from 'ismobilejs'
|
||||||
|
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { eventBus, requireInjection } from '@/utils'
|
import { eventBus, requireInjection } from '@/utils'
|
||||||
import { RouterKey } from '@/symbols'
|
import { RouterKey } from '@/symbols'
|
||||||
|
|
||||||
|
const placeholder = isMobile.any ? 'Search' : 'Press F to search'
|
||||||
|
|
||||||
const router = requireInjection(RouterKey)
|
const router = requireInjection(RouterKey)
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
|
@ -35,7 +42,12 @@ if (process.env.NODE_ENV !== 'test') {
|
||||||
onInput = debounce(onInput, 500)
|
onInput = debounce(onInput, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToSearchScreen = () => router.go('search')
|
const onSubmit = () => {
|
||||||
|
eventBus.emit('TOGGLE_SIDEBAR')
|
||||||
|
maybeGoToSearchScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeGoToSearchScreen = () => isMobile.any || router.go('search')
|
||||||
|
|
||||||
eventBus.on({
|
eventBus.on({
|
||||||
FOCUS_SEARCH_FIELD: () => {
|
FOCUS_SEARCH_FIELD: () => {
|
||||||
|
@ -47,35 +59,44 @@ eventBus.on({
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
#searchForm {
|
#searchForm {
|
||||||
@include vertical-center();
|
display: flex;
|
||||||
flex: 0 0 256px;
|
align-items: stretch;
|
||||||
order: -1;
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: border .2s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
input[type="search"] {
|
button {
|
||||||
width: 218px;
|
display: none;
|
||||||
margin-top: 0;
|
padding: 0 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, .05);
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 667px) {
|
&:focus-within {
|
||||||
z-index: 100;
|
border: 1px solid rgba(255, 255, 255, .2);
|
||||||
position: absolute;
|
}
|
||||||
left: 0;
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
top: var(--header-height);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, .1);
|
|
||||||
|
|
||||||
input[type="search"] {
|
input[type="search"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
border-radius: 0;
|
||||||
|
height: 36px;
|
||||||
|
background: rgba(0, 0, 0, .2);
|
||||||
|
transition: .3s background-color;
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: rgba(0, 0, 0, .5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop & {
|
&::placeholder {
|
||||||
justify-content: flex-end;
|
color: rgba(255, 255, 255, .5);
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
width: 160px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
<template>
|
||||||
|
<button @click.prevent.stop="toggleSidebar">
|
||||||
|
<icon :icon="faBars"/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { eventBus } from '@/utils'
|
||||||
|
|
||||||
|
const toggleSidebar = () => eventBus.emit('TOGGLE_SIDEBAR')
|
||||||
|
</script>
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<span id="volume" class="volume control">
|
<span id="volume" class="volume" :class="level">
|
||||||
<icon
|
<icon
|
||||||
v-if="level === 'muted'"
|
v-if="level === 'muted'"
|
||||||
:icon="faVolumeMute"
|
:icon="faVolumeMute"
|
||||||
|
@ -73,30 +73,45 @@ eventBus.on('KOEL_READY', () => setLevel(preferences.volume))
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
#volume {
|
#volume {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 99;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
// More tweaks
|
|
||||||
[type=range] {
|
[type=range] {
|
||||||
margin: 0 0 0 8px;
|
margin: 0 0 0 8px;
|
||||||
transform: rotate(270deg);
|
width: 120px;
|
||||||
transform-origin: 0;
|
height: 4px;
|
||||||
position: absolute;
|
|
||||||
bottom: -22px;
|
|
||||||
border: 14px solid var(--color-bg-primary);
|
|
||||||
border-left-width: 30px;
|
|
||||||
z-index: 0;
|
|
||||||
width: 140px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover [type=range] {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
[role=button] {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
|
||||||
|
// increase click area
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: ' ';
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: -12px;
|
||||||
|
bottom: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
[type=range] {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
@media only screen and (max-width: 768px) {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`renders 1`] = `<a class="view-profile" data-testid="view-profile-link" href="/#/profile" title="View/edit user profile" data-v-663f2e50=""><img alt="Avatar of John Doe" src="https://example.com/avatar.jpg" data-v-663f2e50=""></a>`;
|
|
@ -33,9 +33,6 @@ const logout = () => eventBus.emit('LOG_OUT')
|
||||||
@include vertical-center();
|
@include vertical-center();
|
||||||
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex: 0 0 var(--extra-panel-width);
|
|
||||||
text-align: right;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
|
|
|
@ -6,9 +6,8 @@
|
||||||
/**
|
/**
|
||||||
* Global event listeners (basically, those without a Vue instance access) go here.
|
* Global event listeners (basically, those without a Vue instance access) go here.
|
||||||
*/
|
*/
|
||||||
import isMobile from 'ismobilejs'
|
|
||||||
import { authService } from '@/services'
|
import { authService } from '@/services'
|
||||||
import { playlistFolderStore, playlistStore, preferenceStore, userStore } from '@/stores'
|
import { playlistFolderStore, playlistStore, userStore } from '@/stores'
|
||||||
import { eventBus, forceReloadWindow, requireInjection } from '@/utils'
|
import { eventBus, forceReloadWindow, requireInjection } from '@/utils'
|
||||||
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
|
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
|
||||||
|
|
||||||
|
@ -42,11 +41,4 @@ eventBus.on({
|
||||||
forceReloadWindow()
|
forceReloadWindow()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.onRouteChanged(() => {
|
|
||||||
// Hide the extra panel away if a main view is triggered on mobile.
|
|
||||||
if (isMobile.phone) {
|
|
||||||
preferenceStore.showExtraPanel = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -31,7 +31,6 @@ export type EventName =
|
||||||
| 'PLAYLIST_DELETE'
|
| 'PLAYLIST_DELETE'
|
||||||
| 'PLAYLIST_FOLDER_DELETE'
|
| 'PLAYLIST_FOLDER_DELETE'
|
||||||
| 'SMART_PLAYLIST_UPDATED'
|
| 'SMART_PLAYLIST_UPDATED'
|
||||||
| 'SONG_STARTED'
|
|
||||||
| 'SONGS_UPDATED'
|
| 'SONGS_UPDATED'
|
||||||
| 'SONGS_DELETED'
|
| 'SONGS_DELETED'
|
||||||
| 'SONG_QUEUED_FROM_ROUTE'
|
| 'SONG_QUEUED_FROM_ROUTE'
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { nextTick, reactive } from 'vue'
|
||||||
import plyr from 'plyr'
|
import plyr from 'plyr'
|
||||||
import lodash from 'lodash'
|
import lodash from 'lodash'
|
||||||
import { expect, it, vi } from 'vitest'
|
import { expect, it, vi } from 'vitest'
|
||||||
import { eventBus, noop } from '@/utils'
|
import { noop } from '@/utils'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { socketService } from '@/services'
|
import { socketService } from '@/services'
|
||||||
|
@ -185,7 +185,6 @@ new class extends UnitTestCase {
|
||||||
it('restarts a song', async () => {
|
it('restarts a song', async () => {
|
||||||
const song = this.setCurrentSong()
|
const song = this.setCurrentSong()
|
||||||
this.mock(Math, 'floor', 1000)
|
this.mock(Math, 'floor', 1000)
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
|
||||||
const broadcastMock = this.mock(socketService, 'broadcast')
|
const broadcastMock = this.mock(socketService, 'broadcast')
|
||||||
const showNotificationMock = this.mock(playbackService, 'showNotification')
|
const showNotificationMock = this.mock(playbackService, 'showNotification')
|
||||||
const restartMock = this.mock(playbackService.player!, 'restart')
|
const restartMock = this.mock(playbackService.player!, 'restart')
|
||||||
|
@ -195,7 +194,6 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
expect(song.play_start_time).toEqual(1000)
|
expect(song.play_start_time).toEqual(1000)
|
||||||
expect(song.play_count_registered).toBe(false)
|
expect(song.play_count_registered).toBe(false)
|
||||||
expect(emitMock).toHaveBeenCalledWith('SONG_STARTED', song)
|
|
||||||
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
|
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
|
||||||
expect(showNotificationMock).toHaveBeenCalled()
|
expect(showNotificationMock).toHaveBeenCalled()
|
||||||
expect(restartMock).toHaveBeenCalled()
|
expect(restartMock).toHaveBeenCalled()
|
||||||
|
@ -298,7 +296,6 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
|
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
|
||||||
const broadcastMock = this.mock(socketService, 'broadcast')
|
const broadcastMock = this.mock(socketService, 'broadcast')
|
||||||
const emitMock = this.mock(eventBus, 'emit')
|
|
||||||
|
|
||||||
playbackService.init()
|
playbackService.init()
|
||||||
await playbackService.resume()
|
await playbackService.resume()
|
||||||
|
@ -306,7 +303,6 @@ new class extends UnitTestCase {
|
||||||
expect(queueStore.current?.playback_state).toEqual('Playing')
|
expect(queueStore.current?.playback_state).toEqual('Playing')
|
||||||
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
|
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
|
||||||
expect(playMock).toHaveBeenCalled()
|
expect(playMock).toHaveBeenCalled()
|
||||||
expect(emitMock).toHaveBeenCalledWith('SONG_STARTED', song)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('plays first in queue if toggled when there is no current song', async () => {
|
it('plays first in queue if toggled when there is no current song', async () => {
|
||||||
|
|
|
@ -155,7 +155,6 @@ class PlaybackService {
|
||||||
song.play_start_time = Math.floor(Date.now() / 1000)
|
song.play_start_time = Math.floor(Date.now() / 1000)
|
||||||
song.play_count_registered = false
|
song.play_count_registered = false
|
||||||
|
|
||||||
eventBus.emit('SONG_STARTED', song)
|
|
||||||
socketService.broadcast('SOCKET_SONG', song)
|
socketService.broadcast('SOCKET_SONG', song)
|
||||||
|
|
||||||
this.player.restart()
|
this.player.restart()
|
||||||
|
@ -295,7 +294,6 @@ class PlaybackService {
|
||||||
}
|
}
|
||||||
|
|
||||||
queueStore.current!.playback_state = 'Playing'
|
queueStore.current!.playback_state = 'Playing'
|
||||||
eventBus.emit('SONG_STARTED', queueStore.current)
|
|
||||||
socketService.broadcast('SOCKET_SONG', queueStore.current)
|
socketService.broadcast('SOCKET_SONG', queueStore.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,5 +44,6 @@ export const favoriteStore = {
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/favorite'))
|
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/favorite'))
|
||||||
|
return this.state.songs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ interface Preferences extends Record<string, any> {
|
||||||
volume: number
|
volume: number
|
||||||
notify: boolean
|
notify: boolean
|
||||||
repeatMode: RepeatMode
|
repeatMode: RepeatMode
|
||||||
showExtraPanel: boolean
|
|
||||||
confirmClosing: boolean
|
confirmClosing: boolean
|
||||||
equalizer: EqualizerPreset,
|
equalizer: EqualizerPreset,
|
||||||
artistsViewMode: ArtistAlbumViewMode | null,
|
artistsViewMode: ArtistAlbumViewMode | null,
|
||||||
|
@ -25,7 +24,6 @@ const preferenceStore = {
|
||||||
volume: 7,
|
volume: 7,
|
||||||
notify: true,
|
notify: true,
|
||||||
repeatMode: 'NO_REPEAT',
|
repeatMode: 'NO_REPEAT',
|
||||||
showExtraPanel: true,
|
|
||||||
confirmClosing: false,
|
confirmClosing: false,
|
||||||
equalizer: {
|
equalizer: {
|
||||||
preamp: 0,
|
preamp: 0,
|
||||||
|
|
|
@ -146,10 +146,12 @@ export const queueStore = {
|
||||||
async fetchRandom (limit = 500) {
|
async fetchRandom (limit = 500) {
|
||||||
const songs = await http.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
|
const songs = await http.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
|
||||||
this.state.songs = songStore.syncWithVault(songs)
|
this.state.songs = songStore.syncWithVault(songs)
|
||||||
|
return this.state.songs
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchInOrder (sortField: SongListSortField, order: SortOrder, limit = 500) {
|
async fetchInOrder (sortField: SongListSortField, order: SortOrder, limit = 500) {
|
||||||
const songs = await http.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
|
const songs = await http.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
|
||||||
this.state.songs = songStore.syncWithVault(songs)
|
this.state.songs = songStore.syncWithVault(songs)
|
||||||
|
return this.state.songs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ export const recentlyPlayedStore = {
|
||||||
|
|
||||||
async fetch () {
|
async fetch () {
|
||||||
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/recently-played'))
|
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/recently-played'))
|
||||||
|
return this.state.songs
|
||||||
},
|
},
|
||||||
|
|
||||||
async add (song: Song) {
|
async add (song: Song) {
|
||||||
|
|
|
@ -13,6 +13,7 @@ export const DialogBoxKey: InjectionKey<Ref<InstanceType<typeof DialogBox>>> = S
|
||||||
export const MessageToasterKey: InjectionKey<Ref<InstanceType<typeof MessageToaster>>> = Symbol('MessageToaster')
|
export const MessageToasterKey: InjectionKey<Ref<InstanceType<typeof MessageToaster>>> = Symbol('MessageToaster')
|
||||||
|
|
||||||
export const SongsKey: ReadonlyInjectionKey<Ref<Song[]>> | InjectionKey<Ref<Song[]>> = Symbol('Songs')
|
export const SongsKey: ReadonlyInjectionKey<Ref<Song[]>> | InjectionKey<Ref<Song[]>> = Symbol('Songs')
|
||||||
|
export const CurrentSongKey: InjectionKey<Ref<Song | null>> = Symbol('CurrentSong')
|
||||||
export const SelectedSongsKey: ReadonlyInjectionKey<Ref<Song[]>> = Symbol('SelectedSongs')
|
export const SelectedSongsKey: ReadonlyInjectionKey<Ref<Song[]>> = Symbol('SelectedSongs')
|
||||||
export const SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> = Symbol('SongListConfig')
|
export const SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> = Symbol('SongListConfig')
|
||||||
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
|
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
|
||||||
|
|
7
resources/assets/js/types.d.ts
vendored
7
resources/assets/js/types.d.ts
vendored
|
@ -355,11 +355,6 @@ type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_na
|
||||||
|
|
||||||
type SortOrder = 'asc' | 'desc'
|
type SortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
type SongListSort = {
|
|
||||||
fields: SongListSortField[]
|
|
||||||
order: SortOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
type MethodOf<T> = { [K in keyof T]: T[K] extends Closure ? K : never; }[keyof T]
|
type MethodOf<T> = { [K in keyof T]: T[K] extends Closure ? K : never; }[keyof T]
|
||||||
|
|
||||||
interface PaginatorResource {
|
interface PaginatorResource {
|
||||||
|
@ -380,3 +375,5 @@ type ToastMessage = {
|
||||||
content: string
|
content: string
|
||||||
timeout: number // seconds
|
timeout: number // seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
||||||
|
|
|
@ -57,6 +57,7 @@ button, [role=button] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|
|
@ -18,16 +18,22 @@
|
||||||
--font-weight-normal: 500;
|
--font-weight-normal: 500;
|
||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
--header-height: 48px;
|
--header-height: auto;
|
||||||
--footer-height: 64px;
|
--footer-height: 84px;
|
||||||
--footer-height-mobile: 74px;
|
--extra-panel-width: 320px;
|
||||||
--extra-panel-width: 334px;
|
--sidebar-width: 256px;
|
||||||
|
|
||||||
--color-black: #181818;
|
--color-black: #181818;
|
||||||
--color-maroon: #bf2043;
|
--color-maroon: #bf2043;
|
||||||
--color-green: #56a052;
|
--color-green: #56a052;
|
||||||
--color-blue: #0191f7;
|
--color-blue: #0191f7;
|
||||||
--color-red: #c34848;
|
--color-red: #c34848;
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
--header-height: 59px;
|
||||||
|
--footer-height: 96px;
|
||||||
|
--extra-panel-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$plyr-blue: var(--color-highlight);
|
$plyr-blue: var(--color-highlight);
|
||||||
|
|
15
resources/assets/sass/vendor/_plyr.scss
vendored
15
resources/assets/sass/vendor/_plyr.scss
vendored
|
@ -48,7 +48,7 @@ $plyr-progress-loading-bg: transparentize(#000, .15) !default;
|
||||||
|
|
||||||
// Volume
|
// Volume
|
||||||
$plyr-volume-track-height: 6px !default;
|
$plyr-volume-track-height: 6px !default;
|
||||||
$plyr-volume-track-bg: darken($plyr-controls-bg, 10%) !default;
|
$plyr-volume-track-bg: rgba(255, 255, 255, .2) !default;
|
||||||
$plyr-volume-thumb-height: ($plyr-volume-track-height * 2) !default;
|
$plyr-volume-thumb-height: ($plyr-volume-track-height * 2) !default;
|
||||||
$plyr-volume-thumb-width: ($plyr-volume-track-height * 2) !default;
|
$plyr-volume-thumb-width: ($plyr-volume-track-height * 2) !default;
|
||||||
$plyr-volume-thumb-bg: var(--color-highlight) !default;
|
$plyr-volume-thumb-bg: var(--color-highlight) !default;
|
||||||
|
@ -85,7 +85,6 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
transition: background .3s ease;
|
transition: background .3s ease;
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin volume-track() {
|
@mixin volume-track() {
|
||||||
|
@ -399,10 +398,7 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
|
||||||
// Playback progress
|
// Playback progress
|
||||||
// <progress> element
|
// <progress> element
|
||||||
&__progress {
|
&__progress {
|
||||||
position: absolute;
|
position: relative;
|
||||||
bottom: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: $plyr-control-spacing;
|
height: $plyr-control-spacing;
|
||||||
background: $plyr-progress-bg;
|
background: $plyr-progress-bg;
|
||||||
|
@ -414,14 +410,14 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1px;
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
@ -513,6 +509,7 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
|
||||||
// Seek tooltip to show time
|
// Seek tooltip to show time
|
||||||
.plyr__tooltip {
|
.plyr__tooltip {
|
||||||
left: 0;
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -652,8 +649,6 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
|
||||||
}
|
}
|
||||||
|
|
||||||
&--audio .plyr__progress {
|
&--audio .plyr__progress {
|
||||||
bottom: auto;
|
|
||||||
top: 0;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue