mirror of
https://github.com/koel/koel
synced 2024-11-24 21:23:06 +00:00
feat: add loading skeletons
This commit is contained in:
parent
feff485d95
commit
2951fa3ddb
26 changed files with 545 additions and 117 deletions
|
@ -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) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 don’t seem to have been playing.</p>
|
<p v-else class="text-secondary">You don’t 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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
102
resources/assets/js/components/ui/skeletons/SongListSkeleton.vue
Normal file
102
resources/assets/js/components/ui/skeletons/SongListSkeleton.vue
Normal 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>
|
|
@ -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)
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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`))
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
16
resources/assets/sass/partials/_skeleton.scss
Normal file
16
resources/assets/sass/partials/_skeleton.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue