feat: revamp Screen headers

This commit is contained in:
Phan An 2022-07-16 11:52:39 +02:00
parent d1c99413b0
commit 63c9677fbe
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
18 changed files with 254 additions and 56 deletions

View file

@ -6,6 +6,7 @@ import { clickaway, droppable, focus } from '@/directives'
import { defineComponent, nextTick } from 'vue'
import { commonStore, userStore } from '@/stores'
import factory from '@/__tests__/factory'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
export default abstract class UnitTestCase {
private backupMethods = new Map()
@ -69,6 +70,9 @@ export default abstract class UnitTestCase {
'koel-clickaway': clickaway,
'koel-focus': focus,
'koel-droppable': droppable
},
components: {
icon: FontAwesomeIcon
}
}
}, options))

View file

@ -1,6 +1,6 @@
<template>
<section id="albumWrapper">
<ScreenHeader has-thumbnail>
<ScreenHeader :layout="headerLayout" has-thumbnail>
{{ album.name }}
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
@ -35,7 +35,7 @@
</template>
</ScreenHeader>
<SongList ref="songList" @press:enter="onPressEnter"/>
<SongList ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<section v-if="useLastfm && showingInfo" class="info-wrapper">
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
@ -69,6 +69,7 @@ const {
SongList,
SongListControls,
ControlsToggle,
headerLayout,
songs,
songList,
showingControls,
@ -76,7 +77,8 @@ const {
onPressEnter,
playAll,
playSelected,
toggleControls
toggleControls,
onScrollBreakpoint
} = useSongList(albumSongs, 'album', { columns: ['track', 'title', 'artist', 'length'] })
const useLastfm = toRef(commonStore.state, 'use_last_fm')

View file

@ -1,9 +1,13 @@
<template>
<section id="songsWrapper">
<ScreenHeader>
<ScreenHeader :layout="headerLayout" has-thumbnail>
All Songs
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="totalSongCount">
<span>{{ pluralize(totalSongCount, 'song') }}</span>
<span>{{ totalDuration }}</span>
@ -18,7 +22,13 @@
</template>
</ScreenHeader>
<SongList ref="songList" @press:enter="onPressEnter" @scrolled-to-end="fetchSongs" @sort="sort"/>
<SongList
ref="songList"
@sort="sort"
@scroll-breakpoint="onScrollBreakpoint"
@press:enter="onPressEnter"
@scrolled-to-end="fetchSongs"
/>
</section>
</template>
@ -39,6 +49,9 @@ const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
thumbnails,
songs,
songList,
duration,
@ -46,7 +59,8 @@ const {
isPhone,
onPressEnter,
playSelected,
toggleControls
toggleControls,
onScrollBreakpoint
} = useSongList(toRef(songStore.state, 'songs'), 'all-songs')
let initialized = false

View file

@ -1,6 +1,6 @@
<template>
<section id="artistWrapper">
<ScreenHeader has-thumbnail>
<ScreenHeader :layout="headerLayout" has-thumbnail>
{{ artist.name }}
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
@ -35,7 +35,7 @@
</template>
</ScreenHeader>
<SongList ref="songList" @press:enter="onPressEnter"/>
<SongList ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<section class="info-wrapper" v-if="useLastfm && showingInfo">
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
@ -66,6 +66,7 @@ const {
SongList,
SongListControls,
ControlsToggle,
headerLayout,
songList,
songs,
showingControls,
@ -73,7 +74,8 @@ const {
onPressEnter,
playAll,
playSelected,
toggleControls
toggleControls,
onScrollBreakpoint
} = useSongList(artistSongs, 'artist', { columns: ['track', 'title', 'album', 'length'] })
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))

View file

@ -1,9 +1,13 @@
<template>
<section id="favoritesWrapper">
<ScreenHeader>
<ScreenHeader :layout="headerLayout" has-thumbnail>
Songs You Love
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ duration }}</span>
@ -32,9 +36,10 @@
<SongList
v-if="songs.length"
ref="songList"
@sort="sort"
@press:delete="removeSelected"
@press:enter="onPressEnter"
@sort="sort"
@scroll-breakpoint="onScrollBreakpoint"
/>
<ScreenEmptyState v-else>
@ -67,9 +72,12 @@ const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
songs,
songList,
duration,
thumbnails,
selectedSongs,
showingControls,
isPhone,
@ -77,6 +85,7 @@ const {
playAll,
playSelected,
toggleControls,
onScrollBreakpoint,
sort
} = useSongList(toRef(favoriteStore.state, 'songs'), 'favorites')

View file

@ -1,9 +1,13 @@
<template>
<section id="playlistWrapper" v-if="playlist">
<ScreenHeader>
<ScreenHeader :layout="headerLayout" has-thumbnail>
{{ playlist.name }}
<ControlsToggle v-if="songs.length" :showing-controls="showingControls" @toggleControls="toggleControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ duration }}</span>
@ -21,10 +25,10 @@
<template v-slot:controls>
<SongListControls
v-if="!isPhone || showingControls"
:config="controlsConfig"
@deletePlaylist="destroy"
@playAll="playAll"
@playSelected="playSelected"
@deletePlaylist="destroy"
:config="controlsConfig"
/>
</template>
</ScreenHeader>
@ -34,6 +38,7 @@
ref="songList"
@press:delete="removeSelected"
@press:enter="onPressEnter"
@scroll-breakpoint="onScrollBreakpoint"
@sort="sort"
/>
@ -77,9 +82,12 @@ const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
songs,
songList,
duration,
thumbnails,
selectedSongs,
showingControls,
isPhone,
@ -87,6 +95,7 @@ const {
playAll,
playSelected,
toggleControls,
onScrollBreakpoint,
sort
} = useSongList(ref<Song[]>([]), 'playlist')

View file

@ -1,9 +1,13 @@
<template>
<section id="queueWrapper">
<ScreenHeader>
<ScreenHeader :layout="headerLayout" :has-thumbnail="shouldDisplayThumbnails">
Current Queue
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ duration }}</span>
@ -26,6 +30,7 @@
@press:delete="removeSelected"
@press:enter="onPressEnter"
@reorder="onReorder"
@scroll-breakpoint="onScrollBreakpoint"
/>
<ScreenEmptyState v-else>
@ -59,17 +64,22 @@ const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
songs,
songList,
duration,
thumbnails,
selectedSongs,
showingControls,
isPhone,
playSelected,
toggleControls
toggleControls,
onScrollBreakpoint
} = useSongList(toRef(queueStore.state, 'songs'), 'queue', { sortable: false })
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
const shouldDisplayThumbnails = computed(() => songs.value.length > 0)
const playAll = (shuffle = true) => playbackService.queueAndPlay(songs.value, shuffle)

View file

@ -1,9 +1,13 @@
<template>
<section id="recentlyPlayedWrapper">
<ScreenHeader>
<ScreenHeader :layout="headerLayout" has-thumbnail>
Recently Played
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ duration }}</span>
@ -18,7 +22,7 @@
</template>
</ScreenHeader>
<SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter"/>
<SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<ScreenEmptyState v-else>
<template v-slot:icon>
@ -40,20 +44,26 @@ import { toRef } from 'vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs')
const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
songs,
songList,
thumbnails,
duration,
showingControls,
isPhone,
onPressEnter,
playAll,
playSelected,
toggleControls
} = useSongList(toRef(recentlyPlayedStore.state, 'songs'), 'recently-played', { sortable: false })
toggleControls,
onScrollBreakpoint
} = useSongList(recentlyPlayedSongs, 'recently-played', { sortable: false })
let initialized = false

View file

@ -1,9 +1,13 @@
<template>
<section id="songResultsWrapper">
<ScreenHeader>
Songs Matching <strong>{{ decodedQ }}</strong>
<ScreenHeader :layout="headerLayout" has-thumbnail>
Songs for <span class="q">{{ decodedQ }}</span>
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-slot:meta v-if="songs.length">
<span>{{ pluralize(songs.length, 'song') }}</span>
<span>{{ duration }}</span>
@ -18,7 +22,7 @@
</template>
</ScreenHeader>
<SongList ref="songList" @press:enter="onPressEnter" @sort="sort"/>
<SongList ref="songList" @sort="sort" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
</section>
</template>
@ -37,8 +41,11 @@ const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
songs,
songList,
thumbnails,
duration,
showingControls,
isPhone,
@ -46,7 +53,8 @@ const {
playAll,
playSelected,
toggleControls,
sort
sort,
onScrollBreakpoint
} = useSongList(toRef(searchStore.state, 'songs'), 'search-results')
const decodedQ = computed(() => decodeURIComponent(q.value))
@ -54,3 +62,9 @@ const decodedQ = computed(() => decodeURIComponent(q.value))
searchStore.resetSongResultState()
searchStore.songSearch(decodedQ.value)
</script>
<style lang="scss" scoped>
.q {
font-weight: var(--font-weight-thin);
}
</style>

View file

@ -43,8 +43,7 @@ new class extends UnitTestCase {
factory<Playlist>('playlist', { name: 'Baz' })
]
const { html } = this.renderComponent()
expect(html()).toMatchSnapshot()
expect(this.renderComponent().html()).toMatchSnapshot()
})
it.each<[keyof AddToMenuConfig, string | string[]]>([

View file

@ -65,7 +65,13 @@
<span class="play"></span>
</div>
<VirtualScroller v-slot="{ item }" :item-height="35" :items="songRows" @scrolled-to-end="$emit('scrolled-to-end')">
<VirtualScroller
v-slot="{ item }"
:item-height="35"
:items="songRows"
@scroll="onScroll"
@scrolled-to-end="$emit('scrolled-to-end')"
>
<SongListItem
:key="item.song.id"
:columns="config.columns"
@ -102,7 +108,7 @@ import {
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scrolled-to-end'])
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
const items = inject(SongsKey, ref([]))
const type = inject(SongListTypeKey, 'all-songs')
@ -127,6 +133,20 @@ const config = computed((): SongListConfig => {
}, inject(SongListConfigKey, {}))
})
let lastScrollTop = 0
const onScroll = e => {
const scroller = e.target as HTMLElement
if (scroller.scrollTop > 512 && lastScrollTop < 512) {
emit('scroll-breakpoint', 'down')
} else if (scroller.scrollTop < 512 && lastScrollTop > 512) {
emit('scroll-breakpoint', 'up')
}
lastScrollTop = scroller.scrollTop
}
/**
* Since song objects themselves are shared by all song lists, we can't use them directly to
* determine their selection status (selected/unselected). Therefore, for each song list, we
@ -380,7 +400,7 @@ onMounted(() => render())
position: absolute;
left: 0;
right: 0;
min-height: 100%;
min-height: 200%;
}
.item {
@ -399,7 +419,7 @@ onMounted(() => render())
.item-container {
left: 12px;
right: 12px;
width: calc(100vw - 24px);
width: calc(200vw - 24px);
}
}
@ -410,7 +430,7 @@ onMounted(() => render())
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-secondary);
width: 100%;
width: 200%;
}
.song-item span {

View file

@ -13,6 +13,7 @@ button {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .3rem;
&:hover {

View file

@ -6,6 +6,8 @@
<style lang="scss">
.btn-group {
--radius: 9999px;
display: flex;
position: relative;
flex-wrap: nowrap;
@ -18,13 +20,13 @@
&:first-of-type {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 9999px;
border-bottom-left-radius: 9999px;
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
&:last-of-type {
border-top-right-radius: 9999px;
border-bottom-right-radius: 9999px;
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
}

View file

@ -22,7 +22,7 @@ import { defineAsyncComponent, reactive } from 'vue'
const SoundBar = defineAsyncComponent(() => import('@/components/ui/SoundBar.vue'))
const state = reactive<OverlayState>({
showing: true,
showing: false,
dismissible: false,
type: 'loading',
message: ''

View file

@ -1,65 +1,107 @@
<template>
<header class="screen-header">
<header class="screen-header" :class="layout">
<div class="thumbnail-wrapper" :class="{ 'non-empty': hasThumbnail }">
<slot name="thumbnail"></slot>
</div>
<div class="heading-wrapper">
<h1>
<slot></slot>
</h1>
<span class="meta text-secondary">
<slot name="meta"></slot>
</span>
</div>
<div class="right">
<div class="heading-wrapper">
<h1 class="name">
<slot></slot>
</h1>
<span class="meta text-secondary">
<slot name="meta"></slot>
</span>
</div>
<slot name="controls"></slot>
<slot name="controls"></slot>
</div>
</header>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue'
const props = withDefaults(defineProps<{ hasThumbnail?: boolean }>(), { hasThumbnail: false })
const props = withDefaults(defineProps<{ hasThumbnail?: boolean, layout?: 'expanded' | 'collapsed' }>(), {
hasThumbnail: false,
layout: 'expanded'
})
const { hasThumbnail } = toRefs(props)
</script>
<style lang="scss">
header.screen-header {
display: flex;
font-weight: var(--font-weight-thin);
padding: 1rem 1.8rem;
align-items: flex-end;
border-bottom: 1px solid var(--color-bg-secondary);
min-height: 96px;
position: relative;
align-items: center;
align-content: stretch;
line-height: normal;
gap: 1.5rem;
padding: .8rem 1rem .8rem 0;
will-change: height;
&.expanded {
padding: 1.8rem;
.thumbnail-wrapper {
width: 192px;
}
h1.name {
font-size: 4rem;
font-weight: bold;
}
.meta {
display: block;
}
.right {
flex-direction: column;
align-items: flex-start;
}
}
.thumbnail-wrapper {
width: 64px;
overflow: hidden;
width: 0;
display: none;
will-change: width, height;
transition: width .3s;
box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.3);
border-radius: 5px;
&.non-empty {
display: block;
}
}
h1 {
.right {
flex: 1;
display: flex;
gap: 1.5rem;
align-items: center;
overflow: hidden;
}
h1.name {
font-size: 2.75rem;
font-weight: var(--font-weight-thin);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.heading-wrapper {
width: 100%;
overflow: hidden;
flex: 1;
}
.meta {
display: block;
display: none;
font-size: .9rem;
line-height: 2;
font-weight: var(--font-weight-light);

View file

@ -0,0 +1,42 @@
<template>
<div class="thumbnail-stack" :class="layout">
<span v-for="thumbnail in displayedThumbnails" :style="{ backgroundImage: `url(${thumbnail}`}"/>
</div>
</template>
<script lang="ts" setup>
import { take } from 'lodash'
import { computed, toRefs } from 'vue'
import { defaultCover } from '@/utils'
const props = defineProps<{ thumbnails: string[] }>()
const { thumbnails } = toRefs(props)
const displayedThumbnails = computed(() => {
return thumbnails.value.length == 0
? [defaultCover]
: (thumbnails.value.length < 4 ? [thumbnails.value[0]] : take(thumbnails.value, 4)).map(url => url || defaultCover)
})
const layout = computed<'mono' | 'tiles'>(() => displayedThumbnails.value.length < 4 ? 'mono' : 'tiles')
</script>
<style lang="scss" scoped>
.thumbnail-stack {
aspect-ratio: 1/1;
display: grid;
overflow: hidden;
&.tiles {
grid-template-columns: 1fr 1fr;
}
span {
display: block;
width: 100%;
height: 100%;
background-size: cover;
background-repeat: no-repeat;
}
}
</style>

View file

@ -21,7 +21,7 @@ const scrollerHeight = ref(0)
const renderAhead = 5
const scrollTop = ref(0)
const emit = defineEmits(['scrolled-to-end'])
const emit = defineEmits(['scrolled-to-end', 'scroll'])
const totalHeight = computed(() => items.value.length * itemHeight.value)
const startPosition = computed(() => Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - renderAhead))
@ -38,6 +38,8 @@ const onScroll = e => requestAnimationFrame(() => {
if (!scroller.value) return
emit('scroll', e)
if (scroller.value.scrollTop + scroller.value.clientHeight + itemHeight.value >= scroller.value.scrollHeight) {
emit('scrolled-to-end')
}

View file

@ -1,4 +1,4 @@
import { orderBy } from 'lodash'
import { orderBy, sampleSize, take } from 'lodash'
import isMobile from 'ismobilejs'
import { computed, provide, reactive, Ref, ref } from 'vue'
import { playbackService } from '@/services'
@ -17,6 +17,7 @@ import {
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
import SongList from '@/components/song/SongList.vue'
import SongListControls from '@/components/song/SongListControls.vue'
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
export const useSongList = (
songs: Ref<Song[]>,
@ -28,9 +29,20 @@ export const useSongList = (
const isPhone = isMobile.phone
const selectedSongs = ref<Song[]>([])
const showingControls = ref(false)
const headerLayout = ref<'expanded' | 'collapsed'>('expanded')
const onScrollBreakpoint = (direction: 'up' | 'down') => {
headerLayout.value = direction === 'down' ? 'collapsed' : 'expanded'
}
const duration = computed(() => songStore.getFormattedLength(songs.value))
const thumbnails = computed(() => {
const songsWithCover = songs.value.filter(song => song.album_cover)
const sampleCovers = sampleSize(songsWithCover, 20).map(song => song.album_cover)
return take(Array.from(new Set(sampleCovers)), 4)
})
const getSongsToPlay = (): Song[] => songList.value.getAllSongsWithSort()
const playAll = (shuffle: boolean) => playbackService.queueAndPlay(getSongsToPlay(), shuffle)
const playSelected = (shuffle: boolean) => playbackService.queueAndPlay(selectedSongs.value, shuffle)
@ -94,10 +106,13 @@ export const useSongList = (
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
songs,
headerLayout,
sortField,
sortOrder,
duration,
thumbnails,
songList,
selectedSongs,
showingControls,
@ -106,6 +121,7 @@ export const useSongList = (
playAll,
playSelected,
toggleControls,
onScrollBreakpoint,
sort
}
}