mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 23:02:43 +01:00
v2
This commit is contained in:
126
lib/app/pages/artist_page.dart
Normal file
126
lib/app/pages/artist_page.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../state/music.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../images.dart';
|
||||
import '../items.dart';
|
||||
|
||||
class ArtistPage extends HookConsumerWidget {
|
||||
final String id;
|
||||
|
||||
const ArtistPage({
|
||||
super.key,
|
||||
@pathParam required this.id,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(sourceIdProvider, (_, __) => context.router.popUntilRoot());
|
||||
|
||||
final artist = ref.watch(artistProvider(id));
|
||||
final albums = ref.watch(albumsByArtistIdProvider(id));
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
ArtistArtImage(
|
||||
artistId: id,
|
||||
thumbnail: false,
|
||||
height: 400,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: _Title(text: artist.valueOrNull?.name ?? ''),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
albums.when(
|
||||
data: (albums) {
|
||||
albums = albums.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
||||
return SliverPadding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
sliver: SliverAlignedGrid.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 24,
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, i) {
|
||||
final album = albums.elementAt(i);
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
subtitle: AlbumSubtitle.year,
|
||||
onTap: () => context.navigateTo(AlbumSongsRoute(
|
||||
id: album.id,
|
||||
)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (_, __) => SliverToBoxAdapter(
|
||||
child: Container(color: Colors.red),
|
||||
),
|
||||
loading: () => const SliverToBoxAdapter(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Title extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _Title({
|
||||
required this.text,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset.fromDirection(pi / 4, 3),
|
||||
blurRadius: 16,
|
||||
color: Colors.black26,
|
||||
),
|
||||
Shadow(
|
||||
offset: Offset.fromDirection(3 * pi / 4, 3),
|
||||
blurRadius: 16,
|
||||
color: Colors.black26,
|
||||
),
|
||||
Shadow(
|
||||
offset: Offset.fromDirection(5 * pi / 4, 3),
|
||||
blurRadius: 16,
|
||||
color: Colors.black26,
|
||||
),
|
||||
Shadow(
|
||||
offset: Offset.fromDirection(7 * pi / 4, 3),
|
||||
blurRadius: 16,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/app/pages/bottom_nav_page.dart
Normal file
216
lib/app/pages/bottom_nav_page.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../now_playing_bar.dart';
|
||||
|
||||
part 'bottom_nav_page.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TabObserver bottomTabObserver(BottomTabObserverRef ref) {
|
||||
return TabObserver();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<String> bottomTabPath(BottomTabPathRef ref) async* {
|
||||
final observer = ref.watch(bottomTabObserverProvider);
|
||||
await for (var tab in observer.path) {
|
||||
yield tab;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LastBottomNavStateService extends _$LastBottomNavStateService {
|
||||
@override
|
||||
Future<void> build() async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final tab = ref.watch(bottomTabPathProvider).valueOrNull;
|
||||
if (tab == null || tab == 'settings' || tab == 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.saveLastBottomNavState(LastBottomNavStateData(id: 1, tab: tab));
|
||||
}
|
||||
}
|
||||
|
||||
class BottomNavTabsPage extends HookConsumerWidget {
|
||||
const BottomNavTabsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final observer = ref.watch(bottomTabObserverProvider);
|
||||
const navElevation = 3.0;
|
||||
|
||||
return AutoTabsRouter(
|
||||
lazyLoad: false,
|
||||
inheritNavigatorObservers: false,
|
||||
navigatorObservers: () => [observer],
|
||||
routes: const [
|
||||
LibraryRouter(),
|
||||
BrowseRouter(),
|
||||
SearchRouter(),
|
||||
SettingsRouter(),
|
||||
],
|
||||
builder: (context, child, animation) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
systemNavigationBarColor: ElevationOverlay.applySurfaceTint(
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Theme.of(context).colorScheme.surfaceTint,
|
||||
navElevation,
|
||||
),
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
children: [
|
||||
FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
const OfflineIndicator(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: const _BottomNavBar(
|
||||
navElevation: navElevation,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OfflineIndicator extends HookConsumerWidget {
|
||||
const OfflineIndicator({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final offline = ref.watch(offlineModeProvider);
|
||||
final testing = useState(false);
|
||||
|
||||
if (!offline) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: 20,
|
||||
bottom: 20,
|
||||
),
|
||||
child: FilledButton.tonal(
|
||||
style: const ButtonStyle(
|
||||
padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(
|
||||
EdgeInsets.zero,
|
||||
),
|
||||
fixedSize: MaterialStatePropertyAll<Size>(
|
||||
Size(42, 42),
|
||||
),
|
||||
minimumSize: MaterialStatePropertyAll<Size>(
|
||||
Size(42, 42),
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
testing.value = true;
|
||||
await ref.read(offlineModeProvider.notifier).setMode(false);
|
||||
testing.value = false;
|
||||
},
|
||||
child: testing.value
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
: const Padding(
|
||||
padding: EdgeInsets.only(left: 2, bottom: 2),
|
||||
child: Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
// color: Theme.of(context).colorScheme.secondary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomNavBar extends HookConsumerWidget {
|
||||
final double navElevation;
|
||||
|
||||
const _BottomNavBar({
|
||||
required this.navElevation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
|
||||
useListenableSelector(tabsRouter, () => tabsRouter.activeIndex);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const NowPlayingBar(),
|
||||
NavigationBar(
|
||||
elevation: navElevation,
|
||||
height: 50,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: (index) {
|
||||
// TODO: replace this with a proper first-time setup flow
|
||||
final hasActiveSource = ref.read(settingsServiceProvider.select(
|
||||
(value) => value.activeSource != null,
|
||||
));
|
||||
|
||||
if (!hasActiveSource) {
|
||||
tabsRouter.setActiveIndex(3);
|
||||
} else {
|
||||
tabsRouter.setActiveIndex(index);
|
||||
}
|
||||
},
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.music_note),
|
||||
label: 'Library',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Builder(builder: (context) {
|
||||
return SvgPicture.asset(
|
||||
'assets/tag_FILL0_wght400_GRAD0_opsz24.svg',
|
||||
colorFilter: ColorFilter.mode(
|
||||
IconTheme.of(context).color!.withOpacity(
|
||||
IconTheme.of(context).opacity ?? 1,
|
||||
),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
height: 28,
|
||||
);
|
||||
}),
|
||||
label: 'Browse',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.search_rounded),
|
||||
label: 'Search',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_rounded),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/app/pages/bottom_nav_page.g.dart
Normal file
56
lib/app/pages/bottom_nav_page.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'bottom_nav_page.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$bottomTabObserverHash() => r'e10c0b870f9b9052ad85fea4342569932edfeefb';
|
||||
|
||||
/// See also [bottomTabObserver].
|
||||
@ProviderFor(bottomTabObserver)
|
||||
final bottomTabObserverProvider = Provider<TabObserver>.internal(
|
||||
bottomTabObserver,
|
||||
name: r'bottomTabObserverProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$bottomTabObserverHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef BottomTabObserverRef = ProviderRef<TabObserver>;
|
||||
String _$bottomTabPathHash() => r'62539f7bf5b8f7e5f0531f564e634228ba1506bf';
|
||||
|
||||
/// See also [bottomTabPath].
|
||||
@ProviderFor(bottomTabPath)
|
||||
final bottomTabPathProvider = StreamProvider<String>.internal(
|
||||
bottomTabPath,
|
||||
name: r'bottomTabPathProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$bottomTabPathHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef BottomTabPathRef = StreamProviderRef<String>;
|
||||
String _$lastBottomNavStateServiceHash() =>
|
||||
r'487cb94cbb70884642c05a72524eb6fd7a4d12ce';
|
||||
|
||||
/// See also [LastBottomNavStateService].
|
||||
@ProviderFor(LastBottomNavStateService)
|
||||
final lastBottomNavStateServiceProvider =
|
||||
AsyncNotifierProvider<LastBottomNavStateService, void>.internal(
|
||||
LastBottomNavStateService.new,
|
||||
name: r'lastBottomNavStateServiceProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$lastBottomNavStateServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$LastBottomNavStateService = AsyncNotifier<void>;
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
||||
281
lib/app/pages/browse_page.dart
Normal file
281
lib/app/pages/browse_page.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../models/music.dart';
|
||||
import '../../models/query.dart';
|
||||
import '../../models/support.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
import '../../services/cache_service.dart';
|
||||
import '../../state/music.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../buttons.dart';
|
||||
import '../images.dart';
|
||||
import '../items.dart';
|
||||
|
||||
part 'browse_page.g.dart';
|
||||
|
||||
@riverpod
|
||||
Stream<List<Album>> albumsCategoryList(
|
||||
AlbumsCategoryListRef ref,
|
||||
ListQuery opt,
|
||||
) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final sourceId = ref.watch(sourceIdProvider);
|
||||
|
||||
return db.albumsList(sourceId, opt).watch();
|
||||
}
|
||||
|
||||
class BrowsePage extends HookConsumerWidget {
|
||||
const BrowsePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
final frequent = ref
|
||||
.watch(albumsCategoryListProvider(const ListQuery(
|
||||
page: Pagination(limit: 20),
|
||||
sort: SortBy(column: 'frequent_rank'),
|
||||
filters: IListConst([
|
||||
FilterWith.isNull(column: 'frequent_rank', invert: true),
|
||||
]),
|
||||
)))
|
||||
.valueOrNull;
|
||||
final recent = ref
|
||||
.watch(albumsCategoryListProvider(const ListQuery(
|
||||
page: Pagination(limit: 20),
|
||||
sort: SortBy(column: 'recent_rank'),
|
||||
filters: IListConst([
|
||||
FilterWith.isNull(column: 'recent_rank', invert: true),
|
||||
]),
|
||||
)))
|
||||
.valueOrNull;
|
||||
final starred = ref
|
||||
.watch(albumsCategoryListProvider(const ListQuery(
|
||||
page: Pagination(limit: 20),
|
||||
sort: SortBy(column: 'starred'),
|
||||
filters: IListConst([
|
||||
FilterWith.isNull(column: 'starred', invert: true),
|
||||
]),
|
||||
)))
|
||||
.valueOrNull;
|
||||
final random = ref
|
||||
.watch(albumsCategoryListProvider(const ListQuery(
|
||||
page: Pagination(limit: 20),
|
||||
sort: SortBy(column: 'RANDOM()'),
|
||||
)))
|
||||
.valueOrNull;
|
||||
|
||||
final genres = ref
|
||||
.watch(albumGenresProvider(const Pagination(
|
||||
limit: 20,
|
||||
)))
|
||||
.valueOrNull;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: RadioPlayFab(
|
||||
onPressed: () {
|
||||
ref.read(audioControlProvider).playRadio(
|
||||
context: QueueContextType.library,
|
||||
getSongs: (query) => ref
|
||||
.read(databaseProvider)
|
||||
.songsList(ref.read(sourceIdProvider), query)
|
||||
.get(),
|
||||
);
|
||||
},
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const SliverSafeArea(
|
||||
sliver: SliverPadding(padding: EdgeInsets.only(top: 8)),
|
||||
),
|
||||
_GenreCategory(
|
||||
title: 'Genres',
|
||||
items: genres?.toList() ?? [],
|
||||
),
|
||||
_AlbumCategory(
|
||||
title: l.resourcesSortByFrequentlyPlayed,
|
||||
items: frequent ?? [],
|
||||
),
|
||||
_AlbumCategory(
|
||||
title: l.resourcesSortByRecentlyPlayed,
|
||||
items: recent ?? [],
|
||||
),
|
||||
_AlbumCategory(
|
||||
title: l.resourcesFilterStarred,
|
||||
items: starred ?? [],
|
||||
),
|
||||
_AlbumCategory(
|
||||
title: l.resourcesSortByRandom,
|
||||
items: random ?? [],
|
||||
),
|
||||
const SliverToBoxAdapter(child: FabPadding()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenreCategory extends HookConsumerWidget {
|
||||
final String title;
|
||||
final List<String> items;
|
||||
|
||||
const _GenreCategory({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
sliver: _Category(
|
||||
title: title,
|
||||
height: 140,
|
||||
itemWidth: 140,
|
||||
items: items.map((genre) => _GenreItem(genre: genre)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenreItem extends HookConsumerWidget {
|
||||
final String genre;
|
||||
|
||||
const _GenreItem({
|
||||
required this.genre,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref
|
||||
.watch(albumsByGenreProvider(
|
||||
genre,
|
||||
const Pagination(limit: 4),
|
||||
))
|
||||
.valueOrNull;
|
||||
final cache = ref.watch(cacheServiceProvider);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (albums == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return ImageCard(
|
||||
onTap: () {
|
||||
context.navigateTo(GenreSongsRoute(genre: genre));
|
||||
},
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
CardClip(
|
||||
child: MultiImage(
|
||||
cacheInfo: albums.map((album) => cache.albumArt(album)),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
type: MaterialType.canvas,
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
elevation: 5,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
genre,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumCategory extends HookConsumerWidget {
|
||||
final String title;
|
||||
final List<Album> items;
|
||||
|
||||
const _AlbumCategory({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return _Category(
|
||||
title: title,
|
||||
height: 190,
|
||||
itemWidth: 140,
|
||||
items: items
|
||||
.map(
|
||||
(album) => AlbumCard(
|
||||
album: album,
|
||||
onTap: () => context.navigateTo(
|
||||
AlbumSongsRoute(
|
||||
id: album.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Category extends HookConsumerWidget {
|
||||
final String title;
|
||||
final List<Widget> items;
|
||||
final double height;
|
||||
final double itemWidth;
|
||||
|
||||
const _Category({
|
||||
required this.title,
|
||||
required this.items,
|
||||
required this.height,
|
||||
required this.itemWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) => SizedBox(
|
||||
width: itemWidth,
|
||||
child: items[index],
|
||||
),
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
114
lib/app/pages/browse_page.g.dart
Normal file
114
lib/app/pages/browse_page.g.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'browse_page.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$albumsCategoryListHash() =>
|
||||
r'e0516a585bf39e8140c72c08fd41f33a817c747d';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
typedef AlbumsCategoryListRef = AutoDisposeStreamProviderRef<List<Album>>;
|
||||
|
||||
/// See also [albumsCategoryList].
|
||||
@ProviderFor(albumsCategoryList)
|
||||
const albumsCategoryListProvider = AlbumsCategoryListFamily();
|
||||
|
||||
/// See also [albumsCategoryList].
|
||||
class AlbumsCategoryListFamily extends Family<AsyncValue<List<Album>>> {
|
||||
/// See also [albumsCategoryList].
|
||||
const AlbumsCategoryListFamily();
|
||||
|
||||
/// See also [albumsCategoryList].
|
||||
AlbumsCategoryListProvider call(
|
||||
ListQuery opt,
|
||||
) {
|
||||
return AlbumsCategoryListProvider(
|
||||
opt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AlbumsCategoryListProvider getProviderOverride(
|
||||
covariant AlbumsCategoryListProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.opt,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'albumsCategoryListProvider';
|
||||
}
|
||||
|
||||
/// See also [albumsCategoryList].
|
||||
class AlbumsCategoryListProvider
|
||||
extends AutoDisposeStreamProvider<List<Album>> {
|
||||
/// See also [albumsCategoryList].
|
||||
AlbumsCategoryListProvider(
|
||||
this.opt,
|
||||
) : super.internal(
|
||||
(ref) => albumsCategoryList(
|
||||
ref,
|
||||
opt,
|
||||
),
|
||||
from: albumsCategoryListProvider,
|
||||
name: r'albumsCategoryListProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$albumsCategoryListHash,
|
||||
dependencies: AlbumsCategoryListFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
AlbumsCategoryListFamily._allTransitiveDependencies,
|
||||
);
|
||||
|
||||
final ListQuery opt;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AlbumsCategoryListProvider && other.opt == opt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, opt.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
||||
41
lib/app/pages/library_albums_page.dart
Normal file
41
lib/app/pages/library_albums_page.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../hooks/use_list_query_paging_controller.dart';
|
||||
import '../items.dart';
|
||||
import '../lists.dart';
|
||||
|
||||
class LibraryAlbumsPage extends HookConsumerWidget {
|
||||
const LibraryAlbumsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pagingController = useLibraryPagingController(
|
||||
ref,
|
||||
libraryTabIndex: 0,
|
||||
getItems: (query) {
|
||||
final db = ref.read(databaseProvider);
|
||||
final sourceId = ref.read(sourceIdProvider);
|
||||
|
||||
return ref.read(offlineModeProvider)
|
||||
? db.albumsListDownloaded(sourceId, query).get()
|
||||
: db.albumsList(sourceId, query).get();
|
||||
},
|
||||
);
|
||||
|
||||
return PagedGridQueryView(
|
||||
pagingController: pagingController,
|
||||
refreshSyncAll: true,
|
||||
itemBuilder: (context, item, index, size) => AlbumCard(
|
||||
album: item,
|
||||
style:
|
||||
size == GridSize.small ? CardStyle.imageOnly : CardStyle.withText,
|
||||
onTap: () => context.navigateTo(AlbumSongsRoute(id: item.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/app/pages/library_artists_page.dart
Normal file
39
lib/app/pages/library_artists_page.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../hooks/use_list_query_paging_controller.dart';
|
||||
import '../items.dart';
|
||||
import '../lists.dart';
|
||||
|
||||
class LibraryArtistsPage extends HookConsumerWidget {
|
||||
const LibraryArtistsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pagingController = useLibraryPagingController(
|
||||
ref,
|
||||
libraryTabIndex: 1,
|
||||
getItems: (query) {
|
||||
final db = ref.read(databaseProvider);
|
||||
final sourceId = ref.read(sourceIdProvider);
|
||||
|
||||
return ref.read(offlineModeProvider)
|
||||
? db.artistsListDownloaded(sourceId, query).get()
|
||||
: db.artistsList(sourceId, query).get();
|
||||
},
|
||||
);
|
||||
|
||||
return PagedListQueryView(
|
||||
pagingController: pagingController,
|
||||
refreshSyncAll: true,
|
||||
itemBuilder: (context, item, index) => ArtistListTile(
|
||||
artist: item,
|
||||
onTap: () => context.navigateTo(ArtistRoute(id: item.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
635
lib/app/pages/library_page.dart
Normal file
635
lib/app/pages/library_page.dart
Normal file
@@ -0,0 +1,635 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../models/query.dart';
|
||||
import '../app_router.dart';
|
||||
import '../context_menus.dart';
|
||||
|
||||
part 'library_page.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TabObserver libraryTabObserver(LibraryTabObserverRef ref) {
|
||||
return TabObserver();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<String> libraryTabPath(LibraryTabPathRef ref) async* {
|
||||
final observer = ref.watch(libraryTabObserverProvider);
|
||||
await for (var tab in observer.path) {
|
||||
yield tab;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LastLibraryStateService extends _$LastLibraryStateService {
|
||||
@override
|
||||
Future<void> build() async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final tab = await ref.watch(libraryTabPathProvider.future);
|
||||
|
||||
await db.saveLastLibraryState(LastLibraryStateData(
|
||||
id: 1,
|
||||
tab: tab,
|
||||
albumsList: ref.watch(libraryListQueryProvider(0)).query,
|
||||
artistsList: ref.watch(libraryListQueryProvider(1)).query,
|
||||
playlistsList: ref.watch(libraryListQueryProvider(2)).query,
|
||||
songsList: ref.watch(libraryListQueryProvider(3)).query,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LibraryLists extends _$LibraryLists {
|
||||
@override
|
||||
IList<LibraryListQuery> build() {
|
||||
return const IListConst([
|
||||
/// Albums
|
||||
LibraryListQuery(
|
||||
options: ListQueryOptions(
|
||||
sortColumns: IListConst([
|
||||
'albums.name',
|
||||
'albums.created',
|
||||
'albums.album_artist',
|
||||
'albums.year',
|
||||
]),
|
||||
filterColumns: IListConst([
|
||||
'albums.starred',
|
||||
'albums.album_artist',
|
||||
'albums.year',
|
||||
'albums.genre',
|
||||
]),
|
||||
),
|
||||
query: ListQuery(
|
||||
page: Pagination(limit: 60),
|
||||
sort: SortBy(column: 'albums.name'),
|
||||
),
|
||||
),
|
||||
|
||||
/// Artists
|
||||
LibraryListQuery(
|
||||
options: ListQueryOptions(
|
||||
sortColumns: IListConst([
|
||||
'artists.name',
|
||||
'artists.album_count',
|
||||
]),
|
||||
filterColumns: IListConst([
|
||||
'artists.starred',
|
||||
]),
|
||||
),
|
||||
query: ListQuery(
|
||||
page: Pagination(limit: 30),
|
||||
sort: SortBy(column: 'artists.name'),
|
||||
),
|
||||
),
|
||||
|
||||
/// Playlists
|
||||
LibraryListQuery(
|
||||
options: ListQueryOptions(
|
||||
sortColumns: IListConst([
|
||||
'playlists.name',
|
||||
'playlists.created',
|
||||
'playlists.changed',
|
||||
]),
|
||||
filterColumns: IListConst([
|
||||
'playlists.owner',
|
||||
]),
|
||||
),
|
||||
query: ListQuery(
|
||||
page: Pagination(limit: 30),
|
||||
sort: SortBy(column: 'playlists.name'),
|
||||
),
|
||||
),
|
||||
|
||||
/// Songs
|
||||
LibraryListQuery(
|
||||
options: ListQueryOptions(
|
||||
sortColumns: IListConst([
|
||||
'songs.album',
|
||||
'songs.artist',
|
||||
'songs.created',
|
||||
'songs.title',
|
||||
'songs.year',
|
||||
]),
|
||||
filterColumns: IListConst([
|
||||
'songs.starred',
|
||||
'songs.artist',
|
||||
'songs.album',
|
||||
'songs.year',
|
||||
'songs.genre',
|
||||
]),
|
||||
),
|
||||
query: ListQuery(
|
||||
page: Pagination(limit: 30),
|
||||
sort: SortBy(column: 'songs.album'),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
final db = ref.read(databaseProvider);
|
||||
final last = await db.getLastLibraryState().getSingleOrNull();
|
||||
if (last == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state
|
||||
.replace(0, state[0].copyWith(query: last.albumsList))
|
||||
.replace(1, state[1].copyWith(query: last.artistsList))
|
||||
.replace(2, state[2].copyWith(query: last.playlistsList))
|
||||
.replace(3, state[3].copyWith(query: last.songsList));
|
||||
}
|
||||
|
||||
void setSortColumn(int index, String column) {
|
||||
state = state.replace(
|
||||
index,
|
||||
state[index].copyWith.query.sort!(column: column),
|
||||
);
|
||||
}
|
||||
|
||||
void toggleDirection(int index) {
|
||||
final toggled = state[index].query.sort?.dir == SortDirection.asc
|
||||
? SortDirection.desc
|
||||
: SortDirection.asc;
|
||||
state = state.replace(
|
||||
index,
|
||||
state[index].copyWith.query.sort!(dir: toggled),
|
||||
);
|
||||
}
|
||||
|
||||
void setFilter(int index, FilterWith filter) {
|
||||
state = state.replace(
|
||||
index,
|
||||
state[index].copyWith.query(
|
||||
filters: state[index].query.filters.updateById(
|
||||
[filter],
|
||||
(e) => e.column,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void removeFilter(int index, String column) {
|
||||
state = state.replace(
|
||||
index,
|
||||
state[index].copyWith.query(
|
||||
filters: state[index]
|
||||
.query
|
||||
.filters
|
||||
.removeWhere((f) => f.column == column)),
|
||||
);
|
||||
}
|
||||
|
||||
void clearFilters(int index) {
|
||||
state = state.replace(index, state[index].copyWith.query(filters: IList()));
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
LibraryListQuery libraryListQuery(LibraryListQueryRef ref, int index) {
|
||||
return ref.watch(libraryListsProvider.select((value) => value[index]));
|
||||
}
|
||||
|
||||
class LibraryTabsPage extends HookConsumerWidget {
|
||||
const LibraryTabsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final observer = ref.watch(libraryTabObserverProvider);
|
||||
|
||||
return AutoTabsRouter.tabBar(
|
||||
inheritNavigatorObservers: false,
|
||||
navigatorObservers: () => [observer],
|
||||
routes: const [
|
||||
LibraryAlbumsRoute(),
|
||||
LibraryArtistsRoute(),
|
||||
LibraryPlaylistsRoute(),
|
||||
LibrarySongsRoute(),
|
||||
],
|
||||
builder: (context, child, tabController) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
floatingActionButton: const _LibraryFilterFab(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryFilterFab extends HookConsumerWidget {
|
||||
const _LibraryFilterFab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
final activeIndex =
|
||||
useListenableSelector(tabsRouter, () => tabsRouter.activeIndex);
|
||||
final tabHasFilters = ref.watch(libraryListQueryProvider(activeIndex)
|
||||
.select((value) => value.query.filters.isNotEmpty));
|
||||
|
||||
List<Widget> dot = [];
|
||||
if (tabHasFilters) {
|
||||
dot.addAll([
|
||||
PositionedDirectional(
|
||||
top: 3,
|
||||
end: 0,
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
size: 11,
|
||||
),
|
||||
),
|
||||
const PositionedDirectional(
|
||||
top: 5,
|
||||
end: 1,
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 7,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return FloatingActionButton(
|
||||
heroTag: null,
|
||||
onPressed: () async {
|
||||
showContextMenu(
|
||||
context: context,
|
||||
ref: ref,
|
||||
builder: (context) => BottomSheetMenu(
|
||||
child: LibraryMenu(
|
||||
tabsRouter: tabsRouter,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'List',
|
||||
child: Stack(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.sort_rounded,
|
||||
size: 28,
|
||||
),
|
||||
...dot,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LibraryMenu extends HookConsumerWidget {
|
||||
final TabsRouter tabsRouter;
|
||||
|
||||
const LibraryMenu({
|
||||
super.key,
|
||||
required this.tabsRouter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
onSelected: (value) {},
|
||||
selected: true,
|
||||
label: const Icon(
|
||||
Icons.grid_on,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
onSelected: (value) {},
|
||||
label: const Icon(
|
||||
Icons.grid_view_outlined,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
FilterChip(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
onSelected: (value) {},
|
||||
label: const Icon(
|
||||
Icons.format_list_bulleted,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_FilterToggleButton(tabsRouter: tabsRouter),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 8)),
|
||||
ListSortFilterOptions(index: tabsRouter.activeIndex),
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 16)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterToggleButton extends HookConsumerWidget {
|
||||
final TabsRouter tabsRouter;
|
||||
|
||||
const _FilterToggleButton({
|
||||
required this.tabsRouter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabHasFilters = ref.watch(
|
||||
libraryListQueryProvider(tabsRouter.activeIndex)
|
||||
.select((value) => value.query.filters.isNotEmpty));
|
||||
|
||||
return FilledButton(
|
||||
onPressed: tabHasFilters
|
||||
? () {
|
||||
ref
|
||||
.read(libraryListsProvider.notifier)
|
||||
.clearFilters(tabsRouter.activeIndex);
|
||||
}
|
||||
: null,
|
||||
child: const Icon(Icons.filter_list_off_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ListSortFilterOptions extends HookConsumerWidget {
|
||||
final int index;
|
||||
|
||||
const ListSortFilterOptions({
|
||||
super.key,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
void Function()? _filterOnEdit(
|
||||
String column,
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final type = column.split('.').last;
|
||||
switch (type) {
|
||||
case 'year':
|
||||
return () {
|
||||
// TODO: year filter dialog
|
||||
// showDialog(
|
||||
// context: context,
|
||||
// builder: (context) {
|
||||
// return Dialog(
|
||||
// child: Text('adsf'),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
};
|
||||
case 'genre':
|
||||
case 'album_artist':
|
||||
case 'owner':
|
||||
case 'album':
|
||||
case 'artist':
|
||||
// TODO: other filter dialogs
|
||||
return () {};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void Function(bool? value)? _filterOnChanged(String column, WidgetRef ref) {
|
||||
final type = column.split('.').last;
|
||||
switch (type) {
|
||||
case 'starred':
|
||||
return (value) {
|
||||
if (value!) {
|
||||
ref.read(libraryListsProvider.notifier).setFilter(
|
||||
index,
|
||||
FilterWith.isNull(column: column, invert: true),
|
||||
);
|
||||
} else {
|
||||
ref.read(libraryListsProvider.notifier).removeFilter(index, column);
|
||||
}
|
||||
};
|
||||
case 'year':
|
||||
// TODO: add/remove filter
|
||||
return null;
|
||||
case 'genre':
|
||||
case 'album_artist':
|
||||
case 'owner':
|
||||
case 'album':
|
||||
case 'artist':
|
||||
// TODO: add/remove filter
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final list = ref.watch(libraryListQueryProvider(index));
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Sort by',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (var column in list.options.sortColumns)
|
||||
SortOptionTile(
|
||||
column: column,
|
||||
value: list.query.sort!.copyWith(column: column),
|
||||
groupValue: list.query.sort!,
|
||||
onColumnChanged: (column) {
|
||||
if (column != null) {
|
||||
ref
|
||||
.read(libraryListsProvider.notifier)
|
||||
.setSortColumn(index, column);
|
||||
}
|
||||
},
|
||||
onDirectionToggle: () =>
|
||||
ref.read(libraryListsProvider.notifier).toggleDirection(index),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Filter',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
for (var column in list.options.filterColumns)
|
||||
FilterOptionTile(
|
||||
column: column,
|
||||
state: list.query.filters.singleWhereOrNull(
|
||||
(e) => e.column == column,
|
||||
),
|
||||
onEdit: _filterOnEdit(column, context, ref),
|
||||
onChanged: _filterOnChanged(column, ref),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SortOptionTile extends HookConsumerWidget {
|
||||
final String column;
|
||||
final SortBy value;
|
||||
final SortBy groupValue;
|
||||
final void Function(String? value) onColumnChanged;
|
||||
final void Function() onDirectionToggle;
|
||||
|
||||
const SortOptionTile({
|
||||
super.key,
|
||||
required this.column,
|
||||
required this.value,
|
||||
required this.groupValue,
|
||||
required this.onColumnChanged,
|
||||
required this.onDirectionToggle,
|
||||
});
|
||||
|
||||
String _sortTitle(AppLocalizations l, String type) {
|
||||
type = type.split('.').last;
|
||||
switch (type) {
|
||||
case 'name':
|
||||
return l.resourcesSortByName;
|
||||
case 'album_artist':
|
||||
return l.resourcesSortByArtist;
|
||||
case 'created':
|
||||
return l.resourcesSortByAdded;
|
||||
case 'year':
|
||||
return l.resourcesSortByYear;
|
||||
case 'album_count':
|
||||
return l.resourcesSortByAlbumCount;
|
||||
case 'changed':
|
||||
return l.resourcesSortByUpdated;
|
||||
case 'album':
|
||||
return l.resourcesSortByAlbum;
|
||||
case 'artist':
|
||||
return l.resourcesSortByArtist;
|
||||
case 'title':
|
||||
return l.resourcesSortByTitle;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return RadioListTile<String?>(
|
||||
value: value.column,
|
||||
groupValue: groupValue.column,
|
||||
onChanged: onColumnChanged,
|
||||
selected: value.column == groupValue.column,
|
||||
title: Text(_sortTitle(l, column)),
|
||||
secondary: value.column == groupValue.column
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
value.dir == SortDirection.desc
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.arrow_downward_rounded,
|
||||
),
|
||||
onPressed: onDirectionToggle,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterOptionTile extends HookConsumerWidget {
|
||||
final String column;
|
||||
final FilterWith? state;
|
||||
final void Function(bool? value)? onChanged;
|
||||
final void Function()? onEdit;
|
||||
|
||||
const FilterOptionTile({
|
||||
super.key,
|
||||
required this.column,
|
||||
required this.state,
|
||||
required this.onChanged,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
String _filterTitle(AppLocalizations l, String type) {
|
||||
type = type.split('.').last;
|
||||
switch (type) {
|
||||
case 'starred':
|
||||
return l.resourcesFilterStarred;
|
||||
case 'year':
|
||||
return l.resourcesFilterYear;
|
||||
case 'genre':
|
||||
return l.resourcesFilterGenre;
|
||||
case 'album_artist':
|
||||
return l.resourcesFilterArtist;
|
||||
case 'owner':
|
||||
return l.resourcesFilterOwner;
|
||||
case 'album':
|
||||
return l.resourcesFilterAlbum;
|
||||
case 'artist':
|
||||
return l.resourcesFilterArtist;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: state == null
|
||||
? false
|
||||
: state!.map(
|
||||
equals: (value) => value.invert ? null : true,
|
||||
greaterThan: (value) => true,
|
||||
isNull: (_) => true,
|
||||
betweenInt: (_) => true,
|
||||
isIn: (value) => value.invert ? null : true,
|
||||
),
|
||||
tristate: state?.map(
|
||||
equals: (value) => true,
|
||||
greaterThan: (value) => false,
|
||||
isNull: (_) => false,
|
||||
betweenInt: (_) => false,
|
||||
isIn: (_) => true,
|
||||
) ??
|
||||
false,
|
||||
title: Text(_filterTitle(l, column)),
|
||||
secondary: onEdit == null
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: onEdit,
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
176
lib/app/pages/library_page.g.dart
Normal file
176
lib/app/pages/library_page.g.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'library_page.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$libraryTabObserverHash() =>
|
||||
r'a976ea55e2168e4684114c47592f25a2b187f15f';
|
||||
|
||||
/// See also [libraryTabObserver].
|
||||
@ProviderFor(libraryTabObserver)
|
||||
final libraryTabObserverProvider = Provider<TabObserver>.internal(
|
||||
libraryTabObserver,
|
||||
name: r'libraryTabObserverProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$libraryTabObserverHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef LibraryTabObserverRef = ProviderRef<TabObserver>;
|
||||
String _$libraryTabPathHash() => r'fe60984ea9d629683d344f809749b1b9362735fa';
|
||||
|
||||
/// See also [libraryTabPath].
|
||||
@ProviderFor(libraryTabPath)
|
||||
final libraryTabPathProvider = StreamProvider<String>.internal(
|
||||
libraryTabPath,
|
||||
name: r'libraryTabPathProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$libraryTabPathHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef LibraryTabPathRef = StreamProviderRef<String>;
|
||||
String _$libraryListQueryHash() => r'6079338e19e0249aaa09868dd405fd3aefc42c2b';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
typedef LibraryListQueryRef = ProviderRef<LibraryListQuery>;
|
||||
|
||||
/// See also [libraryListQuery].
|
||||
@ProviderFor(libraryListQuery)
|
||||
const libraryListQueryProvider = LibraryListQueryFamily();
|
||||
|
||||
/// See also [libraryListQuery].
|
||||
class LibraryListQueryFamily extends Family<LibraryListQuery> {
|
||||
/// See also [libraryListQuery].
|
||||
const LibraryListQueryFamily();
|
||||
|
||||
/// See also [libraryListQuery].
|
||||
LibraryListQueryProvider call(
|
||||
int index,
|
||||
) {
|
||||
return LibraryListQueryProvider(
|
||||
index,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LibraryListQueryProvider getProviderOverride(
|
||||
covariant LibraryListQueryProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.index,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'libraryListQueryProvider';
|
||||
}
|
||||
|
||||
/// See also [libraryListQuery].
|
||||
class LibraryListQueryProvider extends Provider<LibraryListQuery> {
|
||||
/// See also [libraryListQuery].
|
||||
LibraryListQueryProvider(
|
||||
this.index,
|
||||
) : super.internal(
|
||||
(ref) => libraryListQuery(
|
||||
ref,
|
||||
index,
|
||||
),
|
||||
from: libraryListQueryProvider,
|
||||
name: r'libraryListQueryProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$libraryListQueryHash,
|
||||
dependencies: LibraryListQueryFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
LibraryListQueryFamily._allTransitiveDependencies,
|
||||
);
|
||||
|
||||
final int index;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is LibraryListQueryProvider && other.index == index;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, index.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
String _$lastLibraryStateServiceHash() =>
|
||||
r'a49e26b5dc0fcb0f697ec2def08e7336f64c4cb3';
|
||||
|
||||
/// See also [LastLibraryStateService].
|
||||
@ProviderFor(LastLibraryStateService)
|
||||
final lastLibraryStateServiceProvider =
|
||||
AsyncNotifierProvider<LastLibraryStateService, void>.internal(
|
||||
LastLibraryStateService.new,
|
||||
name: r'lastLibraryStateServiceProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$lastLibraryStateServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$LastLibraryStateService = AsyncNotifier<void>;
|
||||
String _$libraryListsHash() => r'7c9fd1ca3b0d70253e0f5d8197abf18b3a18c995';
|
||||
|
||||
/// See also [LibraryLists].
|
||||
@ProviderFor(LibraryLists)
|
||||
final libraryListsProvider =
|
||||
NotifierProvider<LibraryLists, IList<LibraryListQuery>>.internal(
|
||||
LibraryLists.new,
|
||||
name: r'libraryListsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$libraryListsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$LibraryLists = Notifier<IList<LibraryListQuery>>;
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
||||
39
lib/app/pages/library_playlists_page.dart
Normal file
39
lib/app/pages/library_playlists_page.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../hooks/use_list_query_paging_controller.dart';
|
||||
import '../items.dart';
|
||||
import '../lists.dart';
|
||||
|
||||
class LibraryPlaylistsPage extends HookConsumerWidget {
|
||||
const LibraryPlaylistsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pagingController = useLibraryPagingController(
|
||||
ref,
|
||||
libraryTabIndex: 2,
|
||||
getItems: (query) {
|
||||
final db = ref.read(databaseProvider);
|
||||
final sourceId = ref.read(sourceIdProvider);
|
||||
|
||||
return ref.read(offlineModeProvider)
|
||||
? db.playlistsListDownloaded(sourceId, query).get()
|
||||
: db.playlistsList(sourceId, query).get();
|
||||
},
|
||||
);
|
||||
|
||||
return PagedListQueryView(
|
||||
pagingController: pagingController,
|
||||
refreshSyncAll: true,
|
||||
itemBuilder: (context, item, index) => PlaylistListTile(
|
||||
playlist: item,
|
||||
onTap: () => context.navigateTo(PlaylistSongsRoute(id: item.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
81
lib/app/pages/library_songs_page.dart
Normal file
81
lib/app/pages/library_songs_page.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../models/music.dart';
|
||||
import '../../models/query.dart';
|
||||
import '../../models/support.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../hooks/use_list_query_paging_controller.dart';
|
||||
import '../items.dart';
|
||||
import '../lists.dart';
|
||||
import 'library_page.dart';
|
||||
import 'songs_page.dart';
|
||||
|
||||
part 'library_songs_page.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<Song>> songsList(SongsListRef ref, ListQuery opt) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final sourceId = ref.watch(sourceIdProvider);
|
||||
|
||||
return db.songsList(sourceId, opt).get();
|
||||
}
|
||||
|
||||
class LibrarySongsPage extends HookConsumerWidget {
|
||||
const LibrarySongsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
|
||||
final query = ref.watch(libraryListQueryProvider(3).select(
|
||||
(value) => value.query,
|
||||
));
|
||||
|
||||
final getSongs = useCallback(
|
||||
(ListQuery query) {
|
||||
final db = ref.read(databaseProvider);
|
||||
final sourceId = ref.read(sourceIdProvider);
|
||||
|
||||
return ref.read(offlineModeProvider)
|
||||
? db.songsListDownloaded(sourceId, query).get()
|
||||
: db.songsList(sourceId, query).get();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
final play = useCallback(
|
||||
({int? index, bool? shuffle}) => audio.playSongs(
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
startIndex: index,
|
||||
context: QueueContextType.song,
|
||||
shuffle: shuffle,
|
||||
),
|
||||
[query, getSongs],
|
||||
);
|
||||
|
||||
final pagingController = useLibraryPagingController(
|
||||
ref,
|
||||
libraryTabIndex: 3,
|
||||
getItems: getSongs,
|
||||
);
|
||||
|
||||
return PagedListQueryView(
|
||||
pagingController: pagingController,
|
||||
refreshSyncAll: true,
|
||||
itemBuilder: (context, item, index) => QueueContext(
|
||||
type: QueueContextType.song,
|
||||
child: SongListTile(
|
||||
song: item,
|
||||
image: true,
|
||||
onTap: () => play(index: index),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
lib/app/pages/library_songs_page.g.dart
Normal file
111
lib/app/pages/library_songs_page.g.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'library_songs_page.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$songsListHash() => r'a3149eb61f8f1ff326e9b1de0ac1c02d7baa831f';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
typedef SongsListRef = AutoDisposeFutureProviderRef<List<Song>>;
|
||||
|
||||
/// See also [songsList].
|
||||
@ProviderFor(songsList)
|
||||
const songsListProvider = SongsListFamily();
|
||||
|
||||
/// See also [songsList].
|
||||
class SongsListFamily extends Family<AsyncValue<List<Song>>> {
|
||||
/// See also [songsList].
|
||||
const SongsListFamily();
|
||||
|
||||
/// See also [songsList].
|
||||
SongsListProvider call(
|
||||
ListQuery opt,
|
||||
) {
|
||||
return SongsListProvider(
|
||||
opt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SongsListProvider getProviderOverride(
|
||||
covariant SongsListProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.opt,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'songsListProvider';
|
||||
}
|
||||
|
||||
/// See also [songsList].
|
||||
class SongsListProvider extends AutoDisposeFutureProvider<List<Song>> {
|
||||
/// See also [songsList].
|
||||
SongsListProvider(
|
||||
this.opt,
|
||||
) : super.internal(
|
||||
(ref) => songsList(
|
||||
ref,
|
||||
opt,
|
||||
),
|
||||
from: songsListProvider,
|
||||
name: r'songsListProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$songsListHash,
|
||||
dependencies: SongsListFamily._dependencies,
|
||||
allTransitiveDependencies: SongsListFamily._allTransitiveDependencies,
|
||||
);
|
||||
|
||||
final ListQuery opt;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SongsListProvider && other.opt == opt;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, opt.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
||||
429
lib/app/pages/now_playing_page.dart
Normal file
429
lib/app/pages/now_playing_page.dart
Normal file
@@ -0,0 +1,429 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:text_scroll/text_scroll.dart';
|
||||
|
||||
import '../../cache/image_cache.dart';
|
||||
import '../../models/support.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
import '../../state/audio.dart';
|
||||
import '../../state/theme.dart';
|
||||
import '../context_menus.dart';
|
||||
import '../gradient.dart';
|
||||
import '../images.dart';
|
||||
import '../now_playing_bar.dart';
|
||||
|
||||
class NowPlayingPage extends HookConsumerWidget {
|
||||
const NowPlayingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
|
||||
final itemData = ref.watch(mediaItemDataProvider);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final scaffold = AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
systemNavigationBarColor: colors?.gradientLow,
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
itemData?.contextType.value ?? '',
|
||||
style: theme.textTheme.labelMedium,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
// Text(
|
||||
// itemData?.contextTitle ?? '',
|
||||
// style: theme.textTheme.titleMedium,
|
||||
// maxLines: 1,
|
||||
// softWrap: false,
|
||||
// overflow: TextOverflow.fade,
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
const MediaItemGradient(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Column(
|
||||
children: const [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _Art(),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _TrackInfo(),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_Progress(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _Controls(),
|
||||
),
|
||||
SizedBox(height: 64),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (colors != null) {
|
||||
return Theme(data: colors.theme, child: scaffold);
|
||||
} else {
|
||||
return scaffold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Art extends HookConsumerWidget {
|
||||
const _Art();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final itemData = ref.watch(mediaItemDataProvider);
|
||||
final imageCache = ref.watch(imageCacheProvider);
|
||||
|
||||
UriCacheInfo? cacheInfo;
|
||||
if (itemData?.artCache != null) {
|
||||
cacheInfo = UriCacheInfo(
|
||||
uri: itemData!.artCache!.fullArtUri,
|
||||
cacheKey: itemData.artCache!.fullArtCacheKey,
|
||||
cacheManager: imageCache,
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: CardClip(
|
||||
key: ValueKey(cacheInfo?.cacheKey ?? 'default'),
|
||||
child: cacheInfo != null
|
||||
? CardClip(
|
||||
square: false,
|
||||
child: UriCacheInfoImage(
|
||||
// height: 300,
|
||||
fit: BoxFit.contain,
|
||||
placeholderStyle: PlaceholderStyle.spinner,
|
||||
cache: cacheInfo,
|
||||
),
|
||||
)
|
||||
: const PlaceholderImage(thumbnail: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfo extends HookConsumerWidget {
|
||||
const _TrackInfo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final item = ref.watch(mediaItemProvider).valueOrNull;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ScrollableText(
|
||||
item?.title ?? '',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
speed: 50,
|
||||
),
|
||||
Text(
|
||||
item?.artist ?? '',
|
||||
style: theme.textTheme.titleMedium!,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.star_outline_rounded,
|
||||
size: 36,
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollableText extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final double speed;
|
||||
|
||||
const ScrollableText(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.style,
|
||||
this.speed = 35,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final defaultStyle = DefaultTextStyle.of(context);
|
||||
|
||||
return AutoSizeText(
|
||||
text,
|
||||
presetFontSizes: style != null && style?.fontSize != null
|
||||
? [style!.fontSize!]
|
||||
: [defaultStyle.style.fontSize ?? 12],
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
// softWrap: false,
|
||||
overflowReplacement: TextScroll(
|
||||
'$text ',
|
||||
style: style,
|
||||
delayBefore: const Duration(seconds: 3),
|
||||
pauseBetween: const Duration(seconds: 4),
|
||||
mode: TextScrollMode.endless,
|
||||
velocity: Velocity(pixelsPerSecond: Offset(speed, 0)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Progress extends HookConsumerWidget {
|
||||
const _Progress();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
|
||||
final position = ref.watch(positionProvider);
|
||||
final duration = ref.watch(durationProvider);
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
|
||||
final changeValue = useState(position.toDouble());
|
||||
final changing = useState(false);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Slider(
|
||||
value: changing.value ? changeValue.value : position.toDouble(),
|
||||
min: 0,
|
||||
max: max(duration.toDouble(), position.toDouble()),
|
||||
thumbColor: colors?.theme.colorScheme.onBackground,
|
||||
activeColor: colors?.theme.colorScheme.onBackground,
|
||||
inactiveColor: colors?.theme.colorScheme.surface,
|
||||
onChanged: (value) {
|
||||
changeValue.value = value;
|
||||
},
|
||||
onChangeStart: (value) {
|
||||
changing.value = true;
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
changing.value = false;
|
||||
audio.seek(Duration(seconds: value.toInt()));
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.titleMedium!,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(Duration(
|
||||
seconds: changing.value
|
||||
? changeValue.value.toInt()
|
||||
: position)
|
||||
.toString()
|
||||
.substring(2, 7)),
|
||||
Text(Duration(seconds: duration).toString().substring(2, 7)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RepeatButton extends HookConsumerWidget {
|
||||
final double size;
|
||||
|
||||
const RepeatButton({
|
||||
super.key,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
final repeat = ref.watch(repeatModeProvider);
|
||||
|
||||
IconData icon;
|
||||
void Function() action;
|
||||
|
||||
switch (repeat) {
|
||||
case AudioServiceRepeatMode.all:
|
||||
case AudioServiceRepeatMode.group:
|
||||
icon = Icons.repeat_on_rounded;
|
||||
action = () => audio.setRepeatMode(AudioServiceRepeatMode.one);
|
||||
break;
|
||||
case AudioServiceRepeatMode.one:
|
||||
icon = Icons.repeat_one_on_rounded;
|
||||
action = () => audio.setRepeatMode(AudioServiceRepeatMode.none);
|
||||
break;
|
||||
default:
|
||||
icon = Icons.repeat_rounded;
|
||||
action = () => audio.setRepeatMode(AudioServiceRepeatMode.all);
|
||||
break;
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(icon),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 30,
|
||||
onPressed: action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShuffleButton extends HookConsumerWidget {
|
||||
final double size;
|
||||
|
||||
const ShuffleButton({
|
||||
super.key,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
final shuffle = ref.watch(shuffleModeProvider);
|
||||
final queueMode = ref.watch(queueModeProvider).valueOrNull;
|
||||
|
||||
IconData icon;
|
||||
void Function() action;
|
||||
|
||||
switch (shuffle) {
|
||||
case AudioServiceShuffleMode.all:
|
||||
case AudioServiceShuffleMode.group:
|
||||
icon = Icons.shuffle_on_rounded;
|
||||
action = () => audio.setShuffleMode(AudioServiceShuffleMode.none);
|
||||
break;
|
||||
default:
|
||||
icon = Icons.shuffle_rounded;
|
||||
action = () => audio.setShuffleMode(AudioServiceShuffleMode.all);
|
||||
break;
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(queueMode == QueueMode.radio ? Icons.radio_rounded : icon),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 30,
|
||||
onPressed: queueMode == QueueMode.radio ? null : action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Controls extends HookConsumerWidget {
|
||||
const _Controls();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final base = ref.watch(baseThemeProvider);
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
|
||||
return IconTheme(
|
||||
data: IconThemeData(color: base.theme.colorScheme.onBackground),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const RepeatButton(size: 30),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous_rounded),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 60,
|
||||
onPressed: () => audio.skipToPrevious(),
|
||||
),
|
||||
const PlayPauseButton(size: 90),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next_rounded),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 60,
|
||||
onPressed: () => audio.skipToNext(),
|
||||
),
|
||||
const ShuffleButton(size: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Row(
|
||||
// crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music_rounded),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 30,
|
||||
onPressed: () {},
|
||||
),
|
||||
const _MoreButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MoreButton extends HookConsumerWidget {
|
||||
const _MoreButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final song = ref.watch(mediaItemSongProvider).valueOrNull;
|
||||
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 30,
|
||||
onPressed: song != null
|
||||
? () {
|
||||
showContextMenu(
|
||||
context: context,
|
||||
ref: ref,
|
||||
builder: (context) => BottomSheetMenu(
|
||||
child: SongContextMenu(song: song),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
247
lib/app/pages/search_page.dart
Normal file
247
lib/app/pages/search_page.dart
Normal file
@@ -0,0 +1,247 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../models/music.dart';
|
||||
import '../../models/query.dart';
|
||||
import '../../models/support.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
import '../../state/music.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../items.dart';
|
||||
import 'songs_page.dart';
|
||||
|
||||
part 'search_page.g.dart';
|
||||
|
||||
@riverpod
|
||||
class SearchQuery extends _$SearchQuery {
|
||||
@override
|
||||
String? build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
void setQuery(String query) {
|
||||
state = query;
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
FutureOr<SearchResults> searchResult(SearchResultRef ref) async {
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
final db = ref.watch(databaseProvider);
|
||||
final sourceId = ref.watch(sourceIdProvider);
|
||||
|
||||
final ftsQuery = '(source_id : $sourceId) AND (- source_id : "$query"*)';
|
||||
|
||||
final songRowIds = await db.searchSongs(ftsQuery, 5, 0).get();
|
||||
final songs = await db.songsInRowIds(songRowIds).get();
|
||||
final albumRowIds = await db.searchAlbums(ftsQuery, 5, 0).get();
|
||||
final albums = await db.albumsInRowIds(albumRowIds).get();
|
||||
final artistRowIds = await db.searchArtists(ftsQuery, 5, 0).get();
|
||||
final artists = await db.artistsInRowIds(artistRowIds).get();
|
||||
|
||||
return SearchResults(
|
||||
songs: songs.lock,
|
||||
albums: albums.lock,
|
||||
artists: artists.lock,
|
||||
);
|
||||
}
|
||||
|
||||
class SearchPage extends HookConsumerWidget {
|
||||
const SearchPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final results = ref.watch(searchResultProvider).valueOrNull;
|
||||
|
||||
return KeyboardDismissOnTap(
|
||||
dismissOnCapturedTaps: true,
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: CustomScrollView(
|
||||
reverse: true,
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: _SearchBar()),
|
||||
if (results != null && results.songs.isNotEmpty)
|
||||
_SongsSection(songs: results.songs),
|
||||
if (results != null && results.albums.isNotEmpty)
|
||||
_AlbumsSection(albums: results.albums),
|
||||
if (results != null && results.artists.isNotEmpty)
|
||||
_ArtistsSection(artists: results.artists),
|
||||
if (results != null)
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 96))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchBar extends HookConsumerWidget {
|
||||
const _SearchBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController(text: '');
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return Container(
|
||||
color: ElevationOverlay.applySurfaceTint(
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.surfaceTint,
|
||||
1,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 24,
|
||||
left: 24,
|
||||
bottom: 24,
|
||||
top: 8,
|
||||
),
|
||||
child: IgnoreKeyboardDismiss(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: l.searchInputPlaceholder,
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(searchQueryProvider.notifier).setQuery(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends HookConsumerWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(title, style: theme.headlineMedium),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends HookConsumerWidget {
|
||||
final String title;
|
||||
final Iterable<Widget> children;
|
||||
|
||||
const _Section({
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const SizedBox(height: 16),
|
||||
...children.toList().reversed,
|
||||
_SectionHeader(title: title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SongsSection extends HookConsumerWidget {
|
||||
final IList<Song>? songs;
|
||||
|
||||
const _SongsSection({required this.songs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Section(
|
||||
title: l.resourcesSongName(100),
|
||||
children: (songs ?? <Song>[]).map(
|
||||
(song) => QueueContext(
|
||||
type: QueueContextType.album,
|
||||
id: song.albumId!,
|
||||
child: SongListTile(
|
||||
song: song,
|
||||
image: true,
|
||||
onTap: () async {
|
||||
const query = ListQuery(
|
||||
sort: SortBy(column: 'disc, track'),
|
||||
);
|
||||
final albumSongs = await ref.read(
|
||||
albumSongsListProvider(song.albumId!, query).future,
|
||||
);
|
||||
|
||||
ref.read(audioControlProvider).playSongs(
|
||||
context: QueueContextType.album,
|
||||
contextId: song.albumId!,
|
||||
shuffle: true,
|
||||
startIndex: albumSongs.indexOf(song),
|
||||
query: query,
|
||||
getSongs: (query) => ref.read(
|
||||
albumSongsListProvider(song.albumId!, query).future),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumsSection extends HookConsumerWidget {
|
||||
final IList<Album>? albums;
|
||||
|
||||
const _AlbumsSection({required this.albums});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Section(
|
||||
title: l.resourcesAlbumName(100),
|
||||
children: (albums ?? <Album>[]).map(
|
||||
(album) => AlbumListTile(
|
||||
album: album,
|
||||
onTap: () => context.navigateTo(AlbumSongsRoute(id: album.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistsSection extends HookConsumerWidget {
|
||||
final IList<Artist>? artists;
|
||||
|
||||
const _ArtistsSection({required this.artists});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Section(
|
||||
title: l.resourcesArtistName(100),
|
||||
children: (artists ?? <Artist>[]).map(
|
||||
(artist) => ArtistListTile(
|
||||
artist: artist,
|
||||
onTap: () => context.navigateTo(ArtistRoute(id: artist.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/app/pages/search_page.g.dart
Normal file
38
lib/app/pages/search_page.g.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_page.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$searchResultHash() => r'e5240c0c51937e1e946138d27aeaea93dc0231c3';
|
||||
|
||||
/// See also [searchResult].
|
||||
@ProviderFor(searchResult)
|
||||
final searchResultProvider = AutoDisposeFutureProvider<SearchResults>.internal(
|
||||
searchResult,
|
||||
name: r'searchResultProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$searchResultHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef SearchResultRef = AutoDisposeFutureProviderRef<SearchResults>;
|
||||
String _$searchQueryHash() => r'f7624215b3d5a8b917cb0af239666a19a18d91d5';
|
||||
|
||||
/// See also [SearchQuery].
|
||||
@ProviderFor(SearchQuery)
|
||||
final searchQueryProvider =
|
||||
AutoDisposeNotifierProvider<SearchQuery, String?>.internal(
|
||||
SearchQuery.new,
|
||||
name: r'searchQueryProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$searchQueryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SearchQuery = AutoDisposeNotifier<String?>;
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
||||
395
lib/app/pages/settings_page.dart
Normal file
395
lib/app/pages/settings_page.dart
Normal file
@@ -0,0 +1,395 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../models/support.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../state/init.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../app_router.dart';
|
||||
import '../dialogs.dart';
|
||||
|
||||
const kHorizontalPadding = 16.0;
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
// final downloads = ref.watch(downloadServiceProvider.select(
|
||||
// (value) => value.downloads,
|
||||
// ));
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 96),
|
||||
_SectionHeader(l.settingsServersName),
|
||||
const _Sources(),
|
||||
_SectionHeader(l.settingsNetworkName),
|
||||
const _Network(),
|
||||
_SectionHeader(l.settingsAboutName),
|
||||
_About(),
|
||||
// const _SectionHeader('Downloads'),
|
||||
// _Section(
|
||||
// children: downloads
|
||||
// .map(
|
||||
// (e) => ListTile(
|
||||
// isThreeLine: true,
|
||||
// title: Text(e.filename ?? e.url),
|
||||
// subtitle: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.start,
|
||||
// children: [
|
||||
// Row(children: [Text('Progress: ${e.progress}%')]),
|
||||
// Row(children: [Text('Status: ${e.status})')]),
|
||||
// Text('Status: ${e.savedDir}'),
|
||||
// ],
|
||||
// ),
|
||||
// trailing:
|
||||
// CircularProgressIndicator(value: e.progress / 100),
|
||||
// ),
|
||||
// )
|
||||
// .toList(),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _Section({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...children,
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionHeader(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Network extends StatelessWidget {
|
||||
const _Network();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const _Section(
|
||||
children: [
|
||||
_OfflineMode(),
|
||||
_MaxBitrateWifi(),
|
||||
_MaxBitrateMobile(),
|
||||
_StreamFormat(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _About extends HookConsumerWidget {
|
||||
_About();
|
||||
|
||||
final _homepage = Uri.parse('https://github.com/austinried/subtracks');
|
||||
final _donate = Uri.parse('https://ko-fi.com/austinried');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final pkg = ref.watch(packageInfoProvider).requireValue;
|
||||
|
||||
return _Section(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('subtracks'),
|
||||
subtitle: Text(l.settingsAboutVersion(pkg.version)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(l.settingsAboutActionsLicenses),
|
||||
// trailing: const Icon(Icons.open_in_new_rounded),
|
||||
onTap: () {},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(l.settingsAboutActionsProjectHomepage),
|
||||
subtitle: Text(_homepage.toString()),
|
||||
trailing: const Icon(Icons.open_in_new_rounded),
|
||||
onTap: () => launchUrl(
|
||||
_homepage,
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(l.settingsAboutActionsSupport),
|
||||
subtitle: Text(_donate.toString()),
|
||||
trailing: const Icon(Icons.open_in_new_rounded),
|
||||
onTap: () => launchUrl(
|
||||
_donate,
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MaxBitrateWifi extends HookConsumerWidget {
|
||||
const _MaxBitrateWifi();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bitrate = ref.watch(settingsServiceProvider.select(
|
||||
(value) => value.app.maxBitrateWifi,
|
||||
));
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _MaxBitrateOption(
|
||||
title: l.settingsNetworkOptionsMaxBitrateWifiTitle,
|
||||
bitrate: bitrate,
|
||||
onChange: (value) {
|
||||
ref.read(settingsServiceProvider.notifier).setMaxBitrateWifi(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MaxBitrateMobile extends HookConsumerWidget {
|
||||
const _MaxBitrateMobile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bitrate = ref.watch(settingsServiceProvider.select(
|
||||
(value) => value.app.maxBitrateMobile,
|
||||
));
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _MaxBitrateOption(
|
||||
title: l.settingsNetworkOptionsMaxBitrateMobileTitle,
|
||||
bitrate: bitrate,
|
||||
onChange: (value) {
|
||||
ref.read(settingsServiceProvider.notifier).setMaxBitrateMobile(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MaxBitrateOption extends HookConsumerWidget {
|
||||
final String title;
|
||||
final int bitrate;
|
||||
final void Function(int value) onChange;
|
||||
|
||||
const _MaxBitrateOption({
|
||||
required this.title,
|
||||
required this.bitrate,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
static const options = [0, 24, 32, 64, 96, 128, 192, 256, 320];
|
||||
|
||||
String _bitrateText(AppLocalizations l, int bitrate) {
|
||||
return bitrate == 0
|
||||
? l.settingsNetworkValuesUnlimitedKbps
|
||||
: l.settingsNetworkValuesKbps(bitrate.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(_bitrateText(l, bitrate)),
|
||||
onTap: () async {
|
||||
final value = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => MultipleChoiceDialog<int>(
|
||||
title: title,
|
||||
current: bitrate,
|
||||
options: options
|
||||
.map((opt) => MultiChoiceOption.int(
|
||||
title: _bitrateText(l, opt),
|
||||
option: opt,
|
||||
))
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
if (value != null) {
|
||||
onChange(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StreamFormat extends HookConsumerWidget {
|
||||
const _StreamFormat();
|
||||
|
||||
static const options = ['', 'mp3', 'opus', 'ogg'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final streamFormat = ref.watch(
|
||||
settingsServiceProvider.select((value) => value.app.streamFormat),
|
||||
);
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return ListTile(
|
||||
title: Text(l.settingsNetworkOptionsStreamFormat),
|
||||
subtitle: Text(
|
||||
streamFormat ?? l.settingsNetworkOptionsStreamFormatServerDefault,
|
||||
),
|
||||
onTap: () async {
|
||||
final value = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => MultipleChoiceDialog<String>(
|
||||
title: l.settingsNetworkOptionsStreamFormat,
|
||||
current: streamFormat ?? '',
|
||||
options: options
|
||||
.map((opt) => MultiChoiceOption.string(
|
||||
title: opt == ''
|
||||
? l.settingsNetworkOptionsStreamFormatServerDefault
|
||||
: opt,
|
||||
option: opt,
|
||||
))
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
if (value != null) {
|
||||
ref
|
||||
.read(settingsServiceProvider.notifier)
|
||||
.setStreamFormat(value == '' ? null : value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OfflineMode extends HookConsumerWidget {
|
||||
const _OfflineMode();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final offline = ref.watch(offlineModeProvider);
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return SwitchListTile(
|
||||
value: offline,
|
||||
title: Text(l.settingsNetworkOptionsOfflineMode),
|
||||
subtitle: offline
|
||||
? Text(l.settingsNetworkOptionsOfflineModeOn)
|
||||
: Text(l.settingsNetworkOptionsOfflineModeOff),
|
||||
onChanged: (value) {
|
||||
ref.read(offlineModeProvider.notifier).setMode(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Sources extends HookConsumerWidget {
|
||||
const _Sources();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sources = ref.watch(settingsServiceProvider.select(
|
||||
(value) => value.sources,
|
||||
));
|
||||
final activeSource = ref.watch(settingsServiceProvider.select(
|
||||
(value) => value.activeSource,
|
||||
));
|
||||
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Section(
|
||||
children: [
|
||||
for (var source in sources)
|
||||
RadioListTile<int>(
|
||||
value: source.id,
|
||||
groupValue: activeSource?.id,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsServiceProvider.notifier)
|
||||
.setActiveSource(source.id);
|
||||
},
|
||||
title: Text(source.name),
|
||||
subtitle: Text(
|
||||
source.address.toString(),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
secondary: IconButton(
|
||||
icon: const Icon(Icons.edit_rounded),
|
||||
onPressed: () {
|
||||
context.pushRoute(SourceRoute(id: source.id));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l.settingsServersActionsAdd),
|
||||
onPressed: () {
|
||||
context.pushRoute(SourceRoute());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// TODO: remove
|
||||
if (kDebugMode)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Add TEST'),
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsServiceProvider.notifier)
|
||||
.addTestSource('TEST');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
511
lib/app/pages/songs_page.dart
Normal file
511
lib/app/pages/songs_page.dart
Normal file
@@ -0,0 +1,511 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
import '../../models/music.dart';
|
||||
import '../../models/query.dart';
|
||||
import '../../models/support.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
import '../../services/cache_service.dart';
|
||||
import '../../state/music.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../state/theme.dart';
|
||||
import '../buttons.dart';
|
||||
import '../context_menus.dart';
|
||||
import '../gradient.dart';
|
||||
import '../hooks/use_download_actions.dart';
|
||||
import '../hooks/use_list_query_paging_controller.dart';
|
||||
import '../images.dart';
|
||||
import '../items.dart';
|
||||
import '../lists.dart';
|
||||
|
||||
class AlbumSongsPage extends HookConsumerWidget {
|
||||
final String id;
|
||||
|
||||
const AlbumSongsPage({
|
||||
super.key,
|
||||
@pathParam required this.id,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(albumProvider(id)).valueOrNull;
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
final colors = ref.watch(albumArtThemeProvider(id)).valueOrNull;
|
||||
final key = useState(GlobalKey());
|
||||
|
||||
if (album == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final query = useMemoized(() => const ListQuery(
|
||||
page: Pagination(limit: 30),
|
||||
sort: SortBy(column: 'disc, track'),
|
||||
));
|
||||
|
||||
final getSongs = useCallback(
|
||||
(ListQuery query) => ref.read(albumSongsListProvider(id, query).future),
|
||||
[id],
|
||||
);
|
||||
|
||||
final play = useCallback(
|
||||
({int? index, bool? shuffle}) => audio.playSongs(
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
startIndex: index,
|
||||
context: QueueContextType.album,
|
||||
contextId: id,
|
||||
shuffle: shuffle,
|
||||
),
|
||||
[id, query, getSongs],
|
||||
);
|
||||
|
||||
return QueueContext(
|
||||
id: id,
|
||||
type: QueueContextType.album,
|
||||
child: _SongsPage(
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
fab: ShuffleFab(onPressed: () => play(shuffle: true)),
|
||||
onSongTap: (song, index) => play(index: index),
|
||||
background: AlbumArtGradient(key: key.value, id: id),
|
||||
colors: colors,
|
||||
header: _AlbumHeader(
|
||||
album: album,
|
||||
play: () => play(shuffle: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumHeader extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final void Function() play;
|
||||
|
||||
const _AlbumHeader({
|
||||
required this.album,
|
||||
required this.play,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cache = ref.watch(cacheServiceProvider);
|
||||
|
||||
final downloadActions = useAlbumDownloadActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
album: album,
|
||||
);
|
||||
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Header(
|
||||
title: album.name,
|
||||
subtitle: album.albumArtist,
|
||||
imageCache: cache.albumArt(album, thumbnail: false),
|
||||
playText: l.resourcesAlbumActionsPlay,
|
||||
onPlay: play,
|
||||
onMore: () => showContextMenu(
|
||||
context: context,
|
||||
ref: ref,
|
||||
builder: (context) => BottomSheetMenu(
|
||||
child: AlbumContextMenu(album: album),
|
||||
),
|
||||
),
|
||||
downloadActions: downloadActions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaylistSongsPage extends HookConsumerWidget {
|
||||
final String id;
|
||||
|
||||
const PlaylistSongsPage({
|
||||
super.key,
|
||||
@pathParam required this.id,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playlist = ref.watch(playlistProvider(id)).valueOrNull;
|
||||
final audio = ref.watch(audioControlProvider);
|
||||
final colors = ref.watch(playlistArtThemeProvider(id)).valueOrNull;
|
||||
|
||||
if (playlist == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final query = useMemoized(() => const ListQuery(
|
||||
page: Pagination(limit: 30),
|
||||
sort: SortBy(column: 'playlist_songs.position'),
|
||||
));
|
||||
|
||||
final getSongs = useCallback(
|
||||
(ListQuery query) =>
|
||||
ref.read(playlistSongsListProvider(id, query).future),
|
||||
[id],
|
||||
);
|
||||
|
||||
final play = useCallback(
|
||||
({int? index, bool? shuffle}) => audio.playSongs(
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
startIndex: index,
|
||||
context: QueueContextType.playlist,
|
||||
contextId: id,
|
||||
shuffle: shuffle,
|
||||
),
|
||||
[id, query, getSongs],
|
||||
);
|
||||
|
||||
return QueueContext(
|
||||
id: id,
|
||||
type: QueueContextType.playlist,
|
||||
child: _SongsPage(
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
fab: ShuffleFab(onPressed: () => play(shuffle: true)),
|
||||
onSongTap: (song, index) => play(index: index),
|
||||
songImage: true,
|
||||
background: PlaylistArtGradient(id: id),
|
||||
colors: colors,
|
||||
header: _PlaylistHeader(
|
||||
playlist: playlist,
|
||||
play: () => play(shuffle: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistHeader extends HookConsumerWidget {
|
||||
final Playlist playlist;
|
||||
final void Function() play;
|
||||
|
||||
const _PlaylistHeader({
|
||||
required this.playlist,
|
||||
required this.play,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cache = ref.watch(cacheServiceProvider);
|
||||
|
||||
final downloadActions = usePlaylistDownloadActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
playlist: playlist,
|
||||
);
|
||||
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Header(
|
||||
title: playlist.name,
|
||||
subtitle: playlist.comment,
|
||||
imageCache: cache.playlistArt(playlist, thumbnail: false),
|
||||
playText: l.resourcesPlaylistActionsPlay,
|
||||
onPlay: play,
|
||||
onMore: () {
|
||||
showContextMenu(
|
||||
context: context,
|
||||
ref: ref,
|
||||
builder: (context) => BottomSheetMenu(
|
||||
size: MenuSize.small,
|
||||
child: PlaylistContextMenu(playlist: playlist),
|
||||
),
|
||||
);
|
||||
},
|
||||
downloadActions: downloadActions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GenreSongsPage extends HookConsumerWidget {
|
||||
final String genre;
|
||||
|
||||
const GenreSongsPage({
|
||||
super.key,
|
||||
@pathParam required this.genre,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final query = useMemoized(
|
||||
() => ListQuery(
|
||||
page: const Pagination(limit: 30),
|
||||
sort: const SortBy(
|
||||
column: 'albums.created DESC, albums.name, songs.disc, songs.track',
|
||||
),
|
||||
filters: IList(
|
||||
[FilterWith.equals(column: 'songs.genre', value: genre)],
|
||||
),
|
||||
),
|
||||
[genre],
|
||||
);
|
||||
|
||||
final getSongs = useCallback(
|
||||
(ListQuery query) => ref.read(songsByAlbumListProvider(query).future),
|
||||
[],
|
||||
);
|
||||
|
||||
final play = useCallback(
|
||||
({int? index, bool? shuffle}) => ref.read(audioControlProvider).playRadio(
|
||||
context: QueueContextType.genre,
|
||||
contextId: genre,
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
),
|
||||
[query, getSongs],
|
||||
);
|
||||
|
||||
return QueueContext(
|
||||
id: genre,
|
||||
type: QueueContextType.album,
|
||||
child: _SongsPage(
|
||||
query: query,
|
||||
getSongs: getSongs,
|
||||
// onSongTap: (song, index) => play(index: index),
|
||||
songImage: true,
|
||||
background: const BackgroundGradient(),
|
||||
fab: RadioPlayFab(
|
||||
onPressed: () => play(),
|
||||
),
|
||||
header: _GenreHeader(genre: genre),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenreHeader extends HookConsumerWidget {
|
||||
final String genre;
|
||||
|
||||
const _GenreHeader({
|
||||
required this.genre,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final count = ref.watch(songsByGenreCountProvider(genre)).valueOrNull ?? 0;
|
||||
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return _Header(
|
||||
title: genre,
|
||||
subtitle: l.resourcesSongCount(count),
|
||||
downloadActions: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueueContext extends InheritedWidget {
|
||||
final QueueContextType type;
|
||||
final String? id;
|
||||
|
||||
const QueueContext({
|
||||
super.key,
|
||||
required this.type,
|
||||
this.id,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static QueueContext? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<QueueContext>();
|
||||
}
|
||||
|
||||
static QueueContext of(BuildContext context) {
|
||||
final QueueContext? result = maybeOf(context);
|
||||
assert(result != null, 'No QueueContext found in context');
|
||||
return result!;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant QueueContext oldWidget) =>
|
||||
oldWidget.id != id || oldWidget.type != type;
|
||||
}
|
||||
|
||||
class _SongsPage extends HookConsumerWidget {
|
||||
final ListQuery query;
|
||||
final FutureOr<List<Song>> Function(ListQuery query) getSongs;
|
||||
final void Function(Song song, int index)? onSongTap;
|
||||
final bool songImage;
|
||||
final Widget background;
|
||||
final Widget fab;
|
||||
final ColorTheme? colors;
|
||||
final Widget header;
|
||||
|
||||
const _SongsPage({
|
||||
required this.query,
|
||||
required this.getSongs,
|
||||
this.onSongTap,
|
||||
this.songImage = false,
|
||||
required this.background,
|
||||
required this.fab,
|
||||
this.colors,
|
||||
required this.header,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final base = ref.watch(baseThemeProvider);
|
||||
ref.listen(musicSourceProvider, (previous, next) {
|
||||
if (next.id != previous?.id) {
|
||||
context.router.popUntilRoot();
|
||||
}
|
||||
});
|
||||
|
||||
final pagingController = useListQueryPagingController(
|
||||
ref,
|
||||
query: query,
|
||||
getItems: getSongs,
|
||||
);
|
||||
|
||||
final widget = Scaffold(
|
||||
floatingActionButton: fab,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverStack(
|
||||
children: [
|
||||
SliverPositioned.fill(
|
||||
child: Container(
|
||||
color: base.gradientLow,
|
||||
),
|
||||
),
|
||||
SliverPositioned.directional(
|
||||
textDirection: TextDirection.ltr,
|
||||
start: 0,
|
||||
end: 0,
|
||||
top: 0,
|
||||
child: background,
|
||||
),
|
||||
MultiSliver(
|
||||
children: [
|
||||
SliverSafeArea(
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: header,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PagedListQueryView(
|
||||
pagingController: pagingController,
|
||||
useSliver: true,
|
||||
itemBuilder: (context, item, index) => SongListTile(
|
||||
song: item,
|
||||
image: songImage,
|
||||
onTap: () =>
|
||||
onSongTap != null ? onSongTap!(item, index) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (colors != null) {
|
||||
return Theme(data: colors!.theme, child: widget);
|
||||
} else {
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends HookConsumerWidget {
|
||||
final UriCacheInfo? imageCache;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? playText;
|
||||
final void Function()? onPlay;
|
||||
final FutureOr<void> Function()? onMore;
|
||||
final List<DownloadAction> downloadActions;
|
||||
|
||||
const _Header({
|
||||
this.imageCache,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.playText,
|
||||
this.onPlay,
|
||||
this.onMore,
|
||||
required this.downloadActions,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final inheritedStyle = DefaultTextStyle.of(context).style;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
if (imageCache != null)
|
||||
CardClip(
|
||||
square: false,
|
||||
child: UriCacheInfoImage(
|
||||
height: 300,
|
||||
fit: BoxFit.contain,
|
||||
placeholderStyle: PlaceholderStyle.spinner,
|
||||
cache: imageCache!,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge!.copyWith(
|
||||
color: inheritedStyle.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
subtitle ?? '',
|
||||
style: theme.textTheme.titleMedium!.copyWith(
|
||||
color: inheritedStyle.color,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
if (downloadActions.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: downloadActions.first.action,
|
||||
icon: downloadActions.first.type == DownloadActionType.delete
|
||||
? const Icon(Icons.download_done_rounded)
|
||||
: downloadActions.first.iconBuilder(context),
|
||||
),
|
||||
if (onPlay != null)
|
||||
FilledButton.icon(
|
||||
onPressed: onPlay,
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(playText ?? ''),
|
||||
),
|
||||
if (onMore != null)
|
||||
IconButton(
|
||||
onPressed: onMore,
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
259
lib/app/pages/source_page.dart
Normal file
259
lib/app/pages/source_page.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../items.dart';
|
||||
|
||||
class SourcePage extends HookConsumerWidget {
|
||||
final int? id;
|
||||
|
||||
const SourcePage({
|
||||
super.key,
|
||||
@pathParam this.id,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final source = ref.watch(settingsServiceProvider.select(
|
||||
(value) => value.sources.singleWhereOrNull((e) => e.id == id)
|
||||
as SubsonicSettings?,
|
||||
));
|
||||
final form = useState(GlobalKey<FormState>()).value;
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context);
|
||||
final isSaving = useState(false);
|
||||
final isDeleting = useState(false);
|
||||
|
||||
final name = LabeledTextField(
|
||||
label: l.settingsServersFieldsName,
|
||||
initialValue: source?.name,
|
||||
required: true,
|
||||
);
|
||||
final address = LabeledTextField(
|
||||
label: l.settingsServersFieldsAddress,
|
||||
initialValue: source?.address.toString(),
|
||||
keyboardType: TextInputType.url,
|
||||
required: true,
|
||||
validator: (value, label) {
|
||||
if (Uri.tryParse(value!) == null) {
|
||||
return '$label must be a valid URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
final username = LabeledTextField(
|
||||
label: l.settingsServersFieldsUsername,
|
||||
initialValue: source?.username,
|
||||
required: true,
|
||||
);
|
||||
final password = LabeledTextField(
|
||||
label: l.settingsServersFieldsPassword,
|
||||
initialValue: source?.password,
|
||||
obscureText: true,
|
||||
required: true,
|
||||
);
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async => !isSaving.value && !isDeleting.value,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(),
|
||||
floatingActionButton: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (source != null && source.isActive != true)
|
||||
FloatingActionButton(
|
||||
backgroundColor: theme.colorScheme.tertiaryContainer,
|
||||
foregroundColor: theme.colorScheme.onTertiaryContainer,
|
||||
onPressed: !isSaving.value && !isDeleting.value
|
||||
? () async {
|
||||
final router = context.router;
|
||||
|
||||
try {
|
||||
isDeleting.value = true;
|
||||
await ref
|
||||
.read(settingsServiceProvider.notifier)
|
||||
.deleteSource(source.id);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
|
||||
router.pop();
|
||||
}
|
||||
: null,
|
||||
child: isDeleting.value
|
||||
? SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.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 {
|
||||
final router = context.router;
|
||||
if (!form.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var error = false;
|
||||
try {
|
||||
isSaving.value = true;
|
||||
if (source != null) {
|
||||
await ref
|
||||
.read(settingsServiceProvider.notifier)
|
||||
.updateSource(
|
||||
source.copyWith(
|
||||
name: name.value,
|
||||
address: Uri.parse(address.value),
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await ref
|
||||
.read(settingsServiceProvider.notifier)
|
||||
.createSource(
|
||||
SourcesCompanion.insert(
|
||||
name: name.value,
|
||||
address: Uri.parse(address.value),
|
||||
),
|
||||
SubsonicSourcesCompanion.insert(
|
||||
features: IList(),
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
useTokenAuth: const Value(true),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// TOOD: toast the error or whatever
|
||||
print(err);
|
||||
error = true;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
router.pop();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: form,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 96 - kToolbarHeight),
|
||||
Text(
|
||||
source == null
|
||||
? l.settingsServersActionsAdd
|
||||
: l.settingsServersActionsEdit,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
name,
|
||||
address,
|
||||
username,
|
||||
password,
|
||||
const FabPadding(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LabeledTextField extends HookConsumerWidget {
|
||||
final String label;
|
||||
final String? initialValue;
|
||||
final bool obscureText;
|
||||
final bool required;
|
||||
final TextInputType? keyboardType;
|
||||
final String? Function(String? value, String label)? validator;
|
||||
|
||||
// ignore: prefer_const_constructors_in_immutables
|
||||
LabeledTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
this.obscureText = false,
|
||||
this.keyboardType,
|
||||
this.validator,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
late final TextEditingController _controller;
|
||||
|
||||
String get value => _controller.text;
|
||||
|
||||
String? _requiredValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$label is required';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
_controller = useTextEditingController(text: initialValue);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: (value) {
|
||||
String? error;
|
||||
|
||||
if (required) {
|
||||
error = _requiredValidator(value);
|
||||
if (error != null) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
if (validator != null) {
|
||||
return validator!(value, label);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user