Compare commits

..

6 Commits

Author SHA1 Message Date
austinried
f7874bcead remove home tab for now 2025-12-07 13:21:32 +09:00
austinried
ba169092fd artist screen tweaks 2025-12-07 13:21:21 +09:00
austinried
4183e2d3b9 round album art corners in tiles 2025-12-07 11:26:48 +09:00
austinried
c3bb14edbf fix temp db seed 2025-12-07 11:26:33 +09:00
austinried
805e6fff7a artist screen 2025-12-07 11:26:21 +09:00
austinried
d245fc7fef fix song list source change not refreshing 2025-12-06 18:46:26 +09:00
12 changed files with 365 additions and 68 deletions

View File

@ -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',

View File

@ -1,12 +1,124 @@
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,
height: 350,
),
Column(
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: AlignmentGeometry.centerRight,
end: AlignmentGeometry.centerLeft,
colors: [
colorScheme.surface.withAlpha(220),
colorScheme.surface.withAlpha(150),
colorScheme.surface.withAlpha(20),
],
),
),
child: Padding(
padding: EdgeInsetsGeometry.symmetric(
vertical: 12,
horizontal: 16,
),
child: Text(
artist.name,
textAlign: TextAlign.right,
style: textTheme.headlineLarge?.copyWith(
shadows: [
Shadow(
blurRadius: 20,
color: colorScheme.surface,
),
],
),
),
),
),
],
),
],
),
);
}
}

View File

@ -20,7 +20,7 @@ const kIconSize = 26.0;
const kTabHeight = 36.0;
enum LibraryTab {
home(Icon(Symbols.home_rounded)),
// home(Icon(Symbols.home_rounded)),
albums(Icon(Symbols.album_rounded)),
artists(Icon(Symbols.person_rounded)),
songs(Icon(Symbols.music_note_rounded)),
@ -41,7 +41,7 @@ class LibraryScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(
initialLength: LibraryTab.values.length,
initialIndex: 1,
initialIndex: 0,
);
return Scaffold(
@ -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()),
},
),
)
@ -209,7 +216,7 @@ class TabTitleText extends HookConsumerWidget {
String tabLocalization(LibraryTab tab) => switch (tab) {
LibraryTab.albums => l.navigationTabsAlbums,
LibraryTab.home => l.navigationTabsHome,
// LibraryTab.home => l.navigationTabsHome,
LibraryTab.artists => l.navigationTabsArtists,
LibraryTab.songs => l.navigationTabsSongs,
LibraryTab.playlists => l.navigationTabsPlaylists,

View File

@ -1,4 +1,4 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift/drift.dart' show InsertMode, Value;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
@ -7,45 +7,43 @@ final databaseInitializer = FutureProvider<SubtracksDatabase>((ref) async {
final db = SubtracksDatabase();
await db
.into(db.sources)
.insertOnConflictUpdate(
SourcesCompanion.insert(
id: Value(1),
name: 'test subsonic',
// isActive: Value(true),
),
);
await db
.into(db.subsonicSettings)
.insertOnConflictUpdate(
SubsonicSettingsCompanion.insert(
sourceId: Value(1),
address: Uri.parse('http://demo.subsonic.org'),
username: 'guest1',
password: 'guest',
useTokenAuth: Value(true),
),
);
await db
.into(db.sources)
.insertOnConflictUpdate(
SourcesCompanion.insert(
id: Value(2),
name: 'test navidrome',
// isActive: Value(null),
),
);
await db
.into(db.subsonicSettings)
.insertOnConflictUpdate(
SubsonicSettingsCompanion.insert(
sourceId: Value(2),
address: Uri.parse('http://10.0.2.2:4533'),
username: 'admin',
password: 'password',
useTokenAuth: Value(true),
),
);
.batch((batch) {
batch.insertAll(
db.sources,
[
SourcesCompanion.insert(
id: Value(1),
name: 'test subsonic',
isActive: Value(true),
),
SourcesCompanion.insert(
id: Value(2),
name: 'test navidrome',
isActive: Value(null),
),
],
mode: InsertMode.insertOrIgnore,
);
batch.insertAllOnConflictUpdate(db.subsonicSettings, [
SubsonicSettingsCompanion.insert(
sourceId: Value(1),
address: Uri.parse('http://demo.subsonic.org'),
username: 'guest1',
password: 'guest',
useTokenAuth: Value(true),
),
SubsonicSettingsCompanion.insert(
sourceId: Value(2),
address: Uri.parse('http://10.0.2.2:4533'),
username: 'admin',
password: 'password',
useTokenAuth: Value(true),
),
]);
})
.onError((error, stack) {
print(error);
});
return db;
});

View File

@ -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),
);

View File

@ -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,
),

View File

@ -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<int, Album>(
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<Album>(
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;
},
),
);
},
);
}
}

View File

@ -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,

View File

@ -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,
@ -77,9 +134,11 @@ class PlaylistListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
leading: CoverArtImage(
coverArt: playlist.coverArt,
thumbnail: true,
leading: RoundedBoxClip(
child: CoverArtImage(
coverArt: playlist.coverArt,
thumbnail: true,
),
),
title: Text(playlist.name),
subtitle: Text(playlist.comment ?? ''),
@ -106,9 +165,11 @@ class SongListTile extends StatelessWidget {
Widget build(BuildContext context) {
return ListTile(
leading: showLeading
? CoverArtImage(
coverArt: coverArt,
thumbnail: true,
? RoundedBoxClip(
child: CoverArtImage(
coverArt: coverArt,
thumbnail: true,
),
)
: null,
title: Text(song.title),

View File

@ -7,6 +7,7 @@ import '../../../database/query.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
const kPageSize = 30;
@ -29,6 +30,7 @@ class SongsList extends HookConsumerWidget {
state.lastPageIsEmpty ? null : state.nextIntPageKey,
fetchPage: (pageKey) => db.libraryDao.listSongs(
query.copyWith(
sourceId: ref.read(sourceIdProvider),
limit: kPageSize,
offset: (pageKey - 1) * kPageSize,
),

View File

@ -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,
);
}
}

View File

@ -225,6 +225,12 @@ class LibraryDao extends DatabaseAccessor<SubtracksDatabase>
);
}
Selectable<models.Artist> getArtist(int sourceId, String id) {
return db.managers.artists.filter(
(f) => f.sourceId.equals(sourceId) & f.id.equals(id),
);
}
Selectable<models.Playlist> getPlaylist(int sourceId, String id) {
return db.managers.playlists.filter(
(f) => f.sourceId.equals(sourceId) & f.id.equals(id),