mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 09:09:29 +01:00
Compare commits
6 Commits
3fcb938f2b
...
f7874bcead
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7874bcead | ||
|
|
ba169092fd | ||
|
|
4183e2d3b9 | ||
|
|
c3bb14edbf | ||
|
|
805e6fff7a | ||
|
|
d245fc7fef |
@ -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',
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
90
lib/app/ui/lists/albums_list.dart
Normal file
90
lib/app/ui/lists/albums_list.dart
Normal 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;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user