feat: add Genres screens (#1541)

This commit is contained in:
Phan An 2022-10-21 22:06:43 +02:00 committed by GitHub
parent 1f3dccbe5b
commit c70bb3b5af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 857 additions and 19 deletions

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\FetchRandomSongsInGenreRequest;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class FetchRandomSongsInGenreController extends Controller
{
/** @param User $user */
public function __invoke(
FetchRandomSongsInGenreRequest $request,
SongRepository $repository,
Authenticatable $user
) {
return SongResource::collection($repository->getRandomByGenre($request->genre, $request->limit, $user));
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\GenreResource;
use App\Repositories\GenreRepository;
use Illuminate\Http\Response;
class GenreController extends Controller
{
public function __construct(private GenreRepository $repository)
{
}
public function index()
{
return GenreResource::collection($this->repository->getAll());
}
public function show(string $name)
{
$genre = $this->repository->getOne($name);
if (!$genre) {
abort(Response::HTTP_NOT_FOUND);
}
return GenreResource::make($genre);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\GenreFetchSongRequest;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Values\Genre;
use Illuminate\Contracts\Auth\Authenticatable;
class GenreSongController extends Controller
{
/**
* @param User $user
*/
public function __invoke(
string $genre,
SongRepository $repository,
Authenticatable $user,
GenreFetchSongRequest $request
) {
if ($genre === Genre::NO_GENRE) {
$genre = '';
}
return SongResource::collection(
$repository->getByGenre(
$genre,
$request->sort ?: 'songs.title',
$request->order ?: 'asc',
$user
)
);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
/**
* @property-read string $genre
* @property-read int $limit
*/
class FetchRandomSongsInGenreRequest extends Request
{
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
/**
* @property-read string $order
* @property-read string $sort
*/
class GenreFetchSongRequest extends Request
{
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use App\Values\Genre;
use Illuminate\Http\Resources\Json\JsonResource;
class GenreResource extends JsonResource
{
public function __construct(private Genre $genre)
{
parent::__construct($genre);
}
/** @return array<mixed> */
public function toArray($request): array
{
return [
'type' => 'genres',
'name' => $this->genre->name,
'song_count' => $this->genre->songCount,
'length' => $this->genre->length,
];
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Repositories;
use App\Models\Song;
use App\Values\Genre;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class GenreRepository
{
/** @return Collection|array<array-key, Genre> */
public function getAll(): Collection
{
return Song::query()
->select('genre', DB::raw('COUNT(id) AS song_count'), DB::raw('SUM(length) AS length'))
->groupBy('genre')
->orderBy('genre')
->get()
->transform(static fn (object $record): Genre => Genre::make(
name: $record->genre ?: Genre::NO_GENRE,
songCount: $record->song_count,
length: $record->length
));
}
public function getOne(string $name): ?Genre
{
/** @var object|null $record */
$record = Song::query()
->select('genre', DB::raw('COUNT(id) AS song_count'), DB::raw('SUM(length) AS length'))
->groupBy('genre')
->where('genre', $name === Genre::NO_GENRE ? '' : $name)
->first();
return $record
? Genre::make(
name: $record->genre ?: Genre::NO_GENRE,
songCount: $record->song_count,
length: $record->length
)
: null;
}
}

View file

@ -8,6 +8,7 @@ use App\Models\Playlist;
use App\Models\Song;
use App\Models\User;
use App\Repositories\Traits\Searchable;
use App\Values\Genre;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Support\Collection;
@ -82,6 +83,23 @@ class SongRepository extends Repository
->simplePaginate($perPage);
}
public function getByGenre(
string $genre,
string $sortColumn,
string $sortDirection,
?User $scopedUser = null,
int $perPage = 50
): Paginator {
return self::applySort(
Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->where('genre', $genre),
$sortColumn,
$sortDirection
)
->simplePaginate($perPage);
}
/** @return Collection|array<array-key, Song> */
public function getForQueue(
string $sortColumn,
@ -202,4 +220,15 @@ class SongRepository extends Repository
return $query;
}
/** @return Collection|array<array-key, Song> */
public function getRandomByGenre(string $genre, int $limit, ?User $scopedUser = null): Collection
{
return Song::query()
->withMeta($scopedUser ?? $this->auth->user())
->where('genre', $genre === Genre::NO_GENRE ? '' : $genre)
->limit($limit)
->inRandomOrder()
->get();
}
}

17
app/Values/Genre.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace App\Values;
final class Genre
{
public const NO_GENRE = 'No Genre';
private function __construct(public string $name, public int $songCount, public float $length)
{
}
public static function make(string $name, int $songCount, float $length): self
{
return new self(name: $name, songCount: $songCount, length: $length);
}
}

View file

@ -0,0 +1,11 @@
import { Faker } from '@faker-js/faker'
import { genres } from '@/config'
export default (faker: Faker): Genre => {
return {
type: 'genres',
name: faker.helpers.arrayElement(genres),
song_count: faker.datatype.number({ min: 1, max: 1_000 }),
length: faker.datatype.number({ min: 300, max: 300_000 })
}
}

View file

@ -12,6 +12,7 @@ import albumTrackFactory from '@/__tests__/factory/albumTrackFactory'
import albumInfoFactory from '@/__tests__/factory/albumInfoFactory'
import artistInfoFactory from '@/__tests__/factory/artistInfoFactory'
import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory'
import genreFactory from '@/__tests__/factory/genreFactory'
export default factory
.define('artist', faker => artistFactory(faker), artistStates)
@ -21,6 +22,7 @@ export default factory
.define('album-info', faker => albumInfoFactory(faker))
.define('song', faker => songFactory(faker), songStates)
.define('interaction', faker => interactionFactory(faker))
.define('genre', faker => genreFactory(faker))
.define('video', faker => youTubeVideoFactory(faker))
.define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker))
.define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker))

View file

@ -68,7 +68,7 @@ const isStandardArtist = computed(() => artistStore.isStandard(album.value.artis
const showing = computed(() => !albumStore.isUnknown(album.value))
const shuffle = async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
router.go('queue')
}

View file

@ -35,12 +35,12 @@ const isStandardArtist = computed(() => {
})
const play = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!))
router.go('queue')
})
const shuffle = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value!), true)
router.go('queue')
})

View file

@ -64,7 +64,7 @@ const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.val
const showFull = computed(() => !showSummary.value)
const play = async () => {
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
router.go('queue')
}
</script>

View file

@ -66,7 +66,7 @@ const allowDownload = toRef(commonStore.state, 'allow_download')
const showing = computed(() => artistStore.isStandard(artist.value))
const shuffle = async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
router.go('queue')
}

View file

@ -35,12 +35,12 @@ const isStandardArtist = computed(() =>
)
const play = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!))
router.go('queue')
})
const shuffle = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value!), true)
router.go('queue')
})

View file

@ -60,7 +60,7 @@ const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.valu
const showFull = computed(() => !showSummary.value)
const play = async () => {
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
router.go('queue')
}
</script>

View file

@ -18,7 +18,9 @@
<RecentlyPlayedScreen v-show="screen === 'RecentlyPlayed'"/>
<UploadScreen v-show="screen === 'Upload'"/>
<SearchExcerptsScreen v-show="screen === 'Search.Excerpt'"/>
<GenreScreen v-show="screen === 'Genre'"/>
<GenreListScreen v-if="screen === 'Genres'"/>
<SearchSongResultsScreen v-if="screen === 'Search.Songs'"/>
<AlbumScreen v-if="screen === 'Album'"/>
<ArtistScreen v-if="screen === 'Artist'"/>
@ -41,6 +43,7 @@ import HomeScreen from '@/components/screens/HomeScreen.vue'
import QueueScreen from '@/components/screens/QueueScreen.vue'
import AlbumListScreen from '@/components/screens/AlbumListScreen.vue'
import ArtistListScreen from '@/components/screens/ArtistListScreen.vue'
import GenreListScreen from '@/components/screens/GenreListScreen.vue'
import AllSongsScreen from '@/components/screens/AllSongsScreen.vue'
import PlaylistScreen from '@/components/screens/PlaylistScreen.vue'
import FavoritesScreen from '@/components/screens/FavoritesScreen.vue'
@ -52,6 +55,7 @@ const UserListScreen = defineAsyncComponent(() => import('@/components/screens/U
const AlbumArtOverlay = defineAsyncComponent(() => import('@/components/ui/AlbumArtOverlay.vue'))
const AlbumScreen = defineAsyncComponent(() => import('@/components/screens/AlbumScreen.vue'))
const ArtistScreen = defineAsyncComponent(() => import('@/components/screens/ArtistScreen.vue'))
const GenreScreen = defineAsyncComponent(() => import('@/components/screens/GenreScreen.vue'))
const SettingsScreen = defineAsyncComponent(() => import('@/components/screens/SettingsScreen.vue'))
const ProfileScreen = defineAsyncComponent(() => import('@/components/screens/ProfileScreen.vue'))
const YoutubeScreen = defineAsyncComponent(() => import('@/components/screens/YouTubeScreen.vue'))

View file

@ -40,6 +40,12 @@
Artists
</a>
</li>
<li>
<a :class="['genres', activeScreen === 'Genres' ? 'active' : '']" href="#/genres">
<icon :icon="faTags" fixed-width/>
Genres
</a>
</li>
<li v-if="useYouTube">
<a :class="['youtube', activeScreen === 'YouTube' ? 'active' : '']" href="#/youtube">
<icon :icon="faYoutube" fixed-width/>
@ -85,6 +91,7 @@ import {
faListOl,
faMicrophone,
faMusic,
faTags,
faTools,
faUpload,
faUsers

View file

@ -30,12 +30,12 @@ const playlistsInFolder = computed(() => folder.value ? playlistStore.byFolder(f
const playable = computed(() => playlistsInFolder.value.length > 0)
const play = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!))
playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!))
router.go('queue')
})
const shuffle = () => trigger(async () => {
await playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!), true)
playbackService.queueAndPlay(await songStore.fetchForPlaylistFolder(folder.value!), true)
router.go('queue')
})

View file

@ -10,7 +10,7 @@ import AlbumScreen from './AlbumScreen.vue'
let album: Album
new class extends UnitTestCase {
protected async renderComponent () {
private async renderComponent () {
commonStore.state.use_last_fm = true
album = factory<Album>('album', {

View file

@ -0,0 +1,28 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { genreStore } from '@/stores'
import GenreListScreen from './GenreListScreen.vue'
import { waitFor } from '@testing-library/vue'
new class extends UnitTestCase {
protected test () {
it('renders the list of genres and their song counts', async () => {
// ensure there's no duplicated names
const genres = [
factory<Genre>('genre', { name: 'Rock', song_count: 10 }),
factory<Genre>('genre', { name: 'Pop', song_count: 20 }),
factory<Genre>('genre', { name: 'Jazz', song_count: 30 })
]
const fetchMock = this.mock(genreStore, 'fetchAll').mockResolvedValue(genres)
const { getByTitle } = this.render(GenreListScreen)
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled()
genres.forEach(genre => getByTitle(`${genre.name}: ${genre.song_count} songs`))
})
})
}
}

View file

@ -0,0 +1,109 @@
<template>
<section id="genresWrapper">
<ScreenHeader layout="compact">
Genres
</ScreenHeader>
<div class="main-scroll-wrap">
<ul class="genres" v-if="genres">
<li v-for="genre in genres" :key="genre.name" :class="`level-${getLevel(genre)}`">
<a :href="`/#/genres/${genre.name}`" :title="`${genre.name}: ${pluralize(genre.song_count, 'song')}`">
<span class="name">{{ genre.name }}</span>
<span class="count">{{ genre.song_count }}</span>
</a>
</li>
</ul>
<ul class="genres" v-else>
<li v-for="i in 20" :key="i">
<GenreItemSkeleton/>
</li>
</ul>
</div>
</section>
</template>
<script lang="ts" setup>
import { maxBy, minBy } from 'lodash'
import { computed, onMounted, ref } from 'vue'
import { genreStore } from '@/stores'
import { pluralize } from '@/utils'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import GenreItemSkeleton from '@/components/ui/skeletons/GenreItemSkeleton.vue'
const genres = ref<Genre[]>()
const mostPopular = computed(() => maxBy(genres.value, 'song_count'))
const leastPopular = computed(() => minBy(genres.value, 'song_count'))
const levels = computed(() => {
const max = mostPopular.value?.song_count || 1
const min = leastPopular.value?.song_count || 1
const range = max - min
const step = range / 5
return [min, min + step, min + step * 2, min + step * 3, min + step * 4, max]
})
const getLevel = (genre: Genre) => {
const index = levels.value.findIndex((level) => genre.song_count <= level)
return index === -1 ? 5 : index
}
onMounted(async () => genres.value = await genreStore.fetchAll())
</script>
<style lang="scss" scoped>
.genres {
text-align: center;
li {
display: inline-block;
border-radius: 999rem;
margin: .2rem;
font-size: var(--unit);
transition: opacity .2s ease-in-out, transform .2s ease-in-out;
vertical-align: middle;
&:hover {
transform: scale(1.1);
opacity: 1;
}
a {
background: rgba(255, 255, 255, .03);
display: inline-flex;
padding: calc(var(--unit) / 4) calc(var(--unit) / 4) calc(var(--unit) / 4) calc(var(--unit));
gap: 12px;
align-items: center;
border-radius: 999rem;
transition: background-color .2s ease-in-out, color .2s ease-in-out;
&:hover {
color: var(--color-text-primary);
background: var(--color-highlight);
}
&:active {
transform: translateX(2px) translateY(2px);
}
}
.count {
padding: calc(var(--unit) - 1rem) calc(var(--unit) / 2);
background: rgba(255, 255, 255, .1);
border-radius: 999rem;
font-size: calc(var(--unit) * 2 / 3);
box-shadow: 0 0 5px 0 rgba(0, 0, 0, .3);
min-width: calc(var(--unit) * 1.5);
text-align: center;
}
}
@for $i from 0 through 5 {
.level-#{$i} {
$zoom: 1 + $i * .4;
--unit: #{$zoom}rem;
opacity: .8 + $i * .04;
}
}
}
</style>

View file

@ -0,0 +1,77 @@
import { expect, it } from 'vitest'
import { fireEvent, waitFor } from '@testing-library/vue'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { genreStore, songStore } from '@/stores'
import { playbackService } from '@/services'
import GenreScreen from './GenreScreen.vue'
new class extends UnitTestCase {
private async renderComponent (genre?: Genre, songs?: Song[]) {
genre = genre || factory<Genre>('genre')
const fetchGenreMock = this.mock(genreStore, 'fetchOne').mockResolvedValue(genre)
const paginateMock = this.mock(songStore, 'paginateForGenre').mockResolvedValue({
nextPage: 2,
songs: songs || factory<Song>('song', 13)
})
await this.router.activateRoute({
path: `genres/${genre.name}`,
screen: 'Genre'
}, { name: genre.name })
const rendered = this.render(GenreScreen, {
global: {
stubs: {
SongList: this.stub('song-list')
}
}
})
await waitFor(() => {
expect(fetchGenreMock).toHaveBeenCalledWith(genre.name)
expect(paginateMock).toHaveBeenCalledWith(genre.name, 'title', 'asc', 1)
})
await this.tick(2)
return rendered
}
protected test () {
it('renders the song list', async () => {
const { getByTestId } = await this.renderComponent()
expect(getByTestId('song-list')).toBeTruthy()
})
it('shuffles all songs without fetching if genre has <= 500 songs', async () => {
const genre = factory<Genre>('genre', { song_count: 10 })
const songs = factory<Song>('song', 10)
const playbackMock = this.mock(playbackService, 'queueAndPlay')
const { getByTitle } = await this.renderComponent(genre, songs)
await fireEvent.click(getByTitle('Shuffle all songs'))
expect(playbackMock).toHaveBeenCalledWith(songs, true)
})
it('fetches and shuffles all songs if genre has > 500 songs', async () => {
const genre = factory<Genre>('genre', { song_count: 501 })
const songs = factory<Song>('song', 10) // we don't really need to generate 501 songs
const playbackMock = this.mock(playbackService, 'queueAndPlay')
const fetchMock = this.mock(songStore, 'fetchRandomForGenre').mockResolvedValue(songs)
const { getByTitle } = await this.renderComponent(genre, songs)
await fireEvent.click(getByTitle('Shuffle all songs'))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(genre, 500)
expect(playbackMock).toHaveBeenCalledWith(songs)
})
})
}
}

View file

@ -0,0 +1,157 @@
<template>
<section id="genreWrapper">
<ScreenHeader :layout="headerLayout" v-if="genre">
Genre: <span class="text-thin">{{ name }}</span>
<ControlsToggle v-if="songs.length" v-model="showingControls"/>
<template v-slot:thumbnail>
<ThumbnailStack :thumbnails="thumbnails"/>
</template>
<template v-if="genre" v-slot:meta>
<span>{{ pluralize(genre.song_count, 'song') }}</span>
<span>{{ duration }}</span>
</template>
<template v-slot:controls>
<SongListControls v-if="!isPhone || showingControls" @playAll="playAll" @playSelected="playSelected"/>
</template>
</ScreenHeader>
<ScreenHeaderSkeleton v-else/>
<SongListSkeleton v-if="showSkeletons"/>
<SongList
v-else
ref="songList"
@sort="sort"
@press:enter="onPressEnter"
@scroll-breakpoint="onScrollBreakpoint"
@scrolled-to-end="fetch"
/>
<ScreenEmptyState v-if="!songs.length && !loading">
<template v-slot:icon>
<icon :icon="faTags"/>
</template>
No songs in this genre.
</ScreenEmptyState>
</section>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { faTags } from '@fortawesome/free-solid-svg-icons'
import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
import { DialogBoxKey, RouterKey } from '@/symbols'
import { useSongList } from '@/composables'
import { genreStore, songStore } from '@/stores'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
import { playbackService } from '@/services'
const {
SongList,
SongListControls,
ControlsToggle,
ThumbnailStack,
headerLayout,
songs,
songList,
thumbnails,
showingControls,
isPhone,
onPressEnter,
playSelected,
onScrollBreakpoint
} = useSongList(ref<Song[]>([]), 'Playlist')
const router = requireInjection(RouterKey)
const dialog = requireInjection(DialogBoxKey)
let sortField: SongListSortField = 'title'
let sortOrder: SortOrder = 'asc'
const randomSongCount = 500
const name = ref<string | null>(null)
const genre = ref<Genre | null>(null)
const loading = ref(false)
const page = ref<number | null>(1)
const moreSongsAvailable = computed(() => page.value !== null)
const showSkeletons = computed(() => loading.value && songs.value.length === 0)
const duration = computed(() => secondsToHis(genre.value?.length ?? 0))
const sort = async (field: SongListSortField, order: SortOrder) => {
page.value = 1
songs.value = []
sortField = field
sortOrder = order
await fetch()
}
const fetch = async () => {
if (!moreSongsAvailable.value || loading.value) return
loading.value = true
try {
let fetched
[genre.value, fetched] = await Promise.all([
genreStore.fetchOne(name.value!),
songStore.paginateForGenre(name.value!, sortField, sortOrder, page.value!)
])
page.value = fetched.nextPage
songs.value.push(...fetched.songs)
} catch (e) {
dialog.value.error('Failed to fetch genre details or genre was not found.')
logger.error(e)
} finally {
loading.value = false
}
}
const refresh = async () => {
genre.value = null
page.value = 1
songs.value = []
await fetch()
}
const getNameFromRoute = () => {
let param = router.$currentRoute.value.params?.name
return param ? decodeURI(param) : null
}
router.onRouteChanged(route => {
if (route.screen !== 'Genre') return
name.value = getNameFromRoute()
})
const playAll = async () => {
if (!genre.value) return
// we ignore the queueAndPlay's await to avoid blocking the UI
if (genre.value!.song_count <= randomSongCount) {
playbackService.queueAndPlay(songs.value, true)
} else {
playbackService.queueAndPlay(await songStore.fetchRandomForGenre(genre.value!, randomSongCount))
}
router.go('queue')
}
onMounted(() => (name.value = getNameFromRoute()))
watch(name, async () => name.value && await refresh())
// We can't really tell how/if the genres have been updated, so we just refresh the list
eventBus.on('SONGS_UPDATED', async () => await refresh())
</script>

View file

@ -87,7 +87,7 @@ const loading = ref(false)
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
const playAll = async (shuffle = true) => {
await playbackService.queueAndPlay(songs.value, shuffle)
playbackService.queueAndPlay(songs.value, shuffle)
router.go('queue')
}

View file

@ -72,7 +72,7 @@ const playOrQueue = async (event: KeyboardEvent) => {
return
}
await playbackService.queueAndPlay(songs)
playbackService.queueAndPlay(songs)
router.go('queue')
}

View file

@ -47,7 +47,7 @@ const initiatePlayback = async () => {
break
}
await playbackService.queueAndPlay(songs)
playbackService.queueAndPlay(songs)
router.go('queue')
}
</script>

View file

@ -0,0 +1,31 @@
<template>
<article class="skeleton pulse" :style="{ width: `${width}px` }">
<span class="name"></span>
<span class="count pulse"/>
</article>
</template>
<script lang="ts" setup>
const width = Math.random() * 100 + 80
</script>
<style lang="scss" scoped>
.skeleton {
display: flex;
border-radius: 999rem;
overflow: hidden;
height: 28px;
margin: .4rem;
padding: 4px;
flex-shrink: 0;
.name {
flex: 1;
}
.count {
aspect-ratio: 1.2/1;
border-radius: 9999rem;
}
}
</style>

View file

@ -43,7 +43,12 @@ export const useSongList = (songs: Ref<Song[]>, screen: ScreenName, config: Part
})
const getSongsToPlay = (): Song[] => songList.value.getAllSongsWithSort()
const playAll = (shuffle: boolean) => playbackService.queueAndPlay(getSongsToPlay(), shuffle)
const playAll = (shuffle: boolean) => {
playbackService.queueAndPlay(getSongsToPlay(), shuffle)
router.go('queue')
}
const playSelected = (shuffle: boolean) => playbackService.queueAndPlay(selectedSongs.value, shuffle)
const onPressEnter = async (event: KeyboardEvent) => {

View file

@ -82,6 +82,14 @@ export const routes: Route[] = [
path: '/playlist/(?<id>\\d+)',
screen: 'Playlist'
},
{
path: '/genres',
screen: 'Genres'
},
{
path: '/genres/(?<name>\.+)',
screen: 'Genre'
},
{
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
screen: 'Queue',

View file

@ -333,7 +333,7 @@ new class extends UnitTestCase {
const shuffleMock = this.mock(lodash, 'shuffle')
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
await playbackService.queueAndPlay(songs)
playbackService.queueAndPlay(songs)
await nextTick()
expect(shuffleMock).not.toHaveBeenCalled()
@ -350,7 +350,7 @@ new class extends UnitTestCase {
this.setReadOnlyProperty(queueStore, 'first', firstSongInQueue)
const shuffleMock = this.mock(lodash, 'shuffle', shuffledSongs)
await playbackService.queueAndPlay(songs, true)
playbackService.queueAndPlay(songs, true)
await nextTick()
expect(shuffleMock).toHaveBeenCalledWith(songs)

View file

@ -0,0 +1,24 @@
import { expect, it } from 'vitest'
import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { http } from '@/services'
import { genreStore } from '@/stores/genreStore'
new class extends UnitTestCase {
protected test () {
it('fetches all genres', async () => {
const genres = factory<Genre>('genre', 3)
this.mock(http, 'get').mockResolvedValue(genres)
expect(await genreStore.fetchAll()).toEqual(genres)
})
it('fetches a single genre', async () => {
const genre = factory<Genre>('genre')
this.mock(http, 'get').mockResolvedValue(genre)
expect(await genreStore.fetchOne(genre.name)).toEqual(genre)
expect(http.get).toHaveBeenCalledWith(`genres/${genre.name}`)
})
}
}

View file

@ -0,0 +1,6 @@
import { http } from '@/services'
export const genreStore = {
fetchAll: async () => await http.get<Genre[]>('genres'),
fetchOne: async (name: string) => await http.get<Genre>(`genres/${name}`)
}

View file

@ -14,3 +14,4 @@ export * from './settingStore'
export * from './songStore'
export * from './themeStore'
export * from './userStore'
export * from './genreStore'

View file

@ -253,5 +253,43 @@ new class extends UnitTestCase {
expect(syncMock).toHaveBeenCalledWith(songs)
expect(songStore.state.songs).toEqual(reactive(songs))
})
it('paginates for genre', async () => {
const songs = factory<Song>('song', 3)
const reactiveSongs = reactive(songs)
const getMock = this.mock(http, 'get').mockResolvedValueOnce({
data: songs,
links: {
next: 'http://test/api/v1/songs?page=3'
},
meta: {
current_page: 2
}
})
const syncMock = this.mock(songStore, 'syncWithVault', reactiveSongs)
expect(await songStore.paginateForGenre('foo', 'title', 'desc', 2)).toEqual({
songs: reactiveSongs,
nextPage: 3
})
expect(getMock).toHaveBeenCalledWith('genres/foo/songs?page=2&sort=title&order=desc')
expect(syncMock).toHaveBeenCalledWith(songs)
})
it('fetches random songs for genre', async () => {
const songs = factory<Song>('song', 3)
const reactiveSongs = reactive(songs)
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault', reactiveSongs)
expect(await songStore.fetchRandomForGenre('foo')).toEqual(reactiveSongs)
expect(getMock).toHaveBeenCalledWith('genres/foo/songs/random?limit=500')
expect(syncMock).toHaveBeenCalledWith(songs)
})
}
}

View file

@ -170,6 +170,26 @@ export const songStore = {
return uniqBy(songs, 'id')
},
async paginateForGenre (genre: Genre | string, sortField: SongListSortField, sortOrder: SortOrder, page: number) {
const name = typeof genre === 'string' ? genre : genre.name
const resource = await http.get<PaginatorResource>(
`genres/${name}/songs?page=${page}&sort=${sortField}&order=${sortOrder}`
)
const songs = this.syncWithVault(resource.data)
return {
songs,
nextPage: resource.links.next ? ++resource.meta.current_page : null
}
},
async fetchRandomForGenre (genre: Genre | string, limit = 500) {
const name = typeof genre === 'string' ? genre : genre.name
return this.syncWithVault(await http.get<Song[]>(`genres/${name}/songs/random?limit=${limit}`))
},
async paginate (sortField: SongListSortField, sortOrder: SortOrder, page: number) {
const resource = await http.get<PaginatorResource>(
`songs?page=${page}&sort=${sortField}&order=${sortOrder}`

View file

@ -299,6 +299,8 @@ declare type ScreenName =
| 'Profile'
| 'Album'
| 'Artist'
| 'Genres'
| 'Genre'
| 'Playlist'
| 'Upload'
| 'Search.Excerpt'
@ -376,4 +378,11 @@ type ToastMessage = {
timeout: number // seconds
}
type Genre = {
type: 'genres'
name: string
song_count: number
length: number
}
type ExtraPanelTab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'

View file

@ -240,6 +240,10 @@ label {
&thin {
font-weight: var(--font-weight-thin) !important;
}
&bold {
font-weight: var(--font-weight-bold) !important;
}
}
.d- {

View file

@ -1,5 +1,5 @@
.skeleton {
.pulse {
.pulse, &.pulse {
animation: skeleton-pulse 2s infinite;
background-color: rgba(255, 255, 255, .05);
}

View file

@ -12,6 +12,9 @@ use App\Http\Controllers\V6\API\ExcerptSearchController;
use App\Http\Controllers\V6\API\FavoriteSongController;
use App\Http\Controllers\V6\API\FetchAlbumInformationController;
use App\Http\Controllers\V6\API\FetchArtistInformationController;
use App\Http\Controllers\V6\API\FetchRandomSongsInGenreController;
use App\Http\Controllers\V6\API\GenreController;
use App\Http\Controllers\V6\API\GenreSongController;
use App\Http\Controllers\V6\API\OverviewController;
use App\Http\Controllers\V6\API\PlayCountController;
use App\Http\Controllers\V6\API\PlaylistController;
@ -55,6 +58,10 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
Route::delete('songs', DeleteSongsController::class);
Route::apiResource('genres', GenreController::class);
Route::get('genres/{genre}/songs', GenreSongController::class);
Route::get('genres/{genre}/songs/random', FetchRandomSongsInGenreController::class);
Route::apiResource('users', UserController::class);
Route::get('search', ExcerptSearchController::class);

View file

@ -0,0 +1,59 @@
<?php
namespace Tests\Feature\V6;
use App\Models\Song;
use App\Values\Genre;
class GenreTest extends TestCase
{
private const JSON_STRUCTURE = [
'type',
'name',
'song_count',
'length',
];
public function testGetAllGenres(): void
{
Song::factory()->count(5)->create(['genre' => 'Rock']);
Song::factory()->count(2)->create(['genre' => 'Pop']);
Song::factory()->count(10)->create(['genre' => '']);
$this->getAs('api/genres')
->assertJsonStructure(['*' => self::JSON_STRUCTURE])
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5])
->assertJsonFragment(['name' => 'Pop', 'song_count' => 2])
->assertJsonFragment(['name' => Genre::NO_GENRE, 'song_count' => 10]);
}
public function testGetOneGenre(): void
{
Song::factory()->count(5)->create(['genre' => 'Rock']);
$this->getAs('api/genres/Rock')
->assertJsonStructure(self::JSON_STRUCTURE)
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5]);
}
public function testGetNonExistingGenreThrowsNotFound(): void
{
$this->getAs('api/genres/NonExistingGenre')->assertNotFound();
}
public function testPaginateSongsInGenre(): void
{
Song::factory()->count(5)->create(['genre' => 'Rock']);
$this->getAs('api/genres/Rock/songs')
->assertJsonStructure(SongTest::JSON_COLLECTION_STRUCTURE);
}
public function testGetRandomSongsInGenre(): void
{
Song::factory()->count(5)->create(['genre' => 'Rock']);
$this->getAs('api/genres/Rock/songs/random?limit=500')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
}
}

View file

@ -30,7 +30,7 @@ class SongTest extends TestCase
'created_at',
];
private const JSON_COLLECTION_STRUCTURE = [
public const JSON_COLLECTION_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],