feat: add loading skeletons

This commit is contained in:
Phan An 2022-07-30 17:08:20 +02:00
parent feff485d95
commit 2951fa3ddb
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
26 changed files with 545 additions and 117 deletions

View file

@ -14,8 +14,13 @@
data-testid="album-list" data-testid="album-list"
@scroll="scrolling" @scroll="scrolling"
> >
<template v-if="showSkeletons">
<AlbumCardSkeleton v-for="i in 10" :key="i" :layout="itemLayout"/>
</template>
<template v-else>
<AlbumCard v-for="album in albums" :key="album.id" :album="album" :layout="itemLayout"/> <AlbumCard v-for="album in albums" :key="album.id" :album="album" :layout="itemLayout"/>
<ToTopButton/> <ToTopButton/>
</template>
</div> </div>
</section> </section>
</template> </template>
@ -27,6 +32,7 @@ import { albumStore, preferenceStore as preferences } from '@/stores'
import { useInfiniteScroll } from '@/composables' import { useInfiniteScroll } from '@/composables'
import AlbumCard from '@/components/album/AlbumCard.vue' import AlbumCard from '@/components/album/AlbumCard.vue'
import AlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ViewModeSwitch from '@/components/ui/ViewModeSwitch.vue' import ViewModeSwitch from '@/components/ui/ViewModeSwitch.vue'
@ -40,21 +46,22 @@ const {
makeScrollable makeScrollable
} = useInfiniteScroll(async () => await fetchAlbums()) } = useInfiniteScroll(async () => await fetchAlbums())
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
watch(viewMode, () => (preferences.albumsViewMode = viewMode.value)) watch(viewMode, () => (preferences.albumsViewMode = viewMode.value))
let initialized = false let initialized = false
let loading = false const loading = ref(false)
const page = ref<number | null>(1) const page = ref<number | null>(1)
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
const moreAlbumsAvailable = computed(() => page.value !== null) const moreAlbumsAvailable = computed(() => page.value !== null)
const showSkeletons = computed(() => loading.value && albums.value.length === 0)
const fetchAlbums = async () => { const fetchAlbums = async () => {
if (loading || !moreAlbumsAvailable.value) return if (loading.value || !moreAlbumsAvailable.value) return
loading = true loading.value = true
page.value = await albumStore.paginate(page.value!) page.value = await albumStore.paginate(page.value!)
loading = false loading.value = false
} }
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => { eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {

View file

@ -1,6 +1,8 @@
<template> <template>
<section id="albumWrapper"> <section id="albumWrapper">
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout"> <ScreenHeaderSkeleton v-if="loading"/>
<ScreenHeader v-if="!loading && album" :layout="songs.length === 0 ? 'collapsed' : headerLayout">
{{ album.name }} {{ album.name }}
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/> <ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
@ -35,9 +37,10 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongList ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/> <SongListSkeleton v-if="loading"/>
<SongList v-else ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<section v-if="useLastfm && showingInfo" class="info-wrapper"> <section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
<CloseModalBtn class="close-modal" @click="showingInfo = false"/> <CloseModalBtn class="close-modal" @click="showingInfo = false"/>
<div class="inner"> <div class="inner">
<AlbumInfo :album="album" mode="full"/> <AlbumInfo :album="album" mode="full"/>
@ -48,29 +51,36 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue' import { computed, defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
import { eventBus, pluralize, secondsToHis } from '@/utils' import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores' import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { useSongList } from '@/composables' import { useSongList } from '@/composables'
import router from '@/router' import router from '@/router'
import { DialogBoxKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue' import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue')) const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue')) const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
const props = defineProps<{ album: Album }>() const dialog = requireInjection(DialogBoxKey)
const { album } = toRefs(props)
const albumSongs = ref<Song[]>([]) const props = defineProps<{ album: number }>()
const { album: id } = toRefs(props)
const album = ref<Album>()
const songs = ref<Song[]>([])
const showingInfo = ref(false)
const loading = ref(false)
const { const {
SongList, SongList,
SongListControls, SongListControls,
ControlsToggle, ControlsToggle,
headerLayout, headerLayout,
songs,
songList, songList,
showingControls, showingControls,
isPhone, isPhone,
@ -79,13 +89,13 @@ const {
playSelected, playSelected,
toggleControls, toggleControls,
onScrollBreakpoint onScrollBreakpoint
} = useSongList(albumSongs, 'album', { columns: ['track', 'title', 'artist', 'length'] }) } = useSongList(songs, 'album', { columns: ['track', 'title', 'artist', 'length'] })
const useLastfm = toRef(commonStore.state, 'use_last_fm') const useLastfm = toRef(commonStore.state, 'use_last_fm')
const allowDownload = toRef(commonStore.state, 'allow_download') const allowDownload = toRef(commonStore.state, 'allow_download')
const showingInfo = ref(false)
const isNormalArtist = computed(() => { const isNormalArtist = computed(() => {
if (!album.value) return true
return !artistStore.isVarious(album.value.artist_id) && !artistStore.isUnknown(album.value.artist_id) return !artistStore.isVarious(album.value.artist_id) && !artistStore.isUnknown(album.value.artist_id)
}) })
@ -93,12 +103,24 @@ const download = () => downloadService.fromAlbum(album.value)
const showInfo = () => (showingInfo.value = true) const showInfo = () => (showingInfo.value = true)
onMounted(async () => { onMounted(async () => {
albumSongs.value = await songStore.fetchForAlbum(album.value) loading.value = true
try {
[album.value, songs.value] = await Promise.all([
albumStore.resolve(id.value),
songStore.fetchForAlbum(id.value)
])
} catch (e) {
logger.error(e)
dialog.value.error('Failed to load album. Please try again.')
} finally {
loading.value = false
}
}) })
eventBus.on('SONGS_UPDATED', () => { eventBus.on('SONGS_UPDATED', () => {
// if the current album has been deleted, go back to the list // if the current album has been deleted, go back to the list
albumStore.byId(album.value.id) || router.go('albums') albumStore.byId(id.value) || router.go('albums')
}) })
</script> </script>

View file

@ -22,7 +22,9 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongListSkeleton v-if="showSkeletons"/>
<SongList <SongList
v-else
ref="songList" ref="songList"
@sort="sort" @sort="sort"
@scroll-breakpoint="onScrollBreakpoint" @scroll-breakpoint="onScrollBreakpoint"
@ -41,6 +43,7 @@ import { useSongList } from '@/composables'
import router from '@/router' import router from '@/router'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const totalSongCount = toRef(commonStore.state, 'song_count') const totalSongCount = toRef(commonStore.state, 'song_count')
const totalDuration = computed(() => secondsToHis(commonStore.state.song_length)) const totalDuration = computed(() => secondsToHis(commonStore.state.song_length))
@ -64,12 +67,13 @@ const {
} = useSongList(toRef(songStore.state, 'songs'), 'all-songs') } = useSongList(toRef(songStore.state, 'songs'), 'all-songs')
let initialized = false let initialized = false
let loading = false const loading = ref(false)
let sortField: SongListSortField = 'title' // @todo get from query string let sortField: SongListSortField = 'title' // @todo get from query string
let sortOrder: SortOrder = 'asc' let sortOrder: SortOrder = 'asc'
const page = ref<number | null>(1) const page = ref<number | null>(1)
const moreSongsAvailable = computed(() => page.value !== null) const moreSongsAvailable = computed(() => page.value !== null)
const showSkeletons = computed(() => loading.value && songs.value.length === 0)
const sort = async (field: SongListSortField, order: SortOrder) => { const sort = async (field: SongListSortField, order: SortOrder) => {
page.value = 1 page.value = 1
@ -81,11 +85,11 @@ const sort = async (field: SongListSortField, order: SortOrder) => {
} }
const fetchSongs = async () => { const fetchSongs = async () => {
if (!moreSongsAvailable.value || loading) return if (!moreSongsAvailable.value || loading.value) return
loading = true loading.value = true
page.value = await songStore.paginate(sortField, sortOrder, page.value!) page.value = await songStore.paginate(sortField, sortOrder, page.value!)
loading = false loading.value = false
} }
const playAll = async (shuffle: boolean) => { const playAll = async (shuffle: boolean) => {

View file

@ -14,8 +14,13 @@
data-testid="artist-list" data-testid="artist-list"
@scroll="scrolling" @scroll="scrolling"
> >
<template v-if="showSkeletons">
<ArtistCardSkeleton v-for="i in 10" :key="i" :layout="itemLayout"/>
</template>
<template v-else>
<ArtistCard v-for="artist in artists" :key="artist.id" :artist="artist" :layout="itemLayout"/> <ArtistCard v-for="artist in artists" :key="artist.id" :artist="artist" :layout="itemLayout"/>
<ToTopButton/> <ToTopButton/>
</template>
</div> </div>
</section> </section>
</template> </template>
@ -27,6 +32,7 @@ import { artistStore, preferenceStore as preferences } from '@/stores'
import { useInfiniteScroll } from '@/composables' import { useInfiniteScroll } from '@/composables'
import ArtistCard from '@/components/artist/ArtistCard.vue' import ArtistCard from '@/components/artist/ArtistCard.vue'
import ArtistCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ViewModeSwitch from '@/components/ui/ViewModeSwitch.vue' import ViewModeSwitch from '@/components/ui/ViewModeSwitch.vue'
@ -40,21 +46,22 @@ const {
makeScrollable makeScrollable
} = useInfiniteScroll(async () => await fetchArtists()) } = useInfiniteScroll(async () => await fetchArtists())
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
watch(viewMode, () => preferences.artistsViewMode = viewMode.value) watch(viewMode, () => preferences.artistsViewMode = viewMode.value)
let initialized = false let initialized = false
let loading = false const loading = ref(false)
const page = ref<number | null>(1) const page = ref<number | null>(1)
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
const moreArtistsAvailable = computed(() => page.value !== null) const moreArtistsAvailable = computed(() => page.value !== null)
const showSkeletons = computed(() => loading.value && artists.value.length === 0)
const fetchArtists = async () => { const fetchArtists = async () => {
if (loading || !moreArtistsAvailable.value) return if (loading.value || !moreArtistsAvailable.value) return
loading = true loading.value = true
page.value = await artistStore.paginate(page.value!) page.value = await artistStore.paginate(page.value!)
loading = false loading.value = false
} }
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => { eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {

View file

@ -1,6 +1,8 @@
<template> <template>
<section id="artistWrapper"> <section id="artistWrapper">
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout"> <ScreenHeaderSkeleton v-if="loading"/>
<ScreenHeader v-if="!loading && artist" :layout="songs.length === 0 ? 'collapsed' : headerLayout">
{{ artist.name }} {{ artist.name }}
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/> <ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
@ -35,9 +37,10 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongList ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/> <SongListSkeleton v-if="loading"/>
<SongList v-else ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<section class="info-wrapper" v-if="useLastfm && showingInfo"> <section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
<CloseModalBtn class="close-modal" @click="showingInfo = false"/> <CloseModalBtn class="close-modal" @click="showingInfo = false"/>
<div class="inner"> <div class="inner">
<ArtistInfo :artist="artist" mode="full"/> <ArtistInfo :artist="artist" mode="full"/>
@ -48,19 +51,27 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue' import { defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
import { eventBus, pluralize, secondsToHis } from '@/utils' import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores' import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { useSongList, useThirdPartyServices } from '@/composables' import { useSongList, useThirdPartyServices } from '@/composables'
import router from '@/router' import router from '@/router'
import { DialogBoxKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue' import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const props = defineProps<{ artist: Artist }>() const dialog = requireInjection(DialogBoxKey)
const { artist } = toRefs(props)
const artistSongs = ref<Song[]>([]) const props = defineProps<{ artist: number }>()
const { artist: id } = toRefs(props)
const artist = ref<Artist>()
const songs = ref<Song[]>([])
const showingInfo = ref(false)
const loading = ref(false)
const { const {
SongList, SongList,
@ -68,7 +79,6 @@ const {
ControlsToggle, ControlsToggle,
headerLayout, headerLayout,
songList, songList,
songs,
showingControls, showingControls,
isPhone, isPhone,
onPressEnter, onPressEnter,
@ -76,7 +86,7 @@ const {
playSelected, playSelected,
toggleControls, toggleControls,
onScrollBreakpoint onScrollBreakpoint
} = useSongList(artistSongs, 'artist', { columns: ['track', 'title', 'album', 'length'] }) } = useSongList(songs, 'artist', { columns: ['track', 'title', 'album', 'length'] })
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue')) const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue')) const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
@ -84,18 +94,28 @@ const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnClos
const { useLastfm } = useThirdPartyServices() const { useLastfm } = useThirdPartyServices()
const allowDownload = toRef(commonStore.state, 'allow_download') const allowDownload = toRef(commonStore.state, 'allow_download')
const showingInfo = ref(false)
const download = () => downloadService.fromArtist(artist.value) const download = () => downloadService.fromArtist(artist.value)
const showInfo = () => (showingInfo.value = true) const showInfo = () => (showingInfo.value = true)
onMounted(async () => { onMounted(async () => {
artistSongs.value = await songStore.fetchForArtist(artist.value) loading.value = true
try {
[artist.value, songs.value] = await Promise.all([
artistStore.resolve(id.value),
songStore.fetchForArtist(id.value)
])
} catch (e) {
logger.error(e)
dialog.value.error('Failed to load artist. Please try again.')
} finally {
loading.value = false
}
}) })
eventBus.on('SONGS_UPDATED', () => { eventBus.on('SONGS_UPDATED', () => {
// if the current artist has been deleted, go back to the list // if the current artist has been deleted, go back to the list
artistStore.byId(artist.value.id) || router.go('artists') artistStore.byId(id.value) || router.go('artists')
}) })
</script> </script>

View file

@ -33,6 +33,7 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongList <SongList
v-if="songs.length" v-if="songs.length"
ref="songList" ref="songList"
@ -63,10 +64,11 @@ import { eventBus, pluralize } from '@/utils'
import { commonStore, favoriteStore } from '@/stores' import { commonStore, favoriteStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { useSongList } from '@/composables' import { useSongList } from '@/composables'
import { nextTick, toRef } from 'vue' import { nextTick, ref, toRef } from 'vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const { const {
SongList, SongList,
@ -95,9 +97,12 @@ const download = () => downloadService.fromFavorites()
const removeSelected = () => selectedSongs.value.length && favoriteStore.unlike(selectedSongs.value) const removeSelected = () => selectedSongs.value.length && favoriteStore.unlike(selectedSongs.value)
let initialized = false let initialized = false
const loading = ref(false)
const fetchSongs = async () => { const fetchSongs = async () => {
loading.value = true
await favoriteStore.fetch() await favoriteStore.fetch()
loading.value = false
await nextTick() await nextTick()
sort() sort()
} }

View file

@ -33,8 +33,9 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongList <SongList
v-if="songs.length" v-if="!loading && songs.length"
ref="songList" ref="songList"
@press:delete="removeSelected" @press:delete="removeSelected"
@press:enter="onPressEnter" @press:enter="onPressEnter"
@ -73,6 +74,7 @@ import { MessageToasterKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const toaster = requireInjection(MessageToasterKey) const toaster = requireInjection(MessageToasterKey)
const playlist = ref<Playlist>() const playlist = ref<Playlist>()

View file

@ -24,6 +24,7 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongListSkeleton v-if="fetchingRandomSongs"/>
<SongList <SongList
v-if="songs.length" v-if="songs.length"
ref="songList" ref="songList"
@ -49,7 +50,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { faCoffee } from '@fortawesome/free-solid-svg-icons' import { faCoffee } from '@fortawesome/free-solid-svg-icons'
import { computed, toRef } from 'vue' import { computed, ref, toRef } from 'vue'
import { logger, pluralize, requireInjection } from '@/utils' import { logger, pluralize, requireInjection } from '@/utils'
import { commonStore, queueStore } from '@/stores' import { commonStore, queueStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService } from '@/services'
@ -58,6 +59,7 @@ import { DialogBoxKey } from '@/symbols'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const dialog = requireInjection(DialogBoxKey) const dialog = requireInjection(DialogBoxKey)
const controlConfig: Partial<SongListControlsConfig> = { clearQueue: true } const controlConfig: Partial<SongListControlsConfig> = { clearQueue: true }
@ -80,13 +82,16 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(toRef(queueStore.state, 'songs'), 'queue', { sortable: false }) } = useSongList(toRef(queueStore.state, 'songs'), 'queue', { sortable: false })
const fetchingRandomSongs = ref(false)
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0) const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
const playAll = (shuffle = true) => playbackService.queueAndPlay(songs.value, shuffle) const playAll = (shuffle = true) => playbackService.queueAndPlay(songs.value, shuffle)
const shuffleSome = async () => { const shuffleSome = async () => {
try { try {
fetchingRandomSongs.value = true
await queueStore.fetchRandom() await queueStore.fetchRandom()
fetchingRandomSongs.value = false
await playbackService.playFirstInQueue() await playbackService.playFirstInQueue()
} catch (e) { } catch (e) {
dialog.value.error('Failed to fetch songs to play. Please try again.', 'Error') dialog.value.error('Failed to fetch songs to play. Please try again.', 'Error')

View file

@ -22,6 +22,7 @@
</template> </template>
</ScreenHeader> </ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/> <SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<ScreenEmptyState v-else> <ScreenEmptyState v-else>
@ -39,10 +40,11 @@ import { faClock } from '@fortawesome/free-regular-svg-icons'
import { eventBus, pluralize } from '@/utils' import { eventBus, pluralize } from '@/utils'
import { recentlyPlayedStore } from '@/stores' import { recentlyPlayedStore } from '@/stores'
import { useSongList } from '@/composables' import { useSongList } from '@/composables'
import { toRef } from 'vue' import { ref, toRef } from 'vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs') const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs')
@ -66,11 +68,14 @@ const {
} = useSongList(recentlyPlayedSongs, 'recently-played', { sortable: false }) } = useSongList(recentlyPlayedSongs, 'recently-played', { sortable: false })
let initialized = false let initialized = false
let loading = ref(false)
eventBus.on({ eventBus.on({
'LOAD_MAIN_CONTENT': async (view: MainViewName) => { 'LOAD_MAIN_CONTENT': async (view: MainViewName) => {
if (view === 'RecentlyPlayed' && !initialized) { if (view === 'RecentlyPlayed' && !initialized) {
loading.value = true
await recentlyPlayedStore.fetch() await recentlyPlayedStore.fetch()
loading.value = false
initialized = true initialized = true
} }
} }

View file

@ -1,19 +1,30 @@
<template> <template>
<section> <section>
<h1>Top Albums</h1> <h1>Top Albums</h1>
<ol v-if="loading" class="two-cols top-album-list">
<li v-for="i in 4" :key="i">
<AlbumCardSkeleton layout="compact"/>
</li>
</ol>
<template v-else>
<ol v-if="albums.length" class="two-cols top-album-list"> <ol v-if="albums.length" class="two-cols top-album-list">
<li v-for="album in albums" :key="album.id"> <li v-for="album in albums" :key="album.id">
<AlbumCard :album="album" layout="compact"/> <AlbumCard :album="album" layout="compact"/>
</li> </li>
</ol> </ol>
<p v-else class="text-secondary">No albums found.</p> <p v-else class="text-secondary">No albums found.</p>
</template>
</section> </section>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { toRef } from 'vue' import { toRef } from 'vue'
import { overviewStore } from '@/stores' import { overviewStore } from '@/stores'
import AlbumCard from '@/components/album/AlbumCard.vue' import AlbumCard from '@/components/album/AlbumCard.vue'
import AlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
const albums = toRef(overviewStore.state, 'mostPlayedAlbums') const albums = toRef(overviewStore.state, 'mostPlayedAlbums')
const loading = toRef(overviewStore.state, 'loading')
</script> </script>

View file

@ -1,12 +1,20 @@
<template> <template>
<section> <section>
<h1>Top Artists</h1> <h1>Top Artists</h1>
<ol v-if="loading" class="two-cols top-album-list">
<li v-for="i in 4" :key="i">
<ArtistCardSkeleton layout="compact"/>
</li>
</ol>
<template v-else>
<ol v-if="artists.length" class="two-cols top-artist-list"> <ol v-if="artists.length" class="two-cols top-artist-list">
<li v-for="artist in artists" :key="artist.id"> <li v-for="artist in artists" :key="artist.id">
<ArtistCard :artist="artist" layout="compact"/> <ArtistCard :artist="artist" layout="compact"/>
</li> </li>
</ol> </ol>
<p v-else class="text-secondary">No artists found.</p> <p v-else class="text-secondary">No artists found.</p>
</template>
</section> </section>
</template> </template>
@ -15,6 +23,8 @@ import { toRef } from 'vue'
import { overviewStore } from '@/stores' import { overviewStore } from '@/stores'
import ArtistCard from '@/components/artist/ArtistCard.vue' import ArtistCard from '@/components/artist/ArtistCard.vue'
import ArtistCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
const artists = toRef(overviewStore.state, 'mostPlayedArtists') const artists = toRef(overviewStore.state, 'mostPlayedArtists')
const loading = toRef(overviewStore.state, 'loading')
</script> </script>

View file

@ -1,12 +1,19 @@
<template> <template>
<section> <section>
<h1>Most Played</h1> <h1>Most Played</h1>
<ol v-if="loading" class="top-song-list">
<li v-for="i in 3" :key="i">
<SongCardSkeleton/>
</li>
</ol>
<template v-else>
<ol v-if="songs.length" class="top-song-list"> <ol v-if="songs.length" class="top-song-list">
<li v-for="song in songs" :key="song.id"> <li v-for="song in songs" :key="song.id">
<SongCard :song="song"/> <SongCard :song="song"/>
</li> </li>
</ol> </ol>
<p v-else class="text-secondary">You dont seem to have been playing.</p> <p v-else class="text-secondary">You dont seem to have been playing.</p>
</template>
</section> </section>
</template> </template>
@ -15,6 +22,8 @@ import { toRef } from 'vue'
import { overviewStore } from '@/stores' import { overviewStore } from '@/stores'
import SongCard from '@/components/song/SongCard.vue' import SongCard from '@/components/song/SongCard.vue'
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
const songs = toRef(overviewStore.state, 'mostPlayedSongs') const songs = toRef(overviewStore.state, 'mostPlayedSongs')
const loading = toRef(overviewStore.state, 'loading')
</script> </script>

View file

@ -1,13 +1,20 @@
<template> <template>
<section> <section>
<h1>New Albums</h1> <h1>New Albums</h1>
<ol v-if="loading" class="recently-added-album-list">
<li v-for="i in 2" :key="i">
<AlbumCardSkeleton layout="compact"/>
</li>
</ol>
<template v-else>
<ol v-if="albums.length" class="recently-added-album-list"> <ol v-if="albums.length" class="recently-added-album-list">
<li v-for="album in albums" :key="album.id"> <li v-for="album in albums" :key="album.id">
<AlbumCard :album="album" layout="compact"/> <AlbumCard :album="album" layout="compact"/>
</li> </li>
</ol> </ol>
<p v-else class="text-secondary">No albums added yet.</p> <p v-else class="text-secondary">No albums added yet.</p>
</template>
</section> </section>
</template> </template>
@ -16,6 +23,8 @@ import { toRef } from 'vue'
import { overviewStore } from '@/stores' import { overviewStore } from '@/stores'
import AlbumCard from '@/components/album/AlbumCard.vue' import AlbumCard from '@/components/album/AlbumCard.vue'
import AlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
const albums = toRef(overviewStore.state, 'recentlyAddedAlbums') const albums = toRef(overviewStore.state, 'recentlyAddedAlbums')
const loading = toRef(overviewStore.state, 'loading')
</script> </script>

View file

@ -1,12 +1,19 @@
<template> <template>
<section> <section>
<h1>New Songs</h1> <h1>New Songs</h1>
<ol v-if="loading" class="recently-added-song-list">
<li v-for="i in 3" :key="i">
<SongCardSkeleton/>
</li>
</ol>
<template v-else>
<ol v-if="songs.length" class="recently-added-song-list"> <ol v-if="songs.length" class="recently-added-song-list">
<li v-for="song in songs" :key="song.id"> <li v-for="song in songs" :key="song.id">
<SongCard :song="song"/> <SongCard :song="song"/>
</li> </li>
</ol> </ol>
<p v-else class="text-secondary">No songs added so far.</p> <p v-else class="text-secondary">No songs added so far.</p>
</template>
</section> </section>
</template> </template>
@ -15,6 +22,8 @@ import { toRef } from 'vue'
import { overviewStore } from '@/stores' import { overviewStore } from '@/stores'
import SongCard from '@/components/song/SongCard.vue' import SongCard from '@/components/song/SongCard.vue'
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
const songs = toRef(overviewStore.state, 'recentlyAddedSongs') const songs = toRef(overviewStore.state, 'recentlyAddedSongs')
const loading = toRef(overviewStore.state, 'loading')
</script> </script>

View file

@ -14,25 +14,33 @@
</Btn> </Btn>
</h1> </h1>
<ol v-if="loading" class="recent-song-list">
<li v-for="i in 3" :key="i">
<SongCardSkeleton/>
</li>
</ol>
<template v-else>
<ol v-if="songs.length" class="recent-song-list"> <ol v-if="songs.length" class="recent-song-list">
<li v-for="song in songs" :key="song.id"> <li v-for="song in songs" :key="song.id">
<SongCard :song="song"/> <SongCard :song="song"/>
</li> </li>
</ol> </ol>
<p v-else class="text-secondary">No songs played as of late.</p> <p v-else class="text-secondary">No songs played as of late.</p>
</template>
</section> </section>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { toRef } from 'vue' import { toRef } from 'vue'
import router from '@/router' import router from '@/router'
import { recentlyPlayedStore } from '@/stores' import { overviewStore, recentlyPlayedStore } from '@/stores'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/Btn.vue'
import SongCard from '@/components/song/SongCard.vue' import SongCard from '@/components/song/SongCard.vue'
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
const songs = toRef(recentlyPlayedStore.excerptState, 'songs') const songs = toRef(recentlyPlayedStore.excerptState, 'songs')
const loading = toRef(overviewStore.state, 'loading')
const goToRecentlyPlayedScreen = () => router.go('recently-played') const goToRecentlyPlayedScreen = () => router.go('recently-played')
</script> </script>

View file

@ -8,40 +8,35 @@
@contextmenu.prevent="requestContextMenu" @contextmenu.prevent="requestContextMenu"
@dblclick.prevent="play" @dblclick.prevent="play"
> >
<span :style="{ backgroundImage: `url(${song.album_cover ?? ''}), url(${defaultCover})` }" class="cover"> <aside :style="{ backgroundImage: `url(${song.album_cover ?? ''}), url(${defaultCover})` }" class="cover">
<a class="control" @click.prevent="changeSongState" data-testid="play-control"> <a class="control" @click.prevent="changeSongState" data-testid="play-control">
<icon :icon="song.playback_state === 'Playing' ? faPause : faPlay" class="text-highlight"/> <icon :icon="song.playback_state === 'Playing' ? faPause : faPlay" class="text-highlight"/>
</a> </a>
</span> </aside>
<span class="main"> <main>
<span class="details"> <div class="details">
<span v-if="showPlayCount" :style="{ width: `${song.play_count*100/topPlayCount}%` }" class="play-count"/> <h3>{{ song.title }}</h3>
{{ song.title }} <p class="by text-secondary">
<span class="by text-secondary">
<a :href="`#!/artist/${song.artist_id}`">{{ song.artist_name }}</a> <a :href="`#!/artist/${song.artist_id}`">{{ song.artist_name }}</a>
<template v-if="showPlayCount"> - {{ pluralize(song.play_count, 'play') }}</template> - {{ pluralize(song.play_count, 'play') }}
</span> </p>
</span> </div>
<span class="favorite">
<LikeButton :song="song"/> <LikeButton :song="song"/>
</span> </main>
</span>
</article> </article>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons' import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
import { computed, toRefs } from 'vue' import { toRefs } from 'vue'
import { defaultCover, eventBus, pluralize, startDragging } from '@/utils' import { defaultCover, eventBus, pluralize, startDragging } from '@/utils'
import { queueStore } from '@/stores' import { queueStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import LikeButton from '@/components/song/SongLikeButton.vue' import LikeButton from '@/components/song/SongLikeButton.vue'
const props = withDefaults(defineProps<{ song: Song, topPlayCount?: number }>(), { topPlayCount: 0 }) const props = defineProps<{ song: Song }>()
const { song, topPlayCount } = toRefs(props) const { song } = toRefs(props)
const showPlayCount = computed(() => Boolean(topPlayCount && song.value.play_count))
const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value) const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song') const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song')
@ -80,12 +75,18 @@ article {
color: var(--color-highlight); color: var(--color-highlight);
} }
.favorite { button {
opacity: 0; opacity: 0;
} }
&:hover { &:hover {
.favorite { button {
opacity: 1;
}
}
@media (hover: none) {
button {
opacity: 1; opacity: 1;
} }
} }
@ -146,10 +147,10 @@ article {
} }
} }
.main { main {
flex: 1; flex: 1;
position: relative;
display: flex; display: flex;
align-items: flex-start;
gap: 8px; gap: 8px;
.play-count { .play-count {
@ -162,7 +163,6 @@ article {
} }
.by { .by {
display: block;
font-size: .9rem; font-size: .9rem;
opacity: .8; opacity: .8;

View file

@ -1,10 +1,10 @@
<template> <template>
<header class="screen-header" :class="layout"> <header class="screen-header" :class="layout">
<div class="thumbnail-wrapper"> <aside class="thumbnail-wrapper">
<slot name="thumbnail"></slot> <slot name="thumbnail"></slot>
</div> </aside>
<div class="right"> <main>
<div class="heading-wrapper"> <div class="heading-wrapper">
<h1 class="name"> <h1 class="name">
<slot></slot> <slot></slot>
@ -15,7 +15,7 @@
</div> </div>
<slot name="controls"></slot> <slot name="controls"></slot>
</div> </main>
</header> </header>
</template> </template>
@ -53,7 +53,7 @@ header.screen-header {
display: block; display: block;
} }
.right { main {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
@ -77,7 +77,7 @@ header.screen-header {
} }
} }
.right { main {
flex: 1; flex: 1;
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;

View file

@ -0,0 +1,65 @@
<template>
<article class="skeleton" :class="layout">
<aside class="thumbnail pulse"/>
<footer>
<p class="name pulse"/>
<p class="artist pulse"/>
<p class="meta pulse"/>
</footer>
</article>
</template>
<script lang="ts" setup>
const props = withDefaults(defineProps<{ layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
</script>
<style lang="scss" scoped>
.skeleton {
display: flex;
flex-direction: column;
gap: 1.5rem;
border: 1px solid var(--color-bg-secondary);
padding: 16px;
border-radius: 8px;
max-width: 256px;
aside {
aspect-ratio: 1/1;
border-radius: 50%;
}
footer {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 0.7rem;
p {
height: 1.2em;
width: 80%;
&.artist {
width: 33%;
}
&.meta {
width: 55%;
}
}
}
&.compact {
flex-direction: row;
align-items: center;
max-width: 100%;
padding: 10px;
border-radius: 5px;
aside {
width: 80px;
}
}
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<header class="skeleton screen-header expanded">
<aside class="thumbnail-wrapper pulse"/>
<main>
<h1 class="pulse"/>
<p class="meta pulse"/>
<p class="controls pulse"/>
</main>
</header>
</template>
<style lang="scss" scoped>
.skeleton {
display: flex;
align-items: flex-end;
position: relative;
align-content: stretch;
padding: 1.8rem;
.thumbnail-wrapper {
margin-right: 1.5rem;
width: 192px;
aspect-ratio: 1/1;
border-radius: 50%;
display: block !important;
}
main {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
gap: 1rem;
}
h1 {
height: 4rem;
width: 80%;
}
p {
height: 1.2rem;
width: 40%;
&.controls {
height: 2.5rem;
border-radius: 999px;
width: 25%;
}
}
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<article class="skeleton">
<aside class="pulse"/>
<main>
<div class="details">
<h3 class="pulse"/>
<p class="pulse"/>
</div>
</main>
</article>
</template>
<style lang="scss" scoped>
.skeleton {
display: flex;
gap: 12px;
padding: 8px 12px 8px 8px;
border-radius: 5px;
align-items: center;
border: 1px solid var(--color-bg-secondary);
aside {
width: 48px;
aspect-ratio: 1/1;
border-radius: 50%;
}
main {
flex: 1;
display: flex;
align-items: flex-start;
gap: 8px;
.details {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
h3, p {
width: 66%;
height: 1.2em;
}
p {
width: 33%;
}
}
}
</style>

View file

@ -0,0 +1,102 @@
<template>
<div class="skeleton">
<div class="song-list-header">
<span class="track-number">
<span class="text"/>
</span>
<span class="title">
<span class="text"/>
</span>
<span class="artist">
<span class="text"/>
</span>
<span class="album">
<span class="text"/>
</span>
<span class="time">
<span class="text"/>
</span>
<span class="favorite"/>
</div>
<div v-for="i in 40" :key="i" class="song-item">
<span class="track-number">
<span class="text pulse"/>
</span>
<span class="title">
<span class="text pulse"/>
</span>
<span class="artist">
<span class="text pulse"/>
</span>
<span class="album">
<span class="text pulse"/>
</span>
<span class="time">
<span class="text pulse"/>
</span>
<span class="favorite">
<span class="text pulse"/>
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.skeleton {
.song-list-header {
background: var(--color-bg-secondary);
height: 35px;
display: flex;
}
.song-item {
display: flex;
border-bottom: 1px solid var(--color-bg-secondary);
height: 35px;
}
.song-list-header span span, .pulse {
display: inline-block;
height: 1.2rem;
border-radius: 999px;
width: 80%;
}
.song-list-header span span {
background: rgba(255, 255, 255, .1);
width: 40%;
}
span:not(.text) {
padding: 8px;
vertical-align: middle;
}
.track-number {
flex-basis: 66px;
padding-left: 24px;
}
.title {
flex: 1;
}
.artist {
flex-basis: 23%;
}
.album {
flex-basis: 27%;
}
.time {
flex-basis: 96px;
padding-right: 24px;
text-align: right;
}
.favorite {
flex-basis: 36px;
}
}
</style>

View file

@ -1,6 +1,6 @@
import isMobile from 'ismobilejs' import isMobile from 'ismobilejs'
import { loadMainView, use } from '@/utils' import { loadMainView, use } from '@/utils'
import { albumStore, artistStore, playlistStore, queueStore, songStore, userStore } from '@/stores' import { playlistStore, queueStore, songStore, userStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService } from '@/services'
class Router { class Router {
@ -23,8 +23,8 @@ class Router {
'/youtube': () => loadMainView('YouTube'), '/youtube': () => loadMainView('YouTube'),
'/visualizer': () => loadMainView('Visualizer'), '/visualizer': () => loadMainView('Visualizer'),
'/profile': () => loadMainView('Profile'), '/profile': () => loadMainView('Profile'),
'/album/(\\d+)': async (id: number) => loadMainView('Album', await albumStore.resolve(id)), '/album/(\\d+)': async (id: string) => loadMainView('Album', parseInt(id)),
'/artist/(\\d+)': async (id: number) => loadMainView('Artist', await artistStore.resolve(id)), '/artist/(\\d+)': async (id: string) => loadMainView('Artist', parseInt(id)),
'/playlist/(\\d+)': (id: number) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)), '/playlist/(\\d+)': (id: number) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)),
'/song/([a-z0-9]{32})': async (id: string) => { '/song/([a-z0-9]{32})': async (id: string) => {
const song = await songStore.resolve(id) const song = await songStore.resolve(id)

View file

@ -12,10 +12,13 @@ export const overviewStore = {
recentlyAddedAlbums: [], recentlyAddedAlbums: [],
mostPlayedSongs: [], mostPlayedSongs: [],
mostPlayedAlbums: [], mostPlayedAlbums: [],
mostPlayedArtists: [] mostPlayedArtists: [],
loading: false
}), }),
async init () { async init () {
this.state.loading = true
const resource = await httpService.get<{ const resource = await httpService.get<{
most_played_songs: Song[], most_played_songs: Song[],
most_played_albums: Album[], most_played_albums: Album[],
@ -32,6 +35,7 @@ export const overviewStore = {
recentlyPlayedStore.excerptState.songs = songStore.syncWithVault(resource.recently_played_songs) recentlyPlayedStore.excerptState.songs = songStore.syncWithVault(resource.recently_played_songs)
this.refresh() this.refresh()
this.state.loading = false
}, },
refresh () { refresh () {

View file

@ -154,17 +154,21 @@ export const songStore = {
}) })
}, },
async fetchForAlbum (album: Album) { async fetchForAlbum (album: Album | number) {
const id = typeof album === 'number' ? album : album.id
return await cache.remember<Song[]>( return await cache.remember<Song[]>(
[`album.songs`, album.id], [`album.songs`, id],
async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${album.id}/songs`)) async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${id}/songs`))
) )
}, },
async fetchForArtist (artist: Artist) { async fetchForArtist (artist: Artist | number) {
const id = typeof artist === 'number' ? artist : artist.id
return await cache.remember<Song[]>( return await cache.remember<Song[]>(
['artist.songs', artist.id], ['artist.songs', id],
async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${artist.id}/songs`)) async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${id}/songs`))
) )
}, },

View file

@ -9,6 +9,6 @@
@import "#/vendor/_plyr.scss"; @import "#/vendor/_plyr.scss";
@import "#/vendor/_nprogress.scss"; @import "#/vendor/_nprogress.scss";
@import "#/vendor/_alertify.scss";
@import "#/partials/_skeleton.scss";
@import "#/partials/_shared.scss"; @import "#/partials/_shared.scss";

View file

@ -0,0 +1,16 @@
.skeleton {
.pulse {
animation: skeleton-pulse 2s infinite;
background-color: rgba(255, 255, 255, .05);
}
@keyframes skeleton-pulse {
0%, 100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
}