feat: tabs for Artist/Album screens (#1532)

This commit is contained in:
Phan An 2022-10-18 16:07:41 +02:00 committed by GitHub
parent 628cb7a4f4
commit 41f6abc087
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 402 additions and 117 deletions

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource;
use App\Models\Artist;
use App\Repositories\AlbumRepository;
class ArtistAlbumController extends Controller
{
public function __construct(private AlbumRepository $albumRepository)
{
}
public function index(Artist $artist)
{
return AlbumResource::collection($this->albumRepository->getByArtist($artist));
}
}

View file

@ -3,6 +3,7 @@
namespace App\Repositories;
use App\Models\Album;
use App\Models\Artist;
use App\Models\User;
use App\Repositories\Traits\Searchable;
use Illuminate\Contracts\Pagination\Paginator;
@ -52,6 +53,16 @@ class AlbumRepository extends Repository
->get();
}
/** @return Collection|array<array-key, Album> */
public function getByArtist(Artist $artist): Collection
{
return Album::query()
->where('artist_id', $artist->id)
->orWhereIn('id', $artist->songs()->pluck('album_id'))
->orderBy('name')
->get();
}
public function paginate(): Paginator
{
return Album::query()

View file

@ -28,7 +28,6 @@
>
Shuffle
</a>
<a
v-if="allowDownload"
:title="`Download all songs in the album ${album.name}`"

View file

@ -3,7 +3,7 @@ import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { commonStore, songStore } from '@/stores'
import { fireEvent } from '@testing-library/vue'
import { playbackService, mediaInfoService } from '@/services'
import { mediaInfoService, playbackService } from '@/services'
import AlbumInfoComponent from './AlbumInfo.vue'
let album: Album
@ -26,7 +26,8 @@ new class extends UnitTestCase {
},
global: {
stubs: {
TrackList: this.stub()
TrackList: this.stub(),
AlbumThumbnail: this.stub('thumbnail')
}
}
})
@ -39,11 +40,16 @@ new class extends UnitTestCase {
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
const { getByTestId } = await this.renderComponent(mode)
const { getByTestId, queryByTestId } = await this.renderComponent(mode)
getByTestId('album-artist-thumbnail')
getByTestId('album-info-tracks')
if (mode === 'aside') {
getByTestId('thumbnail')
} else {
expect(queryByTestId('thumbnail')).toBeNull()
}
expect(getByTestId('album-info').classList.contains(mode)).toBe(true)
})

View file

@ -1,6 +1,6 @@
<template>
<article :class="mode" class="album-info" data-testid="album-info">
<h1 class="name">
<h1 v-if="mode === 'aside'" class="name">
<span>{{ album.name }}</span>
<button :title="`Play all songs in ${album.name}`" class="control" type="button" @click.prevent="play">
<icon :icon="faCirclePlay" size="xl"/>
@ -8,7 +8,7 @@
</h1>
<main>
<AlbumThumbnail :entity="album"/>
<AlbumThumbnail v-if="mode === 'aside'" :entity="album"/>
<template v-if="info">
<div v-if="info.wiki?.summary" class="wiki">
@ -36,7 +36,7 @@ import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
import { useThirdPartyServices } from '@/composables'
import { songStore } from '@/stores'
import { playbackService, mediaInfoService } from '@/services'
import { mediaInfoService, playbackService } from '@/services'
import { RouterKey } from '@/symbols'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
@ -75,6 +75,10 @@ const play = async () => {
.track-listing {
margin-top: 2rem;
::v-deep(h1) {
margin-bottom: 1.2rem;
}
}
}
</style>

View file

@ -3,7 +3,7 @@
exports[`renders 1`] = `
<article class="full item" title="IV by Led Zeppelin" data-testid="album-card" draggable="true" tabindex="0" data-v-b204153b=""><span class="cover" data-testid="album-artist-thumbnail" data-v-e37470a2="" data-v-b204153b=""><a class="control control-play" href="" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs in the album IV</span><span class="icon" data-v-e37470a2=""></span></a></span>
<footer data-v-b204153b=""><a href="#/album/42" class="name" data-testid="name" data-v-b204153b="">IV</a><a href="#/artist/17" class="artist" data-v-b204153b="">Led Zeppelin</a>
<p class="meta" data-v-b204153b=""><a title="Shuffle all songs in the album IV" class="shuffle-album" data-testid="shuffle-album" href="" role="button" data-v-b204153b=""> Shuffle </a><a title="Download all songs in the album IV" class="download-album" data-testid="download-album" href="" role="button" data-v-b204153b=""> Download </a></p>
<p class="meta" data-v-b204153b=""><a title="Shuffle all songs in the album IV" class="shuffle-album" data-testid="shuffle-album" href="" role="button" data-v-b204153b=""> Shuffle </a><a title="Download all songs in the album IV" class="download-album" data-testid="download-album" href="" role="button" data-v-b204153b=""> Download </a></p>
</footer>
</article>
`;

View file

@ -28,7 +28,6 @@
>
Shuffle
</a>
<a
v-if="allowDownload"
:title="`Download all songs by ${artist.name}`"

View file

@ -23,6 +23,11 @@ new class extends UnitTestCase {
props: {
artist,
mode
},
global: {
stubs: {
ArtistThumbnail: this.stub('thumbnail')
}
}
})
@ -34,9 +39,13 @@ new class extends UnitTestCase {
protected test () {
it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => {
const { getByTestId } = await this.renderComponent(mode)
const { getByTestId, queryByTestId } = await this.renderComponent(mode)
getByTestId('album-artist-thumbnail')
if (mode === 'aside') {
getByTestId('thumbnail')
} else {
expect(queryByTestId('thumbnail'))
}
expect(getByTestId('artist-info').classList.contains(mode)).toBe(true)
})

View file

@ -1,6 +1,6 @@
<template>
<article :class="mode" class="artist-info" data-testid="artist-info">
<h1 class="name">
<h1 v-if="mode === 'aside'" class="name">
<span>{{ artist.name }}</span>
<button :title="`Play all songs by ${artist.name}`" class="control" type="button" @click.prevent="play">
<icon :icon="faCirclePlay" size="xl"/>
@ -8,7 +8,7 @@
</h1>
<main>
<ArtistThumbnail :entity="artist"/>
<ArtistThumbnail v-if="mode === 'aside'" :entity="artist"/>
<template v-if="info">
<div v-if="info.bio?.summary" class="bio">
@ -32,7 +32,7 @@
<script lang="ts" setup>
import { faCirclePlay } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRefs, watch } from 'vue'
import { playbackService, mediaInfoService } from '@/services'
import { mediaInfoService, playbackService } from '@/services'
import { useThirdPartyServices } from '@/composables'
import { songStore } from '@/stores'
import { RouterKey } from '@/symbols'

View file

@ -4,7 +4,7 @@ exports[`renders 1`] = `
<article class="full item" title="Led Zeppelin" data-testid="artist-card" draggable="true" tabindex="0" data-v-85d5de45=""><span class="cover" data-testid="album-artist-thumbnail" data-v-e37470a2="" data-v-85d5de45=""><a class="control control-play" href="" role="button" data-v-e37470a2=""><span class="hidden" data-v-e37470a2="">Play all songs by Led Zeppelin</span><span class="icon" data-v-e37470a2=""></span></a></span>
<footer data-v-85d5de45="">
<div class="info" data-v-85d5de45=""><a href="#/artist/42" class="name" data-testid="name" data-v-85d5de45="">Led Zeppelin</a></div>
<p class="meta" data-v-85d5de45=""><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" data-testid="shuffle-artist" href="" role="button" data-v-85d5de45=""> Shuffle </a><a title="Download all songs by Led Zeppelin" class="download-artist" data-testid="download-artist" href="" role="button" data-v-85d5de45=""> Download </a></p>
<p class="meta" data-v-85d5de45=""><a title="Shuffle all songs by Led Zeppelin" class="shuffle-artist" data-testid="shuffle-artist" href="" role="button" data-v-85d5de45=""> Shuffle </a><a title="Download all songs by Led Zeppelin" class="download-artist" data-testid="download-artist" href="" role="button" data-v-85d5de45=""> Download </a></p>
</footer>
</article>
`;

View file

@ -138,6 +138,7 @@ const logout = () => eventBus.emit('LOG_OUT')
padding: 2rem 1.7rem;
background: var(--color-bg-secondary);
overflow: auto;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
@media (hover: none) {
// Enable scroll with momentum on touch devices
@ -169,7 +170,7 @@ const logout = () => eventBus.emit('LOG_OUT')
flex-direction: row;
padding: .5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, .05);
box-shadow: 0 0 30px 0 rgba(0, 0, 0, .75);
box-shadow: 0 0 30px 0 rgba(0, 0, 0, .5);
}
.top, .bottom {

View file

@ -82,20 +82,19 @@ onMounted(() => router.resolve())
overflow: hidden;
> section {
position: absolute;
max-height: 100%;
min-height: 100%;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
.main-scroll-wrap {
&:not(.song-list-wrap) {
padding: 1.5rem;
}
overflow: scroll;
display: flex;
flex-direction: column;
padding: 1.5rem;
@supports (scrollbar-gutter: stable) {
overflow: auto;

View file

@ -155,6 +155,7 @@ nav {
overflow: auto;
overflow-x: hidden;
-ms-overflow-style: -ms-autohiding-scrollbar;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
> * + * {
margin-top: 2.25rem;

View file

@ -9,7 +9,7 @@ exports[`renders 1`] = `
<div class="logo" data-v-6b5b01a9=""><img alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="128" data-v-6b5b01a9=""></div>
<p class="current-version" data-v-6b5b01a9="">v0.0.0</p>
<!--v-if-->
<p class="author" data-v-6b5b01a9=""> Made with ❤️ by <a href="https://github.com/phanan" rel="noopener" target="_blank" data-v-6b5b01a9="">Phan An</a> and quite a few <a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">awesome</a> <a href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">contributors</a>. </p>
<p class="author" data-v-6b5b01a9=""> Made with ❤️ by <a href="https://github.com/phanan" rel="noopener" target="_blank" data-v-6b5b01a9="">Phan An</a> and quite a few <a href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">awesome</a><a href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank" data-v-6b5b01a9="">contributors</a>. </p>
<!--v-if-->
<p data-v-6b5b01a9=""> Loving Koel? Please consider supporting its development via <a href="https://github.com/users/phanan/sponsorship" rel="noopener" target="_blank" data-v-6b5b01a9="">GitHub Sponsors</a> and/or <a href="https://opencollective.com/koel" rel="noopener" target="_blank" data-v-6b5b01a9="">OpenCollective</a>. </p>
</main>

View file

@ -1,11 +1,10 @@
import { fireEvent, waitFor } from '@testing-library/vue'
import { fireEvent, getByTestId, waitFor } from '@testing-library/vue'
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { albumStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { eventBus } from '@/utils'
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
import AlbumScreen from './AlbumScreen.vue'
let album: Album
@ -36,9 +35,9 @@ new class extends UnitTestCase {
const rendered = this.render(AlbumScreen, {
global: {
stubs: {
CloseModalBtn,
AlbumInfo: this.stub('album-info'),
SongList: this.stub('song-list')
SongList: this.stub('song-list'),
AlbumCard: this.stub('album-card'),
AlbumInfo: this.stub('album-info')
}
}
})
@ -54,17 +53,6 @@ new class extends UnitTestCase {
}
protected test () {
it('shows and hides info', async () => {
const { getByTitle, getByTestId, queryByTestId, html } = await this.renderComponent()
expect(queryByTestId('album-info')).toBeNull()
await fireEvent.click(getByTitle('View album information'))
expect(queryByTestId('album-info')).not.toBeNull()
await fireEvent.click(getByTestId('close-modal-btn'))
expect(queryByTestId('album-info')).toBeNull()
})
it('downloads', async () => {
const downloadMock = this.mock(downloadService, 'fromAlbum')
const { getByText } = await this.renderComponent()
@ -86,5 +74,24 @@ new class extends UnitTestCase {
expect(goMock).toHaveBeenCalledWith('albums')
})
})
it('shows the song list', async () => {
const { getByTestId } = await this.renderComponent()
getByTestId('song-list')
})
it('shows other albums from the same artist', async () => {
const albums = factory<Album>('album', 3)
albums.push(album)
const fetchMock = this.mock(albumStore, 'fetchForArtist').mockResolvedValue(albums)
const { getByLabelText, getAllByTestId } = await this.renderComponent()
await fireEvent.click(getByLabelText('Other Albums'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(album.artist_id)
expect(getAllByTestId('album-card')).toHaveLength(3) // current album is excluded
})
})
}
}

View file

@ -15,7 +15,6 @@
<span v-else class="nope">{{ album.artist_name }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a v-if="useLastfm" class="info" href title="View album information" @click.prevent="showInfo">Info</a>
<a
v-if="allowDownload"
@ -37,20 +36,58 @@
</template>
</ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongList v-else ref="songList" @sort="sort" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<ScreenTabs>
<template #header>
<label :class="{ active: activeTab === 'Songs' }">
Songs
<input type="radio" name="tab" value="Songs" v-model="activeTab"/>
</label>
<label :class="{ active: activeTab === 'OtherAlbums' }">
Other Albums
<input type="radio" name="tab" value="OtherAlbums" v-model="activeTab"/>
</label>
<label :class="{ active: activeTab === 'Info' }" v-if="useLastfm">
Information
<input type="radio" name="tab" value="Info" v-model="activeTab"/>
</label>
</template>
<section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
<div class="inner">
<div v-show="activeTab === 'Songs'">
<SongListSkeleton v-if="loading"/>
<SongList
v-else
ref="songList"
@sort="sort"
@press:enter="onPressEnter"
@scroll-breakpoint="onScrollBreakpoint"
/>
</div>
<div v-show="activeTab === 'OtherAlbums'" class="albums-pane" data-testid="albums-pane">
<template v-if="otherAlbums">
<ul v-if="otherAlbums.length" class="as-list">
<li v-for="album in otherAlbums" :key="album.id">
<AlbumCard :album="album" layout="compact"/>
</li>
</ul>
<p v-else class="text-secondary">No other albums by {{ album.artist_name }} found in the library.</p>
</template>
<ul v-else class="as-list">
<li v-for="i in 12" :key="i">
<AlbumCardSkeleton layout="compact"/>
</li>
</ul>
</div>
<div v-show="activeTab === 'Info'" class="info-pane" v-if="useLastfm && album">
<AlbumInfo :album="album" mode="full"/>
</div>
</section>
</ScreenTabs>
</section>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, toRef, watch } from 'vue'
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
@ -61,17 +98,24 @@ import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
import ScreenTabs from '@/components/ui/ArtistAlbumScreenTabs.vue'
type Tab = 'Songs' | 'OtherAlbums' | 'Info'
const activeTab = ref<Tab>('Songs')
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCard.vue'))
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const albumId = ref<number>()
const album = ref<Album>()
const songs = ref<Song[]>([])
const showingInfo = ref(false)
const loading = ref(false)
let otherAlbums = ref<Album[]>()
let info = ref<ArtistInfo | null>()
const {
SongList,
@ -98,10 +142,22 @@ const isNormalArtist = computed(() => {
})
const download = () => downloadService.fromAlbum(album.value!)
const showInfo = () => (showingInfo.value = true)
onMounted(async () => {
const id = parseInt(router.$currentRoute.value.params!.id)
watch(activeTab, async tab => {
if (tab === 'OtherAlbums' && !otherAlbums.value) {
const albums = await albumStore.fetchForArtist(album.value!.artist_id)
otherAlbums.value = albums.filter(a => a.id !== album.value!.id)
}
})
watch(albumId, async id => {
if (!id) return
album.value = undefined
info.value = undefined
otherAlbums.value = undefined
activeTab.value = 'Songs'
loading.value = true
try {
@ -111,20 +167,36 @@ onMounted(async () => {
])
sort('track')
} catch (e) {
logger.error(e)
} catch (error) {
logger.error(error)
dialog.value.error('Failed to load album. Please try again.')
} finally {
loading.value = false
}
})
onMounted(async () => (albumId.value = parseInt(router.$currentRoute.value.params!.id)))
router.onRouteChanged(route => route.screen === 'Album' && (albumId.value = parseInt(route.params!.id)))
// if the current album has been deleted, go back to the list
eventBus.on('SONGS_UPDATED', () => albumStore.byId(album.value!.id) || router.go('albums'))
eventBus.on('SONGS_UPDATED', () => albumStore.byId(albumId.value) || router.go('albums'))
</script>
<style lang="scss" scoped>
#albumWrapper {
@include artist-album-info-wrapper();
}
.albums-pane {
padding: 1.8rem;
> ul {
@include artist-album-wrapper();
}
}
.info-pane {
padding: 1.8rem;
}
</style>

View file

@ -5,7 +5,6 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
import { artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { eventBus } from '@/utils'
import CloseModalBtn from '@/components/ui/BtnCloseModal.vue'
import ArtistScreen from './ArtistScreen.vue'
let artist: Artist
@ -35,9 +34,9 @@ new class extends UnitTestCase {
const rendered = this.render(ArtistScreen, {
global: {
stubs: {
CloseModalBtn,
ArtistInfo: this.stub('artist-info'),
SongList: this.stub('song-list')
SongList: this.stub('song-list'),
AlbumCard: this.stub('album-card')
}
}
})
@ -53,17 +52,6 @@ new class extends UnitTestCase {
}
protected test () {
it('shows and hides info', async () => {
const { getByTitle, getByTestId, queryByTestId } = await this.renderComponent()
expect(queryByTestId('artist-info')).toBeNull()
await fireEvent.click(getByTitle('View artist information'))
expect(queryByTestId('artist-info')).not.toBeNull()
await fireEvent.click(getByTestId('close-modal-btn'))
expect(queryByTestId('artist-info')).toBeNull()
})
it('downloads', async () => {
const downloadMock = this.mock(downloadService, 'fromArtist')
const { getByText } = await this.renderComponent()
@ -85,5 +73,10 @@ new class extends UnitTestCase {
expect(goMock).toHaveBeenCalledWith('artists')
})
})
it('shows the song list', async () => {
const { getByTestId } = await this.renderComponent()
getByTestId('song-list')
})
}
}

View file

@ -14,7 +14,6 @@
<span>{{ pluralize(albumCount, 'album') }}</span>
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a v-if="useLastfm" class="info" href title="View artist information" @click.prevent="showInfo">Info</a>
<a
v-if="allowDownload"
@ -37,22 +36,57 @@
</template>
</ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongList v-else ref="songList" @sort="sort" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<ScreenTabs>
<template #header>
<label :class="{ active: activeTab === 'Songs' }">
Songs
<input type="radio" name="tab" value="Songs" v-model="activeTab"/>
</label>
<label :class="{ active: activeTab === 'Albums' }">
Albums
<input type="radio" name="tab" value="Albums" v-model="activeTab"/>
</label>
<label :class="{ active: activeTab === 'Info' }" v-if="useLastfm">
Information
<input type="radio" name="tab" value="Info" v-model="activeTab"/>
</label>
</template>
<section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
<div class="inner">
<div v-show="activeTab === 'Songs'" class="songs-pane">
<SongListSkeleton v-if="loading"/>
<SongList
v-else
ref="songList"
@sort="sort"
@press:enter="onPressEnter"
@scroll-breakpoint="onScrollBreakpoint"
/>
</div>
<div v-show="activeTab === 'Albums'" class="albums-pane">
<ul v-if="albums" class="as-list">
<li v-for="album in albums" :key="album.id">
<AlbumCard :album="album" layout="compact"/>
</li>
</ul>
<ul v-else class="as-list">
<li v-for="i in 12" :key="i">
<AlbumCardSkeleton layout="compact"/>
</li>
</ul>
</div>
<div v-show="activeTab === 'Info'" class="info-pane" v-if="useLastfm && artist">
<ArtistInfo :artist="artist" mode="full"/>
</div>
</section>
</ScreenTabs>
</section>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, ref, toRef } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, toRef, watch } from 'vue'
import { eventBus, logger, pluralize, requireInjection } from '@/utils'
import { artistStore, commonStore, songStore } from '@/stores'
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { useSongList, useThirdPartyServices } from '@/composables'
import { DialogBoxKey, RouterKey } from '@/symbols'
@ -61,17 +95,24 @@ import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
import ScreenTabs from '@/components/ui/ArtistAlbumScreenTabs.vue'
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
const AlbumCard = defineAsyncComponent(() => import('@/components/album/AlbumCard.vue'))
const AlbumCardSkeleton = defineAsyncComponent(() => import('@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'))
type Tab = 'Songs' | 'Albums' | 'Info'
const activeTab = ref<Tab>('Songs')
const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const artistId = ref<number>()
const artist = ref<Artist>()
const songs = ref<Song[]>([])
const showingInfo = ref(false)
const loading = ref(false)
let albums = ref<Album[] | undefined>()
let info = ref<ArtistInfo | undefined | null>()
const {
SongList,
@ -98,11 +139,15 @@ const albumCount = computed(() => {
return albums.size
})
const download = () => downloadService.fromArtist(artist.value!)
const showInfo = () => (showingInfo.value = true)
watch(activeTab, async tab => {
if (tab === 'Albums' && !albums.value) {
albums.value = await albumStore.fetchForArtist(artist.value!)
}
})
watch(artistId, async id => {
if (!id) return
onMounted(async () => {
const id = parseInt(router.$currentRoute.value.params!.id)
loading.value = true
try {
@ -110,14 +155,18 @@ onMounted(async () => {
artistStore.resolve(id),
songStore.fetchForArtist(id)
])
} catch (e) {
logger.error(e)
} catch (error) {
logger.error(error)
dialog.value.error('Failed to load artist. Please try again.')
} finally {
loading.value = false
}
})
const download = () => downloadService.fromArtist(artist.value!)
onMounted(() => (artistId.value = parseInt(router.$currentRoute.value.params!.id)))
// if the current artist has been deleted, go back to the list
eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || router.go('artists'))
</script>
@ -128,4 +177,16 @@ eventBus.on('SONGS_UPDATED', () => artistStore.byId(artist.value!.id) || router.
#artistWrapper {
@include artist-album-info-wrapper();
}
.albums-pane {
padding: 1.8rem;
> ul {
@include artist-album-wrapper();
}
}
.info-pane {
padding: 1.8rem;
}
</style>

View file

@ -65,7 +65,7 @@
<script lang="ts" setup>
import { faFile } from '@fortawesome/free-regular-svg-icons'
import { differenceBy } from 'lodash'
import { ref, toRef } from 'vue'
import { ref, toRef, watch } from 'vue'
import { eventBus, pluralize, requireInjection } from '@/utils'
import { commonStore, playlistStore, songStore } from '@/stores'
import { downloadService } from '@/services'
@ -78,6 +78,8 @@ import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const playlistId = ref<number>()
const playlist = ref<Playlist>()
const loading = ref(false)
@ -124,24 +126,16 @@ const fetchSongs = async () => {
sort()
}
router.onRouteChanged(async (route) => {
if (route.screen !== 'Playlist') return
const id = parseInt(route.params!.id)
watch(playlistId, async (id) => {
if (!id) return
if (id === playlist.value?.id) return
const _playlist = playlistStore.byId(id)
if (!_playlist) {
await router.triggerNotFound()
return
}
playlist.value = _playlist
await fetchSongs()
playlist.value = playlistStore.byId(id)
playlist.value ? await fetchSongs() : await router.triggerNotFound()
})
router.onRouteChanged(route => route.screen === 'Playlist' && (playlistId.value = parseInt(route.params!.id)))
eventBus.on({
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated === playlist.value && await fetchSongs()
SMART_PLAYLIST_UPDATED: async (updated: Playlist) => updated.id === playlistId.value && await fetchSongs()
})
</script>

View file

@ -23,6 +23,7 @@
</ScreenHeader>
<SongListSkeleton v-if="loading"/>
<SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
<ScreenEmptyState v-else>

View file

@ -106,7 +106,9 @@ const removeFailedEntries = () => uploadService.removeFailed()
#uploadWrapper {
.upload-panel {
position: relative;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
.upload-files {

View file

@ -61,8 +61,11 @@ eventBus.on('PLAY_YOUTUBE_VIDEO', (payload: { id: string, title: string }) => {
</script>
<style lang="scss" scoped>
#player {
::v-deep(#player) {
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
.instruction {
font-size: 1.5rem;

View file

@ -1,7 +1,7 @@
<template>
<div
ref="wrapper"
class="song-list-wrap main-scroll-wrap"
class="song-list-wrap"
data-testid="song-list"
tabindex="0"
@keydown.delete.prevent.stop="handleDelete"
@ -307,6 +307,7 @@ onMounted(() => render())
position: relative;
display: flex;
flex-direction: column;
overflow: scroll;
@media screen and (max-width: 768px) {
padding: 0 12px;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="song-list-wrap main-scroll-wrap" data-testid="song-list" tabindex="0">
<div class="song-list-wrap" data-testid="song-list" tabindex="0">
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="artist" data-testid="header-artist" role="button" title="Sort by artist"> Artist <!--v-if--><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span><span class="time" data-testid="header-length" role="button" title="Sort by song duration"><!--v-if--><!--v-if--> &nbsp; <br data-testid="icon" icon="[object Object]" class="duration-header"></span><span class="favorite"></span><span class="play"></span></div><br data-testid="virtual-scroller" item-height="35" items="">
</div>
`;

View file

@ -0,0 +1,60 @@
<template>
<div class="tabs">
<header>
<slot name="header"/>
</header>
<main>
<slot/>
</main>
</div>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" scoped>
::v-deep(.tabs) {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
color: var(--color-text-secondary);
}
::v-deep(header) {
display: flex;
background: rgba(0, 0, 0, 0.05);
overflow: hidden;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, .04);
label {
font-size: 1rem;
position: relative;
padding: 1rem 1.8rem;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 0;
opacity: .5;
cursor: pointer;
transition: opacity .2s ease-in-out;
&:hover {
opacity: .8;
}
&.active {
opacity: 1;
}
input {
display: none;
}
}
}
::v-deep(main) {
flex: 1;
overflow: auto;
}
</style>

View file

@ -54,7 +54,7 @@ const initiatePlayback = async () => {
<style lang="scss" scoped>
button {
width: 3rem;
width: 3rem !important;
border-radius: 50%;
border: 2px solid currentColor;

View file

@ -16,8 +16,7 @@
display: flex;
place-content: center;
place-items: center;
height: 100%;
width: 100%;
flex: 1;
text-align: center;
color: rgba(255, 255, 255, .5);
position: relative;

View file

@ -33,6 +33,7 @@ header.screen-header {
display: flex;
align-items: flex-end;
flex-shrink: 0;
border-bottom: 1px solid var(--color-bg-secondary);
position: relative;
align-content: stretch;

View file

@ -84,5 +84,13 @@ export const albumStore = {
this.state.albums = unionBy(this.state.albums, this.syncWithVault(resource.data), 'id')
return resource.links.next ? ++resource.meta.current_page : null
},
async fetchForArtist (artist: Artist | number) {
const id = typeof artist === 'number' ? artist : artist.id
return this.syncWithVault(
await cache.remember<Album[]>(['artist-albums', id], async () => await http.get<Album[]>(`artists/${id}/albums`))
)
}
}

View file

@ -5,7 +5,7 @@
}
@mixin artist-album-wrapper() {
display: grid;
display: grid !important;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@ -83,6 +83,15 @@
a {
border-radius: 3px;
& + a {
&::before {
content: '';
margin-right: .2rem;
color: var(--color-text-secondary);
font-weight: unset;
}
}
}
&:hover {
@ -154,11 +163,14 @@
}
@mixin artist-album-info() {
color: var(--color-text-secondary);
h1 {
@include vertical-center();
font-weight: var(--font-weight-thin);
line-height: 2.8rem;
margin-bottom: 16px;
&.name {
font-size: 2rem;
@ -179,7 +191,7 @@
}
}
.bio {
.bio, .wiki {
margin: 16px 0;
}
@ -202,10 +214,6 @@
overflow: hidden;
}
.wiki {
margin: 16px 0;
}
footer {
margin-top: 24px;
font-size: .9rem;
@ -229,6 +237,10 @@
margin: 0 16px 16px 0;
}
.bio, .wiki {
margin-top: 0;
}
h1.name {
font-size: 2.4rem;

View file

@ -30,7 +30,7 @@
--color-red: #c34848;
@media screen and (max-width: 768px) {
--header-height: 59px;
--header-height: 56px;
--footer-height: 96px;
--extra-panel-width: 100%;
}

View file

@ -3,6 +3,7 @@
use App\Http\Controllers\API\UserController;
use App\Http\Controllers\V6\API\AlbumController;
use App\Http\Controllers\V6\API\AlbumSongController;
use App\Http\Controllers\V6\API\ArtistAlbumController;
use App\Http\Controllers\V6\API\ArtistController;
use App\Http\Controllers\V6\API\ArtistSongController;
use App\Http\Controllers\V6\API\DataController;
@ -33,6 +34,7 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::apiResource('albums.songs', AlbumSongController::class);
Route::apiResource('artists', ArtistController::class);
Route::apiResource('artists.songs', ArtistSongController::class);
Route::apiResource('artists.albums', ArtistAlbumController::class);
Route::get('albums/{album}/information', FetchAlbumInformationController::class);
Route::get('artists/{artist}/information', FetchArtistInformationController::class);

View file

@ -0,0 +1,20 @@
<?php
namespace Tests\Feature\V6;
use App\Models\Album;
use App\Models\Artist;
class ArtistAlbumTest extends TestCase
{
public function testIndex(): void
{
/** @var Artist $artist */
$artist = Artist::factory()->create();
Album::factory(5)->for($artist)->create();
$this->getAs('api/artists/' . $artist->id . '/albums')
->assertJsonStructure(['*' => AlbumTest::JSON_STRUCTURE]);
}
}