9 Commits

Author SHA1 Message Date
austinried
ad6d534286 context menu base and move query to state 2026-01-02 10:27:41 +09:00
austinried
2837d4576e logging framework 2025-12-14 10:09:32 +09:00
austinried
7f6ba4776a source settings (add/edit) 2025-12-10 20:21:43 +09:00
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
26 changed files with 1094 additions and 132 deletions

View File

@@ -8,6 +8,7 @@ import 'screens/playlist_screen.dart';
import 'screens/preload_screen.dart';
import 'screens/root_shell_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/settings_source_screen.dart';
final router = GoRouter(
initialLocation: '/preload',
@@ -29,8 +30,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',
@@ -49,5 +51,12 @@ final router = GoRouter(
path: '/settings',
builder: (context, state) => SettingsScreen(),
),
GoRoute(
path: '/sources/:id',
builder: (context, state) {
final id = state.pathParameters['id'];
return SettingsSourceScreen(id: id == 'add' ? null : int.parse(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

@@ -1,26 +1,25 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../database/query.dart';
import '../../l10n/generated/app_localizations.dart';
import '../state/lists.dart';
import '../state/services.dart';
import '../state/source.dart';
import '../ui/lists/albums_grid.dart';
import '../ui/lists/artists_list.dart';
import '../ui/lists/items.dart';
import '../ui/lists/playlists_list.dart';
import '../ui/lists/songs_list.dart';
import '../ui/menus.dart';
import '../util/custom_scroll_fix.dart';
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 +40,7 @@ class LibraryScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(
initialLength: LibraryTab.values.length,
initialIndex: 1,
initialIndex: 0,
);
return Scaffold(
@@ -75,18 +74,7 @@ class LibraryTabBarView extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sourceId = ref.watch(sourceIdProvider);
final songsQuery = SongsQuery(
sourceId: sourceId,
sort: IList([
SortingTerm.songsAsc(SongsColumn.albumArtist),
SortingTerm.songsAsc(SongsColumn.album),
SortingTerm.songsAsc(SongsColumn.disc),
SortingTerm.songsAsc(SongsColumn.track),
SortingTerm.songsAsc(SongsColumn.title),
]),
);
final songsQuery = ref.watch(songsQueryProvider);
return TabBarView(
controller: tabController,
@@ -107,7 +95,14 @@ class LibraryTabBarView extends HookConsumerWidget {
onTap: () {},
),
),
_ => ArtistsList(),
// _ => SliverToBoxAdapter(child: Container()),
},
menuBuilder: switch (tab) {
LibraryTab.albums => (_) => AlbumsGridFilters(),
// LibraryTab.artists => (_) => AlbumsGridFilters(),
// LibraryTab.playlists => (_) => AlbumsGridFilters(),
// LibraryTab.songs => (_) => AlbumsGridFilters(),
_ => null,
},
),
)
@@ -209,7 +204,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,
@@ -233,10 +228,12 @@ class TabScrollView extends HookConsumerWidget {
super.key,
required this.index,
required this.sliver,
this.menuBuilder,
});
final int index;
final Widget sliver;
final WidgetBuilder? menuBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -244,14 +241,24 @@ class TabScrollView extends HookConsumerWidget {
final scrollProvider = CustomScrollProviderData.of(context);
return CustomScrollView(
controller: scrollProvider.scrollControllers[index],
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
sliver,
],
final listBuilder = menuBuilder;
final floatingActionButton = listBuilder != null
? FabFilter(
listBuilder: listBuilder,
)
: null;
return Scaffold(
floatingActionButton: floatingActionButton,
body: CustomScrollView(
controller: scrollProvider.scrollControllers[index],
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
sliver,
],
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../l10n/generated/app_localizations.dart';
@@ -14,15 +15,14 @@ class SettingsScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final text = TextTheme.of(context);
final textTheme = TextTheme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(l.navigationTabsSettings, style: text.headlineLarge),
title: Text(l.navigationTabsSettings, style: textTheme.headlineLarge),
),
body: ListView(
children: [
// const SizedBox(height: 96),
_SectionHeader(l.settingsServersName),
const _Sources(),
// _SectionHeader(l.settingsNetworkName),
@@ -36,7 +36,9 @@ class SettingsScreen extends HookConsumerWidget {
}
class _Section extends StatelessWidget {
const _Section({required this.children});
const _Section({
required this.children,
});
final List<Widget> children;
@@ -46,14 +48,15 @@ class _Section extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...children,
const SizedBox(height: 32),
],
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title);
const _SectionHeader(
this.title,
);
final String title;
@@ -61,17 +64,14 @@ class _SectionHeader extends StatelessWidget {
Widget build(BuildContext context) {
final text = TextTheme.of(context);
return Column(
children: [
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
child: Text(title, style: text.headlineMedium),
),
),
],
return Padding(
padding: EdgeInsetsGeometry.directional(
start: kHorizontalPadding,
end: kHorizontalPadding,
top: 32,
bottom: 8,
),
child: Text(title, style: text.headlineMedium),
);
}
}
@@ -376,12 +376,12 @@ class _Sources extends HookConsumerWidget {
},
child: Column(
children: [
for (final (source, settings) in sources)
for (final (:source, :subsonicSetting) in sources)
RadioListTile<int>(
value: source.id,
title: Text(source.name),
subtitle: Text(
settings.address.toString(),
subsonicSetting.address.toString(),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
@@ -389,7 +389,7 @@ class _Sources extends HookConsumerWidget {
secondary: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: () {
// context.pushRoute(SourceRoute(id: source.id));
context.push('/sources/${source.id}');
},
),
),
@@ -404,7 +404,7 @@ class _Sources extends HookConsumerWidget {
icon: const Icon(Icons.add_rounded),
label: Text(l.settingsServersActionsAdd),
onPressed: () {
// context.pushRoute(SourceRoute());
context.push('/sources/add');
},
),
],

View File

@@ -0,0 +1,309 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/dao/sources_dao.dart';
import '../../database/database.dart';
import '../../l10n/generated/app_localizations.dart';
import '../../util/logger.dart';
import '../state/database.dart';
import '../ui/menus.dart';
class SettingsSourceScreen extends HookConsumerWidget {
const SettingsSourceScreen({
super.key,
this.id,
});
final int? id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final getSource = useMemoized(
() async => id != null
? (result: await db.sourcesDao.getSource(id!).getSingle())
: await Future.value((result: null)),
[id],
);
final sourceResult = useFuture(getSource).data;
if (sourceResult == null) {
return Scaffold();
}
return _SettingsSourceScreen(source: sourceResult.result);
}
}
class _SettingsSourceScreen extends HookConsumerWidget {
const _SettingsSourceScreen({
required this.source,
});
final SourceSetting? source;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final textTheme = TextTheme.of(context);
final colorScheme = ColorScheme.of(context);
final form = useState(GlobalKey<FormState>()).value;
final isSaving = useState(false);
final isDeleting = useState(false);
final nameController = useTextEditingController(text: source?.source.name);
final addressController = useTextEditingController(
text: source?.subsonicSetting.address.toString(),
);
final usernameController = useTextEditingController(
text: source?.subsonicSetting.username,
);
final passwordController = useTextEditingController(
text: source?.subsonicSetting.password,
);
final forcePlaintextPassword = useState(
!(source?.subsonicSetting.useTokenAuth ?? true),
);
final name = LabeledTextField(
label: l.settingsServersFieldsName,
controller: nameController,
required: true,
);
final address = LabeledTextField(
label: l.settingsServersFieldsAddress,
controller: addressController,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
required: true,
validator: (value, label) {
final uri = Uri.tryParse(value ?? '');
if (uri?.isAbsolute != true || uri?.host.isNotEmpty != true) {
return '$label must be a valid URL';
}
return null;
},
);
final username = LabeledTextField(
label: l.settingsServersFieldsUsername,
controller: usernameController,
autofillHints: const [AutofillHints.username],
required: true,
);
final password = LabeledTextField(
label: l.settingsServersFieldsPassword,
controller: passwordController,
autofillHints: const [AutofillHints.password],
obscureText: true,
required: true,
);
final forcePlaintextSwitch = SwitchListTile(
value: forcePlaintextPassword.value,
title: Text(l.settingsServersOptionsForcePlaintextPasswordTitle),
subtitle: forcePlaintextPassword.value
? Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOn)
: Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOff),
onChanged: (value) => forcePlaintextPassword.value = value,
);
return PopScope(
canPop: !isSaving.value && !isDeleting.value,
child: Scaffold(
appBar: AppBar(
title: Text(
source == null
? l.settingsServersActionsAdd
: l.settingsServersActionsEdit,
style: textTheme.headlineLarge,
),
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (source != null && source?.source.isActive != true)
FloatingActionButton(
backgroundColor: colorScheme.tertiaryContainer,
foregroundColor: colorScheme.onTertiaryContainer,
onPressed: !isSaving.value && !isDeleting.value
? () async {
try {
isDeleting.value = true;
await ref
.read(databaseProvider)
.sourcesDao
.deleteSource(source!.source.id);
} finally {
isDeleting.value = false;
}
if (context.mounted) {
context.pop();
}
}
: null,
child: isDeleting.value
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: colorScheme.onTertiaryContainer,
),
)
: const Icon(Icons.delete_forever_rounded),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: null,
icon: isSaving.value
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(),
)
: const Icon(Icons.save_rounded),
label: Text(l.settingsServersActionsSave),
onPressed: !isSaving.value && !isDeleting.value
? () async {
if (!form.currentState!.validate()) {
return;
}
var error = false;
try {
isSaving.value = true;
if (source != null) {
await ref
.read(databaseProvider)
.sourcesDao
.updateSource(
source!.source.copyWith(
name: nameController.text,
),
source!.subsonicSetting.copyWith(
address: Uri.parse(addressController.text),
username: usernameController.text,
password: passwordController.text,
useTokenAuth: !forcePlaintextPassword.value,
),
);
} else {
await ref
.read(databaseProvider)
.sourcesDao
.createSource(
SourcesCompanion.insert(
name: nameController.text,
),
SubsonicSettingsCompanion.insert(
address: Uri.parse(addressController.text),
username: usernameController.text,
password: passwordController.text,
useTokenAuth: Value(
!forcePlaintextPassword.value,
),
),
);
}
} catch (e, _) {
// showErrorSnackbar(context, e.toString());
// log.severe('Saving source', e, st);
logger.w('fuck');
error = true;
} finally {
isSaving.value = false;
}
if (!error && context.mounted) {
context.pop();
}
}
: null,
),
],
),
body: Form(
key: form,
child: AutofillGroup(
child: ListView(
children: [
name,
address,
username,
password,
const SizedBox(height: 24),
forcePlaintextSwitch,
const FabPadding(),
],
),
),
),
),
);
}
}
class LabeledTextField extends HookConsumerWidget {
const LabeledTextField({
super.key,
required this.label,
required this.controller,
this.obscureText = false,
this.keyboardType,
this.validator,
this.autofillHints,
this.required = false,
});
final String label;
final TextEditingController controller;
final bool obscureText;
final bool required;
final TextInputType? keyboardType;
final Iterable<String>? autofillHints;
final String? Function(String? value, String label)? validator;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textTheme = TextTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Text(label, style: textTheme.titleMedium),
TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
autofillHints: autofillHints,
validator: (value) {
if (required) {
if (value == null || value.isEmpty) {
return '$label is required';
}
}
if (validator != null) {
return validator!(value, label);
}
return null;
},
),
],
),
);
}
}

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';
@@ -6,46 +6,40 @@ import '../../database/database.dart';
final databaseInitializer = FutureProvider<SubtracksDatabase>((ref) async {
final db = SubtracksDatabase();
await db
.into(db.sources)
.insertOnConflictUpdate(
await db.batch((batch) {
batch.insertAll(
db.sources,
[
SourcesCompanion.insert(
id: Value(1),
name: 'test subsonic',
// isActive: Value(true),
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),
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),
),
);
],
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),
),
]);
});
return db;
});

31
lib/app/state/lists.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/query.dart';
import 'source.dart';
final albumsQueryProvider = Provider<AlbumsQuery>((ref) {
final sourceId = ref.watch(sourceIdProvider);
return AlbumsQuery(
sourceId: sourceId,
sort: IList([
SortingTerm.albumsDesc(AlbumsColumn.created),
]),
);
});
final songsQueryProvider = Provider<SongsQuery>((ref) {
final sourceId = ref.watch(sourceIdProvider);
return SongsQuery(
sourceId: sourceId,
sort: IList([
SortingTerm.songsAsc(SongsColumn.albumArtist),
SortingTerm.songsAsc(SongsColumn.album),
SortingTerm.songsAsc(SongsColumn.disc),
SortingTerm.songsAsc(SongsColumn.track),
SortingTerm.songsAsc(SongsColumn.title),
]),
);
});

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../util/logger.dart';
import '../state/source.dart';
import '../util/color_scheme.dart';
import 'theme.dart';
@@ -36,8 +37,12 @@ class CoverArtTheme extends HookConsumerWidget {
: 'https://placehold.net/400x400.png',
),
);
} catch (err) {
print(err);
} catch (error, stackTrace) {
logger.w(
'Could not create color scheme from image provider',
error: error,
stackTrace: stackTrace,
);
return null;
}
},

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,15 +1,16 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/lists.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 60;
@@ -20,23 +21,22 @@ class AlbumsGrid extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(databaseProvider);
final query = ref.watch(albumsQueryProvider);
final controller = usePagingController<int, Album>(
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,
),
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
useValueChanged(query, (_, _) => controller.refresh());
return PagingListener(
controller: controller,
@@ -49,7 +49,9 @@ class AlbumsGrid extends HookConsumerWidget {
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
showNoMoreItemsIndicatorAsGridChild: false,
builderDelegate: PagedChildBuilderDelegate<Album>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) => AlbumGridTile(
album: item,
onTap: () async {
@@ -63,3 +65,14 @@ class AlbumsGrid extends HookConsumerWidget {
);
}
}
class AlbumsGridFilters extends HookConsumerWidget {
const AlbumsGridFilters({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [],
);
}
}

View File

@@ -0,0 +1,92 @@
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 '../menus.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>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
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

@@ -10,6 +10,7 @@ import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 30;
@@ -45,6 +46,7 @@ class ArtistsList extends HookConsumerWidget {
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<AristListItem>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) {
final (:artist, :albumCount) = item;

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

@@ -10,6 +10,7 @@ import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
import 'items.dart';
const kPageSize = 30;
@@ -45,6 +46,7 @@ class PlaylistsList extends HookConsumerWidget {
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<Playlist>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: (context, item, index) {
return PlaylistListTile(
playlist: item,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
@@ -7,6 +8,8 @@ import '../../../database/query.dart';
import '../../hooks/use_on_source.dart';
import '../../hooks/use_paging_controller.dart';
import '../../state/database.dart';
import '../../state/source.dart';
import '../menus.dart';
const kPageSize = 30;
@@ -29,14 +32,15 @@ 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,
),
),
);
useOnSourceChange(ref, (_) => controller.refresh());
useOnSourceSync(ref, controller.refresh);
useValueChanged(query, (_, _) => controller.refresh());
return PagingListener(
controller: controller,
@@ -45,6 +49,7 @@ class SongsList extends HookConsumerWidget {
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate<SongListItem>(
noMoreItemsIndicatorBuilder: (context) => FabPadding(),
itemBuilder: itemBuilder,
),
);

76
lib/app/ui/menus.dart Normal file
View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
Future<void> showContextMenu({
required BuildContext context,
required WidgetBuilder listBuilder,
}) => showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
expand: false,
snap: true,
initialChildSize: 0.3,
minChildSize: 0.3,
maxChildSize: 0.4,
builder: (context, scrollController) => PrimaryScrollController(
controller: scrollController,
child: listBuilder(context),
),
),
);
class ContextMenuList extends StatelessWidget {
const ContextMenuList({
super.key,
required this.children,
});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ListView(
children: children,
);
}
}
class FabFilter extends StatelessWidget {
const FabFilter({
super.key,
required this.listBuilder,
});
final WidgetBuilder listBuilder;
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () {
showContextMenu(
context: context,
listBuilder: listBuilder,
);
},
child: Icon(
Symbols.filter_list_rounded,
weight: 500,
opticalSize: 28,
size: 28,
),
);
}
}
class FabPadding extends StatelessWidget {
const FabPadding({
super.key,
});
@override
Widget build(BuildContext context) {
return const SizedBox(height: 86);
}
}

View File

@@ -11,18 +11,19 @@ ThemeData subtracksTheme([ColorScheme? colorScheme]) {
useMaterial3: true,
);
final text = theme.textTheme;
return theme.copyWith(
textTheme: text.copyWith(
headlineLarge: text.headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
headlineMedium: text.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
headlineSmall: text.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
final text = theme.textTheme.copyWith(
headlineLarge: theme.textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
headlineMedium: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
headlineSmall: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
);
return theme.copyWith(
textTheme: text,
);
}

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

View File

@@ -4,6 +4,8 @@ import '../database.dart';
part 'sources_dao.g.dart';
typedef SourceSetting = ({Source source, SubsonicSetting subsonicSetting});
@DriftAccessor(include: {'../tables.drift'})
class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
with _$SourcesDaoMixin {
@@ -16,7 +18,7 @@ class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
.map((row) => row.read(sources.id));
}
Stream<List<(Source, SubsonicSetting)>> listSources() {
Stream<List<SourceSetting>> listSources() {
final query = select(sources).join([
innerJoin(
subsonicSettings,
@@ -24,18 +26,56 @@ class SourcesDao extends DatabaseAccessor<SubtracksDatabase>
),
]);
return query.watch().map(
(rows) => rows
.map(
(row) => (
row.readTable(sources),
row.readTable(subsonicSettings),
),
)
.toList(),
return query
.map(
(row) => (
source: row.readTable(sources),
subsonicSetting: row.readTable(subsonicSettings),
),
)
.watch();
}
Selectable<SourceSetting> getSource(int id) {
final query = select(sources).join([
innerJoin(
subsonicSettings,
sources.id.equalsExp(subsonicSettings.sourceId),
),
])..where(sources.id.equals(id));
return query.map(
(row) => (
source: row.readTable(sources),
subsonicSetting: row.readTable(subsonicSettings),
),
);
}
Future<void> deleteSource(int id) async {
await sources.deleteWhere((f) => f.id.equals(id));
}
Future<void> updateSource(Source source, SubsonicSetting subsonic) async {
await db.transaction(() async {
await sources.update().replace(source);
await subsonicSettings.update().replace(subsonic);
});
}
Future<void> createSource(
SourcesCompanion source,
SubsonicSettingsCompanion subsonic,
) async {
await db.transaction(() async {
final sourceId = await sources.insertOnConflictUpdate(source);
await subsonicSettings.insertOnConflictUpdate(
subsonic.copyWith(sourceId: Value(sourceId)),
);
});
}
Future<void> setActiveSource(int id) async {
await transaction(() async {
await db.managers.sources.update((o) => o(isActive: Value(null)));

View File

@@ -7,6 +7,7 @@ import '../sources/models.dart' as models;
import 'converters.dart';
import 'dao/library_dao.dart';
import 'dao/sources_dao.dart';
import 'log_interceptor.dart';
part 'database.g.dart';
@@ -27,14 +28,14 @@ class SubtracksDatabase extends _$SubtracksDatabase {
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'my_database',
name: 'subtracks_database',
native: DriftNativeOptions(
databasePath: () async {
final directory = await getApplicationSupportDirectory();
return path.join(directory.absolute.path, 'subtracks.sqlite');
},
),
);
).interceptWith(LogInterceptor());
}
@override

View File

@@ -0,0 +1,120 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:logger/logger.dart';
import '../util/logger.dart';
/// https://drift.simonbinder.eu/examples/tracing/
class LogInterceptor extends QueryInterceptor {
Future<T> _run<T>(
String description,
FutureOr<T> Function() operation,
) async {
final trace = logger.level >= Level.trace;
final stopwatch = trace ? (Stopwatch()..start()) : null;
logger.t('Running $description');
try {
final result = await operation();
if (trace) {
logger.t(' => succeeded after ${stopwatch!.elapsedMilliseconds}ms');
}
return result;
} on Object catch (e, st) {
if (trace) {
logger.t(' => failed after ${stopwatch!.elapsedMilliseconds}ms');
}
logger.e('Query failed', error: e, stackTrace: st);
rethrow;
}
}
@override
TransactionExecutor beginTransaction(QueryExecutor parent) {
logger.t('begin');
return super.beginTransaction(parent);
}
@override
Future<void> commitTransaction(TransactionExecutor inner) {
return _run('commit', () => inner.send());
}
@override
Future<void> rollbackTransaction(TransactionExecutor inner) {
return _run('rollback', () => inner.rollback());
}
@override
Future<void> runBatched(
QueryExecutor executor,
BatchedStatements statements,
) {
return _run(
'batch with $statements',
() => executor.runBatched(statements),
);
}
@override
Future<int> runInsert(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runInsert(statement, args),
);
}
@override
Future<int> runUpdate(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runUpdate(statement, args),
);
}
@override
Future<int> runDelete(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runDelete(statement, args),
);
}
@override
Future<void> runCustom(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runCustom(statement, args),
);
}
@override
Future<List<Map<String, Object?>>> runSelect(
QueryExecutor executor,
String statement,
List<Object?> args,
) {
return _run(
'$statement with $args',
() => executor.runSelect(statement, args),
);
}
}

48
lib/util/logger.dart Normal file
View File

@@ -0,0 +1,48 @@
import 'package:logger/logger.dart';
class LogLevelFilter extends LogFilter {
@override
bool shouldLog(LogEvent event) {
return event.level >= level!;
}
}
class SubtracksLogger extends Logger {
SubtracksLogger({
super.filter,
super.printer,
super.output,
required Level level,
}) : _level = level,
super(level: level);
final Level _level;
Level get level => _level;
}
SubtracksLogger createLogger() {
var isDebug = false;
assert(() {
isDebug = true;
return true;
}());
if (isDebug) {
return SubtracksLogger(
filter: DevelopmentFilter(),
printer: PrettyPrinter(),
output: ConsoleOutput(),
level: Level.debug,
);
}
// TODO: production logger
return SubtracksLogger(
filter: DevelopmentFilter(),
printer: PrettyPrinter(),
output: ConsoleOutput(),
level: Level.debug,
);
}
final logger = createLogger();

View File

@@ -565,6 +565,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
logger:
dependency: "direct main"
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:

View File

@@ -26,6 +26,7 @@ dependencies:
infinite_scroll_pagination: ^5.1.1
intl: any
json_annotation: ^4.9.0
logger: ^2.6.2
material_color_utilities: ^0.11.1
material_symbols_icons: ^4.2874.0
octo_image: ^2.1.0