From 805e6fff7a39bd68297a7132eabd4b5859fa5082 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:26:21 +0900 Subject: [PATCH] artist screen --- lib/app/router.dart | 5 +- lib/app/screens/artist_screen.dart | 103 ++++++++++++++++++++++++++-- lib/app/screens/library_screen.dart | 11 ++- lib/app/ui/images.dart | 6 +- lib/app/ui/lists/albums_grid.dart | 13 ++-- lib/app/ui/lists/albums_list.dart | 90 ++++++++++++++++++++++++ lib/app/ui/lists/header.dart | 3 +- lib/app/ui/lists/items.dart | 57 +++++++++++++++ lib/app/util/clip.dart | 18 +++++ lib/database/dao/library_dao.dart | 6 ++ 10 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 lib/app/ui/lists/albums_list.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index 7150f9c..d95a78a 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -29,8 +29,9 @@ final router = GoRouter( AlbumScreen(id: state.pathParameters['id']!), ), GoRoute( - path: 'artists', - builder: (context, state) => ArtistScreen(), + path: 'artists/:id', + builder: (context, state) => + ArtistScreen(id: state.pathParameters['id']!), ), GoRoute( path: 'playlists/:id', diff --git a/lib/app/screens/artist_screen.dart b/lib/app/screens/artist_screen.dart index 3241e6d..14e898e 100644 --- a/lib/app/screens/artist_screen.dart +++ b/lib/app/screens/artist_screen.dart @@ -1,12 +1,105 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -class ArtistScreen extends StatelessWidget { - const ArtistScreen({super.key}); +import '../../database/query.dart'; +import '../../sources/models.dart'; +import '../state/database.dart'; +import '../state/source.dart'; +import '../ui/cover_art_theme.dart'; +import '../ui/gradient.dart'; +import '../ui/images.dart'; +import '../ui/lists/albums_list.dart'; + +class ArtistScreen extends HookConsumerWidget { + const ArtistScreen({ + super.key, + required this.id, + }); + + final String id; @override - Widget build(BuildContext context) { - return Scaffold( - body: Center(child: Text('Artist!')), + Widget build(BuildContext context, WidgetRef ref) { + final db = ref.watch(databaseProvider); + final sourceId = ref.watch(sourceIdProvider); + + final getArtist = useMemoized( + () => db.libraryDao.getArtist(sourceId, id).getSingle(), + ); + final artist = useFuture(getArtist).data; + + if (artist == null) { + return Container(); + } + + final query = AlbumsQuery( + sourceId: sourceId, + // filter: IList([AlbumsFilter.artistId(artist.id)]), + sort: IList([ + SortingTerm.albumsDesc(AlbumsColumn.year), + SortingTerm.albumsAsc(AlbumsColumn.name), + ]), + ); + + return CoverArtTheme( + coverArt: artist.coverArt, + child: Scaffold( + body: GradientScrollView( + slivers: [ + ArtistHeader(artist: artist), + AlbumsList(query: query), + ], + ), + ), + ); + } +} + +class ArtistHeader extends StatelessWidget { + const ArtistHeader({ + super.key, + required this.artist, + }); + + final Artist artist; + + @override + Widget build(BuildContext context) { + final textTheme = TextTheme.of(context); + final colorScheme = ColorScheme.of(context); + + return SliverToBoxAdapter( + child: Stack( + fit: StackFit.passthrough, + alignment: AlignmentGeometry.bottomCenter, + children: [ + CoverArtImage( + fit: BoxFit.cover, + coverArt: artist.coverArt, + thumbnail: false, + ), + Container( + color: colorScheme.surface.withAlpha(120), + child: Padding( + padding: EdgeInsetsGeometry.symmetric(vertical: 12), + child: Text( + artist.name, + textAlign: TextAlign.center, + style: textTheme.headlineLarge?.copyWith( + shadows: [ + Shadow( + blurRadius: 20, + color: colorScheme.surface, + ), + ], + ), + ), + ), + ), + ], + ), ); } } diff --git a/lib/app/screens/library_screen.dart b/lib/app/screens/library_screen.dart index 03db87b..eca28fc 100644 --- a/lib/app/screens/library_screen.dart +++ b/lib/app/screens/library_screen.dart @@ -77,6 +77,13 @@ class LibraryTabBarView extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final sourceId = ref.watch(sourceIdProvider); + final albumsQuery = AlbumsQuery( + sourceId: sourceId, + sort: IList([ + SortingTerm.albumsDesc(AlbumsColumn.created), + ]), + ); + final songsQuery = SongsQuery( sourceId: sourceId, sort: IList([ @@ -95,7 +102,7 @@ class LibraryTabBarView extends HookConsumerWidget { (tab) => TabScrollView( index: LibraryTab.values.indexOf(tab), sliver: switch (tab) { - LibraryTab.albums => AlbumsGrid(), + LibraryTab.albums => AlbumsGrid(query: albumsQuery), LibraryTab.artists => ArtistsList(), LibraryTab.playlists => PlaylistsList(), LibraryTab.songs => SongsList( @@ -107,7 +114,7 @@ class LibraryTabBarView extends HookConsumerWidget { onTap: () {}, ), ), - _ => ArtistsList(), + _ => SliverToBoxAdapter(child: Container()), }, ), ) diff --git a/lib/app/ui/images.dart b/lib/app/ui/images.dart index 55fe1e4..fb29210 100644 --- a/lib/app/ui/images.dart +++ b/lib/app/ui/images.dart @@ -10,14 +10,14 @@ class CoverArtImage extends HookConsumerWidget { super.key, this.coverArt, this.thumbnail = true, - this.fit, + this.fit = BoxFit.cover, this.height, this.width, }); final String? coverArt; final bool thumbnail; - final BoxFit? fit; + final BoxFit fit; final double? height; final double? width; @@ -37,7 +37,7 @@ class CoverArtImage extends HookConsumerWidget { cacheKey: '$sourceId$coverArt$thumbnail', placeholder: (context, url) => Icon(Symbols.cached_rounded), errorWidget: (context, url, error) => Icon(Icons.error), - fit: BoxFit.cover, + fit: fit, fadeOutDuration: Duration(milliseconds: 100), fadeInDuration: Duration(milliseconds: 200), ); diff --git a/lib/app/ui/lists/albums_grid.dart b/lib/app/ui/lists/albums_grid.dart index bb7566c..f44dba4 100644 --- a/lib/app/ui/lists/albums_grid.dart +++ b/lib/app/ui/lists/albums_grid.dart @@ -1,4 +1,3 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -15,7 +14,12 @@ import 'items.dart'; const kPageSize = 60; class AlbumsGrid extends HookConsumerWidget { - const AlbumsGrid({super.key}); + const AlbumsGrid({ + super.key, + required this.query, + }); + + final AlbumsQuery query; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,11 +28,8 @@ class AlbumsGrid extends HookConsumerWidget { getNextPageKey: (state) => state.lastPageIsEmpty ? null : state.nextIntPageKey, fetchPage: (pageKey) => db.libraryDao.listAlbums( - AlbumsQuery( + query.copyWith( sourceId: ref.read(sourceIdProvider), - sort: IList([ - SortingTerm.albumsDesc(AlbumsColumn.created), - ]), limit: kPageSize, offset: (pageKey - 1) * kPageSize, ), diff --git a/lib/app/ui/lists/albums_list.dart b/lib/app/ui/lists/albums_list.dart new file mode 100644 index 0000000..1bd74a5 --- /dev/null +++ b/lib/app/ui/lists/albums_list.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +import '../../../database/query.dart'; +import '../../../sources/models.dart'; +import '../../hooks/use_on_source.dart'; +import '../../hooks/use_paging_controller.dart'; +import '../../state/database.dart'; +import '../../state/source.dart'; +import 'items.dart'; + +const kPageSize = 30; + +class AlbumsList extends HookConsumerWidget { + const AlbumsList({ + super.key, + required this.query, + }); + + final AlbumsQuery query; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final db = ref.watch(databaseProvider); + final controller = usePagingController( + getNextPageKey: (state) => + state.lastPageIsEmpty ? null : state.nextIntPageKey, + fetchPage: (pageKey) => db.libraryDao.listAlbums( + query.copyWith( + sourceId: ref.read(sourceIdProvider), + limit: kPageSize, + offset: (pageKey - 1) * kPageSize, + ), + ), + ); + + useOnSourceChange(ref, (_) => controller.refresh()); + useOnSourceSync(ref, controller.refresh); + + return PagingListener( + controller: controller, + builder: (context, state, fetchNextPage) { + return PagedSliverList( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + final tile = AlbumListTile( + album: item, + onTap: () { + context.push('/albums/${item.id}'); + }, + ); + + final currentItemYear = item.year; + final previousItemYear = index == 0 + ? currentItemYear + : controller.items?.elementAtOrNull(index - 1)?.year; + + if (index == 0 || currentItemYear != previousItemYear) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 24, + bottom: 8, + left: 16, + right: 16, + ), + child: Text( + item.year?.toString() ?? 'Unknown year', + style: TextTheme.of(context).headlineMedium, + ), + ), + tile, + ], + ); + } + + return tile; + }, + ), + ); + }, + ); + } +} diff --git a/lib/app/ui/lists/header.dart b/lib/app/ui/lists/header.dart index be101c0..a33bcba 100644 --- a/lib/app/ui/lists/header.dart +++ b/lib/app/ui/lists/header.dart @@ -36,6 +36,8 @@ class SongsListHeader extends HookConsumerWidget { children: [ const SizedBox(height: 24), Container( + height: 300, + width: 300, decoration: BoxDecoration( boxShadow: [ BoxShadow( @@ -48,7 +50,6 @@ class SongsListHeader extends HookConsumerWidget { ], ), child: CoverArtImage( - height: 300, thumbnail: false, coverArt: coverArt, fit: BoxFit.contain, diff --git a/lib/app/ui/lists/items.dart b/lib/app/ui/lists/items.dart index f25d1a8..7308d89 100644 --- a/lib/app/ui/lists/items.dart +++ b/lib/app/ui/lists/items.dart @@ -62,6 +62,63 @@ class ArtistListTile extends StatelessWidget { } } +class AlbumListTile extends StatelessWidget { + const AlbumListTile({ + super.key, + required this.album, + this.onTap, + }); + + final Album album; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + final textTheme = TextTheme.of(context); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, right: 18), + child: RoundedBoxClip( + child: CoverArtImage( + coverArt: album.coverArt, + thumbnail: true, + width: 80, + height: 80, + ), + ), + ), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.name, + style: textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + Text( + album.albumArtist ?? 'Unknown album artist', + style: textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + class PlaylistListTile extends StatelessWidget { const PlaylistListTile({ super.key, diff --git a/lib/app/util/clip.dart b/lib/app/util/clip.dart index 623a15f..3d19c49 100644 --- a/lib/app/util/clip.dart +++ b/lib/app/util/clip.dart @@ -19,3 +19,21 @@ class CircleClip extends StatelessWidget { ); } } + +class RoundedBoxClip extends StatelessWidget { + const RoundedBoxClip({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return ClipRRect( + clipBehavior: Clip.antiAlias, + borderRadius: BorderRadiusGeometry.circular(3), + child: child, + ); + } +} diff --git a/lib/database/dao/library_dao.dart b/lib/database/dao/library_dao.dart index 7e0059b..2040c28 100644 --- a/lib/database/dao/library_dao.dart +++ b/lib/database/dao/library_dao.dart @@ -225,6 +225,12 @@ class LibraryDao extends DatabaseAccessor ); } + Selectable getArtist(int sourceId, String id) { + return db.managers.artists.filter( + (f) => f.sourceId.equals(sourceId) & f.id.equals(id), + ); + } + Selectable getPlaylist(int sourceId, String id) { return db.managers.playlists.filter( (f) => f.sourceId.equals(sourceId) & f.id.equals(id),