feat(design): revamp the layout

This commit is contained in:
Phan An 2022-10-13 17:18:47 +02:00
parent 0b85ff18b9
commit a028dc03d0
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
68 changed files with 1201 additions and 1042 deletions

View file

@ -13,10 +13,6 @@ use Illuminate\Support\Collection;
class SmartPlaylistService
{
public function __construct()
{
}
/** @return Collection|array<array-key, Song> */
public function getSongs(Playlist $playlist, ?User $user = null): Collection
{

View file

@ -6,7 +6,6 @@
<div v-if="authenticated" id="main" @dragend="onDragEnd" @dragover="onDragOver" @drop="onDrop">
<Hotkeys/>
<AppHeader/>
<MainWrapper/>
<AppFooter/>
<SupportKoel/>
@ -25,11 +24,11 @@
</template>
<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 { commonStore, preferenceStore as preferences } from '@/stores'
import { commonStore, preferenceStore as preferences, queueStore } from '@/stores'
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 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.
import GlobalEventListeners from '@/components/utils/GlobalEventListeners.vue'
const AppHeader = defineAsyncComponent(() => import('@/components/layout/AppHeader.vue'))
const Hotkeys = defineAsyncComponent(() => import('@/components/utils/HotkeyListener.vue'))
const LoginForm = defineAsyncComponent(() => import('@/components/auth/LoginForm.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 toaster = ref<InstanceType<typeof MessageToaster>>()
const currentSong = ref<Song | null>(null)
const authenticated = 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'
}
watch(() => queueStore.current, song => (currentSong.value = song))
const onDragEnd = () => (showDropZone.value = false)
const onDrop = () => (showDropZone.value = false)
provide(DialogBoxKey, dialog)
provide(MessageToasterKey, toaster)
provide(CurrentSongKey, currentSong)
</script>
<style lang="scss">
@ -169,6 +171,12 @@ provide(MessageToasterKey, toaster)
justify-content: flex-end;
}
#main {
@media screen and (max-width: 768px) {
padding-top: var(--header-height);
}
}
.login-wrapper {
@include vertical-center();
user-select: none;

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu">
<ul>
<nav class="album-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="album-context-menu" data-v-0408531a="">
<ul data-v-0408531a="">
<li data-testid="play">Play All</li>
<li data-testid="shuffle">Shuffle All</li>
<li class="separator"></li>

View file

@ -1,8 +1,8 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu">
<ul>
<nav class="artist-menu menu context-menu" style="top: 42px; left: 420px;" tabindex="0" data-testid="artist-context-menu" data-v-0408531a="">
<ul data-v-0408531a="">
<li data-testid="play">Play All</li>
<li data-testid="shuffle">Shuffle All</li>
<li class="separator"></li>

View file

@ -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)
}
)
}
}

View file

@ -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>

View file

@ -96,12 +96,12 @@ eventBus.on({
form {
position: relative;
min-width: 460px;
max-width: calc(100% - 24px);
max-width: calc(100vw - 24px);
background-color: var(--color-bg-primary);
border-radius: 4px;
@media only screen and (max-width: 667px) {
min-width: calc(100% - 24px);
@media screen and (max-width: 667px) {
min-width: calc(100vw - 24px);
}
> header, > main, > footer {

View file

@ -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>

View file

@ -1,30 +1,22 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import { preferenceStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import FooterExtraControls from './FooterExtraControls.vue'
new class extends UnitTestCase {
protected test () {
it('renders', () => {
preferenceStore.state.showExtraPanel = true
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: {
stubs: {
RepeatModeSwitch: this.stub('RepeatModeSwitch'),
Equalizer: this.stub('Equalizer'),
Volume: this.stub('Volume')
},
provide: {
[CurrentSongKey]: factory<Song>('song', {
playback_state: 'Playing'
})
}
}
}).html()).toMatchSnapshot()

View file

@ -1,11 +1,11 @@
<template>
<div class="other-controls" data-testid="other-controls">
<div class="extra-controls" data-testid="other-controls">
<div v-koel-clickaway="closeEqualizer" class="wrapper">
<Equalizer v-if="useEqualizer" v-show="showEqualizer"/>
<button
v-if="song?.playback_state === 'Playing'"
class="control"
class="visualizer-btn"
data-testid="toggle-visualizer-btn"
title="Show/hide the visualizer"
type="button"
@ -14,24 +14,11 @@
<icon :icon="faBolt"/>
</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
v-if="useEqualizer"
:class="{ active: showEqualizer }"
:title="`${ showEqualizer ? 'Hide' : 'Show'} equalizer`"
class="control equalizer"
class="equalizer"
data-testid="toggle-equalizer-btn"
type="button"
@click.prevent="toggleEqualizer"
@ -39,91 +26,60 @@
<icon :icon="faSliders"/>
</button>
<a v-else :class="{ active: viewingQueue }" class="queue control" href="#/queue">
<icon :icon="faListOl"/>
</a>
<RepeatModeSwitch/>
<Volume/>
</div>
</div>
</template>
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { faBolt, faListOl, faSliders } from '@fortawesome/free-solid-svg-icons'
import { ref, toRef, toRefs } from 'vue'
import { faBolt, faSliders } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { eventBus, isAudioContextSupported as useEqualizer, requireInjection } from '@/utils'
import { preferenceStore } from '@/stores'
import { RouterKey } from '@/symbols'
import { CurrentSongKey } from '@/symbols'
import Equalizer from '@/components/ui/Equalizer.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 } = toRefs(props)
const song = requireInjection(CurrentSongKey, ref(null))
const showExtraPanel = toRef(preferenceStore.state, 'showExtraPanel')
const showEqualizer = ref(false)
const viewingQueue = ref(false)
const toggleExtraPanel = () => (preferenceStore.showExtraPanel = !showExtraPanel.value)
const toggleEqualizer = () => (showEqualizer.value = !showEqualizer.value)
const closeEqualizer = () => (showEqualizer.value = false)
const toggleVisualizer = () => isMobile.any || eventBus.emit('TOGGLE_VISUALIZER')
const router = requireInjection(RouterKey)
router.onRouteChanged(route => (viewingQueue.value = route.screen === 'Queue'))
const toggleVisualizer = () => eventBus.emit('TOGGLE_VISUALIZER')
</script>
<style lang="scss" scoped>
.other-controls {
@include vertical-center();
.extra-controls {
display: flex;
justify-content: flex-end;
position: relative;
flex: 0 0 var(--extra-panel-width);
width: 320px;
color: var(--color-text-secondary);
padding: 0 2rem;
.wrapper {
@include vertical-center();
> * + * {
margin-left: 1rem;
}
display: flex;
justify-content: flex-end;
align-items: center;
gap: 1.5rem;
}
.control {
&.active {
color: var(--color-accent);
}
button {
color: currentColor;
transition: color 0.2s ease-in-out;
&:last-child {
padding-right: 0;
&:hover {
color: var(--color-text-primary);
}
}
@media only screen and (max-width: 768px) {
position: absolute !important;
right: 0;
top: 0;
height: 100%;
width: 188px;
padding-top: 12px; // leave space for the audio track
width: auto;
&::before {
.visualizer-btn {
display: none;
}
.queue {
display: none;
}
> * + * {
margin-left: 1.5rem;
}
}
}
</style>

View file

@ -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()
})
}
}

View file

@ -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>

View file

@ -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()
})
}
}

View file

@ -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>

View file

@ -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()
})
}
}

View file

@ -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>

View file

@ -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()
})
}
}

View file

@ -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>

View file

@ -1,9 +1,10 @@
// Vitest Snapshot v1
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="">
<!--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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -1,29 +1,30 @@
<template>
<footer id="mainFooter" @contextmenu.prevent="requestContextMenu">
<PlayerControls :song="song"/>
<AudioPlayer/>
<div class="media-info-wrap">
<MiddlePane :song="song"/>
<ExtraControls :song="song"/>
<div class="wrapper">
<SongInfo/>
<PlaybackControls/>
<ExtraControls/>
</div>
</footer>
</template>
<script lang="ts" setup>
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 MiddlePane from '@/components/layout/app-footer/FooterMiddlePane.vue'
import PlayerControls from '@/components/layout/app-footer/FooterPlayerControls.vue'
import PlaybackControls from '@/components/layout/app-footer/FooterPlaybackControls.vue'
const song = ref<Song>()
const song = requireInjection(CurrentSongKey, ref(null))
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>
<style lang="scss" scoped>
@ -31,33 +32,14 @@ footer {
background: var(--color-bg-secondary);
height: var(--footer-height);
display: flex;
box-shadow: 0 0 30px 20px rgba(0, 0, 0, .2);
flex-direction: column;
position: relative;
z-index: 99;
z-index: 1;
.media-info-wrap {
flex: 1;
.wrapper {
display: flex;
}
// 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
flex: 1;
}
}
</style>

View file

@ -1,44 +1,73 @@
import { ref, Ref } from 'vue'
import { expect, it } from 'vitest'
import { fireEvent } from '@testing-library/vue'
import { fireEvent, waitFor } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import { commonStore } from '@/stores'
import { albumStore, artistStore, commonStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import ExtraPanel from './ExtraPanel.vue'
import { eventBus } from '@/utils'
new class extends UnitTestCase {
private renderComponent () {
private renderComponent (songRef: Ref<Song | null> = ref(null)) {
return this.render(ExtraPanel, {
props: {
song: factory<Song>('song')
},
global: {
stubs: {
LyricsPane: this.stub(),
AlbumInfo: this.stub(),
ArtistInfo: this.stub(),
YouTubeVideoList: this.stub()
ProfileAvatar: this.stub(),
LyricsPane: this.stub('lyrics'),
AlbumInfo: this.stub('album-info'),
ArtistInfo: this.stub('artist-info'),
YouTubeVideoList: this.stub('youtube-video-list'),
ExtraPanelTabHeader: this.stub()
},
provide: {
[CurrentSongKey]: songRef
}
}
})
}
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
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', () => {
commonStore.state.use_you_tube = false
expect(this.renderComponent().queryByTestId('extra-tab-youtube')).toBeNull()
it('shows About Koel model', async () => {
const emitMock = this.mock(eventBus, 'emit')
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) => {
const { getByTestId, container } = this.renderComponent()
it('logs out', async () => {
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')
})
}
}

View file

@ -1,163 +1,143 @@
<template>
<section id="extra" :class="{ showing }" class="text-secondary" data-testid="extra-panel">
<div class="tabs">
<div class="clear" role="tablist">
<button
id="extraTabLyrics"
: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 id="extraPanel" :class="{ 'showing-pane': selectedTab }">
<div class="controls">
<div class="top">
<SidebarMenuToggleButton class="burger"/>
<ExtraPanelTabHeader v-if="song" v-model="selectedTab"/>
</div>
<div class="panes">
<div
v-show="currentTab === 'Lyrics'"
id="extraPanelLyrics"
aria-labelledby="extraTabLyrics"
role="tabpanel"
tabindex="0"
>
<LyricsPane :song="song"/>
</div>
<div class="bottom">
<button title="About Koel" type="button" @click.prevent="openAboutKoelModal">
<icon :icon="faInfoCircle"/>
</button>
<div
v-show="currentTab === 'Artist'"
id="extraPanelArtist"
aria-labelledby="extraTabArtist"
role="tabpanel"
tabindex="0"
>
<ArtistInfo v-if="artist" :artist="artist" mode="aside"/>
</div>
<button title="Log out" type="button" @click.prevent="logout">
<icon :icon="faArrowRightFromBracket"/>
</button>
<div
v-show="currentTab === 'Album'"
id="extraPanelAlbum"
aria-labelledby="extraTabAlbum"
role="tabpanel"
tabindex="0"
>
<AlbumInfo v-if="album" :album="album" mode="aside"/>
</div>
<div
v-show="currentTab === 'YouTube'"
id="extraPanelYouTube"
aria-labelledby="extraTabYouTube"
role="tabpanel"
tabindex="0"
>
<YouTubeVideoList v-if="useYouTube && song" :song="song"/>
</div>
<ProfileAvatar @click="onProfileLinkClick"/>
</div>
</div>
</section>
<div class="panes" v-if="song" v-show="selectedTab">
<div
v-show="selectedTab === 'Lyrics'"
id="extraPanelLyrics"
aria-labelledby="extraTabLyrics"
role="tabpanel"
tabindex="0"
>
<LyricsPane :song="song"/>
</div>
<div
v-show="selectedTab === 'Artist'"
id="extraPanelArtist"
aria-labelledby="extraTabArtist"
role="tabpanel"
tabindex="0"
>
<ArtistInfo v-if="artist" :artist="artist" mode="aside"/>
<span v-else>Loading</span>
</div>
<div
v-show="selectedTab === 'Album'"
id="extraPanelAlbum"
aria-labelledby="extraTabAlbum"
role="tabpanel"
tabindex="0"
>
<AlbumInfo v-if="album" :album="album" mode="aside"/>
<span v-else>Loading</span>
</div>
<div
v-show="selectedTab === 'YouTube'"
id="extraPanelYouTube"
aria-labelledby="extraTabYouTube"
role="tabpanel"
tabindex="0"
>
<YouTubeVideoList v-if="useYouTube && song" :song="song"/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import { ref, toRef, watch } from 'vue'
import { eventBus } from '@/utils'
import { albumStore, artistStore, preferenceStore as preferences } from '@/stores'
import { useThirdPartyServices } from '@/composables'
import { faArrowRightFromBracket, faInfoCircle } from '@fortawesome/free-solid-svg-icons'
import { defineAsyncComponent, ref, watch } from 'vue'
import { albumStore, artistStore } from '@/stores'
import { useAuthorization, useThirdPartyServices } from '@/composables'
import { eventBus, logger, requireInjection } from '@/utils'
import { CurrentSongKey } from '@/symbols'
import LyricsPane from '@/components/ui/LyricsPane.vue'
import ArtistInfo from '@/components/artist/ArtistInfo.vue'
import AlbumInfo from '@/components/album/AlbumInfo.vue'
import YouTubeVideoList from '@/components/ui/YouTubeVideoList.vue'
import ProfileAvatar from '@/components/ui/ProfileAvatar.vue'
import SidebarMenuToggleButton from '@/components/ui/SidebarMenuToggleButton.vue'
type Tab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
const defaultTab: Tab = 'Lyrics'
const song = ref<Song | null>(null)
const showing = toRef(preferences.state, 'showExtraPanel')
const currentTab = ref<Tab>(defaultTab)
const LyricsPane = defineAsyncComponent(() => import('@/components/ui/LyricsPane.vue'))
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
const YouTubeVideoList = defineAsyncComponent(() => import('@/components/ui/YouTubeVideoList.vue'))
const ExtraPanelTabHeader = defineAsyncComponent(() => import('@/components/ui/ExtraPanelTabHeader.vue'))
const { currentUser } = useAuthorization()
const { useYouTube } = useThirdPartyServices()
const artist = ref<Artist>()
const album = ref<Album>()
const song = requireInjection(CurrentSongKey, ref(null))
const selectedTab = ref<ExtraPanelTab | undefined>(undefined)
watch(showing, (showingExtraPanel) => {
if (showingExtraPanel && !isMobile.any) {
document.documentElement.classList.add('with-extra-panel')
} else {
document.documentElement.classList.remove('with-extra-panel')
}
})
const artist = ref<Artist | null>(null)
const album = ref<Album | null>(null)
watch(song, song => song && fetchSongInfo(song))
const fetchSongInfo = async (_song: Song) => {
song.value = _song
artist.value = null
album.value = null
try {
song.value = _song
artist.value = await artistStore.resolve(_song.artist_id)
album.value = await albumStore.resolve(_song.album_id)
} catch (err) {
throw err
} catch (error) {
logger.log('Failed to fetch media information', error)
}
}
eventBus.on({
SONG_STARTED: async (song: Song) => await fetchSongInfo(song),
KOEL_READY: () => {
// 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)
}
})
const openAboutKoelModal = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
const onProfileLinkClick = () => isMobile.any && (selectedTab.value = undefined)
const logout = () => eventBus.emit('LOG_OUT')
</script>
<style lang="scss">
#extra {
flex: 0 0 var(--extra-panel-width);
padding-top: 2.3rem;
<style lang="scss" scoped>
#extraPanel {
display: flex;
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);
display: none;
overflow: auto;
-ms-overflow-style: -ms-autohiding-scrollbar;
@media (hover: none) {
// Enable scroll with momentum on touch devices
@ -165,38 +145,79 @@ eventBus.on({
-webkit-overflow-scrolling: touch;
}
&.showing {
display: block;
@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);
}
h1 {
font-weight: var(--font-weight-thin);
font-size: 2.2rem;
margin-bottom: 1.25rem;
line-height: 2.8rem;
}
.top, .bottom {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 1rem;
@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 screen and (max-width: 768px) {
flex-direction: row;
gap: .25rem;
}
}
@media only screen and (max-width: 667px) {
@include themed-background();
::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;
width: 100%;
@media screen and (max-width: 768px) {
background: none;
}
[role=tabpanel] {
padding-bottom: calc(var(--footer-height-mobile) + 1rem)
&:hover, &.active {
opacity: 1;
color: var(--color-text-primary);
}
&:active {
transform: scale(.9);
}
&.burger {
display: none;
@media screen and (max-width: 768px) {
display: block;
}
}
}
}

View file

@ -1,9 +1,11 @@
import { ref } from 'vue'
import { waitFor } from '@testing-library/vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import { eventBus } from '@/utils'
import { albumStore, preferenceStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { CurrentSongKey } from '@/symbols'
import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue'
import MainContent from './MainContent.vue'
@ -16,12 +18,13 @@ new class extends UnitTestCase {
global: {
stubs: {
AlbumArtOverlay
},
provide: {
[CurrentSongKey]: ref(factory<Song>('song'))
}
}
})
eventBus.emit('SONG_STARTED', factory<Song>('song'))
await waitFor(() => getByTestId('album-art-overlay'))
})
@ -32,12 +35,13 @@ new class extends UnitTestCase {
global: {
stubs: {
AlbumArtOverlay
},
provide: {
[CurrentSongKey]: ref(factory<Song>('song'))
}
}
})
eventBus.emit('SONG_STARTED', factory<Song>('song'))
await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull())
})

View file

@ -35,7 +35,7 @@ import { defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { eventBus, requireInjection } from '@/utils'
import { preferenceStore } from '@/stores'
import { useThirdPartyServices } from '@/composables'
import { RouterKey } from '@/symbols'
import { CurrentSongKey, RouterKey } from '@/symbols'
import HomeScreen from '@/components/screens/HomeScreen.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 { useYouTube } = useThirdPartyServices()
const router = requireInjection(RouterKey)
const currentSong = requireInjection(CurrentSongKey, ref(null))
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
const showingVisualizer = ref(false)
const screen = ref<ScreenName>('Home')
const currentSong = ref<Song | null>(null)
router.onRouteChanged(route => (screen.value = route.screen))
eventBus.on({
TOGGLE_VISUALIZER: () => (showingVisualizer.value = !showingVisualizer.value),
SONG_STARTED: (song: Song) => (currentSong.value = song)
})
eventBus.on('TOGGLE_VISUALIZER', () => (showingVisualizer.value = !showingVisualizer.value))
onMounted(() => router.resolve())
</script>
@ -94,7 +92,7 @@ onMounted(() => router.resolve())
.main-scroll-wrap {
&:not(.song-list-wrap) {
padding: 24px 24px 48px;
padding: 1.5rem;
}
overflow: scroll;
@ -120,7 +118,7 @@ onMounted(() => router.resolve())
}
}
@media only screen and (max-width: 375px) {
@media screen and (max-width: 768px) {
> section {
// Leave some space for the "Back to top" button
.main-scroll-wrap {

View file

@ -1,5 +1,6 @@
<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">
<h1>Your Music</h1>
@ -78,7 +79,6 @@
</template>
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import {
faCompactDisc,
faHome,
@ -97,8 +97,9 @@ import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composa
import { RouterKey } from '@/symbols'
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 droppableToQueue = ref(false)
@ -128,14 +129,12 @@ const onQueueDrop = async (event: DragEvent) => {
return false
}
const closeIfMobile = () => (mobileShowing.value = false)
const router = requireInjection(RouterKey)
router.onRouteChanged(route => {
// On mobile, hide the sidebar whenever a screen is activated.
if (isMobile.phone) {
showing.value = false
}
mobileShowing.value = false
activeScreen.value = route.screen
})
@ -144,15 +143,15 @@ eventBus.on({
* Listen to toggle sidebar event to show or hide the sidebar.
* This should only be triggered on a mobile device.
*/
TOGGLE_SIDEBAR: () => (showing.value = !showing.value)
TOGGLE_SIDEBAR: () => (mobileShowing.value = !mobileShowing.value)
})
</script>
<style lang="scss" scoped>
nav {
flex: 0 0 256px;
width: var(--sidebar-width);
background-color: var(--color-bg-secondary);
padding: 2.05rem 0;
padding: 2.05rem 1.5rem;
overflow: auto;
overflow-x: hidden;
-ms-overflow-style: -ms-autohiding-scrollbar;
@ -183,55 +182,66 @@ nav {
::v-deep(h1) {
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 16px;
margin-bottom: 12px;
}
::v-deep(a svg) {
opacity: .7;
}
::v-deep(a) {
display: flex;
align-items: center;
gap: .7rem;
height: 36px;
line-height: 36px;
padding: 0 16px 0 12px;
border-left: 4px solid transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.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);
}
position: relative;
&:active {
opacity: .5;
padding: 2px 0 0 2px;
}
&:hover {
border-left-color: var(--color-highlight);
&.active, &:hover {
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
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();
transform: translateX(-100vw);
transition: transform .2s ease-in-out;
position: fixed;
height: calc(100vh - var(--header-height) + var(--footer-height));
width: 100%;
z-index: 99;
top: var(--header-height);
left: -100%;
transition: left .3s ease-in;
height: calc(100vh - var(--header-height));
&.showing {
left: 0;
transform: translateX(0);
}
}
}

View file

@ -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>
`;

View file

@ -8,7 +8,7 @@
</p>
<button data-testid="hide-support-koel" type="button" @click.prevent="close">Hide</button>
<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
</button>
</div>

View file

@ -132,7 +132,6 @@ router.onRouteChanged(route => {
<style lang="scss" scoped>
.playlist {
user-select: none;
overflow: hidden;
&.droppable {
box-shadow: inset 0 0 0 1px var(--color-accent);
@ -145,10 +144,5 @@ router.onRouteChanged(route => {
pointer-events: none;
}
}
input {
width: calc(100% - 32px);
margin: 5px 16px;
}
}
</style>

View file

@ -101,7 +101,7 @@ const download = () => downloadService.fromAlbum(album.value!)
const showInfo = () => (showingInfo.value = true)
onMounted(async () => {
const id = parseInt(router.$currentRoute.value?.params!.id)
const id = parseInt(router.$currentRoute.value.params!.id)
loading.value = true
try {

View file

@ -102,7 +102,7 @@ const download = () => downloadService.fromArtist(artist.value!)
const showInfo = () => (showingInfo.value = true)
onMounted(async () => {
const id = parseInt(router.$currentRoute.value!.params!.id)
const id = parseInt(router.$currentRoute.value.params!.id)
loading.value = true
try {

View file

@ -111,7 +111,7 @@ useScreen('Home').onScreenActivated(async () => {
}
.main-scroll-wrap {
section {
section:not(:last-of-type) {
margin-bottom: 48px;
}

View file

@ -17,10 +17,11 @@
<script lang="ts" setup>
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
import createYouTubePlayer from 'youtube-player'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import type { YouTubePlayer } from 'youtube-player/dist/types'
import { eventBus, use } from '@/utils'
import { eventBus, requireInjection, use } from '@/utils'
import { playbackService } from '@/services'
import { CurrentSongKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
@ -42,20 +43,20 @@ const getPlayer = () => {
return player
}
eventBus.on({
PLAY_YOUTUBE_VIDEO (payload: { id: string, title: string }) {
title.value = payload.title
const currentSong = requireInjection(CurrentSongKey)
use(getPlayer(), player => {
player.loadVideoById(payload.id)
player.playVideo()
})
},
/**
* Pause video playback when a song is played/resumed.
*/
watch(() => currentSong.value?.playback_state, state => state === 'Playing' && player?.pauseVideo())
/**
* Stop video playback when a song is played/resumed.
*/
SONG_STARTED: () => player && player.pauseVideo()
eventBus.on('PLAY_YOUTUBE_VIDEO', (payload: { id: string, title: string }) => {
title.value = payload.title
use(getPlayer(), player => {
player.loadVideoById(payload.id)
player.playVideo()
})
})
</script>

View file

@ -79,6 +79,7 @@ article {
}
button {
color: var(--color-text-secondary);
opacity: 0;
}

View file

@ -1,6 +1,6 @@
<template>
<button @click.stop="toggleLike" :title="title" class="text-secondary" data-testid="like-btn">
<icon v-if="song.liked" :icon="faHeart" class="text-maroon" data-testid="btn-like-liked"/>
<button :title="title" data-testid="like-btn" type="button" @click.stop="toggleLike">
<icon v-if="song.liked" :icon="faHeart" data-testid="btn-like-liked"/>
<icon v-else :icon="faEmptyHeart" data-testid="btn-like-unliked"/>
</button>
</template>
@ -18,11 +18,3 @@ const title = computed(() => `${song.value.liked ? 'Unlike' : 'Like'} ${song.val
const toggleLike = () => favoriteStore.toggleOne(song.value)
</script>
<style lang="scss" scoped>
button {
&:hover .fa-heart {
color: var(--color-maroon);
}
}
</style>

View file

@ -308,6 +308,10 @@ onMounted(() => render())
display: flex;
flex-direction: column;
@media screen and (max-width: 768px) {
padding: 0 12px;
}
.song-list-header {
background: var(--color-bg-secondary);
z-index: 1;
@ -402,8 +406,6 @@ onMounted(() => render())
}
@media only screen and (max-width: 768px) {
padding: 12px;
.song-list-header {
display: none;
}

View file

@ -10,10 +10,10 @@
<SoundBars v-if="song.playback_state === 'Playing'"/>
<span class="text-secondary" v-else>{{ song.track || '' }}</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('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">
<LikeButton :song="song"/>
</span>
@ -65,6 +65,7 @@ const doPlayback = () => {
<style lang="scss">
.song-item {
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-bg-secondary);
max-width: 100% !important; // overriding .item
height: 35px;
@ -83,8 +84,16 @@ const doPlayback = () => {
background-color: rgba(255, 255, 255, .08);
}
&.playing > span {
&.playing {
color: var(--color-accent);
.title {
color: var(--color-accent) !important;
}
}
button {
color: currentColor;
}
}
</style>

View file

@ -171,7 +171,6 @@ const onDrop = async (event: DragEvent) => {
justify-content: center;
align-items: center;
padding-left: 4%; // to balance the play icon
z-index: 99;
pointer-events: none;
@media (hover: none) {

View file

@ -31,7 +31,7 @@ button {
}
position: fixed;
bottom: calc(var(--footer-height-mobile) + 26px);
bottom: calc(var(--footer-height) + 26px);
right: 1.8rem;
z-index: 20;
opacity: 1;

View file

@ -97,3 +97,9 @@ eventBus.on('CONTEXT_MENU_OPENED', target => target === el || close())
defineExpose({ open, close, shown })
</script>
<style lang="scss" scoped>
nav {
user-select: none;
}
</style>

View file

@ -185,6 +185,7 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
display: flex;
flex-direction: column;
left: 0;
box-shadow: 0 0 50x 0 var(--color-bg-primary);
label {
margin-top: 8px;
@ -336,7 +337,7 @@ onMounted(() => eventBus.on('INIT_EQUALIZER', () => init()))
max-width: 414px;
left: auto;
right: 0;
bottom: calc(var(--footer-height-mobile) + 0px);
bottom: var(--footer-height);
display: block;
height: auto;

View file

@ -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']])
})
}
}

View 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>

View 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()
})
})
}
}

View 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>

View 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()
})
}
}

View 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>

View file

@ -2,7 +2,6 @@
<button
:class="{ active: mode !== 'NO_REPEAT' }"
:title="`Change repeat mode (current mode: ${readableMode})`"
class="control"
data-testid="repeat-mode-switch"
type="button"
@click.prevent="changeMode"
@ -39,11 +38,15 @@ const changeMode = () => playbackService.changeRepeatMode()
font-weight: bold;
right: 2px;
top: 2px;
color: var(--color-accent);
color: currentColor;
background: transparent;
}
button {
opacity: .3;
}
.active {
color: var(--color-highlight);
opacity: 1;
}
</style>

View file

@ -1,26 +1,33 @@
<template>
<div id="searchForm" class="side search" role="search">
<form id="searchForm" role="search" @submit.prevent="onSubmit">
<input
ref="input"
v-model="q"
:class="{ dirty: q }"
autocorrect="false"
name="q"
placeholder="Press F to search"
:placeholder="placeholder"
spellcheck="false"
type="search"
@focus="goToSearchScreen"
@focus="maybeGoToSearchScreen"
@input="onInput"
>
</div>
<button type="submit" title="Search">
<icon :icon="faSearch"/>
</button>
</form>
</template>
<script lang="ts" setup>
import isMobile from 'ismobilejs'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { debounce } from 'lodash'
import { eventBus, requireInjection } from '@/utils'
import { RouterKey } from '@/symbols'
const placeholder = isMobile.any ? 'Search' : 'Press F to search'
const router = requireInjection(RouterKey)
const input = ref<HTMLInputElement>()
@ -35,7 +42,12 @@ if (process.env.NODE_ENV !== 'test') {
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({
FOCUS_SEARCH_FIELD: () => {
@ -47,35 +59,44 @@ eventBus.on({
<style lang="scss">
#searchForm {
@include vertical-center();
flex: 0 0 256px;
order: -1;
display: flex;
align-items: stretch;
color: var(--color-text-secondary);
border: 1px solid transparent;
border-radius: 5px;
transition: border .2s ease-in-out;
overflow: hidden;
input[type="search"] {
width: 218px;
margin-top: 0;
}
button {
display: none;
padding: 0 1.5rem;
background: rgba(255, 255, 255, .05);
border-radius: 0;
@media only screen and (max-width: 667px) {
z-index: 100;
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"] {
width: 100%;
@media screen and (max-width: 768px) {
display: block;
}
}
.desktop & {
justify-content: flex-end;
&:focus-within {
border: 1px solid rgba(255, 255, 255, .2);
}
input[type="search"] {
width: 160px;
input[type="search"] {
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);
}
&::placeholder {
color: rgba(255, 255, 255, .5);
}
}
}

View file

@ -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>

View file

@ -1,5 +1,5 @@
<template>
<span id="volume" class="volume control">
<span id="volume" class="volume" :class="level">
<icon
v-if="level === 'muted'"
:icon="faVolumeMute"
@ -73,30 +73,45 @@ eventBus.on('KOEL_READY', () => setLevel(preferences.volume))
<style lang="scss">
#volume {
position: relative;
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
// More tweaks
[type=range] {
margin: 0 0 0 8px;
transform: rotate(270deg);
transform-origin: 0;
position: absolute;
bottom: -22px;
border: 14px solid var(--color-bg-primary);
border-left-width: 30px;
z-index: 0;
width: 140px;
width: 120px;
height: 4px;
border-radius: 4px;
display: none;
}
&:hover [type=range] {
display: block;
}
[role=button] {
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) {

View file

@ -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>`;

View file

@ -33,9 +33,6 @@ const logout = () => eventBus.emit('LOG_OUT')
@include vertical-center();
justify-content: flex-end;
flex: 0 0 var(--extra-panel-width);
text-align: right;
height: 100%;
position: relative;
.avatar {

View file

@ -6,9 +6,8 @@
/**
* Global event listeners (basically, those without a Vue instance access) go here.
*/
import isMobile from 'ismobilejs'
import { authService } from '@/services'
import { playlistFolderStore, playlistStore, preferenceStore, userStore } from '@/stores'
import { playlistFolderStore, playlistStore, userStore } from '@/stores'
import { eventBus, forceReloadWindow, requireInjection } from '@/utils'
import { DialogBoxKey, MessageToasterKey, RouterKey } from '@/symbols'
@ -42,11 +41,4 @@ eventBus.on({
forceReloadWindow()
}
})
router.onRouteChanged(() => {
// Hide the extra panel away if a main view is triggered on mobile.
if (isMobile.phone) {
preferenceStore.showExtraPanel = false
}
})
</script>

View file

@ -31,7 +31,6 @@ export type EventName =
| 'PLAYLIST_DELETE'
| 'PLAYLIST_FOLDER_DELETE'
| 'SMART_PLAYLIST_UPDATED'
| 'SONG_STARTED'
| 'SONGS_UPDATED'
| 'SONGS_DELETED'
| 'SONG_QUEUED_FROM_ROUTE'

View file

@ -2,7 +2,7 @@ import { nextTick, reactive } from 'vue'
import plyr from 'plyr'
import lodash from 'lodash'
import { expect, it, vi } from 'vitest'
import { eventBus, noop } from '@/utils'
import { noop } from '@/utils'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { socketService } from '@/services'
@ -185,7 +185,6 @@ new class extends UnitTestCase {
it('restarts a song', async () => {
const song = this.setCurrentSong()
this.mock(Math, 'floor', 1000)
const emitMock = this.mock(eventBus, 'emit')
const broadcastMock = this.mock(socketService, 'broadcast')
const showNotificationMock = this.mock(playbackService, 'showNotification')
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_count_registered).toBe(false)
expect(emitMock).toHaveBeenCalledWith('SONG_STARTED', song)
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
expect(showNotificationMock).toHaveBeenCalled()
expect(restartMock).toHaveBeenCalled()
@ -298,7 +296,6 @@ new class extends UnitTestCase {
const playMock = this.mock(window.HTMLMediaElement.prototype, 'play')
const broadcastMock = this.mock(socketService, 'broadcast')
const emitMock = this.mock(eventBus, 'emit')
playbackService.init()
await playbackService.resume()
@ -306,7 +303,6 @@ new class extends UnitTestCase {
expect(queueStore.current?.playback_state).toEqual('Playing')
expect(broadcastMock).toHaveBeenCalledWith('SOCKET_SONG', song)
expect(playMock).toHaveBeenCalled()
expect(emitMock).toHaveBeenCalledWith('SONG_STARTED', song)
})
it('plays first in queue if toggled when there is no current song', async () => {

View file

@ -155,7 +155,6 @@ class PlaybackService {
song.play_start_time = Math.floor(Date.now() / 1000)
song.play_count_registered = false
eventBus.emit('SONG_STARTED', song)
socketService.broadcast('SOCKET_SONG', song)
this.player.restart()
@ -295,7 +294,6 @@ class PlaybackService {
}
queueStore.current!.playback_state = 'Playing'
eventBus.emit('SONG_STARTED', queueStore.current)
socketService.broadcast('SOCKET_SONG', queueStore.current)
}

View file

@ -44,5 +44,6 @@ export const favoriteStore = {
async fetch () {
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/favorite'))
return this.state.songs
}
}

View file

@ -6,7 +6,6 @@ interface Preferences extends Record<string, any> {
volume: number
notify: boolean
repeatMode: RepeatMode
showExtraPanel: boolean
confirmClosing: boolean
equalizer: EqualizerPreset,
artistsViewMode: ArtistAlbumViewMode | null,
@ -25,7 +24,6 @@ const preferenceStore = {
volume: 7,
notify: true,
repeatMode: 'NO_REPEAT',
showExtraPanel: true,
confirmClosing: false,
equalizer: {
preamp: 0,

View file

@ -146,10 +146,12 @@ export const queueStore = {
async fetchRandom (limit = 500) {
const songs = await http.get<Song[]>(`queue/fetch?order=rand&limit=${limit}`)
this.state.songs = songStore.syncWithVault(songs)
return this.state.songs
},
async fetchInOrder (sortField: SongListSortField, order: SortOrder, limit = 500) {
const songs = await http.get<Song[]>(`queue/fetch?order=${order}&sort=${sortField}&limit=${limit}`)
this.state.songs = songStore.syncWithVault(songs)
return this.state.songs
}
}

View file

@ -16,6 +16,7 @@ export const recentlyPlayedStore = {
async fetch () {
this.state.songs = songStore.syncWithVault(await http.get<Song[]>('songs/recently-played'))
return this.state.songs
},
async add (song: Song) {

View file

@ -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 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 SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> = Symbol('SongListConfig')
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')

View file

@ -355,11 +355,6 @@ type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_na
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]
interface PaginatorResource {
@ -380,3 +375,5 @@ type ToastMessage = {
content: string
timeout: number // seconds
}
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'

View file

@ -57,6 +57,7 @@ button, [role=button] {
background: transparent;
padding: 0;
border: 0;
color: currentColor;
}
select {

View file

@ -18,16 +18,22 @@
--font-weight-normal: 500;
--font-weight-bold: 700;
--header-height: 48px;
--footer-height: 64px;
--footer-height-mobile: 74px;
--extra-panel-width: 334px;
--header-height: auto;
--footer-height: 84px;
--extra-panel-width: 320px;
--sidebar-width: 256px;
--color-black: #181818;
--color-maroon: #bf2043;
--color-green: #56a052;
--color-blue: #0191f7;
--color-red: #c34848;
@media screen and (max-width: 768px) {
--header-height: 59px;
--footer-height: 96px;
--extra-panel-width: 100%;
}
}
$plyr-blue: var(--color-highlight);

View file

@ -48,7 +48,7 @@ $plyr-progress-loading-bg: transparentize(#000, .15) !default;
// Volume
$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-width: ($plyr-volume-track-height * 2) !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-radius: 100%;
transition: background .3s ease;
cursor: ns-resize;
}
@mixin volume-track() {
@ -399,10 +398,7 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
// Playback progress
// <progress> element
&__progress {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
position: relative;
width: 100%;
height: $plyr-control-spacing;
background: $plyr-progress-bg;
@ -414,14 +410,14 @@ $plyr-bp-captions-large: 768px !default; // When captions jump to the larger fon
left: 0;
top: 0;
width: 100%;
height: 1px;
height: 4px;
overflow: hidden;
@media (hover: none) {
height: 12px;
}
margin: 0;
padding: 0;
vertical-align: top;
-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
.plyr__tooltip {
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 {
bottom: auto;
top: 0;
background: transparent;
}