mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
feat: revamp Screen headers
This commit is contained in:
parent
d1c99413b0
commit
63c9677fbe
18 changed files with 254 additions and 56 deletions
|
@ -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))
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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[]]>([
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -13,6 +13,7 @@ button {
|
|||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .3rem;
|
||||
|
||||
&:hover {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: ''
|
||||
|
|
|
@ -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);
|
||||
|
|
42
resources/assets/js/components/ui/ThumbnailStack.vue
Normal file
42
resources/assets/js/components/ui/ThumbnailStack.vue
Normal 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>
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue