This commit is contained in:
austinried
2023-04-28 09:24:51 +09:00
parent 35b037f66c
commit f0f812e66a
402 changed files with 34368 additions and 62769 deletions

83
lib/app/app.dart Normal file
View File

@@ -0,0 +1,83 @@
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 '../database/database.dart';
import '../services/settings_service.dart';
import '../state/init.dart';
import '../state/theme.dart';
part 'app.g.dart';
class MyApp extends HookConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final init = ref.watch(initProvider);
return init.when(
data: (_) => const App(),
error: (e, s) => Directionality(
textDirection: TextDirection.ltr,
child: Container(
color: Colors.red[900],
child: Column(children: [
const SizedBox(height: 100),
Text(e.toString()),
Text(s.toString()),
]),
),
),
loading: () => const CircularProgressIndicator(),
);
}
}
@Riverpod(keepAlive: true)
class LastPath extends _$LastPath {
@override
String build() {
return '/settings';
}
Future<void> init() async {
final db = ref.read(databaseProvider);
final lastBottomNav = await db.getLastBottomNavState().getSingleOrNull();
final lastLibrary = await db.getLastLibraryState().getSingleOrNull();
if (lastBottomNav == null || lastLibrary == null) return;
// TODO: replace this with a proper first-time setup flow
final hasActiveSource = ref.read(settingsServiceProvider.select(
(value) => value.activeSource != null,
));
if (!hasActiveSource) return;
state = lastBottomNav.tab == 'library'
? '/library/${lastLibrary.tab}'
: '/${lastBottomNav.tab}';
}
}
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appRouter = ref.watch(routerProvider);
final base = ref.watch(baseThemeProvider);
final lastPath = ref.watch(lastPathProvider);
return MaterialApp.router(
theme: base.theme,
debugShowCheckedModeBanner: false,
routerDelegate: appRouter.delegate(
initialDeepLink: lastPath,
),
routeInformationParser: appRouter.defaultRouteParser(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
);
}
}

23
lib/app/app.g.dart Normal file
View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$lastPathHash() => r'25ba5df6bd984fcce011eec40a12fb74627a790a';
/// See also [LastPath].
@ProviderFor(LastPath)
final lastPathProvider = NotifierProvider<LastPath, String>.internal(
LastPath.new,
name: r'lastPathProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$lastPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastPath = Notifier<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

140
lib/app/app_router.dart Normal file
View File

@@ -0,0 +1,140 @@
// ignore_for_file: use_key_in_widget_constructors
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'pages/artist_page.dart';
import 'pages/bottom_nav_page.dart';
import 'pages/browse_page.dart';
import 'pages/library_albums_page.dart';
import 'pages/library_artists_page.dart';
import 'pages/library_page.dart';
import 'pages/library_playlists_page.dart';
import 'pages/library_songs_page.dart';
import 'pages/now_playing_page.dart';
import 'pages/search_page.dart';
import 'pages/settings_page.dart';
import 'pages/songs_page.dart';
import 'pages/source_page.dart';
part 'app_router.gr.dart';
const kCustomTransitionBuilder = TransitionsBuilders.slideRightWithFade;
const kCustomTransitionDuration = 160;
const itemRoutes = [
CustomRoute(
path: 'album/:id',
page: AlbumSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'artist/:id',
page: ArtistPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'playlist/:id',
page: PlaylistSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'genre/:genre',
page: GenreSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
];
class EmptyRouterPage extends AutoRouter {
const EmptyRouterPage({Key? key})
: super(
key: key,
inheritNavigatorObservers: false,
);
}
@MaterialAutoRouter(
replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[
AutoRoute(path: '/', name: 'RootRouter', page: EmptyRouterPage, children: [
AutoRoute(path: '', page: BottomNavTabsPage, children: [
AutoRoute(
path: 'library',
name: 'LibraryRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: LibraryTabsPage, children: [
AutoRoute(path: 'albums', page: LibraryAlbumsPage),
AutoRoute(path: 'artists', page: LibraryArtistsPage),
AutoRoute(path: 'playlists', page: LibraryPlaylistsPage),
AutoRoute(path: 'songs', page: LibrarySongsPage),
]),
...itemRoutes,
]),
AutoRoute(
path: 'browse',
name: 'BrowseRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: BrowsePage),
...itemRoutes,
]),
AutoRoute(
path: 'search',
name: 'SearchRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: SearchPage),
...itemRoutes,
]),
AutoRoute(
path: 'settings',
name: 'SettingsRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: SettingsPage),
CustomRoute(
path: 'source/:id',
page: SourcePage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
]),
]),
]),
CustomRoute(
path: '/now-playing',
page: NowPlayingPage,
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
reverseDurationInMilliseconds: 160,
),
],
)
class AppRouter extends _$AppRouter {}
class TabObserver extends AutoRouterObserver {
final StreamController<String> _controller = StreamController.broadcast();
Stream<String> get path => _controller.stream;
@override
void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) {
_controller.add(route.path);
}
@override
void didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) {
_controller.add(route.path);
}
}

720
lib/app/app_router.gr.dart Normal file
View File

@@ -0,0 +1,720 @@
// **************************************************************************
// AutoRouteGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// AutoRouteGenerator
// **************************************************************************
//
// ignore_for_file: type=lint
part of 'app_router.dart';
class _$AppRouter extends RootStackRouter {
_$AppRouter([GlobalKey<NavigatorState>? navigatorKey]) : super(navigatorKey);
@override
final Map<String, PageFactory> pagesMap = {
RootRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
NowPlayingRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const NowPlayingPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
BottomNavTabsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BottomNavTabsPage(),
);
},
LibraryRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
BrowseRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
SearchRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
SettingsRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
LibraryTabsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryTabsPage(),
);
},
AlbumSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<AlbumSongsRouteArgs>(
orElse: () => AlbumSongsRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: AlbumSongsPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
ArtistRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<ArtistRouteArgs>(
orElse: () => ArtistRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: ArtistPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
PlaylistSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<PlaylistSongsRouteArgs>(
orElse: () => PlaylistSongsRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: PlaylistSongsPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
GenreSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<GenreSongsRouteArgs>(
orElse: () =>
GenreSongsRouteArgs(genre: pathParams.getString('genre')));
return CustomPage<dynamic>(
routeData: routeData,
child: GenreSongsPage(
key: args.key,
genre: args.genre,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
LibraryAlbumsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryAlbumsPage(),
);
},
LibraryArtistsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryArtistsPage(),
);
},
LibraryPlaylistsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryPlaylistsPage(),
);
},
LibrarySongsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibrarySongsPage(),
);
},
BrowseRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BrowsePage(),
);
},
SearchRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SearchPage(),
);
},
SettingsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SettingsPage(),
);
},
SourceRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<SourceRouteArgs>(
orElse: () => SourceRouteArgs(id: pathParams.optInt('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: SourcePage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
};
@override
List<RouteConfig> get routes => [
RouteConfig(
RootRouter.name,
path: '/',
children: [
RouteConfig(
BottomNavTabsRoute.name,
path: '',
parent: RootRouter.name,
children: [
RouteConfig(
LibraryRouter.name,
path: 'library',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
LibraryTabsRoute.name,
path: '',
parent: LibraryRouter.name,
children: [
RouteConfig(
LibraryAlbumsRoute.name,
path: 'albums',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibraryArtistsRoute.name,
path: 'artists',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibraryPlaylistsRoute.name,
path: 'playlists',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibrarySongsRoute.name,
path: 'songs',
parent: LibraryTabsRoute.name,
),
],
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: LibraryRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: LibraryRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: LibraryRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: LibraryRouter.name,
),
],
),
RouteConfig(
BrowseRouter.name,
path: 'browse',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
BrowseRoute.name,
path: '',
parent: BrowseRouter.name,
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: BrowseRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: BrowseRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: BrowseRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: BrowseRouter.name,
),
],
),
RouteConfig(
SearchRouter.name,
path: 'search',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
SearchRoute.name,
path: '',
parent: SearchRouter.name,
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: SearchRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: SearchRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: SearchRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: SearchRouter.name,
),
],
),
RouteConfig(
SettingsRouter.name,
path: 'settings',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
SettingsRoute.name,
path: '',
parent: SettingsRouter.name,
),
RouteConfig(
SourceRoute.name,
path: 'source/:id',
parent: SettingsRouter.name,
),
],
),
],
)
],
),
RouteConfig(
NowPlayingRoute.name,
path: '/now-playing',
),
];
}
/// generated route for
/// [EmptyRouterPage]
class RootRouter extends PageRouteInfo<void> {
const RootRouter({List<PageRouteInfo>? children})
: super(
RootRouter.name,
path: '/',
initialChildren: children,
);
static const String name = 'RootRouter';
}
/// generated route for
/// [NowPlayingPage]
class NowPlayingRoute extends PageRouteInfo<void> {
const NowPlayingRoute()
: super(
NowPlayingRoute.name,
path: '/now-playing',
);
static const String name = 'NowPlayingRoute';
}
/// generated route for
/// [BottomNavTabsPage]
class BottomNavTabsRoute extends PageRouteInfo<void> {
const BottomNavTabsRoute({List<PageRouteInfo>? children})
: super(
BottomNavTabsRoute.name,
path: '',
initialChildren: children,
);
static const String name = 'BottomNavTabsRoute';
}
/// generated route for
/// [EmptyRouterPage]
class LibraryRouter extends PageRouteInfo<void> {
const LibraryRouter({List<PageRouteInfo>? children})
: super(
LibraryRouter.name,
path: 'library',
initialChildren: children,
);
static const String name = 'LibraryRouter';
}
/// generated route for
/// [EmptyRouterPage]
class BrowseRouter extends PageRouteInfo<void> {
const BrowseRouter({List<PageRouteInfo>? children})
: super(
BrowseRouter.name,
path: 'browse',
initialChildren: children,
);
static const String name = 'BrowseRouter';
}
/// generated route for
/// [EmptyRouterPage]
class SearchRouter extends PageRouteInfo<void> {
const SearchRouter({List<PageRouteInfo>? children})
: super(
SearchRouter.name,
path: 'search',
initialChildren: children,
);
static const String name = 'SearchRouter';
}
/// generated route for
/// [EmptyRouterPage]
class SettingsRouter extends PageRouteInfo<void> {
const SettingsRouter({List<PageRouteInfo>? children})
: super(
SettingsRouter.name,
path: 'settings',
initialChildren: children,
);
static const String name = 'SettingsRouter';
}
/// generated route for
/// [LibraryTabsPage]
class LibraryTabsRoute extends PageRouteInfo<void> {
const LibraryTabsRoute({List<PageRouteInfo>? children})
: super(
LibraryTabsRoute.name,
path: '',
initialChildren: children,
);
static const String name = 'LibraryTabsRoute';
}
/// generated route for
/// [AlbumSongsPage]
class AlbumSongsRoute extends PageRouteInfo<AlbumSongsRouteArgs> {
AlbumSongsRoute({
Key? key,
required String id,
}) : super(
AlbumSongsRoute.name,
path: 'album/:id',
args: AlbumSongsRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'AlbumSongsRoute';
}
class AlbumSongsRouteArgs {
const AlbumSongsRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'AlbumSongsRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [ArtistPage]
class ArtistRoute extends PageRouteInfo<ArtistRouteArgs> {
ArtistRoute({
Key? key,
required String id,
}) : super(
ArtistRoute.name,
path: 'artist/:id',
args: ArtistRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'ArtistRoute';
}
class ArtistRouteArgs {
const ArtistRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'ArtistRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [PlaylistSongsPage]
class PlaylistSongsRoute extends PageRouteInfo<PlaylistSongsRouteArgs> {
PlaylistSongsRoute({
Key? key,
required String id,
}) : super(
PlaylistSongsRoute.name,
path: 'playlist/:id',
args: PlaylistSongsRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'PlaylistSongsRoute';
}
class PlaylistSongsRouteArgs {
const PlaylistSongsRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'PlaylistSongsRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [GenreSongsPage]
class GenreSongsRoute extends PageRouteInfo<GenreSongsRouteArgs> {
GenreSongsRoute({
Key? key,
required String genre,
}) : super(
GenreSongsRoute.name,
path: 'genre/:genre',
args: GenreSongsRouteArgs(
key: key,
genre: genre,
),
rawPathParams: {'genre': genre},
);
static const String name = 'GenreSongsRoute';
}
class GenreSongsRouteArgs {
const GenreSongsRouteArgs({
this.key,
required this.genre,
});
final Key? key;
final String genre;
@override
String toString() {
return 'GenreSongsRouteArgs{key: $key, genre: $genre}';
}
}
/// generated route for
/// [LibraryAlbumsPage]
class LibraryAlbumsRoute extends PageRouteInfo<void> {
const LibraryAlbumsRoute()
: super(
LibraryAlbumsRoute.name,
path: 'albums',
);
static const String name = 'LibraryAlbumsRoute';
}
/// generated route for
/// [LibraryArtistsPage]
class LibraryArtistsRoute extends PageRouteInfo<void> {
const LibraryArtistsRoute()
: super(
LibraryArtistsRoute.name,
path: 'artists',
);
static const String name = 'LibraryArtistsRoute';
}
/// generated route for
/// [LibraryPlaylistsPage]
class LibraryPlaylistsRoute extends PageRouteInfo<void> {
const LibraryPlaylistsRoute()
: super(
LibraryPlaylistsRoute.name,
path: 'playlists',
);
static const String name = 'LibraryPlaylistsRoute';
}
/// generated route for
/// [LibrarySongsPage]
class LibrarySongsRoute extends PageRouteInfo<void> {
const LibrarySongsRoute()
: super(
LibrarySongsRoute.name,
path: 'songs',
);
static const String name = 'LibrarySongsRoute';
}
/// generated route for
/// [BrowsePage]
class BrowseRoute extends PageRouteInfo<void> {
const BrowseRoute()
: super(
BrowseRoute.name,
path: '',
);
static const String name = 'BrowseRoute';
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<void> {
const SearchRoute()
: super(
SearchRoute.name,
path: '',
);
static const String name = 'SearchRoute';
}
/// generated route for
/// [SettingsPage]
class SettingsRoute extends PageRouteInfo<void> {
const SettingsRoute()
: super(
SettingsRoute.name,
path: '',
);
static const String name = 'SettingsRoute';
}
/// generated route for
/// [SourcePage]
class SourceRoute extends PageRouteInfo<SourceRouteArgs> {
SourceRoute({
Key? key,
int? id,
}) : super(
SourceRoute.name,
path: 'source/:id',
args: SourceRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'SourceRoute';
}
class SourceRouteArgs {
const SourceRouteArgs({
this.key,
this.id,
});
final Key? key;
final int? id;
@override
String toString() {
return 'SourceRouteArgs{key: $key, id: $id}';
}
}

63
lib/app/buttons.dart Normal file
View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ShuffleFab extends StatelessWidget {
final void Function()? onPressed;
const ShuffleFab({
super.key,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return FloatingActionButton(
heroTag: null,
onPressed: onPressed,
tooltip: l.actionsCancel,
child: const Icon(Icons.shuffle_rounded),
);
}
}
class RadioPlayFab extends StatelessWidget {
final void Function()? onPressed;
const RadioPlayFab({
super.key,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
heroTag: null,
onPressed: onPressed,
child: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.radio_rounded),
Positioned(
bottom: -11,
right: -10,
child: Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.primaryContainer,
size: 26,
),
),
const Positioned(
bottom: -6,
right: -5,
child: Icon(
Icons.play_arrow_rounded,
size: 16,
),
),
],
),
);
}
}

414
lib/app/context_menus.dart Normal file
View File

@@ -0,0 +1,414 @@
// ignore_for_file: use_build_context_synchronously
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/music.dart';
import '../services/cache_service.dart';
import '../state/theme.dart';
import 'app_router.dart';
import 'hooks/use_download_actions.dart';
import 'images.dart';
enum MenuSize {
small,
medium,
}
Future<T?> showContextMenu<T>({
required BuildContext context,
required WidgetRef ref,
required WidgetBuilder builder,
}) {
return showModalBottomSheet<T>(
backgroundColor: ref.read(baseThemeProvider).theme.colorScheme.background,
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: builder,
);
}
class BottomSheetMenu extends HookConsumerWidget {
final Widget child;
final MenuSize size;
const BottomSheetMenu({
super.key,
required this.child,
this.size = MenuSize.medium,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(baseThemeProvider);
final height = size == MenuSize.medium ? 0.4 : 0.25;
return Theme(
data: theme.theme,
child: DraggableScrollableSheet(
expand: false,
initialChildSize: height,
maxChildSize: height,
minChildSize: height - 0.05,
snap: true,
snapSizes: [height - 0.05, height],
builder: (context, scrollController) {
return PrimaryScrollController(
controller: scrollController,
child: SizedBox(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: child,
),
),
);
},
),
);
}
}
class AlbumContextMenu extends HookConsumerWidget {
final Album album;
const AlbumContextMenu({
super.key,
required this.album,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadActions = useAlbumDownloadActions(
context: context,
ref: ref,
album: album,
);
return ListView(
children: [
_AlbumHeader(album: album),
const SizedBox(height: 8),
const _Star(),
if (album.artistId != null) _ViewArtist(id: album.artistId!),
for (var action in downloadActions)
_DownloadAction(key: ValueKey(action.type), downloadAction: action),
],
);
}
}
class SongContextMenu extends HookConsumerWidget {
final Song song;
const SongContextMenu({
super.key,
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
_SongHeader(song: song),
const SizedBox(height: 8),
const _Star(),
if (song.artistId != null) _ViewArtist(id: song.artistId!),
if (song.albumId != null) _ViewAlbum(id: song.albumId!),
// const _DownloadAction(),
],
);
}
}
class ArtistContextMenu extends HookConsumerWidget {
final Artist artist;
const ArtistContextMenu({
super.key,
required this.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
_ArtistHeader(artist: artist),
const SizedBox(height: 8),
const _Star(),
// const _Download(),
],
);
}
}
class PlaylistContextMenu extends HookConsumerWidget {
final Playlist playlist;
const PlaylistContextMenu({
super.key,
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadActions = usePlaylistDownloadActions(
context: context,
ref: ref,
playlist: playlist,
);
return ListView(
children: [
_PlaylistHeader(playlist: playlist),
const SizedBox(height: 8),
for (var action in downloadActions)
_DownloadAction(key: ValueKey(action.type), downloadAction: action),
],
);
}
}
class _AlbumHeader extends HookConsumerWidget {
final Album album;
const _AlbumHeader({
required this.album,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
return _Header(
title: album.name,
subtitle: album.albumArtist,
image: CardClip(
child: UriCacheInfoImage(
cache: cache.albumArt(album, thumbnail: true),
),
),
);
}
}
class _SongHeader extends HookConsumerWidget {
final Song song;
const _SongHeader({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Header(
title: song.title,
subtitle: song.artist,
image: SongAlbumArt(song: song, square: false),
);
}
}
class _ArtistHeader extends HookConsumerWidget {
final Artist artist;
const _ArtistHeader({
required this.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Header(
title: artist.name,
subtitle: l.resourcesAlbumCount(artist.albumCount),
image: CircleClip(child: ArtistArtImage(artistId: artist.id)),
);
}
}
class _PlaylistHeader extends HookConsumerWidget {
final Playlist playlist;
const _PlaylistHeader({
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
final l = AppLocalizations.of(context);
return _Header(
title: playlist.name,
subtitle: l.resourcesSongCount(playlist.songCount),
image: CardClip(
child: UriCacheInfoImage(
cache: cache.playlistArt(playlist, thumbnail: true),
),
),
);
}
}
class _Header extends HookConsumerWidget {
final String title;
final String? subtitle;
final Widget image;
const _Header({
required this.title,
this.subtitle,
required this.image,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 80, width: 80, child: image),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleLarge),
if (subtitle != null)
Text(subtitle!, style: theme.textTheme.titleSmall),
],
),
)
],
),
);
}
}
class _Star extends HookConsumerWidget {
const _Star();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.actionsStar,
icon: const Icon(Icons.star_outline_rounded),
onTap: () {},
);
}
}
class _DownloadAction extends HookConsumerWidget {
final DownloadAction downloadAction;
const _DownloadAction({
super.key,
required this.downloadAction,
});
String _actionText(AppLocalizations l) {
switch (downloadAction.type) {
case DownloadActionType.download:
return l.actionsDownload;
case DownloadActionType.cancel:
return l.actionsDownloadCancel;
case DownloadActionType.delete:
return l.actionsDownloadDelete;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return _MenuItem(
title: _actionText(AppLocalizations.of(context)),
icon: downloadAction.iconBuilder(context),
onTap: downloadAction.action,
);
}
}
class _ViewArtist extends HookConsumerWidget {
final String id;
const _ViewArtist({
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.resourcesArtistActionsView,
icon: const Icon(Icons.person_rounded),
onTap: () async {
final router = context.router;
await router.pop();
if (router.currentPath == '/now-playing') {
await router.pop();
await router.navigate(const LibraryRouter());
}
await router.navigate(ArtistRoute(id: id));
},
);
}
}
class _ViewAlbum extends HookConsumerWidget {
final String id;
const _ViewAlbum({
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.resourcesAlbumActionsView,
icon: const Icon(Icons.album_rounded),
onTap: () async {
final router = context.router;
await router.pop();
if (router.currentPath == '/now-playing') {
await router.pop();
await router.navigate(const LibraryRouter());
}
await router.navigate(AlbumSongsRoute(id: id));
},
);
}
}
class _MenuItem extends StatelessWidget {
final String title;
final Widget icon;
final FutureOr<void> Function()? onTap;
const _MenuItem({
required this.title,
required this.icon,
this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
leading: Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: icon,
),
onTap: onTap,
);
}
}

96
lib/app/dialogs.dart Normal file
View File

@@ -0,0 +1,96 @@
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 '../models/support.dart';
import '../state/theme.dart';
class DeleteDialog extends HookConsumerWidget {
const DeleteDialog({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(baseThemeProvider);
final l = AppLocalizations.of(context);
return Theme(
data: theme.theme,
child: AlertDialog(
title: Text(l.resourcesSongListDeleteAllTitle),
content: Text(l.resourcesSongListDeleteAllContent),
actions: [
FilledButton.tonal(
onPressed: () => Navigator.pop(context, false),
child: Text(l.actionsCancel),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, true),
label: Text(l.actionsDelete),
icon: const Icon(Icons.delete_forever_rounded),
),
],
),
);
}
}
class MultipleChoiceDialog<T> extends HookConsumerWidget {
final String title;
final T current;
final IList<MultiChoiceOption> options;
const MultipleChoiceDialog({
super.key,
required this.title,
required this.current,
required this.options,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final state = useState<T>(current);
List<Widget> choices = [];
for (var opt in options) {
final value = opt.map(
(value) => null,
int: (value) => value.option,
string: (value) => value.option,
) as T;
choices.add(RadioListTile<T>(
value: value,
groupValue: state.value,
title: Text(opt.title),
onChanged: (value) => state.value = value as T,
));
}
return AlertDialog(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(vertical: 20),
content: Material(
type: MaterialType.transparency,
child: SingleChildScrollView(
child: Column(children: choices),
),
),
actions: [
FilledButton.tonal(
onPressed: () => Navigator.pop(context, null),
child: Text(l.actionsCancel),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, state.value),
label: Text(l.actionsOk),
icon: const Icon(Icons.check_rounded),
),
],
);
}
}

76
lib/app/gradient.dart Normal file
View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/support.dart';
import '../state/theme.dart';
class MediaItemGradient extends ConsumerWidget {
const MediaItemGradient({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class AlbumArtGradient extends ConsumerWidget {
final String id;
const AlbumArtGradient({
super.key,
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(albumArtThemeProvider(id)).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class PlaylistArtGradient extends ConsumerWidget {
final String id;
const PlaylistArtGradient({
super.key,
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(playlistArtThemeProvider(id)).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class BackgroundGradient extends HookConsumerWidget {
final ColorTheme? colors;
const BackgroundGradient({
super.key,
this.colors,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final base = ref.watch(baseThemeProvider);
return SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors?.gradientHigh ?? base.gradientHigh,
colors?.gradientLow ?? base.gradientLow,
],
),
),
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../models/music.dart';
import '../../models/support.dart';
import '../../services/download_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../dialogs.dart';
enum DownloadActionType {
download,
cancel,
delete,
}
class DownloadAction {
final DownloadActionType type;
final WidgetBuilder iconBuilder;
final FutureOr<void> Function()? action;
const DownloadAction({
required this.type,
required this.iconBuilder,
this.action,
});
}
List<DownloadAction> useAlbumDownloadActions({
required BuildContext context,
required WidgetRef ref,
required Album album,
}) {
final status = ref.watch(albumDownloadStatusProvider(album.id)).valueOrNull;
return useListDownloadActions(
context: context,
ref: ref,
list: album,
status: status,
onDownload: () =>
ref.read(downloadServiceProvider.notifier).downloadAlbum(album),
onDelete: () =>
ref.read(downloadServiceProvider.notifier).deleteAlbum(album),
onCancel: () =>
ref.read(downloadServiceProvider.notifier).cancelAlbum(album),
);
}
List<DownloadAction> usePlaylistDownloadActions({
required BuildContext context,
required WidgetRef ref,
required Playlist playlist,
}) {
final status =
ref.watch(playlistDownloadStatusProvider(playlist.id)).valueOrNull;
return useListDownloadActions(
context: context,
ref: ref,
list: playlist,
status: status,
onDownload: () =>
ref.read(downloadServiceProvider.notifier).downloadPlaylist(playlist),
onDelete: () =>
ref.read(downloadServiceProvider.notifier).deletePlaylist(playlist),
onCancel: () =>
ref.read(downloadServiceProvider.notifier).cancelPlaylist(playlist),
);
}
List<DownloadAction> useListDownloadActions({
required BuildContext context,
required WidgetRef ref,
required SourceIdentifiable list,
required ListDownloadStatus? status,
required FutureOr<void> Function() onDelete,
required FutureOr<void> Function() onCancel,
required FutureOr<void> Function() onDownload,
}) {
status ??= const ListDownloadStatus(total: 0, downloaded: 0, downloading: 0);
final sourceId = SourceId.from(list);
final offline = ref.watch(offlineModeProvider);
final listDownloadInProgress = ref.watch(downloadServiceProvider
.select((value) => value.listDownloads.contains(sourceId)));
final listDeleteInProgress = ref.watch(downloadServiceProvider
.select((value) => value.deletes.contains(sourceId)));
final listCancelInProgress = ref.watch(downloadServiceProvider
.select((value) => value.listCancels.contains(sourceId)));
DownloadAction delete() {
return DownloadAction(
type: DownloadActionType.delete,
iconBuilder: (context) => const Icon(Icons.delete_forever_rounded),
action: listDeleteInProgress
? null
: () async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => const DeleteDialog(),
);
if (ok == true) {
await onDelete();
}
},
);
}
DownloadAction cancel() {
return DownloadAction(
type: DownloadActionType.cancel,
iconBuilder: (context) => Stack(
alignment: Alignment.center,
children: const [
Icon(Icons.cancel_rounded),
SizedBox(
height: 32,
width: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
],
),
action: listCancelInProgress ? null : onCancel,
);
}
DownloadAction download() {
return DownloadAction(
type: DownloadActionType.download,
iconBuilder: (context) => const Icon(Icons.download_rounded),
action: !offline ? onDownload : null,
);
}
if (status.total == status.downloaded) {
return [delete()];
} else if (status.downloading == 0 && status.downloaded > 0) {
return [download(), delete()];
} else if (listDownloadInProgress || status.downloading > 0) {
return [cancel()];
} else {
return [download()];
}
}

View File

@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../models/query.dart';
import '../../services/sync_service.dart';
import '../../state/settings.dart';
import '../pages/library_page.dart';
import 'use_paging_controller.dart';
PagingController<int, T> useLibraryPagingController<T>(
WidgetRef ref, {
required int libraryTabIndex,
required FutureOr<List<T>> Function(ListQuery query) getItems,
}) {
final queryProvider = libraryListQueryProvider(libraryTabIndex).select(
(value) => value.query,
);
final query = useState(ref.read(queryProvider));
final onPageRequest = useCallback(
(int pageKey, PagingController<int, T> pagingController) =>
_pageRequest(getItems, query.value, pageKey, pagingController),
[query.value],
);
final pagingController = usePagingController<int, T>(
firstPageKey: query.value.page.offset,
onPageRequest: onPageRequest,
);
ref.listen(queryProvider, (_, next) {
query.value = next;
pagingController.refresh();
});
ref.listen(syncServiceProvider, (_, __) => pagingController.refresh());
ref.listen(sourceIdProvider, (_, __) => pagingController.refresh());
ref.listen(offlineModeProvider, (_, __) => pagingController.refresh());
return pagingController;
}
PagingController<int, T> useListQueryPagingController<T>(
WidgetRef ref, {
required ListQuery query,
required FutureOr<List<T>> Function(ListQuery query) getItems,
}) {
final onPageRequest = useCallback(
(int pageKey, PagingController<int, T> pagingController) =>
_pageRequest(getItems, query, pageKey, pagingController),
[query],
);
final pagingController = usePagingController<int, T>(
firstPageKey: query.page.offset,
onPageRequest: onPageRequest,
);
return pagingController;
}
Future<void> _pageRequest<T>(
FutureOr<List<T>> Function(ListQuery query) getItems,
ListQuery query,
int pageKey,
PagingController<int, dynamic> pagingController,
) async {
try {
final newItems = await getItems(query.copyWith.page(offset: pageKey));
final isFirstPage = newItems.isNotEmpty && pageKey == 0;
final alreadyHasItems = pagingController.itemList != null &&
pagingController.itemList!.isNotEmpty;
if (isFirstPage && alreadyHasItems) {
return;
}
final isLastPage = newItems.length < query.page.limit;
if (isLastPage) {
pagingController.appendLastPage(newItems);
} else {
final nextPageKey = pageKey + newItems.length;
pagingController.appendPage(newItems, nextPageKey);
}
} catch (error) {
pagingController.error = error;
}
}

View File

@@ -0,0 +1,66 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
PagingController<PageKeyType, ItemType>
usePagingController<PageKeyType, ItemType>({
required final PageKeyType firstPageKey,
final int? invisibleItemsThreshold,
List<Object?>? keys,
FutureOr<void> Function(PageKeyType pageKey,
PagingController<PageKeyType, ItemType> pagingController)?
onPageRequest,
}) {
final controller = use(
_PagingControllerHook<PageKeyType, ItemType>(
firstPageKey: firstPageKey,
invisibleItemsThreshold: invisibleItemsThreshold,
keys: keys,
),
);
useEffect(() {
listener(PageKeyType pageKey) => onPageRequest?.call(pageKey, controller);
controller.addPageRequestListener(listener);
return () => controller.removePageRequestListener(listener);
}, [onPageRequest]);
return controller;
}
class _PagingControllerHook<PageKeyType, ItemType>
extends Hook<PagingController<PageKeyType, ItemType>> {
const _PagingControllerHook({
required this.firstPageKey,
this.invisibleItemsThreshold,
List<Object?>? keys,
}) : super(keys: keys);
final PageKeyType firstPageKey;
final int? invisibleItemsThreshold;
@override
HookState<PagingController<PageKeyType, ItemType>,
Hook<PagingController<PageKeyType, ItemType>>>
createState() => _PagingControllerHookState<PageKeyType, ItemType>();
}
class _PagingControllerHookState<PageKeyType, ItemType> extends HookState<
PagingController<PageKeyType, ItemType>,
_PagingControllerHook<PageKeyType, ItemType>> {
late final controller = PagingController<PageKeyType, ItemType>(
firstPageKey: hook.firstPageKey,
invisibleItemsThreshold: hook.invisibleItemsThreshold);
@override
PagingController<PageKeyType, ItemType> build(BuildContext context) =>
controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'usePagingController';
}

368
lib/app/images.dart Normal file
View File

@@ -0,0 +1,368 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/music.dart';
import '../models/support.dart';
import '../services/cache_service.dart';
import '../state/music.dart';
import '../state/settings.dart';
import '../state/theme.dart';
part 'images.g.dart';
@riverpod
CacheInfo _artistArtCacheInfo(
_ArtistArtCacheInfoRef ref, {
required String artistId,
bool thumbnail = true,
}) {
final cache = ref.watch(cacheServiceProvider);
return cache.artistArtCacheInfo(artistId, thumbnail: thumbnail);
}
@riverpod
FutureOr<String?> _artistArtCachedUrl(
_ArtistArtCachedUrlRef ref, {
required String artistId,
bool thumbnail = true,
}) async {
final cache = ref.watch(_artistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
final file = await cache.cacheManager.getFileFromCache(cache.cacheKey);
return file?.originalUrl;
}
@riverpod
FutureOr<UriCacheInfo> _artistArtUriCacheInfo(
_ArtistArtUriCacheInfoRef ref, {
required String artistId,
bool thumbnail = true,
}) async {
final cache = ref.watch(cacheServiceProvider);
final info = ref.watch(_artistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
final cachedUrl = await ref.watch(_artistArtCachedUrlProvider(
artistId: artistId,
thumbnail: thumbnail,
).future);
final offline = ref.watch(offlineModeProvider);
// already cached, don't try to get the real url again
if (cachedUrl != null) {
return UriCacheInfo(
uri: Uri.parse(cachedUrl),
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
}
if (offline) {
final file = await cache.imageCache.getFileFromCache(info.cacheKey);
if (file != null) {
return UriCacheInfo(
uri: Uri.parse(file.originalUrl),
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
} else {
return cache.placeholder(thumbnail: thumbnail);
}
}
// assume the url is good or let this fail
return UriCacheInfo(
uri: (await cache.artistArtUri(artistId, thumbnail: thumbnail))!,
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
}
class ArtistArtImage extends HookConsumerWidget {
final String artistId;
final bool thumbnail;
final BoxFit fit;
final PlaceholderStyle placeholderStyle;
final double? height;
final double? width;
const ArtistArtImage({
super.key,
required this.artistId,
this.thumbnail = true,
this.fit = BoxFit.cover,
this.placeholderStyle = PlaceholderStyle.color,
this.height,
this.width,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(_artistArtUriCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
// TODO: figure out how to animate this without messing up with boxfit/ratio
return cache.when(
data: (data) => UriCacheInfoImage(
cache: data,
fit: fit,
placeholderStyle: placeholderStyle,
height: height,
width: width,
),
error: (_, __) => Container(
color: Colors.red,
height: height,
width: width,
),
loading: () => Container(
color: Theme.of(context).colorScheme.secondaryContainer,
height: height,
width: width,
),
);
}
}
class SongAlbumArt extends HookConsumerWidget {
final Song song;
final bool square;
const SongAlbumArt({
super.key,
required this.song,
this.square = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(song.albumId!)).valueOrNull;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: album != null ? AlbumArt(album: album) : const PlaceholderImage(),
);
}
}
class AlbumArt extends HookConsumerWidget {
final Album album;
final bool square;
const AlbumArt({
super.key,
required this.album,
this.square = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(albumArtPaletteProvider(album.id));
final cache = ref.watch(cacheServiceProvider);
Widget image = UriCacheInfoImage(cache: cache.albumArt(album));
if (square) {
image = AspectRatio(aspectRatio: 1.0, child: image);
}
return CardClip(child: image);
}
}
class CircleClip extends StatelessWidget {
final Widget child;
const CircleClip({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return ClipOval(
clipBehavior: Clip.antiAlias,
child: AspectRatio(
aspectRatio: 1.0,
child: child,
),
);
}
}
class CardClip extends StatelessWidget {
final Widget child;
final bool square;
const CardClip({
super.key,
required this.child,
this.square = true,
});
@override
Widget build(BuildContext context) {
final cardShape = Theme.of(context).cardTheme.shape;
return ClipRRect(
borderRadius:
cardShape is RoundedRectangleBorder ? cardShape.borderRadius : null,
child: !square
? child
: AspectRatio(
aspectRatio: 1.0,
child: child,
),
);
}
}
enum PlaceholderStyle {
color,
spinner,
}
class UriCacheInfoImage extends StatelessWidget {
final UriCacheInfo cache;
final BoxFit fit;
final PlaceholderStyle placeholderStyle;
final double? height;
final double? width;
const UriCacheInfoImage({
super.key,
required this.cache,
this.fit = BoxFit.cover,
this.placeholderStyle = PlaceholderStyle.color,
this.height,
this.width,
});
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: cache.uri.toString(),
cacheKey: cache.cacheKey,
cacheManager: cache.cacheManager,
fit: fit,
height: height,
width: width,
fadeInDuration: const Duration(milliseconds: 300),
fadeOutDuration: const Duration(milliseconds: 500),
placeholder: (context, url) =>
placeholderStyle == PlaceholderStyle.spinner
? Container()
: Container(
color: Theme.of(context).colorScheme.secondaryContainer,
),
errorWidget: (context, url, error) => PlaceholderImage(
fit: fit,
height: height,
width: width,
),
);
}
}
class PlaceholderImage extends HookConsumerWidget {
final BoxFit fit;
final double? height;
final double? width;
final bool thumbnail;
const PlaceholderImage({
super.key,
this.fit = BoxFit.cover,
this.height,
this.width,
this.thumbnail = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Image.asset(
thumbnail ? 'assets/placeholder_thumb.png' : 'assets/placeholder.png',
fit: fit,
height: height,
width: width,
);
}
}
class _ExpandedRatio extends StatelessWidget {
final Widget child;
final double aspectRatio;
const _ExpandedRatio({
required this.child,
this.aspectRatio = 1.0,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: AspectRatio(
aspectRatio: aspectRatio,
child: child,
),
);
}
}
class MultiImage extends HookConsumerWidget {
final Iterable<UriCacheInfo> cacheInfo;
const MultiImage({
super.key,
required this.cacheInfo,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final images = cacheInfo.map((cache) => UriCacheInfoImage(cache: cache));
final row1 = <Widget>[];
final row2 = <Widget>[];
if (images.length >= 4) {
row1.addAll([
_ExpandedRatio(child: images.elementAt(0)),
_ExpandedRatio(child: images.elementAt(1)),
]);
row2.addAll([
_ExpandedRatio(child: images.elementAt(2)),
_ExpandedRatio(child: images.elementAt(3)),
]);
}
if (images.length == 3) {
row1.addAll([
_ExpandedRatio(child: images.elementAt(0)),
_ExpandedRatio(child: images.elementAt(1)),
]);
row2.addAll([
_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(2)),
]);
}
if (images.length == 2) {
row1.add(_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(0)));
row2.add(_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(1)));
}
if (images.length == 1) {
row1.addAll([_ExpandedRatio(child: images.elementAt(0))]);
}
return Column(
children: [
Row(children: row1),
Row(children: row2),
],
);
}
}

307
lib/app/images.g.dart Normal file
View File

@@ -0,0 +1,307 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'images.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$artistArtCacheInfoHash() =>
r'f82d3e91aa1596939e376c6a7ea7d3e974c6f0fc';
/// 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 _ArtistArtCacheInfoRef = AutoDisposeProviderRef<CacheInfo>;
/// See also [_artistArtCacheInfo].
@ProviderFor(_artistArtCacheInfo)
const _artistArtCacheInfoProvider = _ArtistArtCacheInfoFamily();
/// See also [_artistArtCacheInfo].
class _ArtistArtCacheInfoFamily extends Family<CacheInfo> {
/// See also [_artistArtCacheInfo].
const _ArtistArtCacheInfoFamily();
/// See also [_artistArtCacheInfo].
_ArtistArtCacheInfoProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtCacheInfoProvider getProviderOverride(
covariant _ArtistArtCacheInfoProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
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'_artistArtCacheInfoProvider';
}
/// See also [_artistArtCacheInfo].
class _ArtistArtCacheInfoProvider extends AutoDisposeProvider<CacheInfo> {
/// See also [_artistArtCacheInfo].
_ArtistArtCacheInfoProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtCacheInfo(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtCacheInfoProvider,
name: r'_artistArtCacheInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtCacheInfoHash,
dependencies: _ArtistArtCacheInfoFamily._dependencies,
allTransitiveDependencies:
_ArtistArtCacheInfoFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtCacheInfoProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
String _$artistArtCachedUrlHash() =>
r'2a5e0fea614ff12a1d562faccec6cfe98394af42';
typedef _ArtistArtCachedUrlRef = AutoDisposeFutureProviderRef<String?>;
/// See also [_artistArtCachedUrl].
@ProviderFor(_artistArtCachedUrl)
const _artistArtCachedUrlProvider = _ArtistArtCachedUrlFamily();
/// See also [_artistArtCachedUrl].
class _ArtistArtCachedUrlFamily extends Family<AsyncValue<String?>> {
/// See also [_artistArtCachedUrl].
const _ArtistArtCachedUrlFamily();
/// See also [_artistArtCachedUrl].
_ArtistArtCachedUrlProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtCachedUrlProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtCachedUrlProvider getProviderOverride(
covariant _ArtistArtCachedUrlProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
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'_artistArtCachedUrlProvider';
}
/// See also [_artistArtCachedUrl].
class _ArtistArtCachedUrlProvider extends AutoDisposeFutureProvider<String?> {
/// See also [_artistArtCachedUrl].
_ArtistArtCachedUrlProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtCachedUrl(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtCachedUrlProvider,
name: r'_artistArtCachedUrlProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtCachedUrlHash,
dependencies: _ArtistArtCachedUrlFamily._dependencies,
allTransitiveDependencies:
_ArtistArtCachedUrlFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtCachedUrlProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
String _$artistArtUriCacheInfoHash() =>
r'9bdc0f5654882265236ef746ea697a6d107a4b6f';
typedef _ArtistArtUriCacheInfoRef = AutoDisposeFutureProviderRef<UriCacheInfo>;
/// See also [_artistArtUriCacheInfo].
@ProviderFor(_artistArtUriCacheInfo)
const _artistArtUriCacheInfoProvider = _ArtistArtUriCacheInfoFamily();
/// See also [_artistArtUriCacheInfo].
class _ArtistArtUriCacheInfoFamily extends Family<AsyncValue<UriCacheInfo>> {
/// See also [_artistArtUriCacheInfo].
const _ArtistArtUriCacheInfoFamily();
/// See also [_artistArtUriCacheInfo].
_ArtistArtUriCacheInfoProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtUriCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtUriCacheInfoProvider getProviderOverride(
covariant _ArtistArtUriCacheInfoProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
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'_artistArtUriCacheInfoProvider';
}
/// See also [_artistArtUriCacheInfo].
class _ArtistArtUriCacheInfoProvider
extends AutoDisposeFutureProvider<UriCacheInfo> {
/// See also [_artistArtUriCacheInfo].
_ArtistArtUriCacheInfoProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtUriCacheInfo(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtUriCacheInfoProvider,
name: r'_artistArtUriCacheInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtUriCacheInfoHash,
dependencies: _ArtistArtUriCacheInfoFamily._dependencies,
allTransitiveDependencies:
_ArtistArtUriCacheInfoFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtUriCacheInfoProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.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

433
lib/app/items.dart Normal file
View File

@@ -0,0 +1,433 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/music.dart';
import '../services/cache_service.dart';
import '../services/download_service.dart';
import '../state/audio.dart';
import '../state/music.dart';
import '../state/theme.dart';
import 'context_menus.dart';
import 'images.dart';
import 'pages/songs_page.dart';
enum CardStyle {
imageOnly,
withText,
}
enum AlbumSubtitle {
artist,
year,
}
class AlbumCard extends HookConsumerWidget {
final Album album;
final void Function()? onTap;
final CardStyle style;
final AlbumSubtitle subtitle;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.style = CardStyle.withText,
this.subtitle = AlbumSubtitle.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(albumArtPaletteProvider(album.id));
final cache = ref.watch(cacheServiceProvider);
final info = cache.albumArt(album);
final image = CardClip(child: UriCacheInfoImage(cache: info));
Widget content;
if (style == CardStyle.imageOnly) {
content = image;
} else {
content = Column(
children: [
image,
_AlbumCardText(album: album, subtitle: subtitle),
],
);
}
return ImageCard(
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: AlbumContextMenu(album: album),
),
);
},
child: content,
);
}
}
class ImageCard extends StatelessWidget {
final Widget child;
final void Function()? onTap;
final void Function()? onLongPress;
const ImageCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return Card(
surfaceTintColor: Colors.transparent,
margin: const EdgeInsets.all(0),
child: Stack(
fit: StackFit.passthrough,
alignment: Alignment.bottomCenter,
children: [
child,
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
),
],
),
);
}
}
class _AlbumCardText extends StatelessWidget {
final Album album;
final AlbumSubtitle subtitle;
const _AlbumCardText({
required this.album,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.only(top: 4, bottom: 8),
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: Text(
album.name,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
(subtitle == AlbumSubtitle.artist
? album.albumArtist
: album.year?.toString()) ??
'',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: textTheme.bodySmall,
),
],
),
);
}
}
class AlbumListTile extends HookConsumerWidget {
final Album album;
final void Function()? onTap;
const AlbumListTile({
super.key,
required this.album,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final artist = ref.watch(albumProvider(album.artistId!)).valueOrNull;
return ListTile(
leading: AlbumArt(album: album),
title: Text(album.name),
subtitle: Text(album.albumArtist ?? artist!.name),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: AlbumContextMenu(album: album),
),
);
},
);
}
}
class ArtistListTile extends HookConsumerWidget {
final Artist artist;
final void Function()? onTap;
const ArtistListTile({
super.key,
required this.artist,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: CircleClip(
child: ArtistArtImage(artistId: artist.id),
),
title: Text(artist.name),
subtitle: Text(AppLocalizations.of(context).resourcesAlbumCount(
artist.albumCount,
)),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: ArtistContextMenu(artist: artist),
),
);
},
);
}
}
class PlaylistListTile extends HookConsumerWidget {
final Playlist playlist;
final void Function()? onTap;
const PlaylistListTile({
super.key,
required this.playlist,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(playlistArtPaletteProvider(playlist.id));
final cache = ref.watch(cacheServiceProvider).playlistArt(playlist);
return ListTile(
leading: CardClip(
child: UriCacheInfoImage(cache: cache),
),
title: Text(playlist.name),
subtitle: Text(AppLocalizations.of(context).resourcesSongCount(
playlist.songCount,
)),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: PlaylistContextMenu(playlist: playlist),
),
);
},
);
}
}
class SongListTile extends HookConsumerWidget {
final Song song;
final void Function()? onTap;
final bool image;
const SongListTile({
super.key,
required this.song,
this.onTap,
this.image = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Material(
type: MaterialType.transparency,
child: ListTile(
title: _SongTitle(song: song),
subtitle: _SongSubtitle(song: song),
leading: image ? SongAlbumArt(song: song) : null,
trailing: IconButton(
icon: const Icon(
Icons.star_outline_rounded,
size: 36,
),
onPressed: () {},
),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: SongContextMenu(song: song),
),
);
},
),
);
}
}
class _SongSubtitle extends HookConsumerWidget {
final Song song;
const _SongSubtitle({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadTaskId = ref.watch(songProvider(song.id).select(
(value) => value.valueOrNull?.downloadTaskId,
));
final downloadFilePath = ref.watch(songProvider(song.id).select(
(value) => value.valueOrNull?.downloadFilePath,
));
final download = ref.watch(downloadServiceProvider.select(
(value) => value.downloads.firstWhereOrNull(
(e) => e.taskId == downloadTaskId,
),
));
final inheritedStyle = DefaultTextStyle.of(context).style;
Widget? downloadIndicator;
if (downloadFilePath != null) {
downloadIndicator = const Padding(
padding: EdgeInsetsDirectional.only(end: 3),
child: Icon(
Icons.download_done_rounded,
size: 20,
),
);
} else if (downloadTaskId != null || download != null) {
downloadIndicator = Padding(
padding: const EdgeInsetsDirectional.only(start: 4, end: 9),
child: SizedBox(
height: 10,
width: 10,
child: CircularProgressIndicator(
strokeWidth: 2,
value: download != null && download.progress > 0
? download.progress / 100
: null,
),
),
);
}
return Row(
children: [
if (downloadIndicator != null) downloadIndicator,
Expanded(
child: Text(
song.artist ?? song.album ?? '',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: TextStyle(
color: inheritedStyle.color,
),
),
),
],
);
}
}
class _SongTitle extends HookConsumerWidget {
final Song song;
const _SongTitle({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaItem = ref.watch(mediaItemProvider).valueOrNull;
final mediaItemData = ref.watch(mediaItemDataProvider);
final inheritedStyle = DefaultTextStyle.of(context).style;
final theme = Theme.of(context);
final queueContext = QueueContext.maybeOf(context);
final playing = mediaItem != null &&
mediaItemData != null &&
mediaItem.id == song.id &&
mediaItemData.contextId == queueContext?.id &&
mediaItemData.contextType == queueContext?.type;
return Row(
children: [
if (playing)
Padding(
padding: const EdgeInsetsDirectional.only(end: 2),
child: Icon(
Icons.play_arrow_rounded,
size: 18,
color: theme.colorScheme.primary,
),
),
Expanded(
child: Text(
song.title,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: TextStyle(
color: playing ? theme.colorScheme.primary : inheritedStyle.color,
),
),
),
],
);
}
}
class FabPadding extends StatelessWidget {
const FabPadding({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(height: 86);
}
}

129
lib/app/lists.dart Normal file
View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../services/sync_service.dart';
import 'items.dart';
class PagedListQueryView<T> extends HookConsumerWidget {
final PagingController<int, T> pagingController;
final bool refreshSyncAll;
final bool fabPadding;
final bool useSliver;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
const PagedListQueryView({
super.key,
required this.pagingController,
this.refreshSyncAll = false,
this.fabPadding = true,
this.useSliver = false,
required this.itemBuilder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final builderDelegate = PagedChildBuilderDelegate<T>(
itemBuilder: (context, item, index) => itemBuilder(context, item, index),
noMoreItemsIndicatorBuilder:
fabPadding ? (context) => const FabPadding() : null,
);
final listView = useSliver
? PagedSliverList<int, T>(
pagingController: pagingController,
builderDelegate: builderDelegate,
)
: PagedListView<int, T>(
pagingController: pagingController,
builderDelegate: builderDelegate,
);
if (refreshSyncAll) {
return SyncAllRefresh(child: listView);
} else {
return listView;
}
}
}
enum GridSize {
small,
large,
}
class PagedGridQueryView<T> extends HookConsumerWidget {
final PagingController<int, T> pagingController;
final bool refreshSyncAll;
final bool fabPadding;
final GridSize size;
final Widget Function(BuildContext context, T item, int index, GridSize size)
itemBuilder;
const PagedGridQueryView({
super.key,
required this.pagingController,
this.refreshSyncAll = false,
this.fabPadding = true,
this.size = GridSize.small,
required this.itemBuilder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
SliverGridDelegate gridDelegate;
double spacing;
if (size == GridSize.small) {
spacing = 4;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
);
} else {
spacing = 12;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
);
}
final listView = PagedGridView<int, T>(
padding: MediaQuery.of(context).padding + EdgeInsets.all(spacing),
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) =>
itemBuilder(context, item, index, size),
noMoreItemsIndicatorBuilder:
fabPadding ? (context) => const FabPadding() : null,
),
gridDelegate: gridDelegate,
showNoMoreItemsIndicatorAsGridChild: false,
);
if (refreshSyncAll) {
return SyncAllRefresh(child: listView);
} else {
return listView;
}
}
}
class SyncAllRefresh extends HookConsumerWidget {
final Widget child;
const SyncAllRefresh({
super.key,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return RefreshIndicator(
onRefresh: () => ref.read(syncServiceProvider.notifier).syncAll(),
child: child,
);
}
}

View File

@@ -0,0 +1,226 @@
import 'package:audio_service/audio_service.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../cache/image_cache.dart';
import '../models/support.dart';
import '../services/audio_service.dart';
import '../state/audio.dart';
import '../state/theme.dart';
import 'app_router.dart';
import 'images.dart';
import 'pages/now_playing_page.dart';
class NowPlayingBar extends HookConsumerWidget {
const NowPlayingBar({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final noItem = ref.watch(mediaItemProvider).valueOrNull == null;
final widget = GestureDetector(
onTap: () {
context.navigateTo(const NowPlayingRoute());
},
child: Material(
elevation: 3,
color: colors?.darkBackground,
// surfaceTintColor: theme?.colorScheme.background,
child: Column(
children: [
SizedBox(
height: 70,
child: Row(
mainAxisSize: MainAxisSize.max,
children: const [
Padding(
padding: EdgeInsets.all(10),
child: _ArtImage(),
),
Expanded(
child: Padding(
padding: EdgeInsets.only(right: 4),
child: _TrackInfo(),
),
),
Padding(
padding: EdgeInsets.only(right: 16, top: 2),
child: PlayPauseButton(size: 48),
),
],
),
),
const _ProgressBar(),
],
),
),
);
if (noItem) {
return Container();
}
if (colors != null) {
return Theme(data: colors.theme, child: widget);
} else {
return widget;
}
}
}
class _ArtImage extends HookConsumerWidget {
const _ArtImage();
@override
Widget build(BuildContext context, WidgetRef ref) {
final imageCache = ref.watch(imageCacheProvider);
final uri =
ref.watch(mediaItemProvider.select((e) => e.valueOrNull?.artUri));
final cacheKey = ref.watch(mediaItemDataProvider.select(
(value) => value?.artCache?.thumbnailArtCacheKey,
));
UriCacheInfo? cache;
if (uri != null && cacheKey != null) {
cache = UriCacheInfo(
uri: uri,
cacheKey: cacheKey,
cacheManager: imageCache,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: CardClip(
key: ValueKey(cacheKey ?? 'default'),
child: cache == null
? const PlaceholderImage()
: UriCacheInfoImage(
cache: cache,
),
),
);
}
}
class _TrackInfo extends HookConsumerWidget {
const _TrackInfo();
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(mediaItemProvider);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.when(
data: (data) => [
// Text(
// data?.title ?? 'Nothing!!!',
// maxLines: 1,
// softWrap: false,
// overflow: TextOverflow.fade,
// style: Theme.of(context).textTheme.labelLarge,
// ),
ScrollableText(
data?.title ?? 'Nothing!!!',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 2),
Text(
data?.artist ?? 'Nothing!!!',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.labelMedium,
),
],
error: (_, __) => const [Text('Error!')],
loading: () => const [Text('loading.....')],
),
],
);
}
}
class PlayPauseButton extends HookConsumerWidget {
final double size;
const PlayPauseButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playing = ref.watch(playingProvider);
final state = ref.watch(processingStateProvider);
Widget icon;
if (state == AudioProcessingState.loading ||
state == AudioProcessingState.buffering) {
icon = Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.circle),
SizedBox(
height: size / 3,
width: size / 3,
child: CircularProgressIndicator(
strokeWidth: size / 16,
color: Theme.of(context).colorScheme.background,
),
),
],
);
} else if (playing) {
icon = const Icon(Icons.pause_circle_rounded);
} else {
icon = const Icon(Icons.play_circle_rounded);
}
return IconButton(
iconSize: size,
padding: EdgeInsets.zero,
onPressed: () {
if (playing) {
ref.read(audioControlProvider).pause();
} else {
ref.read(audioControlProvider).play();
}
},
icon: icon,
color: Theme.of(context).colorScheme.onBackground,
);
}
}
class _ProgressBar extends HookConsumerWidget {
const _ProgressBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider);
return Container(
height: 4,
color: colors?.darkerBackground,
child: Row(
children: [
Flexible(
flex: position,
child: Container(color: colors?.onDarkerBackground),
),
Flexible(flex: duration - position, child: Container()),
],
),
);
}
}

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

View 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',
),
],
),
],
);
}
}

View 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

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

View 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

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

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

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

View 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

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

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

View 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

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

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

View 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

View 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');
},
),
],
),
],
);
}
}

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

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

30
lib/cache/image_cache.dart vendored Normal file
View File

@@ -0,0 +1,30 @@
// ignore_for_file: implementation_imports
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_cache_manager/src/storage/file_system/file_system_io.dart';
import 'package:http/http.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../http/client.dart';
part 'image_cache.g.dart';
CacheManager _openImageCache(BaseClient httpClient) {
const key = 'images';
return CacheManager(
Config(
key,
stalePeriod: const Duration(days: 2147483647),
maxNrOfCacheObjects: 2147483647,
repo: JsonCacheInfoRepository(databaseName: key),
fileSystem: IOFileSystem(key),
fileService: HttpFileService(httpClient: httpClient),
),
);
}
@Riverpod(keepAlive: true)
CacheManager imageCache(ImageCacheRef ref) {
final http = ref.watch(httpClientProvider);
return _openImageCache(http);
}

23
lib/cache/image_cache.g.dart vendored Normal file
View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'image_cache.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$imageCacheHash() => r'aaeb74898734c2776f594e05eb82262af20e079f';
/// See also [imageCache].
@ProviderFor(imageCache)
final imageCacheProvider = Provider<CacheManager>.internal(
imageCache,
name: r'imageCacheProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$imageCacheHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ImageCacheRef = ProviderRef<CacheManager>;
// 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

View File

@@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import '../models/query.dart';
import '../models/settings.dart';
class DurationSecondsConverter extends TypeConverter<Duration, int> {
const DurationSecondsConverter();
@override
Duration fromSql(int fromDb) => Duration(seconds: fromDb);
@override
int toSql(Duration value) => value.inSeconds;
}
class UriConverter extends TypeConverter<Uri, String> {
const UriConverter();
@override
Uri fromSql(String fromDb) => Uri.parse(fromDb);
@override
String toSql(Uri value) => value.toString();
}
class ListQueryConverter extends TypeConverter<ListQuery, String> {
const ListQueryConverter();
@override
ListQuery fromSql(String fromDb) => ListQuery.fromJson(jsonDecode(fromDb));
@override
String toSql(ListQuery value) => jsonEncode(value.toJson());
}
class SubsonicFeatureListConverter
extends TypeConverter<IList<SubsonicFeature>, String> {
const SubsonicFeatureListConverter();
@override
IList<SubsonicFeature> fromSql(String fromDb) {
return IList<SubsonicFeature>.fromJson(
jsonDecode(fromDb),
(item) => SubsonicFeature.values.byName(item as String),
);
}
@override
String toSql(IList<SubsonicFeature> value) {
return jsonEncode(value.toJson((e) => e.toString()));
}
}
class IListIntConverter extends TypeConverter<IList<int>, String> {
const IListIntConverter();
@override
IList<int> fromSql(String fromDb) {
return IList<int>.fromJson(
jsonDecode(fromDb),
(item) => int.parse(item as String),
);
}
@override
String toSql(IList<int> value) {
return jsonEncode(value.toJson((e) => jsonEncode(e)));
}
}

627
lib/database/database.dart Normal file
View File

@@ -0,0 +1,627 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:drift/native.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/music.dart';
import '../models/query.dart';
import '../models/settings.dart';
import '../models/support.dart';
import 'converters.dart';
part 'database.g.dart';
@DriftDatabase(include: {'tables.drift'})
class SubtracksDatabase extends _$SubtracksDatabase {
SubtracksDatabase() : super(_openConnection());
SubtracksDatabase.connection(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration {
return MigrationStrategy(
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
/// Runs a database opertion in a background isolate.
///
/// **Only pass top-level functions to [computation]!**
///
/// **Do not use non-serializable data inside [computation]!**
Future<Ret> background<Ret>(
FutureOr<Ret> Function(SubtracksDatabase) computation,
) async {
return computeWithDatabase(
connect: SubtracksDatabase.connection,
computation: computation,
);
}
MultiSelectable<Album> albumsList(int sourceId, ListQuery opt) {
return filterAlbums(
(_) => _filterPredicate('albums', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Album> albumsListDownloaded(int sourceId, ListQuery opt) {
return filterAlbumsDownloaded(
(_, __) => _filterPredicate('albums', sourceId, opt),
(_, __) => _filterOrderBy(opt),
(_, __) => _filterLimit(opt),
);
}
MultiSelectable<Artist> artistsList(int sourceId, ListQuery opt) {
return filterArtists(
(_) => _filterPredicate('artists', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Artist> artistsListDownloaded(int sourceId, ListQuery opt) {
return filterArtistsDownloaded(
(_, __, ___) => _filterPredicate('artists', sourceId, opt),
(_, __, ___) => _filterOrderBy(opt),
(_, __, ___) => _filterLimit(opt),
);
}
MultiSelectable<Playlist> playlistsList(int sourceId, ListQuery opt) {
return filterPlaylists(
(_) => _filterPredicate('playlists', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Playlist> playlistsListDownloaded(
int sourceId, ListQuery opt) {
return filterPlaylistsDownloaded(
(_, __, ___) => _filterPredicate('playlists', sourceId, opt),
(_, __, ___) => _filterOrderBy(opt),
(_, __, ___) => _filterLimit(opt),
);
}
MultiSelectable<Song> songsList(int sourceId, ListQuery opt) {
return filterSongs(
(_) => _filterPredicate('songs', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Song> songsListDownloaded(int sourceId, ListQuery opt) {
return filterSongsDownloaded(
(_) => _filterPredicate('songs', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
Expression<bool> _filterPredicate(String table, int sourceId, ListQuery opt) {
return opt.filters.map((filter) => buildFilter<bool>(filter)).fold(
CustomExpression('$table.source_id = $sourceId'),
(previousValue, element) => previousValue & element,
);
}
OrderBy _filterOrderBy(ListQuery opt) {
return opt.sort != null
? OrderBy([_buildOrder(opt.sort!)])
: const OrderBy.nothing();
}
Limit _filterLimit(ListQuery opt) {
return Limit(opt.page.limit, opt.page.offset);
}
MultiSelectable<Song> albumSongsList(SourceId sid, ListQuery opt) {
return listQuery(
select(songs)
..where((tbl) =>
tbl.sourceId.equals(sid.sourceId) & tbl.albumId.equals(sid.id)),
opt,
);
}
MultiSelectable<Song> songsByAlbumList(int sourceId, ListQuery opt) {
return filterSongsByGenre(
(_, __) => _filterPredicate('songs', sourceId, opt),
(_, __) => _filterOrderBy(opt),
(_, __) => _filterLimit(opt),
);
}
MultiSelectable<Song> playlistSongsList(SourceId sid, ListQuery opt) {
return listQueryJoined(
select(songs).join([
innerJoin(
playlistSongs,
playlistSongs.sourceId.equalsExp(songs.sourceId) &
playlistSongs.songId.equalsExp(songs.id),
useColumns: false,
),
])
..where(playlistSongs.sourceId.equals(sid.sourceId) &
playlistSongs.playlistId.equals(sid.id)),
opt,
).map((row) => row.readTable(songs));
}
Future<void> saveArtists(Iterable<ArtistsCompanion> artists) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.artists, artists);
});
}
Future<void> deleteArtistsNotIn(int sourceId, Iterable<String> ids) async {
await (delete(artists)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(ids),
))
.go();
}
Future<void> saveAlbums(Iterable<AlbumsCompanion> albums) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.albums, albums);
});
}
Future<void> deleteAlbumsNotIn(int sourceId, Iterable<String> ids) async {
final alsoKeep = (await albumIdsWithDownloaded(sourceId).get()).toSet();
ids = ids.toList()..addAll(alsoKeep);
await (delete(albums)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(ids),
))
.go();
}
Future<void> savePlaylists(
Iterable<PlaylistWithSongsCompanion> playlistsWithSongs,
) async {
final playlists = playlistsWithSongs.map((e) => e.playist);
final playlistSongs = playlistsWithSongs.expand((e) => e.songs);
final sourceId = playlists.first.sourceId.value;
await (delete(this.playlistSongs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) &
tbl.playlistId.isIn(playlists.map((e) => e.id.value)),
))
.go();
await batch((batch) {
batch.insertAllOnConflictUpdate(this.playlists, playlists);
batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs);
});
}
Future<void> deletePlaylistsNotIn(int sourceId, Iterable<String> ids) async {
await (delete(playlists)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(ids),
))
.go();
await (delete(playlistSongs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) & tbl.playlistId.isNotIn(ids),
))
.go();
}
Future<void> savePlaylistSongs(
int sourceId,
List<String> ids,
Iterable<PlaylistSongsCompanion> playlistSongs,
) async {
await (delete(this.playlistSongs)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(ids),
))
.go();
await batch((batch) {
batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs);
});
}
Future<void> saveSongs(Iterable<SongsCompanion> songs) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.songs, songs);
});
}
Future<void> deleteSongsNotIn(int sourceId, Iterable<String> ids) async {
await (delete(songs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) &
tbl.id.isNotIn(ids) &
tbl.downloadFilePath.isNull() &
tbl.downloadTaskId.isNull(),
))
.go();
final remainingIds = (await (selectOnly(songs)
..addColumns([songs.id])
..where(songs.sourceId.equals(sourceId)))
.map((row) => row.read(songs.id))
.get())
.whereNotNull();
await (delete(playlistSongs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) &
tbl.songId.isNotIn(remainingIds),
))
.go();
}
Selectable<LastBottomNavStateData> getLastBottomNavState() {
return select(lastBottomNavState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastBottomNavState(LastBottomNavStateData update) {
return into(lastBottomNavState).insertOnConflictUpdate(update);
}
Selectable<LastLibraryStateData> getLastLibraryState() {
return select(lastLibraryState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastLibraryState(LastLibraryStateData update) {
return into(lastLibraryState).insertOnConflictUpdate(update);
}
Selectable<LastAudioStateData> getLastAudioState() {
return select(lastAudioState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastAudioState(LastAudioStateCompanion update) {
return into(lastAudioState).insertOnConflictUpdate(update);
}
Future<void> insertQueue(Iterable<QueueCompanion> songs) async {
await batch((batch) {
batch.insertAll(queue, songs);
});
}
Future<void> clearQueue() async {
await delete(queue).go();
}
Future<void> setCurrentTrack(int index) async {
await transaction(() async {
await (update(queue)..where((tbl) => tbl.index.equals(index).not()))
.write(const QueueCompanion(currentTrack: Value(null)));
await (update(queue)..where((tbl) => tbl.index.equals(index)))
.write(const QueueCompanion(currentTrack: Value(true)));
});
}
Future<void> createSource(
SourcesCompanion source,
SubsonicSourcesCompanion subsonic,
) async {
await transaction(() async {
final count = await sourcesCount().getSingle();
if (count == 0) {
source = source.copyWith(isActive: const Value(true));
}
final id = await into(sources).insert(source);
subsonic = subsonic.copyWith(sourceId: Value(id));
await into(subsonicSources).insert(subsonic);
});
}
Future<void> updateSource(SubsonicSettings source) async {
await transaction(() async {
await into(sources).insertOnConflictUpdate(source.toSourceInsertable());
await into(subsonicSources)
.insertOnConflictUpdate(source.toSubsonicInsertable());
});
}
Future<void> deleteSource(int sourceId) async {
await transaction(() async {
await (delete(subsonicSources)
..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(sources)..where((tbl) => tbl.id.equals(sourceId))).go();
await (delete(songs)..where((tbl) => tbl.sourceId.equals(sourceId))).go();
await (delete(albums)..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(artists)..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(playlistSongs)
..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(playlists)..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
});
}
Future<void> setActiveSource(int id) async {
await batch((batch) {
batch.update(
sources,
const SourcesCompanion(isActive: Value(null)),
where: (t) => t.id.isNotValue(id),
);
batch.update(
sources,
const SourcesCompanion(isActive: Value(true)),
where: (t) => t.id.equals(id),
);
});
}
Future<void> updateSettings(AppSettingsCompanion settings) async {
await into(appSettings).insertOnConflictUpdate(settings);
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'subtracks.sqlite'));
// return NativeDatabase.createInBackground(file, logStatements: true);
return NativeDatabase.createInBackground(file);
});
}
@Riverpod(keepAlive: true)
SubtracksDatabase database(DatabaseRef ref) {
return SubtracksDatabase();
}
OrderingTerm _buildOrder(SortBy sort) {
OrderingMode? mode =
sort.dir == SortDirection.asc ? OrderingMode.asc : OrderingMode.desc;
return OrderingTerm(
expression: CustomExpression(sort.column),
mode: mode,
);
}
SimpleSelectStatement<T, R> listQuery<T extends HasResultSet, R>(
SimpleSelectStatement<T, R> query,
ListQuery opt,
) {
if (opt.page.limit > 0) {
query.limit(opt.page.limit, offset: opt.page.offset);
}
if (opt.sort != null) {
OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc
? OrderingMode.asc
: OrderingMode.desc;
query.orderBy([
(t) => OrderingTerm(
expression: CustomExpression(opt.sort!.column),
mode: mode,
)
]);
}
for (var filter in opt.filters) {
query.where((tbl) => buildFilter(filter));
}
return query;
}
JoinedSelectStatement<T, R> listQueryJoined<T extends HasResultSet, R>(
JoinedSelectStatement<T, R> query,
ListQuery opt,
) {
if (opt.page.limit > 0) {
query.limit(opt.page.limit, offset: opt.page.offset);
}
if (opt.sort != null) {
OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc
? OrderingMode.asc
: OrderingMode.desc;
query.orderBy([
OrderingTerm(
expression: CustomExpression(opt.sort!.column),
mode: mode,
)
]);
}
for (var filter in opt.filters) {
query.where(buildFilter(filter));
}
return query;
}
CustomExpression<T> buildFilter<T extends Object>(
FilterWith filter,
) {
return filter.when(
equals: (column, value, invert) => CustomExpression<T>(
'$column ${invert ? '<>' : '='} \'$value\'',
),
greaterThan: (column, value, orEquals) => CustomExpression<T>(
'$column ${orEquals ? '>=' : '>'} $value',
),
isNull: (column, invert) => CustomExpression<T>(
'$column ${invert ? 'IS NOT' : 'IS'} NULL',
),
betweenInt: (column, from, to) => CustomExpression<T>(
'$column BETWEEN $from AND $to',
),
isIn: (column, invert, values) => CustomExpression<T>(
'$column ${invert ? 'NOT IN' : 'IN'} (${values.join(',')})',
),
);
}
class AlbumSongsCompanion {
final AlbumsCompanion album;
final Iterable<SongsCompanion> songs;
AlbumSongsCompanion(this.album, this.songs);
}
class ArtistAlbumsCompanion {
final ArtistsCompanion artist;
final Iterable<AlbumsCompanion> albums;
ArtistAlbumsCompanion(this.artist, this.albums);
}
class PlaylistWithSongsCompanion {
final PlaylistsCompanion playist;
final Iterable<PlaylistSongsCompanion> songs;
PlaylistWithSongsCompanion(this.playist, this.songs);
}
// Future<void> saveArtist(
// SubtracksDatabase db,
// ArtistAlbumsCompanion artistAlbums,
// ) async {
// return db.background((db) async {
// final artist = artistAlbums.artist;
// final albums = artistAlbums.albums;
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.artists, [artist]);
// batch.insertAllOnConflictUpdate(db.albums, albums);
// // remove this artistId from albums not found in source
// // don't delete them since they coud have been moved to another artist
// // that we haven't synced yet
// final albumIds = {for (var a in albums) a.id.value};
// batch.update(
// db.albums,
// const AlbumsCompanion(artistId: Value(null)),
// where: (tbl) =>
// tbl.sourceId.equals(artist.sourceId.value) &
// tbl.artistId.equals(artist.id.value) &
// tbl.id.isNotIn(albumIds),
// );
// });
// });
// }
// Future<void> saveAlbum(
// SubtracksDatabase db,
// AlbumSongsCompanion albumSongs,
// ) async {
// return db.background((db) async {
// final album = albumSongs.album.copyWith(synced: Value(DateTime.now()));
// final songs = albumSongs.songs;
// final songIds = {for (var a in songs) a.id.value};
// final hardDeletedSongIds = (await (db.selectOnly(db.songs)
// ..addColumns([db.songs.id])
// ..where(
// db.songs.sourceId.equals(album.sourceId.value) &
// db.songs.albumId.equals(album.id.value) &
// db.songs.id.isNotIn(songIds) &
// db.songs.downloadFilePath.isNull() &
// db.songs.downloadTaskId.isNull(),
// ))
// .map((row) => row.read(db.songs.id))
// .get())
// .whereNotNull();
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.albums, [album]);
// batch.insertAllOnConflictUpdate(db.songs, songs);
// // soft delete songs that have been downloaded so that the user
// // can decide to keep or remove them later
// // TODO: add a setting to skip soft delete and just remove download too
// batch.update(
// db.songs,
// const SongsCompanion(isDeleted: Value(true)),
// where: (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.albumId.equals(album.id.value) &
// tbl.id.isNotIn(songIds) &
// (tbl.downloadFilePath.isNotNull() | tbl.downloadTaskId.isNotNull()),
// );
// // safe to hard delete songs that have not been downloaded
// batch.deleteWhere(
// db.songs,
// (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.id.isIn(hardDeletedSongIds),
// );
// // also need to remove these songs from any playlists that contain them
// batch.deleteWhere(
// db.playlistSongs,
// (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.songId.isIn(hardDeletedSongIds),
// );
// });
// });
// }
// Future<void> savePlaylist(
// SubtracksDatabase db,
// PlaylistWithSongsCompanion playlistWithSongs,
// ) async {
// return db.background((db) async {
// final playlist =
// playlistWithSongs.playist.copyWith(synced: Value(DateTime.now()));
// final songs = playlistWithSongs.songs;
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.playlists, [playlist]);
// batch.insertAllOnConflictUpdate(db.songs, songs);
// batch.insertAllOnConflictUpdate(
// db.playlistSongs,
// songs.mapIndexed(
// (index, song) => PlaylistSongsCompanion.insert(
// sourceId: playlist.sourceId.value,
// playlistId: playlist.id.value,
// songId: song.id.value,
// position: index,
// ),
// ),
// );
// // the new playlist could be shorter than the old one, so we delete
// // playlist songs above our new playlist's length
// batch.deleteWhere(
// db.playlistSongs,
// (tbl) =>
// tbl.sourceId.equals(playlist.sourceId.value) &
// tbl.playlistId.equals(playlist.id.value) &
// tbl.position.isBiggerOrEqualValue(songs.length),
// );
// });
// });
// }

5547
lib/database/database.g.dart Normal file

File diff suppressed because it is too large Load Diff

547
lib/database/tables.drift Normal file
View File

@@ -0,0 +1,547 @@
import '../models/music.dart';
import '../models/settings.dart';
import '../models/support.dart';
import 'converters.dart';
--
-- SCHEMA
--
CREATE TABLE queue(
"index" INT NOT NULL PRIMARY KEY UNIQUE,
source_id INT NOT NULL,
id TEXT NOT NULL,
context ENUM(QueueContextType) NOT NULL,
context_id TEXT,
current_track BOOLEAN UNIQUE
);
CREATE INDEX queue_index ON queue ("index");
CREATE INDEX queue_current_track ON queue ("current_track");
CREATE TABLE last_audio_state(
id INT NOT NULL PRIMARY KEY,
queue_mode ENUM(QueueMode) NOT NULL,
shuffle_indicies TEXT MAPPED BY `const IListIntConverter()`,
repeat ENUM(RepeatMode) NOT NULL
);
CREATE TABLE last_bottom_nav_state(
id INT NOT NULL PRIMARY KEY,
tab TEXT NOT NULL
);
CREATE TABLE last_library_state(
id INT NOT NULL PRIMARY KEY,
tab TEXT NOT NULL,
albums_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
artists_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
playlists_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
songs_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`
);
CREATE TABLE app_settings(
id INT NOT NULL PRIMARY KEY,
max_bitrate_wifi INT NOT NULL,
max_bitrate_mobile INT NOT NULL,
stream_format TEXT
) WITH AppSettings;
CREATE TABLE sources(
id INT NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
address TEXT NOT NULL MAPPED BY `const UriConverter()`,
is_active BOOLEAN UNIQUE,
created_at DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP))
);
CREATE TABLE subsonic_sources(
source_id INT NOT NULL PRIMARY KEY,
features TEXT NOT NULL MAPPED BY `const SubsonicFeatureListConverter()`,
username TEXT NOT NULL,
password TEXT NOT NULL,
use_token_auth BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
);
CREATE TABLE artists(
source_id INT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL COLLATE NOCASE,
album_count INT NOT NULL,
starred DATETIME,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Artist;
CREATE INDEX artists_source_id ON artists (source_id);
CREATE VIRTUAL TABLE artists_fts USING fts5(source_id, name, content=artists, content_rowid=rowid);
CREATE TRIGGER artists_ai AFTER INSERT ON artists BEGIN
INSERT INTO artists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TRIGGER artists_ad AFTER DELETE ON artists BEGIN
INSERT INTO artists_fts(artists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
END;
CREATE TRIGGER artists_au AFTER UPDATE ON artists BEGIN
INSERT INTO artists_fts(artists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
INSERT INTO artists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TABLE albums(
source_id INT NOT NULL,
id TEXT NOT NULL,
artist_id TEXT,
name TEXT NOT NULL COLLATE NOCASE,
album_artist TEXT COLLATE NOCASE,
created DATETIME NOT NULL,
cover_art TEXT,
genre TEXT,
year INT,
starred DATETIME,
song_count INT NOT NULL,
frequent_rank INT,
recent_rank INT,
is_deleted BOOLEAN NOT NULL DEFAULT 0,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Album;
CREATE INDEX albums_source_id ON albums (source_id);
CREATE INDEX albums_source_id_artist_id_idx ON albums (source_id, artist_id);
CREATE VIRTUAL TABLE albums_fts USING fts5(source_id, name, content=albums, content_rowid=rowid);
CREATE TRIGGER albums_ai AFTER INSERT ON albums BEGIN
INSERT INTO albums_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TRIGGER albums_ad AFTER DELETE ON albums BEGIN
INSERT INTO albums_fts(albums_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
END;
CREATE TRIGGER albums_au AFTER UPDATE ON albums BEGIN
INSERT INTO albums_fts(albums_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
INSERT INTO albums_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TABLE playlists(
source_id INT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL COLLATE NOCASE,
comment TEXT COLLATE NOCASE,
cover_art TEXT,
song_count INT NOT NULL,
created DATETIME NOT NULL,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Playlist;
CREATE INDEX playlists_source_id ON playlists (source_id);
CREATE TABLE playlist_songs(
source_id INT NOT NULL,
playlist_id TEXT NOT NULL,
song_id TEXT NOT NULL,
position INT NOT NULL,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, playlist_id, position),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
);
CREATE INDEX playlist_songs_source_id_playlist_id_idx ON playlist_songs (source_id, playlist_id);
CREATE INDEX playlist_songs_source_id_song_id_idx ON playlist_songs (source_id, song_id);
CREATE VIRTUAL TABLE playlists_fts USING fts5(source_id, name, content=playlists, content_rowid=rowid);
CREATE TRIGGER playlists_ai AFTER INSERT ON playlists BEGIN
INSERT INTO playlists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TRIGGER playlists_ad AFTER DELETE ON playlists BEGIN
INSERT INTO playlists_fts(playlists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
END;
CREATE TRIGGER playlists_au AFTER UPDATE ON playlists BEGIN
INSERT INTO playlists_fts(playlists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
INSERT INTO playlists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TABLE songs(
source_id INT NOT NULL,
id TEXT NOT NULL,
album_id TEXT,
artist_id TEXT,
title TEXT NOT NULL COLLATE NOCASE,
album TEXT COLLATE NOCASE,
artist TEXT COLLATE NOCASE,
duration INT MAPPED BY `const DurationSecondsConverter()`,
track INT,
disc INT,
starred DATETIME,
genre TEXT,
download_task_id TEXT UNIQUE,
download_file_path TEXT UNIQUE,
is_deleted BOOLEAN NOT NULL DEFAULT 0,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Song;
CREATE INDEX songs_source_id_album_id_idx ON songs (source_id, album_id);
CREATE INDEX songs_source_id_artist_id_idx ON songs (source_id, artist_id);
CREATE INDEX songs_download_task_id_idx ON songs (download_task_id);
CREATE VIRTUAL TABLE songs_fts USING fts5(source_id, title, content=songs, content_rowid=rowid);
CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
INSERT INTO songs_fts(rowid, source_id, title)
VALUES (new.rowid, new.source_id, new.title);
END;
CREATE TRIGGER songs_ad AFTER DELETE ON songs BEGIN
INSERT INTO songs_fts(songs_fts, rowid, source_id, title)
VALUES('delete', old.rowid, old.source_id, old.title);
END;
CREATE TRIGGER songs_au AFTER UPDATE ON songs BEGIN
INSERT INTO songs_fts(songs_fts, rowid, source_id, title)
VALUES('delete', old.rowid, old.source_id, old.title);
INSERT INTO songs_fts(rowid, source_id, title)
VALUES (new.rowid, new.source_id, new.title);
END;
--
-- QUERIES
--
sourcesCount:
SELECT COUNT(*)
FROM sources;
allSubsonicSources WITH SubsonicSettings:
SELECT
sources.id,
sources.name,
sources.address,
sources.is_active,
sources.created_at,
subsonic_sources.features,
subsonic_sources.username,
subsonic_sources.password,
subsonic_sources.use_token_auth
FROM sources
JOIN subsonic_sources ON subsonic_sources.source_id = sources.id;
albumIdsWithDownloaded:
SELECT albums.id
FROM albums
JOIN songs on songs.source_id = albums.source_id AND songs.album_id = albums.id
WHERE
albums.source_id = :source_id
AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
GROUP BY albums.id;
searchArtists:
SELECT rowid
FROM artists_fts
WHERE artists_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
searchAlbums:
SELECT rowid
FROM albums_fts
WHERE albums_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
searchPlaylists:
SELECT rowid
FROM playlists_fts
WHERE playlists_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
searchSongs:
SELECT rowid
FROM songs_fts
WHERE songs_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
artistById:
SELECT * FROM artists
WHERE source_id = :source_id AND id = :id;
albumById:
SELECT * FROM albums
WHERE source_id = :source_id AND id = :id;
albumsByArtistId:
SELECT * FROM albums
WHERE source_id = :source_id AND artist_id = :artist_id;
albumsInIds:
SELECT * FROM albums
WHERE source_id = :source_id AND id IN :ids;
playlistById:
SELECT * FROM playlists
WHERE source_id = :source_id AND id = :id;
songById:
SELECT * FROM songs
WHERE source_id = :source_id AND id = :id;
albumGenres:
SELECT
genre
FROM albums
WHERE genre IS NOT NULL AND source_id = :source_id
GROUP BY genre
ORDER BY COUNT(genre) DESC
LIMIT :limit OFFSET :offset;
albumsByGenre:
SELECT
albums.*
FROM albums
JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE songs.source_id = :source_id AND songs.genre = :genre
GROUP BY albums.id
ORDER BY albums.created DESC, albums.name
LIMIT :limit OFFSET :offset;
filterSongsByGenre:
SELECT
songs.*
FROM songs
JOIN albums ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE $predicate
ORDER BY $order
LIMIT $limit;
songsByGenreCount:
SELECT
COUNT(*)
FROM songs
WHERE songs.source_id = :source_id AND songs.genre = :genre;
songsWithDownloadTasks:
SELECT * FROM songs
WHERE download_task_id IS NOT NULL;
songByDownloadTask:
SELECT * FROM songs
WHERE download_task_id = :task_id;
clearSongDownloadTaskBySong:
UPDATE songs SET
download_task_id = NULL
WHERE source_id = :source_id AND id = :id;
completeSongDownload:
UPDATE songs SET
download_task_id = NULL,
download_file_path = :file_path
WHERE download_task_id = :task_id;
clearSongDownloadTask:
UPDATE songs SET
download_task_id = NULL,
download_file_path = NULL
WHERE download_task_id = :task_id;
updateSongDownloadTask:
UPDATE songs SET
download_task_id = :task_id
WHERE source_id = :source_id AND id = :id;
deleteSongDownloadFile:
UPDATE songs SET
download_task_id = NULL,
download_file_path = NULL
WHERE source_id = :source_id AND id = :id;
albumDownloadStatus WITH ListDownloadStatus:
SELECT
COUNT(*) as total,
COUNT(CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS downloaded,
COUNT(CASE WHEN songs.download_task_id IS NOT NULL THEN songs.id ELSE NULL END) AS downloading
FROM albums
JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE albums.source_id = :source_id AND albums.id = :id;
playlistDownloadStatus WITH ListDownloadStatus:
SELECT
COUNT(DISTINCT songs.id) as total,
COUNT(DISTINCT CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS downloaded,
COUNT(DISTINCT CASE WHEN songs.download_task_id IS NOT NULL THEN songs.id ELSE NULL END) AS downloading
FROM playlists
JOIN playlist_songs ON
playlist_songs.source_id = playlists.source_id
AND playlist_songs.playlist_id = playlists.id
JOIN songs ON
songs.source_id = playlist_songs.source_id
AND songs.id = playlist_songs.song_id
WHERE
playlists.source_id = :source_id AND playlists.id = :id;
filterAlbums:
SELECT
albums.*
FROM albums
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterAlbumsDownloaded:
SELECT
albums.*
FROM albums
LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE $predicate
GROUP BY albums.source_id, albums.id
HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
ORDER BY $order
LIMIT $limit;
filterArtists:
SELECT
artists.*
FROM artists
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterArtistsDownloaded WITH Artist:
SELECT
artists.*,
COUNT(DISTINCT CASE WHEN songs.download_file_path IS NOT NULL THEN songs.album_id ELSE NULL END) AS album_count
FROM artists
LEFT JOIN albums ON artists.source_id = albums.source_id AND artists.id = albums.artist_id
LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE $predicate
GROUP BY artists.source_id, artists.id
HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
ORDER BY $order
LIMIT $limit;
filterPlaylists:
SELECT
playlists.*
FROM playlists
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterPlaylistsDownloaded WITH Playlist:
SELECT
playlists.*,
COUNT(CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS song_count
FROM playlists
LEFT JOIN playlist_songs ON playlist_songs.source_id = playlists.source_id AND playlist_songs.playlist_id = playlists.id
LEFT JOIN songs ON playlist_songs.source_id = songs.source_id AND playlist_songs.song_id = songs.id
WHERE $predicate
GROUP BY playlists.source_id, playlists.id
HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
ORDER BY $order
LIMIT $limit;
filterSongs:
SELECT
songs.*
FROM songs
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterSongsDownloaded:
SELECT
songs.*
FROM songs
WHERE $predicate AND songs.download_file_path IS NOT NULL
ORDER BY $order
LIMIT $limit;
playlistIsDownloaded:
SELECT
COUNT(*) = 0
FROM playlists
JOIN playlist_songs ON
playlist_songs.source_id = playlists.source_id
AND playlist_songs.playlist_id = playlists.id
JOIN songs ON
songs.source_id = playlist_songs.source_id
AND songs.id = playlist_songs.song_id
WHERE
playlists.source_id = :source_id AND playlists.id = :id
AND songs.download_file_path IS NULL;
playlistHasDownloadsInProgress:
SELECT
COUNT(*) > 0
FROM playlists
JOIN playlist_songs ON
playlist_songs.source_id = playlists.source_id
AND playlist_songs.playlist_id = playlists.id
JOIN songs ON
songs.source_id = playlist_songs.source_id
AND songs.id = playlist_songs.song_id
WHERE playlists.source_id = :source_id AND playlists.id = :id
AND songs.download_task_id IS NOT NULL;
songsInIds:
SELECT *
FROM songs
WHERE source_id = :source_id AND id IN :ids;
songsInRowIds:
SELECT *
FROM songs
WHERE ROWID IN :row_ids;
albumsInRowIds:
SELECT *
FROM albums
WHERE ROWID IN :row_ids;
artistsInRowIds:
SELECT *
FROM artists
WHERE ROWID IN :row_ids;
playlistsInRowIds:
SELECT *
FROM playlists
WHERE ROWID IN :row_ids;
currentTrackIndex:
SELECT
queue."index"
FROM queue
WHERE queue.current_track = 1;
queueLength:
SELECT COUNT(*) FROM queue;
queueInIndicies:
SELECT *
FROM queue
WHERE queue."index" IN :indicies;
getAppSettings:
SELECT * FROM app_settings
WHERE id = 1;

23
lib/database/util.dart Normal file
View File

@@ -0,0 +1,23 @@
import 'package:intl/intl.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
Future<String> datapasePath(String database) async =>
join((await getApplicationSupportDirectory()).path, 'databases', database);
extension DateTimeExt on DateTime {
String toDb() => DateFormat('yyyy-MM-dd hh:mm:ss').format(toUtc());
static DateTime parseUtc(Object? obj) {
final str = obj.toString();
return DateTime.parse(hasTimeZone(str) ? str : '${obj}Z').toLocal();
}
static DateTime? tryParseUtc(Object? obj) {
final str = obj.toString();
return DateTime.tryParse(hasTimeZone(str) ? str : '${obj}Z')?.toLocal();
}
static bool hasTimeZone(String str) =>
RegExp(r'(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])').hasMatch(str);
}

24
lib/http/client.dart Normal file
View File

@@ -0,0 +1,24 @@
import 'package:http/http.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'client.g.dart';
const Map<String, String> subtracksHeaders = {
'user-agent': 'subtracks/android',
};
class SubtracksHttpClient extends BaseClient {
SubtracksHttpClient();
@override
Future<StreamedResponse> send(BaseRequest request) {
request.headers.addAll(subtracksHeaders);
print('${request.method} ${request.url}');
return request.send();
}
}
@Riverpod(keepAlive: true)
BaseClient httpClient(HttpClientRef ref) {
return SubtracksHttpClient();
}

23
lib/http/client.g.dart Normal file
View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'client.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$httpClientHash() => r'78f604dbc249a854728d71bde8289d48f6700be7';
/// See also [httpClient].
@ProviderFor(httpClient)
final httpClientProvider = Provider<BaseClient>.internal(
httpClient,
name: r'httpClientProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$httpClientHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef HttpClientRef = ProviderRef<BaseClient>;
// 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

196
lib/l10n/app_ar.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "مميز",
"@actionsStar": {},
"actionsUnstar": "ازل التمييز",
"@actionsUnstar": {},
"messagesNothingHere": "لا شيء هنا…",
"@messagesNothingHere": {},
"navigationTabsHome": "الرئيسية",
"@navigationTabsHome": {},
"navigationTabsLibrary": "المكتبة",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "بحث",
"@navigationTabsSearch": {},
"navigationTabsSettings": "الإعدادات",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "شَغل الألبوم",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "أعرض الألبوم",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "فرز الألبومات",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{} few{ألبوم} many{} other{}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "أظهر الفنان",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "فرز الفنانين",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{} few{فنان} many{فنانان} other{فنانان}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "حسب النوع",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "موسوم",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "شغل قائمة التشغيل",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{} few{قائمة تشغيل} many{قائمتان تشغيل} other{قائمتان تشغيل}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{} few{صف} many{صفين} other{صفين}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "أشهر الأغاني",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{} few{أُغْنِيَة} many{} other{}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "أضيف حديثا",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "حسب الفنان/ة",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "مشغل كثيرا",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "حسب الاسم",
"@resourcesSortByName": {},
"resourcesSortByRandom": "عشوائي",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "شغل حديثا",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "حسب السنة",
"@resourcesSortByYear": {},
"searchHeaderTitle": "بحث: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "بحث",
"@searchInputPlaceholder": {},
"searchMoreResults": "المزيد…",
"@searchMoreResults": {},
"searchNowPlayingContext": "نتائج البحث",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "الرخص",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "موقع المشروع",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "حول",
"@settingsAboutName": {},
"settingsAboutVersion": "الإصدار {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "موسيقى",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "لا تستورد سجل التشغيل",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "استيراد سجل التشغيل",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "استيراد سجل التشغيل",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "الشبكة",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "أقصى معدل نقل بيانات (mobile)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "أقصى معدل نقل بيانات (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "الحد الأقصى من وقت التخزين المؤقت",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "الحد الأدنى من وقت التخزين المؤقت",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value} كيلو بايت في الثانية",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} ثواني",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "غير محدود",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "مسح ذاكرة التخزين المؤقت للصور",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "إعادة ضبط",
"@settingsResetName": {},
"settingsServersActionsAdd": "أضف سيرفر",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "حذف",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "عدل السيرفر",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "حفظ",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "أخبر الأتصال",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "العناوين",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "كلمة المرور",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "إسم المستخدم",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "الأتصال ب {address} فشل، ابحث في الإعدادات او السيرفر",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "الأتصال ب {address} جيد!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "السيرفرات",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "أرسل كلمة المرور على شكل توكِن",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "أرسل كلمة المرور بنص عادي (قديم ، تأكد من أن اتصالك آمن!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "أظهر كلمة المرور",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_ca.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Afegir als favorits",
"@actionsStar": {},
"actionsUnstar": "Retirar estrella",
"@actionsUnstar": {},
"messagesNothingHere": "Aquí no hi ha res…",
"@messagesNothingHere": {},
"navigationTabsHome": "Inici",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Biblioteca",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Cercar",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Paràmetres",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Reproduir l'àlbum",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Veure l'àlbum",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Ordenar els àlbums",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Àlbum} other{Àlbums}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Veure l'artista",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Ordenar els artistes",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artista} other{Artistes}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Per gènere",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Favorits",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Reproduir la llista de reproducció",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Llista de reproducció} other{Llistes de reproducció}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Cua} other{Cues}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Millors cançons",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Cançó} other{Cançons}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Afegit recentment",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Per artista",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Escoltat freqüentment",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Pel nom",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Aleatori",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Reproduït recentment",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Per any",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Cercar: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Cercar",
"@searchInputPlaceholder": {},
"searchMoreResults": "Més…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Resultats de la cerca",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Llicències",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Pàgina d'inici del projecte",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Quant a",
"@settingsAboutName": {},
"settingsAboutVersion": "versió {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Música",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "No capturar l'historial de reproducció",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Capturar l'historial de reproduccions",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Capturar la lectura",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Xarxa",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Taxa de bits màxima (mòbil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Taxa de bits màxima (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Temps màxim en memòria intermèdia",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Temps mínim en memòria intermèdia",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value} kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} segons",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Il·limitat",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Esborrar la memòria cau d'imatges",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Reinicialitzar",
"@settingsResetName": {},
"settingsServersActionsAdd": "Afegir un servidor",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Esborrar",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Editar el servidor",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Desar",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Comprovar la connexió",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adreça",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Contrasenya",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nom dusuari",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "La connexió a {address} ha fallat, comprova la configuració o el servidor",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Connexió a {address} OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Servidors",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Enviar contrasenya com a token + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Enviar la contrasenya en text sense format (obsolet, assegura't que la teva connexió sigui segura!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Forçar la contrasenya de text sense format",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

166
lib/l10n/app_cs.arb Normal file
View File

@@ -0,0 +1,166 @@
{
"actionsStar": "Ohodnotit",
"@actionsStar": {},
"actionsUnstar": "Zrušit hodnocení",
"@actionsUnstar": {},
"messagesNothingHere": "Zde nic není…",
"@messagesNothingHere": {},
"navigationTabsHome": "Domů",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Knihovna",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Hledat",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Nastavení",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Přehrát album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Zobrazit album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Seřadit alba",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} few{Alba} many{Alba} other{Alba}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Zobrazit umělce",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Seřadit umělce",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Umělec} few{Umělci} many{Umělci} other{Umělci}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Podle žánru",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Ohodnocené",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Přehrát seznam skladeb",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Seznam skladeb} few{Seznamy skladeb} many{Seznamy skladeb} other{Seznamy skladeb}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Fronta} few{Fronty} many{Fronty} other{Fronty}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Top skladby",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Skladba} few{Skladby} many{Skladby} other{Skladby}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Nedávno přidané",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Podle umělce",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Často přehrávané",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Podle názvu",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Náhodně",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Často přehrávané",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Podle roku",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Hledat: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Hledat",
"@searchInputPlaceholder": {},
"searchMoreResults": "Více…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Výsledky hledání",
"@searchNowPlayingContext": {},
"settingsNetworkName": "Síť",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maximální datový tok (mobil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maximální datový tok (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} sekund",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Neomezeno",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsServersActionsAdd": "Přidat server",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Odstranit",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Upravit server",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Uložit",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Otestovat spojení",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adresa",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Heslo",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Uživ. jméno",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Připojení k {address} selhalo, zkontrolujte nastavení nebo server",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Připojení k {address} je OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Servery",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Posílat heslo jako token + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Posílat heslo v prostém textu (zastaralé, ujistěte se, že je vaše připojení zabezpečené!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Vynutit heslo ve formátu prostého textu",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

176
lib/l10n/app_da.arb Normal file
View File

@@ -0,0 +1,176 @@
{
"messagesNothingHere": "Intet her…",
"@messagesNothingHere": {},
"navigationTabsHome": "Hjem",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Bibliotek",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Søg",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Indstillinger",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Afspil album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Se album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Sortér albums",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Albums}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Se kunstnere",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Sortér kunstnere",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Kunstner} other{Kunstnere}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Efter genre",
"@resourcesFilterGenre": {},
"resourcesPlaylistActionsPlay": "Afspil spilleliste",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Spilleliste} other{Spillelister}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Kø} other{Køer}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Top sange",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Sang} other{Sange}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByArtist": "Efter kunstner",
"@resourcesSortByArtist": {},
"resourcesSortByName": "Efter navn",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Tilfældig",
"@resourcesSortByRandom": {},
"resourcesSortByYear": "Efter år",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Søg: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Søg",
"@searchInputPlaceholder": {},
"searchMoreResults": "Mere…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Søgeresultater",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licenser",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Projekt hjemmeside",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Omkring",
"@settingsAboutName": {},
"settingsAboutVersion": "version {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Musik",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobble afspilningshistorik",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobble afspilninger",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Netværk",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maksimum bitrate (mobil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maksimum bitrate (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Maksimum buffertid",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Minimum buffertid",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} sekunder",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Ubegrænset",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Ryd billede cache",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Nulstil",
"@settingsResetName": {},
"settingsServersActionsAdd": "Tilføj server",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Slet",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Redigér server",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Gem",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Test forbindelse",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adresse",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Adgangskode",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Brugernavn",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Forbindelse til {address} mislykkedes, tjek indstillinger eller server",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Forbindelse til {address} OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Servere",
"@settingsServersName": {}
}

196
lib/l10n/app_de.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Markieren",
"@actionsStar": {},
"actionsUnstar": "Markierung entfernen",
"@actionsUnstar": {},
"messagesNothingHere": "Hier ist nichts…",
"@messagesNothingHere": {},
"navigationTabsHome": "Startseite",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Bibliothek",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Suche",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Einstellungen",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Album abspielen",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Album anzeigen",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Alben sortieren",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Alben}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Interpret anzeigen",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Interpreten sortieren",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Interpret} other{Interpreten}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Nach Genre",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Favoriten",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Wiedergabeliste abspielen",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Wiedergabeliste} other{Wiedergabelisten}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Warteschlange} other{Warteschlangen}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Top Lieder",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Lied} other{Lieder}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Kürzlich hinzugefügt",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Nach Interpreten",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Häufig abgespielt",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Nach Name",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Zufällig",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Kürzlich abgespielt",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Nach Jahr",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Suche: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Suche",
"@searchInputPlaceholder": {},
"searchMoreResults": "Mehr…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Suchergebnis",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Lizenzen",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Projektseite",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Über",
"@settingsAboutName": {},
"settingsAboutVersion": "Version {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Musik",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Kein Scrobble für Wiedergabeverlauf",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobble Wiedergabeverlauf",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobble Wiedergabe",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Netzwerk",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maximale Bitrate (Mobil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maximale Bitrate (WLAN)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Maximale Pufferzeit",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Minimale Pufferzeit",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} Sekunden",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Unbegrenzt",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Bildzwischenspeicher löschen",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Zurücksetzen",
"@settingsResetName": {},
"settingsServersActionsAdd": "Server hinzufügen",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Löschen",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Server bearbeiten",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Speichern",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Verbindung testen",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adresse",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Passwort",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nutzername",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Verbindung zu {address} fehlgeschlagen, überprüfe Einstellungen oder Server",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Verbindung zu {address} ist OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Server",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Sende Passwort als Token + Salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Passwort als Klartext senden (Veraltet, stellen Sie sicher, dass Ihre Verbindung sicher ist!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Erzwinge Klartextpasswort",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

276
lib/l10n/app_en.arb Normal file
View File

@@ -0,0 +1,276 @@
{
"actionsCancel": "Cancel",
"@actionsCancel": {},
"actionsDelete": "Delete",
"@actionsDelete": {},
"actionsDownload": "Download",
"@actionsDownload": {},
"actionsDownloadCancel": "Cancel download",
"@actionsDownloadCancel": {},
"actionsDownloadDelete": "Delete downloaded",
"@actionsDownloadDelete": {},
"actionsOk": "OK",
"@actionsOk": {},
"actionsStar": "Star",
"@actionsStar": {},
"actionsUnstar": "Unstar",
"@actionsUnstar": {},
"controlsShuffle": "Shuffle",
"@controlsShuffle": {},
"messagesNothingHere": "Nothing here…",
"@messagesNothingHere": {},
"navigationTabsHome": "Home",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Library",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Search",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Settings",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Play album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "View album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumCount": "{count,plural, =1{{count} album} other{{count} albums}}",
"@resourcesAlbumCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesAlbumListsSort": "Sort albums",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Albums}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "View artist",
"@resourcesArtistActionsView": {},
"resourcesArtistCount": "{count,plural, =1{{count} artist} other{{count} artists}}",
"@resourcesArtistCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistListsSort": "Sort artists",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artist} other{Artists}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterAlbum": "Album",
"@resourcesFilterAlbum": {},
"resourcesFilterArtist": "Artist",
"@resourcesFilterArtist": {},
"resourcesFilterGenre": "Genre",
"@resourcesFilterGenre": {},
"resourcesFilterOwner": "Owner",
"@resourcesFilterOwner": {},
"resourcesFilterStarred": "Starred",
"@resourcesFilterStarred": {},
"resourcesFilterYear": "Year",
"@resourcesFilterYear": {},
"resourcesPlaylistActionsPlay": "Play playlist",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistCount": "{count,plural, =1{{count} playlist} other{{count} playlists}}",
"@resourcesPlaylistCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesPlaylistName": "{count,plural, =1{Playlist} other{Playlists}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Queue} other{Queues}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongCount": "{count,plural, =1{{count} song} other{{count} songs}}",
"@resourcesSongCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListDeleteAllContent": "This will remove all downloaded song files.",
"@resourcesSongListDeleteAllContent": {},
"resourcesSongListDeleteAllTitle": "Delete downloads?",
"@resourcesSongListDeleteAllTitle": {},
"resourcesSongListsArtistTopSongs": "Top Songs",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Song} other{Songs}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Recently added",
"@resourcesSortByAdded": {},
"resourcesSortByAlbum": "Album",
"@resourcesSortByAlbum": {},
"resourcesSortByAlbumCount": "Album count",
"@resourcesSortByAlbumCount": {},
"resourcesSortByArtist": "Artist",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Frequently played",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Name",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Random",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Recently played",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByTitle": "Title",
"@resourcesSortByTitle": {},
"resourcesSortByUpdated": "Recently updated",
"@resourcesSortByUpdated": {},
"resourcesSortByYear": "Year",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Search: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Search",
"@searchInputPlaceholder": {},
"searchMoreResults": "More…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Search results",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licenses",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Project homepage",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutActionsSupport": "Support the developer 💜",
"@settingsAboutActionsSupport": {},
"settingsAboutName": "About",
"@settingsAboutName": {},
"settingsAboutVersion": "version {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Music",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Don't scrobble play history",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobble play history",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobble plays",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Network",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maximum bitrate (mobile data)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maximum bitrate (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Maximum buffer time",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Minimum buffer time",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkOptionsOfflineMode": "Offline mode",
"@settingsNetworkOptionsOfflineMode": {},
"settingsNetworkOptionsOfflineModeOff": "Use the internet to sync music.",
"@settingsNetworkOptionsOfflineModeOff": {},
"settingsNetworkOptionsOfflineModeOn": "Don't use the internet to sync or play music.",
"@settingsNetworkOptionsOfflineModeOn": {},
"settingsNetworkOptionsStreamFormat": "Preferred stream format",
"@settingsNetworkOptionsStreamFormat": {},
"settingsNetworkOptionsStreamFormatServerDefault": "Use server default",
"@settingsNetworkOptionsStreamFormatServerDefault": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} seconds",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Unlimited",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Clear Image Cache",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Reset",
"@settingsResetName": {},
"settingsServersActionsAdd": "Add source",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Delete",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Edit source",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Save",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Test connection",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Address",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsName": "Name",
"@settingsServersFieldsName": {},
"settingsServersFieldsPassword": "Password",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Username",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Connection to {address} failed, check settings or server",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Connection to {address} OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Sources",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Send password as token + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Send password in plaintext (legacy, make sure your connection is secure!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Force plaintext password",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_es.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Estrella",
"@actionsStar": {},
"actionsUnstar": "Retirar estrella",
"@actionsUnstar": {},
"messagesNothingHere": "Nada aquí…",
"@messagesNothingHere": {},
"navigationTabsHome": "Casa",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Biblioteca",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Buscar",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Entorno",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Reproducir Álbum",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Ver Álbum",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Ordenar Álbumes",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Álbum} other{Álbumes}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Ver Artista",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Oredenar Artistas",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artista} other{Artistas}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Por Género",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Estrellas",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Reproducir Lista de reproducción",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Lista de reproducción} other{Listas de reproducción}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Cola} other{Colas}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Mejores Canciones",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Cancion} other{Canciones}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Recientemente Añadido",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Por Artista",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Jugado Frecuentemente",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Por Nombre",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Aleatorio",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Recientemente Jugado",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Por Año",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Buscar: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Buscar",
"@searchInputPlaceholder": {},
"searchMoreResults": "Más…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Resultados de la búsqueda",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licencias",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Página de inicio del proyecto",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Información",
"@settingsAboutName": {},
"settingsAboutVersion": "versión {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Música",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "No hagas historial de reproducción de scrobble",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Historial de reproducción de scrobble",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Obras de teatro de Scrobble",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Sitio",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Tasa de bits máxima (mobile)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Tasa de bits máxima (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Máxima de buffer tiempo",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Mínimo de buffer tiempo",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value} kilobytes por segundo",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} segundos",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Ilimitados",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Caché de imágenes claras",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Reinicializar",
"@settingsResetName": {},
"settingsServersActionsAdd": "Agregar Servidor",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Supr",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Editar Servidor",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Enviar",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Conexión de prueba",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Alocución",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "La contraseña",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nombre de usuario",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "La conexión a {address} falló, verifique la configuracón o el servidor",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "¡Conexión con {address} Ok!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Servidores",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Enviar contraseña como token + sal",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Enviar contraseña en texto plano (¡legado, asegúrese de que su conexión sea segura!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Forzar contraseña de texto sin formato",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_fr.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Mettre en favoris",
"@actionsStar": {},
"actionsUnstar": "Enlever des favoris",
"@actionsUnstar": {},
"messagesNothingHere": "Rien ici…",
"@messagesNothingHere": {},
"navigationTabsHome": "Accueil",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Bibliothèque",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Recherche",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Paramètres",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Jouer l'album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Voir l'album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Trier les albums",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Albums}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Voir l'artiste",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Trier les artistes",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artiste} other{Artistes}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Par Genre",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Favoris",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Lire la playlist",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Playlist} other{Playlists}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{File d'attente} other{Files d'attente}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Meilleures Chansons",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Chanson} other{Chansons}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Récemment Ajouté",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Par Artiste",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Fréquemment Joué",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Par Nom",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Aléatoire",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Récemment Joué",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Par Année",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Recherche : {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Recherche",
"@searchInputPlaceholder": {},
"searchMoreResults": "Plus…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Résultats de recherche",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licences",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Page d'accueil du projet",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "À propos",
"@settingsAboutName": {},
"settingsAboutVersion": "version {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Musique",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Ne pas scrobbler l'historique de lecture",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobbler l'historique de lecture",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobbler la lecture",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Réseau",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Débit binaire maximum (mobile)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Débit binaire maximum (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Temps maximum en mémoire tampon",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Temps minimum en mémoire tampon",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbit/s",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} secondes",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Illimité",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Vider le cache d'images",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Réinitialiser",
"@settingsResetName": {},
"settingsServersActionsAdd": "Ajouter un serveur",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Supprimer",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Modifier le serveur",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Sauvegarder",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Tester la connexion",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adresse",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Mot de passe",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nom d'utilisateur",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Échec de la connexion à {address}, vérifiez les paramètres ou le serveur",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Connexion à {address} OK !",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Serveurs",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Envoyer le mot de passe sous forme de jeton + salage",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Envoyer le mot de passe en test clair (héritage, assurez-vous que la connexion est sécurisée !)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Forcer le mot de passe en texte clair",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_gl.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Estrela",
"@actionsStar": {},
"actionsUnstar": "Retirar",
"@actionsUnstar": {},
"messagesNothingHere": "Nada por aquí…",
"@messagesNothingHere": {},
"navigationTabsHome": "Inicio",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Biblioteca",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Buscar",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Axustes",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Reproducir",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Ver Álbum",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Ordenar Álbums",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Álbum} other{Álbums}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Ver Artista",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Ordenar Artistas",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artista} other{Artistas}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Por xénero",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Favoritas",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Reproducir lista",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Listaxe} other{Listaxes}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Fila} other{Filas}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Máis reproducidas",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Canción} other{Cancións}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Últimas engadidas",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Por artista",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Reproducidas a miúdo",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Por nome",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Ao chou",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Reproducidas a miúdo",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Por ano",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Buscar: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Buscar",
"@searchInputPlaceholder": {},
"searchMoreResults": "Máis…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Resultados",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licenzas",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Web do Proxecto",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Acerca de",
"@settingsAboutName": {},
"settingsAboutVersion": "versión {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Música",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Non rexistrar o historial de reprodución",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Rexistrar o historial de reprodución",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Rexistrar reprodución",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Rede",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Bitrate máx. (móbil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Bitrate máx. (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Tempo máximo na memoria",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Tempo mínimo na memoria",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} segundos",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Sen límite",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Limpar a caché de imaxes",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Restablecer",
"@settingsResetName": {},
"settingsServersActionsAdd": "Engadir Servidor",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Eliminar",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Editar Servidor",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Gardar",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Comprobar Conexión",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Enderezo",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Contrasinal",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Identificador",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Fallou a conexión a {address}, comproba os axustes",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Conexión con {address} OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Servidores",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Enviar contrasinal como token + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Enviar contrasinal en texto plano (herdado, pon coidado en que a conexión sexa segura!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Forzar contrasinal en texto plano",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_it.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Aggiungi ai preferiti",
"@actionsStar": {},
"actionsUnstar": "Rimuovi dai preferiti",
"@actionsUnstar": {},
"messagesNothingHere": "Non c'è niente qui…",
"@messagesNothingHere": {},
"navigationTabsHome": "Home",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Libreria",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Cerca",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Impostazioni",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Riproduci album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Vedi album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Ordina album",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Album}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Vedi artista",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Ordina artisti",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artista} other{Artisti}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Per genere",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Preferiti",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Riproduci playlist",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Playlist} other{Playlist}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Coda} other{Code}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Brani più popolari",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Brano} other{Brani}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Aggiunti di recente",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Per artista",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Ascoltati frequentemente",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Per nome",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Casuale",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Ascoltati di recente",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Per anno",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Ricerca: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Ricerca",
"@searchInputPlaceholder": {},
"searchMoreResults": "Mostra di più…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Risultati della ricerca",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licenze",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Pagina principale del progetto",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Informazioni",
"@settingsAboutName": {},
"settingsAboutVersion": "versione {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Musica",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Non eseguire lo scrobbling della cronologia d'ascolto",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobbling della cronologia di ascolto",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobbling delle riproduzioni",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Rete",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Bitrate massimo (rete dati)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Bitrate massimo (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Tempo di buffer massimo",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Tempo di buffer minimo",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} secondi",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Illimitato",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Pulisci la cache delle immagini",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Reimposta",
"@settingsResetName": {},
"settingsServersActionsAdd": "Aggiungi server",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Rimuovi",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Modifica server",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Salva",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Prova connessione",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Indirizzo",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Password",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nome utente",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Connessione a {address} fallita, controlla le impostazioni o il server",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Connesso a {address} con successo!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Server",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Invia la password come token + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Invia password in chiaro (deprecato, assicurati che la tua connessione sia sicura!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Forza password in chiaro",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

64
lib/l10n/app_ja.arb Normal file
View File

@@ -0,0 +1,64 @@
{
"navigationTabsHome": "ホーム",
"@navigationTabsHome": {},
"navigationTabsLibrary": "ライブラリ",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "検索",
"@navigationTabsSearch": {},
"navigationTabsSettings": "設定",
"@navigationTabsSettings": {},
"resourcesAlbumName": "{count,plural, other{アルバム}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistName": "{count,plural, other{アーティスト}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterStarred": "星付きアルバム",
"@resourcesFilterStarred": {},
"resourcesPlaylistName": "{count,plural, other{プレイリスト}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "人気曲",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, other{歌}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByFrequentlyPlayed": "よく聴くアルバム",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByRandom": "ランダムアルバム",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "最近再生した",
"@resourcesSortByRecentlyPlayed": {},
"searchInputPlaceholder": "検索",
"@searchInputPlaceholder": {},
"settingsAboutActionsProjectHomepage": "ホームページ",
"@settingsAboutActionsProjectHomepage": {},
"settingsMusicName": "音楽",
"@settingsMusicName": {},
"settingsNetworkName": "ネット",
"@settingsNetworkName": {},
"settingsResetName": "リセット",
"@settingsResetName": {},
"settingsServersName": "サーバ",
"@settingsServersName": {}
}

196
lib/l10n/app_nb-NO.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Stjernemerk",
"@actionsStar": {},
"actionsUnstar": "Fjern stjernemerking",
"@actionsUnstar": {},
"messagesNothingHere": "Ingenting her …",
"@messagesNothingHere": {},
"navigationTabsHome": "Hjem",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Bibliotek",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Søk",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Innstillinger",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Spill album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Vis album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Sorter album",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Album}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Vis artist",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Sorter artister",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artist} other{Artister}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Etter sjanger",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Stjernemerket",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Spill av spilleliste",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Spilleliste} other{Spillelister}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Kø} other{Køer}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Toppspor",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Spor} other{Spor}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Nylig tillagt",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Etter artist",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Ofte spilt",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Etter navn",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Tilfeldig",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Nylig spilt",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Etter år",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Søk: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Søk",
"@searchInputPlaceholder": {},
"searchMoreResults": "Mer …",
"@searchMoreResults": {},
"searchNowPlayingContext": "Søkeresultater",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Lisenser",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Prosjekthjemmeside",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Om",
"@settingsAboutName": {},
"settingsAboutVersion": "versjon {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Musikk",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Ikke utfør sporinfodeling av avspillingshistorikk",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Sporinfodelings-avspillinghistorikk",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Sporinfodelingsavspillinger",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Nettverk",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maksimal bitrate (mobil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maksimal bitrate (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Maksimal mellomlagringstid",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Minimal mellomlagringstid",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value} kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} sekunder",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Ubegrenset",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Tøm bildehurtiglager",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Tilbakestill",
"@settingsResetName": {},
"settingsServersActionsAdd": "Legg til tjener",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Slett",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Rediger tjener",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Lagre",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Test tilkobling",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adresse",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Passord",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Brukernavn",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Tilkobling til {address} mislyktes. Sjekk innstillingene eller tjeneren.",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Tilkobling til {address} OK.",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Tjenere",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Send passord som symbol + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Send passord i klartekst (Foreldet. Forsikre deg om at tilkoblingen er sikker.)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Påtving klartekstspassord",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_nb.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Stjernemerk",
"@actionsStar": {},
"actionsUnstar": "Fjern stjernemerking",
"@actionsUnstar": {},
"messagesNothingHere": "Ingenting her …",
"@messagesNothingHere": {},
"navigationTabsHome": "Hjem",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Bibliotek",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Søk",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Innstillinger",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Spill album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Vis album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Sorter album",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} other{Album}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Vis artist",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Sorter artister",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artist} other{Artister}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Etter sjanger",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Stjernemerket",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Spill av spilleliste",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Spilleliste} other{Spillelister}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Kø} other{Køer}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Toppspor",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Spor} other{Spor}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Nylig tillagt",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Etter artist",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Ofte spilt",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Etter navn",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Tilfeldig",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Nylig spilt",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Etter år",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Søk: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Søk",
"@searchInputPlaceholder": {},
"searchMoreResults": "Mer …",
"@searchMoreResults": {},
"searchNowPlayingContext": "Søkeresultater",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Lisenser",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Prosjekthjemmeside",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Om",
"@settingsAboutName": {},
"settingsAboutVersion": "versjon {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Musikk",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Ikke utfør sporinfodeling av avspillingshistorikk",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Sporinfodelings-avspillinghistorikk",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Sporinfodelingsavspillinger",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Nettverk",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maksimal bitrate (mobil)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maksimal bitrate (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Maksimal mellomlagringstid",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Minimal mellomlagringstid",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value} kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} sekunder",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Ubegrenset",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Tøm bildehurtiglager",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Tilbakestill",
"@settingsResetName": {},
"settingsServersActionsAdd": "Legg til tjener",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Slett",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Rediger tjener",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Lagre",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Test tilkobling",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adresse",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Passord",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Brukernavn",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Tilkobling til {address} mislyktes. Sjekk innstillingene eller tjeneren.",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Tilkobling til {address} OK.",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Tjenere",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Send passord som symbol + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Send passord i klartekst (Foreldet. Forsikre deg om at tilkoblingen er sikker.)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Påtving klartekstspassord",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_pa.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "ਤਾਰਾ",
"@actionsStar": {},
"actionsUnstar": "ਤਾਰਾ ਹਟਾਓ",
"@actionsUnstar": {},
"messagesNothingHere": "ਇੱਥੇ ਕੁਝ ਨਹੀਂ ਹੈ…",
"@messagesNothingHere": {},
"navigationTabsHome": "ਘਰ",
"@navigationTabsHome": {},
"navigationTabsLibrary": "ਲਾਇਬ੍ਰੇਰੀ",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "ਖੋਜ",
"@navigationTabsSearch": {},
"navigationTabsSettings": "ਸੈਟਿੰਗਾਂ",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "ਐਲਬਮ ਚਲਾਓ",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "ਐਲਬਮ ਦੇਖੋ",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "ਐਲਬਮਾਂ ਨੂੰ ਕਰਮਬੱਧ ਕਰੋ",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{ਐਲਬਮ} other{ਐਲਬਮਾਂ}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "ਕਲਾਕਾਰ ਦੇਖੋ",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "ਕਲਾਕਾਰਾਂ ਦੀ ਛਾਂਟੀ",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{ਕਲਾਕਾਰ} other{ਕਲਾਕਾਰਾਂ}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "ਸ਼ੈਲੀ ਦੁਆਰਾ",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "ਸਟਾਰ ਕੀਤੇ ਗਏ",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "ਪਲੇਲਿਸਟ ਚਲਾਓ",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{ਪਲੇਲਿਸਟ} other{ਪਲੇਲਿਸਟਸ}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{ਕਤਾਰ} other{ਕਤਾਰਾਂ}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "ਉੱਤਮ ਗਾਣੇ",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{ਗਾਣਾ} other{ਗਾਣੇ}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "ਤਾਜ਼ਾ ਸ਼ਾਮਿਲ",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "ਕਲਾਕਾਰ ਦੁਆਰਾ",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "ਅਕਸਰ ਚਲਾਏ ਗਏ",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "ਨਾਮ ਦੁਆਰਾ",
"@resourcesSortByName": {},
"resourcesSortByRandom": "ਅਟਕਲ-ਪੱਚੂ",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "ਹਾਲ ਹੀ ਵਿੱਚ ਚਲਾਏ ਗਏ",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "ਸਾਲ ਦੁਆਰਾ",
"@resourcesSortByYear": {},
"searchHeaderTitle": "ਖੋਜ: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "ਖੋਜੋ",
"@searchInputPlaceholder": {},
"searchMoreResults": "ਹੋਰ…",
"@searchMoreResults": {},
"searchNowPlayingContext": "ਖੋਜ ਨਤੀਜੇ",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "ਲਾਇਸੰਸ",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "ਪ੍ਰੋਜੈਕਟ ਹੋਮਪੇਜ",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "ਬਾਰੇ",
"@settingsAboutName": {},
"settingsAboutVersion": "ਸੰਸਕਰਣ {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "ਸੰਗੀਤ",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "ਸੁਣੇ ਹੋਏ ਗਾਣੇ ਸਕ੍ਰੋਬਲ ਨਾ ਕਰੋ",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "ਸੁਣੇ ਹੋਏ ਗਾਣੇ ਸਕ੍ਰੋਬਲ ਕਰੋ",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "ਗਾਣੇ ਸਕ੍ਰੋਬਲ ਕਰੋ",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "ਨੈੱਟਵਰਕ",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "ਵੱਧ ਤੋਂ ਵੱਧ ਬਿੱਟਰੇਟ (ਮੋਬਾਈਲ)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "ਵੱਧ ਤੋਂ ਵੱਧ ਬਿੱਟਰੇਟ (ਵਾਈ-ਫਾਈ)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "ਵੱਧ ਤੋਂ ਵੱਧ ਬਫਰ ਸਮਾਂ",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "ਘੱਟੋ-ਘੱਟ ਬਫਰ ਸਮਾਂ",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} ਸਕਿੰਟ",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "ਅਸੀਮਿਤ",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "ਚਿੱਤਰ ਕੈਸ਼ ਸਾਫ਼ ਕਰੋ",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "ਰੀਸੈਟ ਕਰੋ",
"@settingsResetName": {},
"settingsServersActionsAdd": "ਸਰਵਰ ਸ਼ਾਮਲ ਕਰੋ",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "ਮਿਟਾਓ",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "ਸਰਵਰ ਦਾ ਸੰਪਾਦਨ",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "ਸੇਵ ਕਰੋ",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "ਟੈਸਟ ਕਨੈਕਸ਼ਨ",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "ਪਤਾ",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "ਪਾਸਵਰਡ",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "ਯੂਜ਼ਰਨੇਮ",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "{address} ਨਾਲ ਕਨੈਕਸ਼ਨ ਅਸਫਲ, ਸੈਟਿੰਗਾਂ ਜਾਂ ਸਰਵਰ ਦੀ ਜਾਂਚ ਕਰੋ",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "{address} ਨਾਲ ਕਨੈਕਸ਼ਨ ਠੀਕ ਹੈ!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "ਸਰਵਰ",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "ਪਾਸਵਰਡ ਨੂੰ ਟੋਕਨ + ਸਾਲ੍ਟ ਵਜੋਂ ਭੇਜੋ",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "ਪਾਸਵਰਡ ਨੂੰ ਸਾਦੇ ਟੈਕਸਟ ਵਿੱਚ ਭੇਜੋ (ਪੁਰਾਣਾ, ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਹਾਡਾ ਕਨੈਕਸ਼ਨ ਸੁਰੱਖਿਅਤ ਹੈ!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "ਸਾਦੇ ਪਾਸਵਰਡ ਨੂੰ ਜਬਰੀ ਕਰੋ",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_pl.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Ulubione",
"@actionsStar": {},
"actionsUnstar": "Usuń ulubione",
"@actionsUnstar": {},
"messagesNothingHere": "Pusto tu…",
"@messagesNothingHere": {},
"navigationTabsHome": "Strona główna",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Kolekcja",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Wyszukaj",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Ustawienia",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Otwarzaj album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Zobacz album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Sortowanie albumów",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Album} few{Albumy} many{Albumów} other{Albumów}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Zobacz wykonawcę",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Sortowanie wykonawców",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Wykonawca} few{Wykonawcy} many{Wykonawców} other{Wykonawców}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Wg gatunku",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Ulubione",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Odtwarzaj playlistę",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Playlista} few{Playlisty} many{Playlist} other{Playlist}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Kolejka} few{Kolejki} many{Kolejek} other{Kolejek}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Najpopularniejsze utwory",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Utwór} few{Utwory} many{Utworów} other{Utworów}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Ostatnio dodane",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Wg wykonawcy",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Często odtwarzane",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Wg nazwy",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Losowo",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Ostatnio odtwarzane",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Wg roku",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Szukaj: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Szukaj",
"@searchInputPlaceholder": {},
"searchMoreResults": "Więcej…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Wyniki wyszukiwania",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licencje",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Strona główna projektu",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Informacje o projekcie",
"@settingsAboutName": {},
"settingsAboutVersion": "wersja {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Muzyka",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Nie śledź swojej historii odtwarzania",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Śledź swoją histrorię odtwarzania",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Śledzenie odtworzeń",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Sieć",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Maksymalny bitrate (sieć komórkowa)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Maksymalny bitrate (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Maksymalny czas buforowania",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Minimalny czas buforowania",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} sekund",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Bez ograniczeń",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Wyczyść pamięć podręczną obrazów",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Przywracanie",
"@settingsResetName": {},
"settingsServersActionsAdd": "Dodaj serwer",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Usuń",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Edytuj serwer",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Zapisz",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Przetestuj połączenie",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adres serwera",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Hasło",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nazwa użytkownika",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Błąd połączenia z {address}, sprawdź ustawienia lub serwer",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Połączeno poprawnie z {address}!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Serwery",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Wyślij hasło jako token i ciąg zaburzający",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Wyślij hasło jako tekst (przestarzałe, upewnij się, że Twoje połączenie jest zabezpieczone)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Wymuś hasło jako tekst (plaintext)",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_pt.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Favorito",
"@actionsStar": {},
"actionsUnstar": "Remover favorito",
"@actionsUnstar": {},
"messagesNothingHere": "Não existe nada…",
"@messagesNothingHere": {},
"navigationTabsHome": "Início",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Biblioteca",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Procurar",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Definições",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Tocar Álbum",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Ver Álbum",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Ordenar Álbuns",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Álbum} other{Álbuns}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Ver Artista",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Ordenar Artistas",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Artista} other{Artistas}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Por Género",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Favoritos",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Tocar Playlist",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Lista} other{Listas}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Fila} other{Filas}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Top Músicas",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Música} other{Músicas}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Adicionado recentemente",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Por Artista",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Mais Tocado",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Por Nome",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Aleatório",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Ouviu recentemente",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Por Ano",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Procurar: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Procurar",
"@searchInputPlaceholder": {},
"searchMoreResults": "Mais…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Resultados da Pesquisa",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Licenças",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Página do Projeto",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Acerca",
"@settingsAboutName": {},
"settingsAboutVersion": "versão {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Música",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Não enviar histórico de reproduções por scrobble",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Enviar histórico de reproduções por scrobble",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Enviar reproduções por scrobble",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Rede",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Bitrate Máximo (móvel)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Bitrate Máximo (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Tempo de buffer máximo",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Tempo de buffer mínimo",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} segundos",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Ilimitado",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Limpar cache de Imagens",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Redefinir",
"@settingsResetName": {},
"settingsServersActionsAdd": "Adicionar Servidor",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Apagar",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Editar Servidor",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Guardar",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Testar Ligação",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Endereço",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Senha",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Nome de utilizador",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Ligação a {address} falhou, verifique definições ou servidor",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Ligação a {address} OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Servidores",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Enviar senha como token + sal",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Enviar senha em texto simples (antigo, certifique-se que a sua ligação é segura!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Forçar password em texto simples",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_ru.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Избранное",
"@actionsStar": {},
"actionsUnstar": "Убрать из избранного",
"@actionsUnstar": {},
"messagesNothingHere": "Здесь ничего нет…",
"@messagesNothingHere": {},
"navigationTabsHome": "Главная",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Библиотека",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Поиск",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Настройки",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Воспроизвести альбом",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Посмотреть альбом",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Сортировка альбомов",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Альбом} few{Альбомы} many{Альбомов} other{Альбомов}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Посмотреть исполнителя",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Сортировать исполнителей",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Исполнитель} few{Исполнители} many{Исполнителей} other{Исполнителей}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "По жанру",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Избранные",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Воспроизвести плейлист",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Плейлист} few{Плейлисты} many{Плейлистов} other{Плейлистов}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Очередь} few{Очереди} many{Очередей} other{Очередей}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Лучшие треки",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Трек} few{Трека} many{Треков} other{Треков}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Недавно добавленные",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "По исполнителю",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Часто проигрываемые",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "По имени",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Случайно",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Недавно проигранные",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "По году",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Поиск: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Поиск",
"@searchInputPlaceholder": {},
"searchMoreResults": "Больше…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Результаты поиска",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Лицензии",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Сайт проекта",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "О Subtracks",
"@settingsAboutName": {},
"settingsAboutVersion": "версия {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Музыка",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Не отправлять историю воспроизведений",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Скробблинг истории воспроизведения",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Скробблинг",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Сеть",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Максимальный битрейт (мобильный интернет)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Максимальный битрейт (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Максимальное время буферизации",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Минимальное время буферизации",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value} кбит/с",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} секунд",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Без ограничений",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Очистить кэш изображения",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Сброс",
"@settingsResetName": {},
"settingsServersActionsAdd": "Добавить сервер",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Удалить",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Редактировать сервер",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Сохранить",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Проверить подключение",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Адрес",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Пароль",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Имя пользователя",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Не удалось подключиться к {address}, проверьте настройки или сервер",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Подключение к {address} установлено!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Серверы",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Отправить пароль в виде токена",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Отправить пароль в виде текста (устарело, убедитесь, что ваше соединение безопасно!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Принудительно использовать текстовой пароль",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_tr.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Yıldızla",
"@actionsStar": {},
"actionsUnstar": "Yıldızı Kaldır",
"@actionsUnstar": {},
"messagesNothingHere": "Burada bir şey yok …",
"@messagesNothingHere": {},
"navigationTabsHome": "Giriş",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Kütüphane",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Arama",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Ayarlar",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Albümü Çal",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Albüme Git",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Albümleri Sırala",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, =1{Albüm} other{Albümler}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Sanatçıya Git",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Sanatçıları Sırala",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, =1{Sanatçı} other{Sanatçılar}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Türe göre",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Yıldızlı",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Listeyi Çal",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, =1{Çalma Listesi} other{Çalma Listeleri}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, =1{Sıra} other{Sıralar}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "En İyi Şarkılar",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, =1{Şarkı} other{Şarkılar}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Son Eklenenler",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Sanatçıya Göre",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Sık Dinlenenler",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "İsme göre",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Rastgele",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Yeni Dinleneler",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Yıla göre",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Aranan: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Ara",
"@searchInputPlaceholder": {},
"searchMoreResults": "Tüm Sonuçlar …",
"@searchMoreResults": {},
"searchNowPlayingContext": "Arama Sonuçları",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Lisanslar",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Proje Ana Sayfası",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Hakkında",
"@settingsAboutName": {},
"settingsAboutVersion": "sürüm {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Müzik",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Müzik geçmişini profilime ekleme",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Müzik geçmişini profilime ekle",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Çalan müziği profilime ekle",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Ağ",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Azami bithızı (SIM interneti)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Azami bithızı (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Azami arabellek süresi",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Asgari arabellek süresi",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kb/sn",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} saniye",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Sınırsız",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Görüntü Önbelleğini Temizle",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Sıfırlama",
"@settingsResetName": {},
"settingsServersActionsAdd": "Sunucu Ekle",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Kaldır",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Sunucu Ayarı",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Kaydet",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Bağlantıyı Sına",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Adres",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Şifre",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Kullanıcı Adı",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "{address} ile bağlantı başarısız. Sunucu ayarlarınızın doğrulığundan emin olun.",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "{address} ile bağlantı başarılı!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Sunucular",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Şifreyi dizgecik ve tuz karışımı olarak gönder",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Şifreyi düzmetin olarak gönderir (eski yöntem, bağlantının güvenli (HTTPS) olduğu sunucularda kullanın!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Düzyazı şifre kullanmayı zorla",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_vi.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "Đánh dấu sao",
"@actionsStar": {},
"actionsUnstar": "Bỏ dấu sao",
"@actionsUnstar": {},
"messagesNothingHere": "Không có gì ở đây…",
"@messagesNothingHere": {},
"navigationTabsHome": "Trang chủ",
"@navigationTabsHome": {},
"navigationTabsLibrary": "Thư Viện",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "Tìm kiếm",
"@navigationTabsSearch": {},
"navigationTabsSettings": "Thiết Lập",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "Phát Album",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "Xem Album",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "Sắp xếp Album",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, other{Album}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "Xem Nghệ sĩ",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "Sắp xếp nghệ sĩ",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, other{Nghệ sĩ}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "Theo thể loại",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "Có gắn dấu sao",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "Phát Danh sách phát",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, other{Danh sách phát}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, other{Hàng chờ}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "Bài hát hàng đầu",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, other{Bài hát}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "Thêm vào gần đây",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "Theo nghệ sĩ",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "Thường xuyên chơi",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "Theo tên",
"@resourcesSortByName": {},
"resourcesSortByRandom": "Ngẫu Nhiên",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "Đã phát gần đây",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "Theo năm",
"@resourcesSortByYear": {},
"searchHeaderTitle": "Tìm kiếm: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "Tìm kiếm",
"@searchInputPlaceholder": {},
"searchMoreResults": "Nhiều hơn…",
"@searchMoreResults": {},
"searchNowPlayingContext": "Kết quả tìm kiếm",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "Giấy phép",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "Trang chủ Dự án",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "Giới thiệu",
"@settingsAboutName": {},
"settingsAboutVersion": "phiên bản {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "Âm nhạc",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "Đừng scrobble lịch sử chơi",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Lịch sử chơi Scrobble",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobble lượt chơi",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "Mạng",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "Tối đa bitrate (mobile)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "Tối đa bitrate (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "Thời gian đệm tối đa",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "Thời gian đệm tối thiểu",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} giây",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "Không giới hạn",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "Xóa bộ nhớ đệm hình ảnh",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "Cài đặt lại",
"@settingsResetName": {},
"settingsServersActionsAdd": "Thêm máy chủ",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "Xóa",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "Chỉnh sửa máy chủ",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "Lưu",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "Kiểm tra kết nối",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "Địa chỉ",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "Mật Khẩu",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "Tên đăng nhập",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "Kết nối với {address} không thành công, hãy kiểm tra cài đặt hoặc máy chủ",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "Kết nối với {address} OK!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "Máy chủ",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Gửi mật khẩu dưới dạng token + salt",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Gửi mật khẩu ở dạng văn bản rõ ràng (kế thừa, đảm bảo kết nối của bạn an toàn!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "Buộc mật khẩu văn bản thuần túy",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_zh-Hans.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "收藏",
"@actionsStar": {},
"actionsUnstar": "移除收藏",
"@actionsUnstar": {},
"messagesNothingHere": "什么都没有…",
"@messagesNothingHere": {},
"navigationTabsHome": "首页",
"@navigationTabsHome": {},
"navigationTabsLibrary": "所有",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "搜索",
"@navigationTabsSearch": {},
"navigationTabsSettings": "设置",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "播放专辑",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "查看专辑",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "专辑排序",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, other{专辑}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "查看歌手",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "歌手排序",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, other{歌手}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "根据类型",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "已收藏",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "全部播放",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, other{播放列表}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, other{队列}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "热门歌曲",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, other{歌曲}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "最近添加",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "根据歌手",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "播放最多",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "根据名称",
"@resourcesSortByName": {},
"resourcesSortByRandom": "随机",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "最近播放",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "根据年份",
"@resourcesSortByYear": {},
"searchHeaderTitle": "搜索: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "搜索",
"@searchInputPlaceholder": {},
"searchMoreResults": "更多…",
"@searchMoreResults": {},
"searchNowPlayingContext": "搜索结果",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "许可",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "项目地址",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "关于",
"@settingsAboutName": {},
"settingsAboutVersion": "版本 {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "音乐",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "不记录scrobble历史",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobble播放历史",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobble模式",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "网络",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "最大比特率 (3G/4G/5G)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "最大比特率 (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "最大缓冲时间",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "最小缓冲时间",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} 秒",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "不限制",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "清除图片缓存",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "重置",
"@settingsResetName": {},
"settingsServersActionsAdd": "添加服务器",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "删除",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "编辑服务器",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "保存",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "测试连接",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "地址",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "密码",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "用户名",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "连接到 {address} 失败,检查设置或服务器",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "连接到 {address} 正常!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "服务器",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "密码以 token + salt 加密发送",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "密码以明文发送(不推荐,注意链接安全!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "强制使用明文密码",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

196
lib/l10n/app_zh.arb Normal file
View File

@@ -0,0 +1,196 @@
{
"actionsStar": "收藏",
"@actionsStar": {},
"actionsUnstar": "移除收藏",
"@actionsUnstar": {},
"messagesNothingHere": "什么都没有…",
"@messagesNothingHere": {},
"navigationTabsHome": "首页",
"@navigationTabsHome": {},
"navigationTabsLibrary": "所有",
"@navigationTabsLibrary": {},
"navigationTabsSearch": "搜索",
"@navigationTabsSearch": {},
"navigationTabsSettings": "设置",
"@navigationTabsSettings": {},
"resourcesAlbumActionsPlay": "播放专辑",
"@resourcesAlbumActionsPlay": {},
"resourcesAlbumActionsView": "查看专辑",
"@resourcesAlbumActionsView": {},
"resourcesAlbumListsSort": "专辑排序",
"@resourcesAlbumListsSort": {},
"resourcesAlbumName": "{count,plural, other{专辑}}",
"@resourcesAlbumName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesArtistActionsView": "查看歌手",
"@resourcesArtistActionsView": {},
"resourcesArtistListsSort": "歌手排序",
"@resourcesArtistListsSort": {},
"resourcesArtistName": "{count,plural, other{歌手}}",
"@resourcesArtistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesFilterGenre": "根据类型",
"@resourcesFilterGenre": {},
"resourcesFilterStarred": "已收藏",
"@resourcesFilterStarred": {},
"resourcesPlaylistActionsPlay": "全部播放",
"@resourcesPlaylistActionsPlay": {},
"resourcesPlaylistName": "{count,plural, other{播放列表}}",
"@resourcesPlaylistName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesQueueName": "{count,plural, other{队列}}",
"@resourcesQueueName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSongListsArtistTopSongs": "热门歌曲",
"@resourcesSongListsArtistTopSongs": {},
"resourcesSongName": "{count,plural, other{歌曲}}",
"@resourcesSongName": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"resourcesSortByAdded": "最近添加",
"@resourcesSortByAdded": {},
"resourcesSortByArtist": "根据歌手",
"@resourcesSortByArtist": {},
"resourcesSortByFrequentlyPlayed": "播放最多",
"@resourcesSortByFrequentlyPlayed": {},
"resourcesSortByName": "根据名称",
"@resourcesSortByName": {},
"resourcesSortByRandom": "随机",
"@resourcesSortByRandom": {},
"resourcesSortByRecentlyPlayed": "最近播放",
"@resourcesSortByRecentlyPlayed": {},
"resourcesSortByYear": "根据年份",
"@resourcesSortByYear": {},
"searchHeaderTitle": "搜索: {query}",
"@searchHeaderTitle": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchInputPlaceholder": "搜索",
"@searchInputPlaceholder": {},
"searchMoreResults": "更多…",
"@searchMoreResults": {},
"searchNowPlayingContext": "搜索结果",
"@searchNowPlayingContext": {},
"settingsAboutActionsLicenses": "许可",
"@settingsAboutActionsLicenses": {},
"settingsAboutActionsProjectHomepage": "项目地址",
"@settingsAboutActionsProjectHomepage": {},
"settingsAboutName": "关于",
"@settingsAboutName": {},
"settingsAboutVersion": "版本 {version}",
"@settingsAboutVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"settingsMusicName": "音乐",
"@settingsMusicName": {},
"settingsMusicOptionsScrobbleDescriptionOff": "不记录scrobble历史",
"@settingsMusicOptionsScrobbleDescriptionOff": {},
"settingsMusicOptionsScrobbleDescriptionOn": "Scrobble播放历史",
"@settingsMusicOptionsScrobbleDescriptionOn": {},
"settingsMusicOptionsScrobbleTitle": "Scrobble模式",
"@settingsMusicOptionsScrobbleTitle": {},
"settingsNetworkName": "网络",
"@settingsNetworkName": {},
"settingsNetworkOptionsMaxBitrateMobileTitle": "最大比特率 (3G/4G/5G)",
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
"settingsNetworkOptionsMaxBitrateWifiTitle": "最大比特率 (Wi-Fi)",
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
"settingsNetworkOptionsMaxBufferTitle": "最大缓冲时间",
"@settingsNetworkOptionsMaxBufferTitle": {},
"settingsNetworkOptionsMinBufferTitle": "最小缓冲时间",
"@settingsNetworkOptionsMinBufferTitle": {},
"settingsNetworkValuesKbps": "{value}kbps",
"@settingsNetworkValuesKbps": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesSeconds": "{value} 秒",
"@settingsNetworkValuesSeconds": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settingsNetworkValuesUnlimitedKbps": "不限制",
"@settingsNetworkValuesUnlimitedKbps": {},
"settingsResetActionsClearImageCache": "清除图片缓存",
"@settingsResetActionsClearImageCache": {},
"settingsResetName": "重置",
"@settingsResetName": {},
"settingsServersActionsAdd": "添加服务器",
"@settingsServersActionsAdd": {},
"settingsServersActionsDelete": "删除",
"@settingsServersActionsDelete": {},
"settingsServersActionsEdit": "编辑服务器",
"@settingsServersActionsEdit": {},
"settingsServersActionsSave": "保存",
"@settingsServersActionsSave": {},
"settingsServersActionsTestConnection": "测试连接",
"@settingsServersActionsTestConnection": {},
"settingsServersFieldsAddress": "地址",
"@settingsServersFieldsAddress": {},
"settingsServersFieldsPassword": "密码",
"@settingsServersFieldsPassword": {},
"settingsServersFieldsUsername": "用户名",
"@settingsServersFieldsUsername": {},
"settingsServersMessagesConnectionFailed": "连接到 {address} 失败,检查设置或服务器",
"@settingsServersMessagesConnectionFailed": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersMessagesConnectionOk": "连接到 {address} 正常!",
"@settingsServersMessagesConnectionOk": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"settingsServersName": "服务器",
"@settingsServersName": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "密码以 token + salt 加密发送",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "密码以明文发送(不推荐,注意链接安全!)",
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
"settingsServersOptionsForcePlaintextPasswordTitle": "强制使用明文密码",
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
}

22
lib/main.dart Normal file
View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:stack_trace/stack_trace.dart' as stack_trace;
import 'package:worker_manager/worker_manager.dart';
import 'app/app.dart';
void main() async {
// TOOD: probably remove before live
// https://stackoverflow.com/a/73770713
FlutterError.demangleStackTrace = (StackTrace stack) {
if (stack is stack_trace.Trace) return stack.vmTrace;
if (stack is stack_trace.Chain) return stack.toTrace().vmTrace;
return stack;
};
// keep some threads warm for background (palette generation) tasks
await Executor().warmUp();
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: MyApp()));
}

115
lib/models/music.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'music.freezed.dart';
abstract class SourceIdentifiable {
int get sourceId;
String get id;
}
abstract class SongList extends SourceIdentifiable {
String get name;
int get songCount;
}
@freezed
class SourceId with _$SourceId implements SourceIdentifiable {
const factory SourceId({
required int sourceId,
required String id,
}) = _SourceId;
factory SourceId.from(SourceIdentifiable item) {
return SourceId(sourceId: item.sourceId, id: item.id);
}
}
@freezed
class SourceIdSet with _$SourceIdSet {
const factory SourceIdSet({
required int sourceId,
required ISet<String> ids,
}) = _SourceIdSet;
}
@freezed
class Artist with _$Artist {
const factory Artist({
required int sourceId,
required String id,
required String name,
required int albumCount,
DateTime? starred,
// @Default(IListConst([])) IList<Album> albums,
}) = _Artist;
}
@freezed
class Album with _$Album implements SongList {
const factory Album({
required int sourceId,
required String id,
required String name,
String? artistId,
String? albumArtist,
required DateTime created,
String? coverArt,
int? year,
DateTime? starred,
// DateTime? synced,
String? genre,
required int songCount,
@Default(false) bool isDeleted,
// @Default(IListConst([])) IList<Song> songs,
int? frequentRank,
int? recentRank,
}) = _Album;
}
@freezed
class Playlist with _$Playlist implements SongList {
const factory Playlist({
required int sourceId,
required String id,
required String name,
String? comment,
String? coverArt,
required int songCount,
required DateTime created,
// DateTime? synced,
// @Default(IListConst([])) IList<Song> songs,
}) = _Playlist;
}
@freezed
class Song with _$Song implements SourceIdentifiable {
const factory Song({
required int sourceId,
required String id,
String? albumId,
String? artistId,
required String title,
String? artist,
String? album,
Duration? duration,
int? track,
int? disc,
DateTime? starred,
String? genre,
String? downloadTaskId,
String? downloadFilePath,
@Default(false) bool isDeleted,
}) = _Song;
}
@freezed
class SearchResults with _$SearchResults {
const factory SearchResults({
String? query,
@Default(IListConst([])) IList<Song> songs,
@Default(IListConst([])) IList<Album> albums,
@Default(IListConst([])) IList<Artist> artists,
}) = _SearchResults;
}

File diff suppressed because it is too large Load Diff

97
lib/models/query.dart Normal file
View File

@@ -0,0 +1,97 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'query.freezed.dart';
part 'query.g.dart';
enum SortDirection {
asc('ASC'),
desc('DESC');
const SortDirection(this.value);
final String value;
}
@freezed
class Pagination with _$Pagination {
const factory Pagination({
required int limit,
@Default(0) int offset,
}) = _Pagination;
factory Pagination.fromJson(Map<String, dynamic> json) =>
_$PaginationFromJson(json);
}
@freezed
class SortBy with _$SortBy {
const factory SortBy({
required String column,
@Default(SortDirection.asc) SortDirection dir,
}) = _SortBy;
factory SortBy.fromJson(Map<String, dynamic> json) => _$SortByFromJson(json);
}
@freezed
class FilterWith with _$FilterWith {
const factory FilterWith.equals({
required String column,
required String value,
@Default(false) bool invert,
}) = _FilterWithEquals;
const factory FilterWith.greaterThan({
required String column,
required String value,
@Default(false) bool orEquals,
}) = _FilterWithGreaterThan;
const factory FilterWith.isNull({
required String column,
@Default(false) bool invert,
}) = _FilterWithIsNull;
const factory FilterWith.betweenInt({
required String column,
required int from,
required int to,
}) = _FilterWithBetweenInt;
const factory FilterWith.isIn({
required String column,
@Default(false) bool invert,
@Default(IListConst([])) IList<String> values,
}) = _FilterWithIsIn;
factory FilterWith.fromJson(Map<String, dynamic> json) =>
_$FilterWithFromJson(json);
}
@freezed
class ListQuery with _$ListQuery {
const factory ListQuery({
@Default(Pagination(limit: -1, offset: 0)) Pagination page,
SortBy? sort,
@Default(IListConst([])) IList<FilterWith> filters,
}) = _ListQuery;
factory ListQuery.fromJson(Map<String, dynamic> json) =>
_$ListQueryFromJson(json);
}
@freezed
class ListQueryOptions with _$ListQueryOptions {
const factory ListQueryOptions({
required IList<String> sortColumns,
required IList<String> filterColumns,
}) = _ListQueryOptions;
}
@freezed
class LibraryListQuery with _$LibraryListQuery {
const factory LibraryListQuery({
required ListQueryOptions options,
required ListQuery query,
}) = _LibraryListQuery;
}

File diff suppressed because it is too large Load Diff

143
lib/models/query.g.dart Normal file
View File

@@ -0,0 +1,143 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'query.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$_Pagination _$$_PaginationFromJson(Map<String, dynamic> json) =>
_$_Pagination(
limit: json['limit'] as int,
offset: json['offset'] as int? ?? 0,
);
Map<String, dynamic> _$$_PaginationToJson(_$_Pagination instance) =>
<String, dynamic>{
'limit': instance.limit,
'offset': instance.offset,
};
_$_SortBy _$$_SortByFromJson(Map<String, dynamic> json) => _$_SortBy(
column: json['column'] as String,
dir: $enumDecodeNullable(_$SortDirectionEnumMap, json['dir']) ??
SortDirection.asc,
);
Map<String, dynamic> _$$_SortByToJson(_$_SortBy instance) => <String, dynamic>{
'column': instance.column,
'dir': _$SortDirectionEnumMap[instance.dir]!,
};
const _$SortDirectionEnumMap = {
SortDirection.asc: 'asc',
SortDirection.desc: 'desc',
};
_$_FilterWithEquals _$$_FilterWithEqualsFromJson(Map<String, dynamic> json) =>
_$_FilterWithEquals(
column: json['column'] as String,
value: json['value'] as String,
invert: json['invert'] as bool? ?? false,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_FilterWithEqualsToJson(_$_FilterWithEquals instance) =>
<String, dynamic>{
'column': instance.column,
'value': instance.value,
'invert': instance.invert,
'runtimeType': instance.$type,
};
_$_FilterWithGreaterThan _$$_FilterWithGreaterThanFromJson(
Map<String, dynamic> json) =>
_$_FilterWithGreaterThan(
column: json['column'] as String,
value: json['value'] as String,
orEquals: json['orEquals'] as bool? ?? false,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_FilterWithGreaterThanToJson(
_$_FilterWithGreaterThan instance) =>
<String, dynamic>{
'column': instance.column,
'value': instance.value,
'orEquals': instance.orEquals,
'runtimeType': instance.$type,
};
_$_FilterWithIsNull _$$_FilterWithIsNullFromJson(Map<String, dynamic> json) =>
_$_FilterWithIsNull(
column: json['column'] as String,
invert: json['invert'] as bool? ?? false,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_FilterWithIsNullToJson(_$_FilterWithIsNull instance) =>
<String, dynamic>{
'column': instance.column,
'invert': instance.invert,
'runtimeType': instance.$type,
};
_$_FilterWithBetweenInt _$$_FilterWithBetweenIntFromJson(
Map<String, dynamic> json) =>
_$_FilterWithBetweenInt(
column: json['column'] as String,
from: json['from'] as int,
to: json['to'] as int,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_FilterWithBetweenIntToJson(
_$_FilterWithBetweenInt instance) =>
<String, dynamic>{
'column': instance.column,
'from': instance.from,
'to': instance.to,
'runtimeType': instance.$type,
};
_$_FilterWithIsIn _$$_FilterWithIsInFromJson(Map<String, dynamic> json) =>
_$_FilterWithIsIn(
column: json['column'] as String,
invert: json['invert'] as bool? ?? false,
values: json['values'] == null
? const IListConst([])
: IList<String>.fromJson(json['values'], (value) => value as String),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_FilterWithIsInToJson(_$_FilterWithIsIn instance) =>
<String, dynamic>{
'column': instance.column,
'invert': instance.invert,
'values': instance.values.toJson(
(value) => value,
),
'runtimeType': instance.$type,
};
_$_ListQuery _$$_ListQueryFromJson(Map<String, dynamic> json) => _$_ListQuery(
page: json['page'] == null
? const Pagination(limit: -1, offset: 0)
: Pagination.fromJson(json['page'] as Map<String, dynamic>),
sort: json['sort'] == null
? null
: SortBy.fromJson(json['sort'] as Map<String, dynamic>),
filters: json['filters'] == null
? const IListConst([])
: IList<FilterWith>.fromJson(json['filters'],
(value) => FilterWith.fromJson(value as Map<String, dynamic>)),
);
Map<String, dynamic> _$$_ListQueryToJson(_$_ListQuery instance) =>
<String, dynamic>{
'page': instance.page,
'sort': instance.sort,
'filters': instance.filters.toJson(
(value) => value,
),
};

120
lib/models/settings.dart Normal file
View File

@@ -0,0 +1,120 @@
import 'package:drift/drift.dart' show Value;
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../database/database.dart';
part 'settings.freezed.dart';
@freezed
class Settings with _$Settings {
const factory Settings({
@Default(IListConst([])) IList<SourceSettings> sources,
SourceSettings? activeSource,
@Default(AppSettings()) AppSettings app,
}) = _Settings;
}
@freezed
class AppSettings with _$AppSettings {
const AppSettings._();
const factory AppSettings({
@Default(0) int maxBitrateWifi,
@Default(192) int maxBitrateMobile,
@Default('mp3') String? streamFormat,
}) = _AppSettings;
AppSettingsCompanion toCompanion() {
return AppSettingsCompanion.insert(
id: const Value(1),
maxBitrateWifi: maxBitrateWifi,
maxBitrateMobile: maxBitrateMobile,
streamFormat: Value(streamFormat),
);
}
}
class ParentChild<T> {
final T parent;
final T child;
ParentChild(this.parent, this.child);
}
abstract class SourceSettings {
const SourceSettings();
int get id;
String get name;
Uri get address;
bool? get isActive;
DateTime get createdAt;
}
enum SubsonicFeature {
emptyQuerySearch('emptyQuerySearch');
const SubsonicFeature(this.value);
final String value;
@override
String toString() => value;
}
@freezed
class SubsonicSettings with _$SubsonicSettings implements SourceSettings {
const SubsonicSettings._();
const factory SubsonicSettings({
required int id,
@Default(IListConst([])) IList<SubsonicFeature> features,
required String name,
required Uri address,
required bool? isActive,
required DateTime createdAt,
required String username,
required String password,
@Default(true) bool useTokenAuth,
}) = _SubsonicSettings;
SourcesCompanion toSourceInsertable() {
return SourcesCompanion(
id: Value(id),
name: Value(name),
address: Value(address),
createdAt: Value(createdAt),
);
}
SubsonicSourcesCompanion toSubsonicInsertable() {
return SubsonicSourcesCompanion(
sourceId: Value(id),
features: Value(features),
username: Value(username),
password: Value(password),
useTokenAuth: Value(useTokenAuth),
);
}
}
@freezed
class SubsonicSourceSettings with _$SubsonicSourceSettings {
const SubsonicSourceSettings._();
const factory SubsonicSourceSettings({
required SourceSettings source,
required SubsonicSettings subsonic,
}) = _SubsonicSourceSettings;
}
enum NetworkMode {
mobile('mobile'),
wifi('wifi');
const NetworkMode(this.value);
final String value;
@override
String toString() => value;
}

View File

@@ -0,0 +1,810 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'settings.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$Settings {
IList<SourceSettings> get sources => throw _privateConstructorUsedError;
SourceSettings? get activeSource => throw _privateConstructorUsedError;
AppSettings get app => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SettingsCopyWith<Settings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SettingsCopyWith<$Res> {
factory $SettingsCopyWith(Settings value, $Res Function(Settings) then) =
_$SettingsCopyWithImpl<$Res, Settings>;
@useResult
$Res call(
{IList<SourceSettings> sources,
SourceSettings? activeSource,
AppSettings app});
$AppSettingsCopyWith<$Res> get app;
}
/// @nodoc
class _$SettingsCopyWithImpl<$Res, $Val extends Settings>
implements $SettingsCopyWith<$Res> {
_$SettingsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? sources = null,
Object? activeSource = freezed,
Object? app = null,
}) {
return _then(_value.copyWith(
sources: null == sources
? _value.sources
: sources // ignore: cast_nullable_to_non_nullable
as IList<SourceSettings>,
activeSource: freezed == activeSource
? _value.activeSource
: activeSource // ignore: cast_nullable_to_non_nullable
as SourceSettings?,
app: null == app
? _value.app
: app // ignore: cast_nullable_to_non_nullable
as AppSettings,
) as $Val);
}
@override
@pragma('vm:prefer-inline')
$AppSettingsCopyWith<$Res> get app {
return $AppSettingsCopyWith<$Res>(_value.app, (value) {
return _then(_value.copyWith(app: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$_SettingsCopyWith<$Res> implements $SettingsCopyWith<$Res> {
factory _$$_SettingsCopyWith(
_$_Settings value, $Res Function(_$_Settings) then) =
__$$_SettingsCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{IList<SourceSettings> sources,
SourceSettings? activeSource,
AppSettings app});
@override
$AppSettingsCopyWith<$Res> get app;
}
/// @nodoc
class __$$_SettingsCopyWithImpl<$Res>
extends _$SettingsCopyWithImpl<$Res, _$_Settings>
implements _$$_SettingsCopyWith<$Res> {
__$$_SettingsCopyWithImpl(
_$_Settings _value, $Res Function(_$_Settings) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? sources = null,
Object? activeSource = freezed,
Object? app = null,
}) {
return _then(_$_Settings(
sources: null == sources
? _value.sources
: sources // ignore: cast_nullable_to_non_nullable
as IList<SourceSettings>,
activeSource: freezed == activeSource
? _value.activeSource
: activeSource // ignore: cast_nullable_to_non_nullable
as SourceSettings?,
app: null == app
? _value.app
: app // ignore: cast_nullable_to_non_nullable
as AppSettings,
));
}
}
/// @nodoc
class _$_Settings implements _Settings {
const _$_Settings(
{this.sources = const IListConst([]),
this.activeSource,
this.app = const AppSettings()});
@override
@JsonKey()
final IList<SourceSettings> sources;
@override
final SourceSettings? activeSource;
@override
@JsonKey()
final AppSettings app;
@override
String toString() {
return 'Settings(sources: $sources, activeSource: $activeSource, app: $app)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_Settings &&
const DeepCollectionEquality().equals(other.sources, sources) &&
(identical(other.activeSource, activeSource) ||
other.activeSource == activeSource) &&
(identical(other.app, app) || other.app == app));
}
@override
int get hashCode => Object.hash(runtimeType,
const DeepCollectionEquality().hash(sources), activeSource, app);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_SettingsCopyWith<_$_Settings> get copyWith =>
__$$_SettingsCopyWithImpl<_$_Settings>(this, _$identity);
}
abstract class _Settings implements Settings {
const factory _Settings(
{final IList<SourceSettings> sources,
final SourceSettings? activeSource,
final AppSettings app}) = _$_Settings;
@override
IList<SourceSettings> get sources;
@override
SourceSettings? get activeSource;
@override
AppSettings get app;
@override
@JsonKey(ignore: true)
_$$_SettingsCopyWith<_$_Settings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$AppSettings {
int get maxBitrateWifi => throw _privateConstructorUsedError;
int get maxBitrateMobile => throw _privateConstructorUsedError;
String? get streamFormat => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$AppSettingsCopyWith<AppSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AppSettingsCopyWith<$Res> {
factory $AppSettingsCopyWith(
AppSettings value, $Res Function(AppSettings) then) =
_$AppSettingsCopyWithImpl<$Res, AppSettings>;
@useResult
$Res call({int maxBitrateWifi, int maxBitrateMobile, String? streamFormat});
}
/// @nodoc
class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings>
implements $AppSettingsCopyWith<$Res> {
_$AppSettingsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? maxBitrateWifi = null,
Object? maxBitrateMobile = null,
Object? streamFormat = freezed,
}) {
return _then(_value.copyWith(
maxBitrateWifi: null == maxBitrateWifi
? _value.maxBitrateWifi
: maxBitrateWifi // ignore: cast_nullable_to_non_nullable
as int,
maxBitrateMobile: null == maxBitrateMobile
? _value.maxBitrateMobile
: maxBitrateMobile // ignore: cast_nullable_to_non_nullable
as int,
streamFormat: freezed == streamFormat
? _value.streamFormat
: streamFormat // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$_AppSettingsCopyWith<$Res>
implements $AppSettingsCopyWith<$Res> {
factory _$$_AppSettingsCopyWith(
_$_AppSettings value, $Res Function(_$_AppSettings) then) =
__$$_AppSettingsCopyWithImpl<$Res>;
@override
@useResult
$Res call({int maxBitrateWifi, int maxBitrateMobile, String? streamFormat});
}
/// @nodoc
class __$$_AppSettingsCopyWithImpl<$Res>
extends _$AppSettingsCopyWithImpl<$Res, _$_AppSettings>
implements _$$_AppSettingsCopyWith<$Res> {
__$$_AppSettingsCopyWithImpl(
_$_AppSettings _value, $Res Function(_$_AppSettings) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? maxBitrateWifi = null,
Object? maxBitrateMobile = null,
Object? streamFormat = freezed,
}) {
return _then(_$_AppSettings(
maxBitrateWifi: null == maxBitrateWifi
? _value.maxBitrateWifi
: maxBitrateWifi // ignore: cast_nullable_to_non_nullable
as int,
maxBitrateMobile: null == maxBitrateMobile
? _value.maxBitrateMobile
: maxBitrateMobile // ignore: cast_nullable_to_non_nullable
as int,
streamFormat: freezed == streamFormat
? _value.streamFormat
: streamFormat // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
class _$_AppSettings extends _AppSettings {
const _$_AppSettings(
{this.maxBitrateWifi = 0,
this.maxBitrateMobile = 192,
this.streamFormat = 'mp3'})
: super._();
@override
@JsonKey()
final int maxBitrateWifi;
@override
@JsonKey()
final int maxBitrateMobile;
@override
@JsonKey()
final String? streamFormat;
@override
String toString() {
return 'AppSettings(maxBitrateWifi: $maxBitrateWifi, maxBitrateMobile: $maxBitrateMobile, streamFormat: $streamFormat)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_AppSettings &&
(identical(other.maxBitrateWifi, maxBitrateWifi) ||
other.maxBitrateWifi == maxBitrateWifi) &&
(identical(other.maxBitrateMobile, maxBitrateMobile) ||
other.maxBitrateMobile == maxBitrateMobile) &&
(identical(other.streamFormat, streamFormat) ||
other.streamFormat == streamFormat));
}
@override
int get hashCode =>
Object.hash(runtimeType, maxBitrateWifi, maxBitrateMobile, streamFormat);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_AppSettingsCopyWith<_$_AppSettings> get copyWith =>
__$$_AppSettingsCopyWithImpl<_$_AppSettings>(this, _$identity);
}
abstract class _AppSettings extends AppSettings {
const factory _AppSettings(
{final int maxBitrateWifi,
final int maxBitrateMobile,
final String? streamFormat}) = _$_AppSettings;
const _AppSettings._() : super._();
@override
int get maxBitrateWifi;
@override
int get maxBitrateMobile;
@override
String? get streamFormat;
@override
@JsonKey(ignore: true)
_$$_AppSettingsCopyWith<_$_AppSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$SubsonicSettings {
int get id => throw _privateConstructorUsedError;
IList<SubsonicFeature> get features => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
Uri get address => throw _privateConstructorUsedError;
bool? get isActive => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
String get username => throw _privateConstructorUsedError;
String get password => throw _privateConstructorUsedError;
bool get useTokenAuth => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SubsonicSettingsCopyWith<SubsonicSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SubsonicSettingsCopyWith<$Res> {
factory $SubsonicSettingsCopyWith(
SubsonicSettings value, $Res Function(SubsonicSettings) then) =
_$SubsonicSettingsCopyWithImpl<$Res, SubsonicSettings>;
@useResult
$Res call(
{int id,
IList<SubsonicFeature> features,
String name,
Uri address,
bool? isActive,
DateTime createdAt,
String username,
String password,
bool useTokenAuth});
}
/// @nodoc
class _$SubsonicSettingsCopyWithImpl<$Res, $Val extends SubsonicSettings>
implements $SubsonicSettingsCopyWith<$Res> {
_$SubsonicSettingsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? features = null,
Object? name = null,
Object? address = null,
Object? isActive = freezed,
Object? createdAt = null,
Object? username = null,
Object? password = null,
Object? useTokenAuth = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
features: null == features
? _value.features
: features // ignore: cast_nullable_to_non_nullable
as IList<SubsonicFeature>,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
address: null == address
? _value.address
: address // ignore: cast_nullable_to_non_nullable
as Uri,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
username: null == username
? _value.username
: username // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
useTokenAuth: null == useTokenAuth
? _value.useTokenAuth
: useTokenAuth // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$_SubsonicSettingsCopyWith<$Res>
implements $SubsonicSettingsCopyWith<$Res> {
factory _$$_SubsonicSettingsCopyWith(
_$_SubsonicSettings value, $Res Function(_$_SubsonicSettings) then) =
__$$_SubsonicSettingsCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
IList<SubsonicFeature> features,
String name,
Uri address,
bool? isActive,
DateTime createdAt,
String username,
String password,
bool useTokenAuth});
}
/// @nodoc
class __$$_SubsonicSettingsCopyWithImpl<$Res>
extends _$SubsonicSettingsCopyWithImpl<$Res, _$_SubsonicSettings>
implements _$$_SubsonicSettingsCopyWith<$Res> {
__$$_SubsonicSettingsCopyWithImpl(
_$_SubsonicSettings _value, $Res Function(_$_SubsonicSettings) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? features = null,
Object? name = null,
Object? address = null,
Object? isActive = freezed,
Object? createdAt = null,
Object? username = null,
Object? password = null,
Object? useTokenAuth = null,
}) {
return _then(_$_SubsonicSettings(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
features: null == features
? _value.features
: features // ignore: cast_nullable_to_non_nullable
as IList<SubsonicFeature>,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
address: null == address
? _value.address
: address // ignore: cast_nullable_to_non_nullable
as Uri,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
username: null == username
? _value.username
: username // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
useTokenAuth: null == useTokenAuth
? _value.useTokenAuth
: useTokenAuth // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _$_SubsonicSettings extends _SubsonicSettings {
const _$_SubsonicSettings(
{required this.id,
this.features = const IListConst([]),
required this.name,
required this.address,
required this.isActive,
required this.createdAt,
required this.username,
required this.password,
this.useTokenAuth = true})
: super._();
@override
final int id;
@override
@JsonKey()
final IList<SubsonicFeature> features;
@override
final String name;
@override
final Uri address;
@override
final bool? isActive;
@override
final DateTime createdAt;
@override
final String username;
@override
final String password;
@override
@JsonKey()
final bool useTokenAuth;
@override
String toString() {
return 'SubsonicSettings(id: $id, features: $features, name: $name, address: $address, isActive: $isActive, createdAt: $createdAt, username: $username, password: $password, useTokenAuth: $useTokenAuth)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_SubsonicSettings &&
(identical(other.id, id) || other.id == id) &&
const DeepCollectionEquality().equals(other.features, features) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.username, username) ||
other.username == username) &&
(identical(other.password, password) ||
other.password == password) &&
(identical(other.useTokenAuth, useTokenAuth) ||
other.useTokenAuth == useTokenAuth));
}
@override
int get hashCode => Object.hash(
runtimeType,
id,
const DeepCollectionEquality().hash(features),
name,
address,
isActive,
createdAt,
username,
password,
useTokenAuth);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_SubsonicSettingsCopyWith<_$_SubsonicSettings> get copyWith =>
__$$_SubsonicSettingsCopyWithImpl<_$_SubsonicSettings>(this, _$identity);
}
abstract class _SubsonicSettings extends SubsonicSettings {
const factory _SubsonicSettings(
{required final int id,
final IList<SubsonicFeature> features,
required final String name,
required final Uri address,
required final bool? isActive,
required final DateTime createdAt,
required final String username,
required final String password,
final bool useTokenAuth}) = _$_SubsonicSettings;
const _SubsonicSettings._() : super._();
@override
int get id;
@override
IList<SubsonicFeature> get features;
@override
String get name;
@override
Uri get address;
@override
bool? get isActive;
@override
DateTime get createdAt;
@override
String get username;
@override
String get password;
@override
bool get useTokenAuth;
@override
@JsonKey(ignore: true)
_$$_SubsonicSettingsCopyWith<_$_SubsonicSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$SubsonicSourceSettings {
SourceSettings get source => throw _privateConstructorUsedError;
SubsonicSettings get subsonic => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SubsonicSourceSettingsCopyWith<SubsonicSourceSettings> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SubsonicSourceSettingsCopyWith<$Res> {
factory $SubsonicSourceSettingsCopyWith(SubsonicSourceSettings value,
$Res Function(SubsonicSourceSettings) then) =
_$SubsonicSourceSettingsCopyWithImpl<$Res, SubsonicSourceSettings>;
@useResult
$Res call({SourceSettings source, SubsonicSettings subsonic});
$SubsonicSettingsCopyWith<$Res> get subsonic;
}
/// @nodoc
class _$SubsonicSourceSettingsCopyWithImpl<$Res,
$Val extends SubsonicSourceSettings>
implements $SubsonicSourceSettingsCopyWith<$Res> {
_$SubsonicSourceSettingsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? source = null,
Object? subsonic = null,
}) {
return _then(_value.copyWith(
source: null == source
? _value.source
: source // ignore: cast_nullable_to_non_nullable
as SourceSettings,
subsonic: null == subsonic
? _value.subsonic
: subsonic // ignore: cast_nullable_to_non_nullable
as SubsonicSettings,
) as $Val);
}
@override
@pragma('vm:prefer-inline')
$SubsonicSettingsCopyWith<$Res> get subsonic {
return $SubsonicSettingsCopyWith<$Res>(_value.subsonic, (value) {
return _then(_value.copyWith(subsonic: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$_SubsonicSourceSettingsCopyWith<$Res>
implements $SubsonicSourceSettingsCopyWith<$Res> {
factory _$$_SubsonicSourceSettingsCopyWith(_$_SubsonicSourceSettings value,
$Res Function(_$_SubsonicSourceSettings) then) =
__$$_SubsonicSourceSettingsCopyWithImpl<$Res>;
@override
@useResult
$Res call({SourceSettings source, SubsonicSettings subsonic});
@override
$SubsonicSettingsCopyWith<$Res> get subsonic;
}
/// @nodoc
class __$$_SubsonicSourceSettingsCopyWithImpl<$Res>
extends _$SubsonicSourceSettingsCopyWithImpl<$Res,
_$_SubsonicSourceSettings>
implements _$$_SubsonicSourceSettingsCopyWith<$Res> {
__$$_SubsonicSourceSettingsCopyWithImpl(_$_SubsonicSourceSettings _value,
$Res Function(_$_SubsonicSourceSettings) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? source = null,
Object? subsonic = null,
}) {
return _then(_$_SubsonicSourceSettings(
source: null == source
? _value.source
: source // ignore: cast_nullable_to_non_nullable
as SourceSettings,
subsonic: null == subsonic
? _value.subsonic
: subsonic // ignore: cast_nullable_to_non_nullable
as SubsonicSettings,
));
}
}
/// @nodoc
class _$_SubsonicSourceSettings extends _SubsonicSourceSettings {
const _$_SubsonicSourceSettings(
{required this.source, required this.subsonic})
: super._();
@override
final SourceSettings source;
@override
final SubsonicSettings subsonic;
@override
String toString() {
return 'SubsonicSourceSettings(source: $source, subsonic: $subsonic)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_SubsonicSourceSettings &&
(identical(other.source, source) || other.source == source) &&
(identical(other.subsonic, subsonic) ||
other.subsonic == subsonic));
}
@override
int get hashCode => Object.hash(runtimeType, source, subsonic);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_SubsonicSourceSettingsCopyWith<_$_SubsonicSourceSettings> get copyWith =>
__$$_SubsonicSourceSettingsCopyWithImpl<_$_SubsonicSourceSettings>(
this, _$identity);
}
abstract class _SubsonicSourceSettings extends SubsonicSourceSettings {
const factory _SubsonicSourceSettings(
{required final SourceSettings source,
required final SubsonicSettings subsonic}) = _$_SubsonicSourceSettings;
const _SubsonicSourceSettings._() : super._();
@override
SourceSettings get source;
@override
SubsonicSettings get subsonic;
@override
@JsonKey(ignore: true)
_$$_SubsonicSourceSettingsCopyWith<_$_SubsonicSourceSettings> get copyWith =>
throw _privateConstructorUsedError;
}

186
lib/models/support.dart Normal file
View File

@@ -0,0 +1,186 @@
import 'package:audio_service/audio_service.dart' show MediaItem;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:palette_generator/palette_generator.dart';
part 'support.freezed.dart';
part 'support.g.dart';
@freezed
class UriCacheInfo with _$UriCacheInfo {
const factory UriCacheInfo({
required Uri uri,
required String cacheKey,
required CacheManager cacheManager,
}) = _UriCacheInfo;
}
@freezed
class CacheInfo with _$CacheInfo {
const factory CacheInfo({
required String cacheKey,
required CacheManager cacheManager,
}) = _CacheInfo;
}
@freezed
class Palette with _$Palette {
const Palette._();
const factory Palette({
PaletteColor? vibrantColor,
PaletteColor? dominantColor,
PaletteColor? mutedColor,
PaletteColor? darkMutedColor,
PaletteColor? darkVibrantColor,
PaletteColor? lightMutedColor,
PaletteColor? lightVibrantColor,
}) = _Palette;
factory Palette.fromPaletteGenerator(PaletteGenerator generator) {
return Palette(
vibrantColor: generator.vibrantColor,
dominantColor: generator.dominantColor,
mutedColor: generator.mutedColor,
darkMutedColor: generator.darkMutedColor,
darkVibrantColor: generator.darkVibrantColor,
lightMutedColor: generator.lightMutedColor,
lightVibrantColor: generator.lightVibrantColor,
);
}
}
@freezed
class ColorTheme with _$ColorTheme {
const factory ColorTheme({
required ThemeData theme,
required Color gradientHigh,
required Color gradientLow,
required Color darkBackground,
required Color darkerBackground,
required Color onDarkerBackground,
}) = _ColorTheme;
}
enum QueueContextType {
song('song'),
album('album'),
playlist('playlist'),
library('library'),
genre('genre');
const QueueContextType(this.value);
final String value;
@override
String toString() => value;
}
enum QueueMode {
user('user'),
radio('radio');
const QueueMode(this.value);
final String value;
@override
String toString() => value;
}
enum RepeatMode {
none('none'),
all('all'),
one('one');
const RepeatMode(this.value);
final String value;
@override
String toString() => value;
}
@freezed
class QueueItemState with _$QueueItemState {
const factory QueueItemState({
required String id,
required QueueContextType contextType,
String? contextId,
String? contextTitle,
}) = _QueueItemState;
factory QueueItemState.fromJson(Map<String, dynamic> json) =>
_$QueueItemStateFromJson(json);
}
@freezed
class MediaItemData with _$MediaItemData {
const factory MediaItemData({
required int sourceId,
String? albumId,
@MediaItemArtCacheConverter() MediaItemArtCache? artCache,
required QueueContextType contextType,
String? contextId,
}) = _MediaItemData;
factory MediaItemData.fromJson(Map<String, dynamic> json) =>
_$MediaItemDataFromJson(json);
}
@freezed
class MediaItemArtCache with _$MediaItemArtCache {
const factory MediaItemArtCache({
required Uri fullArtUri,
required String fullArtCacheKey,
required Uri thumbnailArtUri,
required String thumbnailArtCacheKey,
}) = _MediaItemArtCache;
factory MediaItemArtCache.fromJson(Map<String, dynamic> json) =>
_$MediaItemArtCacheFromJson(json);
}
class MediaItemArtCacheConverter
implements JsonConverter<MediaItemArtCache, Map<String, dynamic>> {
const MediaItemArtCacheConverter();
@override
MediaItemArtCache fromJson(Map<String, dynamic> json) =>
MediaItemArtCache.fromJson(json);
@override
Map<String, dynamic> toJson(MediaItemArtCache object) => object.toJson();
}
extension MediaItemPlus on MediaItem {
MediaItemData get data => MediaItemData.fromJson(extras!['data']);
set data(MediaItemData data) {
extras!['data'] = data.toJson();
}
}
@freezed
class ListDownloadStatus with _$ListDownloadStatus {
const factory ListDownloadStatus({
required int total,
required int downloaded,
required int downloading,
}) = _ListDownloadStatus;
}
@freezed
class MultiChoiceOption with _$MultiChoiceOption {
const factory MultiChoiceOption({
required String title,
}) = _MultiChoiceOption;
factory MultiChoiceOption.int({
required String title,
required int option,
}) = _MultiChoiceOptionInt;
factory MultiChoiceOption.string({
required String title,
required String option,
}) = _MultiChoiceOptionString;
}

File diff suppressed because it is too large Load Diff

82
lib/models/support.g.dart Normal file
View File

@@ -0,0 +1,82 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'support.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$_QueueItemState _$$_QueueItemStateFromJson(Map<String, dynamic> json) =>
_$_QueueItemState(
id: json['id'] as String,
contextType: $enumDecode(_$QueueContextTypeEnumMap, json['contextType']),
contextId: json['contextId'] as String?,
contextTitle: json['contextTitle'] as String?,
);
Map<String, dynamic> _$$_QueueItemStateToJson(_$_QueueItemState instance) =>
<String, dynamic>{
'id': instance.id,
'contextType': _$QueueContextTypeEnumMap[instance.contextType]!,
'contextId': instance.contextId,
'contextTitle': instance.contextTitle,
};
const _$QueueContextTypeEnumMap = {
QueueContextType.song: 'song',
QueueContextType.album: 'album',
QueueContextType.playlist: 'playlist',
QueueContextType.library: 'library',
QueueContextType.genre: 'genre',
};
_$_MediaItemData _$$_MediaItemDataFromJson(Map<String, dynamic> json) =>
_$_MediaItemData(
sourceId: json['sourceId'] as int,
albumId: json['albumId'] as String?,
artCache:
_$JsonConverterFromJson<Map<String, dynamic>, MediaItemArtCache>(
json['artCache'], const MediaItemArtCacheConverter().fromJson),
contextType: $enumDecode(_$QueueContextTypeEnumMap, json['contextType']),
contextId: json['contextId'] as String?,
);
Map<String, dynamic> _$$_MediaItemDataToJson(_$_MediaItemData instance) =>
<String, dynamic>{
'sourceId': instance.sourceId,
'albumId': instance.albumId,
'artCache':
_$JsonConverterToJson<Map<String, dynamic>, MediaItemArtCache>(
instance.artCache, const MediaItemArtCacheConverter().toJson),
'contextType': _$QueueContextTypeEnumMap[instance.contextType]!,
'contextId': instance.contextId,
};
Value? _$JsonConverterFromJson<Json, Value>(
Object? json,
Value? Function(Json json) fromJson,
) =>
json == null ? null : fromJson(json as Json);
Json? _$JsonConverterToJson<Json, Value>(
Value? value,
Json? Function(Value value) toJson,
) =>
value == null ? null : toJson(value);
_$_MediaItemArtCache _$$_MediaItemArtCacheFromJson(Map<String, dynamic> json) =>
_$_MediaItemArtCache(
fullArtUri: Uri.parse(json['fullArtUri'] as String),
fullArtCacheKey: json['fullArtCacheKey'] as String,
thumbnailArtUri: Uri.parse(json['thumbnailArtUri'] as String),
thumbnailArtCacheKey: json['thumbnailArtCacheKey'] as String,
);
Map<String, dynamic> _$$_MediaItemArtCacheToJson(
_$_MediaItemArtCache instance) =>
<String, dynamic>{
'fullArtUri': instance.fullArtUri.toString(),
'fullArtCacheKey': instance.fullArtCacheKey,
'thumbnailArtUri': instance.thumbnailArtUri.toString(),
'thumbnailArtCacheKey': instance.thumbnailArtCacheKey,
};

View File

@@ -0,0 +1,704 @@
import 'dart:async';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pool/pool.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:synchronized/synchronized.dart';
import '../cache/image_cache.dart';
import '../database/database.dart';
import '../models/music.dart';
import '../models/query.dart';
import '../models/support.dart';
import '../sources/music_source.dart';
import '../state/settings.dart';
import 'cache_service.dart';
import 'settings_service.dart';
part 'audio_service.g.dart';
class QueueSourceItem {
final MediaItem mediaItem;
final UriAudioSource audioSource;
final QueueData queueData;
const QueueSourceItem({
required this.mediaItem,
required this.audioSource,
required this.queueData,
});
}
class QueueSlice {
final QueueSourceItem? prev;
final QueueSourceItem? current;
final QueueSourceItem? next;
const QueueSlice(this.prev, this.current, this.next);
}
@Riverpod(keepAlive: true)
FutureOr<AudioControl> audioControlInit(AudioControlInitRef ref) async {
final imageCache = ref.watch(imageCacheProvider);
return AudioService.init(
builder: () => AudioControl(AudioPlayer(), ref),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.subtracks2.channel.audio',
androidNotificationChannelName: 'Music playback',
androidNotificationIcon: 'drawable/ic_stat_name',
),
cacheManager: imageCache,
cacheKeyResolver: (mediaItem) =>
mediaItem.data.artCache?.thumbnailArtCacheKey ?? '',
);
}
@Riverpod(keepAlive: true)
AudioControl audioControl(AudioControlRef ref) {
return ref.watch(audioControlInitProvider).requireValue;
}
class AudioControl extends BaseAudioHandler with QueueHandler, SeekHandler {
static const radioLength = 10;
Stream<Duration> get position => _player.positionStream;
BehaviorSubject<QueueMode> queueMode = BehaviorSubject.seeded(QueueMode.user);
BehaviorSubject<List<int>?> shuffleIndicies = BehaviorSubject.seeded(null);
BehaviorSubject<AudioServiceRepeatMode> repeatMode =
BehaviorSubject.seeded(AudioServiceRepeatMode.none);
final Ref _ref;
final AudioPlayer _player;
int? _queueLength;
int? _previousCurrentTrackIndex;
final _syncPool = Pool(1);
final _playLock = Lock();
final _currentIndexIgnore = <int?>[null, 0];
final ConcatenatingAudioSource _audioSource =
ConcatenatingAudioSource(children: []);
SubtracksDatabase get _db => _ref.read(databaseProvider);
CacheService get _cache => _ref.read(cacheServiceProvider);
MusicSource get _source => _ref.read(musicSourceProvider);
int get _sourceId => _ref.read(sourceIdProvider);
AudioControl(this._player, this._ref) {
_player.playbackEventStream.listen((PlaybackEvent event) {
final playing = _player.playing;
playbackState.add(playbackState.value.copyWith(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
},
androidCompactActionIndices: const [0, 1, 3],
processingState: const {
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState]!,
playing: playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
queueIndex: event.currentIndex,
));
});
shuffleIndicies.listen((value) {
playbackState.add(playbackState.value.copyWith(
shuffleMode: value != null
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
));
});
repeatMode.listen((value) {
playbackState.add(playbackState.value.copyWith(repeatMode: value));
});
_player.processingStateStream.listen((event) async {
if (event == ProcessingState.completed) {
if (_audioSource.length > 0) {
yell('completed');
await stop();
await seek(Duration.zero);
}
}
});
_ref.listen(sourceIdProvider, (_, __) async {
await _clearAudioSource(true);
await _db.clearQueue();
});
_ref.listen(maxBitrateProvider, (prev, next) async {
if (prev?.valueOrNull != next.valueOrNull) {
await _resyncQueue(true);
}
});
_ref.listen(
settingsServiceProvider.select((value) => value.app.streamFormat),
(prev, next) async {
await _resyncQueue(true);
});
_player.durationStream.listen((duration) {
if (mediaItem.valueOrNull == null) return;
final index = queue.valueOrNull?.indexOf(mediaItem.value!);
final updated = mediaItem.value!.copyWith(duration: duration);
mediaItem.add(updated);
if (index != null) {
queue.add(queue.value..replaceRange(index, index + 1, [updated]));
}
});
_player.currentIndexStream.listen((index) async {
if (_currentIndexIgnore.contains(index)) {
_currentIndexIgnore.remove(index);
return;
}
if (index == null || index >= _audioSource.sequence.length) return;
final queueIndex = _audioSource.sequence[index].tag;
if (queueIndex != null) {
await _db.setCurrentTrack(queueIndex);
}
});
_db.currentTrackIndex().watchSingleOrNull().listen((index) {
// distict() except for when in loop one mode
if (repeatMode.value != AudioServiceRepeatMode.one &&
_previousCurrentTrackIndex == index) {
return;
}
_previousCurrentTrackIndex = index;
_syncPool.withResource(() => _syncQueue(index));
});
}
Future<void> init() async {
await _player.setAudioSource(_audioSource, preload: false);
final last = await _db.getLastAudioState().getSingleOrNull();
if (last == null) return;
_queueLength = await _db.queueLength().getSingleOrNull();
final repeat = {
RepeatMode.none: AudioServiceRepeatMode.none,
RepeatMode.all: AudioServiceRepeatMode.all,
RepeatMode.one: AudioServiceRepeatMode.one,
}[last.repeat]!;
repeatMode.add(repeat);
await repeatMode.firstWhere((e) => e == repeat);
queueMode.add(last.queueMode);
await queueMode.firstWhere((e) => e == last.queueMode);
if (last.shuffleIndicies != null) {
final indicies = last.shuffleIndicies!.unlock;
shuffleIndicies.add(indicies);
await shuffleIndicies.firstWhere((e) => e == indicies);
}
final startIndex = await _db.currentTrackIndex().getSingleOrNull();
if (startIndex != null && _queueLength != null) {
await _preparePlayer(startIndex);
}
}
Future<void> playSongs({
QueueMode mode = QueueMode.user,
required QueueContextType context,
String? contextId,
required ListQuery query,
required FutureOr<Iterable<Song>> Function(ListQuery query) getSongs,
int? startIndex,
bool? shuffle,
}) async {
if (!_playLock.locked) {
return _playLock.synchronized(
() => _playSongs(
mode: mode,
context: context,
contextId: contextId,
query: query,
getSongs: getSongs,
startIndex: startIndex,
shuffle: shuffle,
),
);
}
}
Future<void> playRadio({
required QueueContextType context,
String? contextId,
ListQuery query = const ListQuery(),
required FutureOr<Iterable<Song>> Function(ListQuery query) getSongs,
}) async {
await playSongs(
mode: QueueMode.radio,
context: QueueContextType.library,
contextId: contextId,
query: query.copyWith(
sort: SortBy(
column: 'SIN(songs.ROWID + ${Random().nextInt(10000)})',
),
),
getSongs: getSongs,
startIndex: 0,
);
}
Future<void> _playSongs({
QueueMode mode = QueueMode.user,
required QueueContextType context,
String? contextId,
required ListQuery query,
required FutureOr<Iterable<Song>> Function(ListQuery query) getSongs,
int? startIndex,
bool? shuffle,
}) async {
shuffle = shuffle ?? shuffleIndicies.valueOrNull != null;
queueMode.add(mode);
if (mode == QueueMode.radio) {
if (repeatMode.value != AudioServiceRepeatMode.none) {
await _loop(AudioServiceRepeatMode.none);
}
if (shuffleIndicies.value != null) {
await _shuffle(unshuffle: true);
}
}
await _clearAudioSource();
const limit = 500;
_queueLength = 0;
if ((startIndex == null || startIndex >= limit) && shuffle) {
startIndex = Random().nextInt(limit);
} else {
startIndex ??= 0;
}
// clear the queue and load the initial songs only up to the startIndex
await _db.transaction(() async {
await _db.clearQueue();
while (_queueLength! <= startIndex!) {
final songs = await getSongs(query.copyWith(
page: Pagination(limit: limit, offset: _queueLength!),
));
await _loadQueueSongs(songs, _queueLength!, context, contextId);
if (songs.length < limit) {
break;
}
}
});
// if there are less songs than the limit and we're shuffling,
// choose a new random startIndex
if (startIndex >= _queueLength!) {
startIndex = Random().nextInt(_queueLength!);
}
await _preparePlayer(startIndex, shuffle);
await _db.setCurrentTrack(startIndex);
play();
const maxLength = 10000;
// no need to do extra loading if we've already loaded everything
if (_queueLength! < limit) return;
while (true) {
final songs = await getSongs(query.copyWith(
page: Pagination(limit: limit, offset: _queueLength!),
));
await _loadQueueSongs(songs, _queueLength!, context, contextId);
if (songs.length < limit || _queueLength! >= maxLength) {
break;
}
}
}
Future<void> _loadQueueSongs(
Iterable<Song> songs,
int total,
QueueContextType context,
String? contextId,
) async {
await _db.insertQueue(songs.mapIndexed(
(i, song) => QueueCompanion.insert(
index: Value(i + (_queueLength ?? 0)),
sourceId: song.sourceId,
id: song.id,
context: context,
contextId: Value(contextId),
),
));
_queueLength = (_queueLength ?? 0) + songs.length;
if (shuffleIndicies.valueOrNull != null) {
await _generateShuffleIndicies(startIndex: _player.currentIndex);
}
}
Future<void> _preparePlayer(int startIndex, [bool? shuffle]) async {
if (shuffle == true) {
await _shuffle(startIndex: startIndex);
} else if (shuffle == false) {
await _shuffle(unshuffle: true);
}
final slice = await _getQueueSlice(startIndex);
if (slice == null) {
throw StateError('Could not get queue slice!');
}
final list =
[slice.prev, slice.current, slice.next].whereNotNull().toList();
mediaItem.add(slice.current!.mediaItem);
queue.add(list.map((e) => e.mediaItem).toList());
yell('addAll');
await _audioSource.addAll(list.map((e) => e.audioSource).toList());
await _player.seek(Duration.zero, index: list.indexOf(slice.current!));
}
Future<void> _syncQueue(int? index) async {
if (index == null || _queueLength == null) return;
final slice = await _getQueueSlice(index);
if (slice == null || slice.current == null) return;
mediaItem.add(slice.current!.mediaItem);
queue.add(
[slice.prev, slice.current, slice.next]
.map((e) => e?.mediaItem)
.whereNotNull()
.toList(),
);
final sourceIndex = _player.currentIndex;
final sourceNeedsNext = sourceIndex == _audioSource.length - 1;
final sourceNeedsPrev = sourceIndex == 0;
if (sourceNeedsNext && slice.next != null) {
yell('add');
await _audioSource.add(slice.next!.audioSource);
}
if (sourceNeedsPrev && slice.prev != null) {
await _insertFirstAudioSource(slice.prev!.audioSource);
}
}
Future<void> _loop(AudioServiceRepeatMode mode) async {
repeatMode.add(mode);
await repeatMode.firstWhere((e) => e == mode);
await _resyncQueue();
}
Future<void> _generateShuffleIndicies({
bool unshuffle = false,
int? startIndex,
}) async {
final indicies = unshuffle
? null
: (List.generate(_queueLength!, (i) => i + 1)
..insert(0, 0)
..removeLast()
..shuffle());
if (indicies != null && startIndex != null) {
indicies.removeAt(indicies.indexOf(startIndex));
indicies.insert(0, startIndex);
}
shuffleIndicies.add(indicies);
await shuffleIndicies.firstWhere((e) => e == indicies);
}
Future<void> _shuffle({bool unshuffle = false, int? startIndex}) async {
await _generateShuffleIndicies(
unshuffle: unshuffle,
startIndex: startIndex,
);
await _resyncQueue();
}
Future<void> _resyncQueue([bool reloadCurrent = false]) async {
return _syncPool.withResource(() async {
final currentSource =
_player.sequenceState?.currentSource as UriAudioSource?;
if (currentSource == null) return;
final currentSourceIndex = _player.sequence!.indexOf(currentSource);
await _pruneAudioSources(currentSourceIndex);
if (reloadCurrent && !currentSource.uri.isScheme('file')) {
final position = _player.position;
final item = (await _getQueueItems([currentSource.tag]))[0];
await _audioSource.clear();
await _audioSource.add(item.audioSource);
await seek(position);
}
await _syncQueue(currentSource.tag);
});
}
int _realIndex(int index) {
if (shuffleIndicies.valueOrNull == null) {
return index;
}
if (index < 0 || index >= shuffleIndicies.value!.length) {
return -1;
}
return shuffleIndicies.value![index];
}
int _effectiveIndex(int index) {
if (shuffleIndicies.valueOrNull == null) {
return index;
}
return shuffleIndicies.value!.indexOf(index);
}
Future<void> _insertFirstAudioSource(AudioSource source) {
yell('insert');
final wait = _audioSource.insert(0, source);
_currentIndexIgnore.add(1);
return wait;
}
Future<void> _pruneAudioSources(int keepIndex) async {
if (keepIndex > 0) {
yell('removeRange 0');
final wait = _audioSource.removeRange(0, keepIndex);
_currentIndexIgnore.add(0);
await wait;
}
if (_audioSource.length > 1) {
yell('removeRange 1');
await _audioSource.removeRange(1, _audioSource.length);
}
}
Future<void> _clearAudioSource([bool clearMetadata = false]) async {
// await _player.stop();
yell('_clearAudioSource');
await _audioSource.clear();
if (clearMetadata) {
mediaItem.add(null);
queue.add([]);
queueTitle.add('');
}
}
Future<QueueSlice?> _getQueueSlice(int index) async {
if (_queueLength == null) {
return null;
}
final effectiveIndex = _effectiveIndex(index);
int nextIndex;
int prevIndex;
if (repeatMode.value == AudioServiceRepeatMode.none) {
nextIndex = _realIndex(effectiveIndex + 1);
prevIndex = _realIndex(effectiveIndex - 1);
} else if (repeatMode.value == AudioServiceRepeatMode.one) {
nextIndex = index;
prevIndex = index;
} else {
nextIndex = _realIndex(
effectiveIndex + 1 >= _queueLength! ? 0 : effectiveIndex + 1,
);
prevIndex = _realIndex(
effectiveIndex - 1 < 0 ? _queueLength! - 1 : effectiveIndex - 1,
);
}
final slice = await _getQueueItems([prevIndex, index, nextIndex]);
final current = slice.firstWhereOrNull(
(e) => e.queueData.index == index,
);
final next = slice.firstWhereOrNull((e) => e.queueData.index == nextIndex);
final prev = slice.firstWhereOrNull((e) => e.queueData.index == prevIndex);
return QueueSlice(prev, current, next);
}
Future<List<QueueSourceItem>> _getQueueItems(List<int> indexes) async {
final slice = await _db.queueInIndicies(indexes).get();
final songs =
await _db.songsInIds(_sourceId, slice.map((e) => e.id).toList()).get();
final songMap = {for (var song in songs) song.id: song};
final albumIds = songs.map((e) => e.albumId).whereNotNull().toSet();
final albums = await _db.albumsInIds(_sourceId, albumIds.toList()).get();
final albumArtMap = {
for (var album in albums) album.id: _mapArtCache(album)
};
final queueItems = slice.map(
(item) => _mapSong(
songMap[item.id]!,
MediaItemData(
sourceId: item.sourceId,
contextType: item.context,
contextId: item.contextId,
artCache: albumArtMap[songMap[item.id]!.albumId],
),
item,
),
);
return queueItems.toList();
}
QueueSourceItem _mapSong(Song song, MediaItemData data, QueueData queueData) {
final item = MediaItem(
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
duration: song.duration,
artUri: data.artCache?.thumbnailArtUri ?? _cache.placeholderThumbImageUri,
extras: {},
);
item.data = data;
return QueueSourceItem(
mediaItem: item,
audioSource: song.downloadFilePath != null
? AudioSource.file(song.downloadFilePath!, tag: queueData.index)
: AudioSource.uri(_source.streamUri(song.id), tag: queueData.index),
queueData: queueData,
);
}
MediaItemArtCache _mapArtCache(Album album) {
final full = _cache.albumArt(album, thumbnail: false);
final thumbnail = _cache.albumArt(album, thumbnail: true);
return MediaItemArtCache(
fullArtUri: full.uri,
fullArtCacheKey: full.cacheKey,
thumbnailArtUri: thumbnail.uri,
thumbnailArtCacheKey: thumbnail.cacheKey,
);
}
///
/// AudioHandler
///
@override
Future<void> play() async {
await _player.play();
}
@override
Future<void> pause() async {
await _player.pause();
}
@override
Future<void> stop() async {
await _player.stop();
}
@override
Future<void> seek(Duration position) async {
await _player.seek(position);
}
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
@override
Future<void> skipToQueueItem(int index) async {
if (_player.effectiveIndices == null || _player.effectiveIndices!.isEmpty) {
return;
}
index = _player.effectiveIndices![index];
if (index < 0 || index >= queue.value.length) {
return;
}
await _player.seek(Duration.zero, index: index);
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
if (queueMode.value == QueueMode.radio) {
switch (repeatMode) {
case AudioServiceRepeatMode.all:
case AudioServiceRepeatMode.group:
case AudioServiceRepeatMode.one:
return _loop(AudioServiceRepeatMode.one);
default:
return _loop(AudioServiceRepeatMode.none);
}
}
return _loop(repeatMode);
}
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
if (queueMode.value == QueueMode.radio) {
return;
}
switch (shuffleMode) {
case AudioServiceShuffleMode.all:
case AudioServiceShuffleMode.group:
return _shuffle(startIndex: _player.sequenceState?.currentSource?.tag);
case AudioServiceShuffleMode.none:
return _shuffle(unshuffle: true);
}
}
}
void yell(String msg) {
print('===================================================================<');
print(msg);
print('===================================================================>');
}

View File

@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audio_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$audioControlInitHash() => r'ecde06a9f4f7be5ca28e1f5f3c1f3e7fb2ce8dc5';
/// See also [audioControlInit].
@ProviderFor(audioControlInit)
final audioControlInitProvider = FutureProvider<AudioControl>.internal(
audioControlInit,
name: r'audioControlInitProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$audioControlInitHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef AudioControlInitRef = FutureProviderRef<AudioControl>;
String _$audioControlHash() => r'ea50108f29366182238a5e68d6cdcd1d874e4ba2';
/// See also [audioControl].
@ProviderFor(audioControl)
final audioControlProvider = Provider<AudioControl>.internal(
audioControl,
name: r'audioControlProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$audioControlHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef AudioControlRef = ProviderRef<AudioControl>;
// 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

View File

@@ -0,0 +1,112 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../cache/image_cache.dart';
import '../models/music.dart';
import '../models/support.dart';
import '../sources/music_source.dart';
import '../state/init.dart';
import '../state/settings.dart';
part 'cache_service.g.dart';
String _cacheKey(
String type,
int sourceId,
String albumId, [
bool thumbnail = false,
]) {
return '$type${thumbnail ? 'Thumb' : ''}-$sourceId-$albumId';
}
String albumArtCacheKey(
int sourceId,
String albumId,
bool thumbnail,
) {
return _cacheKey('albumArt', sourceId, albumId, thumbnail);
}
String playlistArtCacheKey(
int sourceId,
String albumId,
bool thumbnail,
) {
return _cacheKey('playlistArt', sourceId, albumId, thumbnail);
}
String artistArtCacheKey(
int sourceId,
String albumId,
bool thumbnail,
) {
return _cacheKey('artistArt', sourceId, albumId, thumbnail);
}
@Riverpod(keepAlive: true)
CacheService cacheService(CacheServiceRef ref) {
final imageCache = ref.watch(imageCacheProvider);
final source = ref.watch(musicSourceProvider);
final placeholderImageUri =
ref.watch(placeholderImageUriProvider).requireValue;
final placeholderThumbImageUri =
ref.watch(placeholderThumbImageUriProvider).requireValue;
return CacheService(
imageCache: imageCache,
source: source,
placeholderImageUri: placeholderImageUri,
placeholderThumbImageUri: placeholderThumbImageUri,
);
}
class CacheService {
final CacheManager imageCache;
final MusicSource source;
final Uri placeholderImageUri;
final Uri placeholderThumbImageUri;
CacheService({
required this.imageCache,
required this.source,
required this.placeholderImageUri,
required this.placeholderThumbImageUri,
});
UriCacheInfo albumArt(Album album, {bool thumbnail = true}) {
final id = album.coverArt ?? album.id;
return UriCacheInfo(
uri: source.coverArtUri(id, thumbnail: thumbnail),
cacheKey: albumArtCacheKey(source.id, album.id, thumbnail),
cacheManager: imageCache,
);
}
UriCacheInfo playlistArt(Playlist playlist, {bool thumbnail = true}) {
final id = playlist.coverArt ?? playlist.id;
return UriCacheInfo(
uri: source.coverArtUri(id),
cacheKey: playlistArtCacheKey(source.id, playlist.id, thumbnail),
cacheManager: imageCache);
}
UriCacheInfo placeholder({bool thumbnail = true}) {
final uri = thumbnail ? placeholderThumbImageUri : placeholderImageUri;
return UriCacheInfo(
uri: uri,
cacheKey: uri.toString(),
cacheManager: imageCache,
);
}
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true}) {
return source.artistArtUri(artistId, thumbnail: thumbnail);
}
CacheInfo artistArtCacheInfo(String artistId, {bool thumbnail = true}) {
return CacheInfo(
cacheKey: artistArtCacheKey(source.id, artistId, thumbnail),
cacheManager: imageCache,
);
}
}

View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cache_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$cacheServiceHash() => r'5e83011fbdfc5a962d43e3311b666dde2c455e24';
/// See also [cacheService].
@ProviderFor(cacheService)
final cacheServiceProvider = Provider<CacheService>.internal(
cacheService,
name: r'cacheServiceProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$cacheServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef CacheServiceRef = ProviderRef<CacheService>;
// 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

View File

@@ -0,0 +1,589 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../http/client.dart';
import '../models/music.dart';
import '../models/query.dart';
import '../models/support.dart';
import '../state/music.dart';
import '../state/settings.dart';
import 'cache_service.dart';
part 'download_service.freezed.dart';
part 'download_service.g.dart';
@freezed
class DownloadState with _$DownloadState {
const factory DownloadState({
@Default(IListConst([])) IList<Download> downloads,
@Default(IListConst([])) IList<SourceId> deletes,
@Default(IListConst([])) IList<SourceId> listDownloads,
@Default(IListConst([])) IList<SourceId> listCancels,
required String saveDir,
}) = _DownloadState;
}
@freezed
class Download with _$Download {
const Download._();
const factory Download({
required String taskId,
required DownloadTaskStatus status,
required int progress,
required String url,
required String? filename,
required String savedDir,
required int timeCreated,
required bool allowCellular,
}) = _Download;
factory Download.fromTask(DownloadTask task) {
return Download(
taskId: task.taskId,
status: task.status,
progress: task.progress,
url: task.url,
filename: task.filename,
savedDir: task.savedDir,
timeCreated: task.timeCreated,
allowCellular: task.allowCellular,
);
}
DownloadTask toTask() {
return DownloadTask(
taskId: taskId,
status: status,
progress: progress,
url: url,
filename: filename,
savedDir: savedDir,
timeCreated: timeCreated,
allowCellular: allowCellular,
);
}
}
enum SongDownloadState {
none,
inProgress,
completed,
}
@Riverpod(keepAlive: true)
class DownloadService extends _$DownloadService {
static final ReceivePort _port = ReceivePort();
@override
DownloadState build() {
return const DownloadState(saveDir: '');
}
Future<void> init() async {
await FlutterDownloader.initialize(
// debug: true,
ignoreSsl: true,
);
state = state.copyWith(
saveDir: path.join(
(await getApplicationDocumentsDirectory()).path,
'downloads',
),
);
_bindBackgroundIsolate();
await _syncDownloadTasks();
FlutterDownloader.registerCallback(downloadCallback, step: 1);
}
Future<void> downloadAlbum(Album album) async {
return _downloadList(album, () async {
final cache = ref.read(cacheServiceProvider);
await Future.wait([
_cacheArtistArt(album.sourceId, album.artistId, false),
_cacheArtistArt(album.sourceId, album.artistId, true),
_cacheImage(cache.albumArt(album, thumbnail: false)),
_cacheImage(cache.albumArt(album, thumbnail: true)),
]);
if (_isCanceled(album)) return;
final songs = await _albumSongs(album, SongDownloadState.none);
if (_isCanceled(album)) return;
for (var song in songs) {
await _downloadSong(song);
if (_isCanceled(album)) return;
}
});
}
Future<void> downloadPlaylist(Playlist playlist) async {
return _downloadList(playlist, () async {
final songs = await _playlistSongs(playlist, SongDownloadState.none);
if (_isCanceled(playlist)) return;
final albumIds = songs.map((e) => e.albumId).whereNotNull().toSet();
final albums =
await ref.read(albumsInIdsProvider(albumIds.toIList()).future);
final artistIds = albums.map((e) => e.artistId).whereNotNull().toSet();
final cache = ref.read(cacheServiceProvider);
await Future.wait([
_cacheImage(cache.playlistArt(playlist, thumbnail: true)),
_cacheImage(cache.playlistArt(playlist, thumbnail: false)),
...albums.map((a) => _cacheImage(cache.albumArt(a, thumbnail: true))),
...albums.map((a) => _cacheImage(cache.albumArt(a, thumbnail: false))),
...artistIds.map(
(artistId) => _cacheArtistArt(playlist.sourceId, artistId, true),
),
...artistIds.map(
(artistId) => _cacheArtistArt(playlist.sourceId, artistId, false),
),
]);
if (_isCanceled(playlist)) return;
for (var song in songs) {
await _downloadSong(song);
if (_isCanceled(playlist)) return;
}
});
}
Future<void> cancelAlbum(Album album) async {
return _cancelList(album, () async {
final songs = await _albumSongs(album, SongDownloadState.inProgress);
for (var song in songs) {
try {
await FlutterDownloader.cancel(taskId: song.downloadTaskId!);
} catch (e) {
//
}
}
});
}
Future<void> cancelPlaylist(Playlist playlist) async {
return _cancelList(playlist, () async {
final songs =
await _playlistSongs(playlist, SongDownloadState.inProgress);
for (var song in songs) {
await FlutterDownloader.cancel(taskId: song.downloadTaskId!);
}
});
}
Future<void> deleteAlbum(Album album) async {
return _deleteList(album, () async {
final db = ref.read(databaseProvider);
final songs = await _albumSongs(album, SongDownloadState.completed);
for (var song in songs) {
await _tryDeleteFile(song.downloadFilePath!);
await db.deleteSongDownloadFile(song.sourceId, song.id);
}
});
}
Future<void> deletePlaylist(Playlist playlist) async {
return _deleteList(playlist, () async {
final db = ref.read(databaseProvider);
final songs = await _playlistSongs(playlist, SongDownloadState.completed);
for (var song in songs) {
if (await _tryDeleteFile(song.downloadFilePath!)) {
await db.deleteSongDownloadFile(song.sourceId, song.id);
}
}
});
}
Future<void> deleteAll(int sourceId) async {
final db = ref.read(databaseProvider);
final albumIds = await db.albumIdsWithDownloaded(sourceId).get();
for (var id in albumIds) {
await deleteAlbum(await (db.albumById(sourceId, id)).getSingle());
}
}
Future<void> _downloadList(
SourceIdentifiable list,
Future<void> Function() callback,
) async {
final sourceId = SourceId.from(list);
if (state.listDownloads.contains(sourceId)) {
return;
}
state = state.copyWith(listDownloads: state.listDownloads.add(sourceId));
try {
await callback();
} finally {
state = state.copyWith(
listDownloads: state.listDownloads.remove(sourceId),
);
}
}
Future<void> _cancelList(
SourceIdentifiable list,
Future<void> Function() callback,
) async {
final sourceId = SourceId.from(list);
if (state.listCancels.contains(sourceId)) return;
state = state.copyWith(
listCancels: state.listCancels.add(sourceId),
);
if (state.listDownloads.contains(sourceId)) {
var tries = 0;
while (tries < 60) {
await Future.delayed(const Duration(seconds: 1));
if (!state.listDownloads.contains(sourceId)) {
break;
}
}
}
try {
await callback();
} finally {
state = state.copyWith(
listCancels: state.listCancels.remove(sourceId),
);
}
}
Future<void> _deleteList(
SourceIdentifiable list,
Future<void> Function() callback,
) async {
final sourceId = SourceId.from(list);
if (state.deletes.contains(sourceId)) {
return;
}
state = state.copyWith(deletes: state.deletes.add(sourceId));
try {
await callback();
} finally {
state = state.copyWith(deletes: state.deletes.remove(sourceId));
}
}
Future<void> _downloadSong(Song song) async {
if (song.downloadFilePath != null || song.downloadTaskId != null) {
return;
}
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final http = ref.read(httpClientProvider);
final uri = source.downloadUri(song.id);
final head = await http.head(uri);
final contentType = head.headers['content-type'];
if (contentType == null) {
throw StateError('Bad HTTP response from HEAD during download');
}
final mime = contentType.split(';').first.toLowerCase();
if (!mime.startsWith('audio') && !mime.startsWith('application')) {
throw StateError('Download error: MIME-type $mime is not audio');
}
String? ext = extensionFromMime(mime);
if (ext == mime) {
ext = null;
}
final saveDir = Directory(
path.join(state.saveDir, song.sourceId.toString()),
);
await saveDir.create(recursive: true);
final taskId = await FlutterDownloader.enqueue(
url: source.downloadUri(song.id).toString(),
savedDir: saveDir.path,
fileName: ext != null ? '${song.id}.$ext' : song.id,
headers: subtracksHeaders,
openFileFromNotification: false,
showNotification: false,
);
await db.updateSongDownloadTask(taskId, song.sourceId, song.id);
}
Future<void> _cacheImage(UriCacheInfo cache) async {
final cachedFile = await cache.cacheManager.getFileFromCache(
cache.cacheKey,
ignoreMemCache: true,
);
if (cachedFile == null) {
try {
await cache.cacheManager.getSingleFile(
cache.uri.toString(),
key: cache.cacheKey,
);
} catch (_) {}
}
}
Future<void> _cacheArtistArt(
int sourceId,
String? artistId,
bool thumbnail,
) async {
if (artistId == null) {
return;
}
final cache = ref.read(cacheServiceProvider);
try {
final uri = await cache.artistArtUri(artistId, thumbnail: thumbnail);
if (uri == null) {
return;
}
await _cacheImage(UriCacheInfo(
uri: uri,
cacheKey:
cache.artistArtCacheInfo(artistId, thumbnail: thumbnail).cacheKey,
cacheManager: cache.imageCache,
));
} catch (_) {}
}
bool _isCanceled(SourceIdentifiable item) {
return state.listCancels.contains(SourceId.from(item));
}
List<FilterWith> _downloadFilters(SongDownloadState state) {
switch (state) {
case SongDownloadState.none:
return [
const FilterWith.isNull(column: 'download_task_id'),
const FilterWith.isNull(column: 'download_file_path'),
];
case SongDownloadState.completed:
return [
const FilterWith.isNull(column: 'download_file_path', invert: true),
];
case SongDownloadState.inProgress:
return [
const FilterWith.isNull(column: 'download_task_id', invert: true),
];
}
}
Future<List<Song>> _albumSongs(
Album album,
SongDownloadState state,
) {
return ref
.read(databaseProvider)
.albumSongsList(
SourceId.from(album),
ListQuery(
sort: const SortBy(column: 'disc, track'),
filters: _downloadFilters(state).lock,
),
)
.get();
}
Future<List<Song>> _playlistSongs(
Playlist playlist,
SongDownloadState state,
) {
return ref
.read(databaseProvider)
.playlistSongsList(
SourceId.from(playlist),
ListQuery(
sort: const SortBy(column: 'playlist_songs.position'),
filters: _downloadFilters(state).lock,
),
)
.get();
}
Future<void> _syncDownloadTasks() async {
final tasks = await FlutterDownloader.loadTasks() ?? [];
final downloads = tasks.map((e) => Download.fromTask(e)).toIList();
state = state.copyWith(downloads: downloads);
final db = ref.read(databaseProvider);
final songs = await db.songsWithDownloadTasks().get();
await _deleteTasksNotIn(songs.map((e) => e.downloadTaskId!).toList());
final deleteTaskStatus = [
DownloadTaskStatus.canceled,
DownloadTaskStatus.failed,
DownloadTaskStatus.undefined,
];
for (var song in songs) {
final download = downloads.firstWhereOrNull(
(t) => t.taskId == song.downloadTaskId,
);
if (download == null) {
await db.clearSongDownloadTaskBySong(song.sourceId, song.id);
continue;
}
if (deleteTaskStatus.anyIs(download.status)) {
await _clearFailedDownload(download);
} else if (download.status == DownloadTaskStatus.complete) {
await _completeDownload(download);
}
}
}
Future<bool> _tryDeleteFile(String filePath) async {
try {
final file = File(filePath);
await file.delete();
return true;
} catch (_) {
return false;
}
}
Future<void> _deleteTasksNotIn(List<String> taskIds) async {
if (taskIds.isEmpty) {
return;
}
await FlutterDownloader.loadTasksWithRawQuery(
query: 'DELETE FROM task WHERE task_id NOT IN '
'(${taskIds.map((e) => "'$e'").join(',')})',
);
}
Future<void> _deleteTask(String taskId) async {
await FlutterDownloader.loadTasksWithRawQuery(
query: 'DELETE FROM task WHERE task_id = \'$taskId\'',
);
}
Future<DownloadTask?> _getTask(String taskId) async {
return (await FlutterDownloader.loadTasksWithRawQuery(
query: 'SELECT * FROM task WHERE task_id = \'$taskId\'',
))
?.firstOrNull;
}
Future<void> _completeDownload(Download download) async {
final db = ref.read(databaseProvider);
await db.completeSongDownload(
path.join(download.savedDir, download.filename),
download.taskId,
);
await _deleteTask(download.taskId);
state = state.copyWith(
downloads: state.downloads.removeWhere(
(d) => d.taskId == download.taskId,
),
);
}
Future<void> _clearFailedDownload(Download download) async {
final db = ref.read(databaseProvider);
await db.clearSongDownloadTask(download.taskId);
await _deleteTask(download.taskId);
await _tryDeleteFile(path.join(download.savedDir, download.filename));
state = state.copyWith(
downloads: state.downloads.removeWhere(
(d) => d.taskId == download.taskId,
),
);
}
void _bindBackgroundIsolate([retry = 0]) {
final isSuccess = IsolateNameServer.registerPortWithName(
_port.sendPort,
'downloader_send_port',
);
if (!isSuccess && retry < 100) {
_unbindBackgroundIsolate();
_bindBackgroundIsolate(retry + 1);
return;
} else if (retry >= 100) {
throw StateError('Could not bind background isolate for downloads');
}
_port.asyncMap((dynamic data) async {
final taskId = (data as List<dynamic>)[0] as String;
final status = DownloadTaskStatus(data[1] as int);
final progress = data[2] as int;
var download = state.downloads.firstWhereOrNull(
(task) => task.taskId == taskId,
);
if (download == null) {
final task = await _getTask(taskId);
if (task == null) {
return;
}
download = Download.fromTask(task);
}
download = download.copyWith(status: status, progress: progress);
state = state.copyWith(
downloads: state.downloads.replaceFirstWhere(
(d) => d.taskId == taskId,
(d) => download!,
addIfNotFound: true,
),
);
final deleteTaskStatus = [
DownloadTaskStatus.canceled,
DownloadTaskStatus.failed,
DownloadTaskStatus.undefined,
];
if (status == DownloadTaskStatus.complete) {
await _completeDownload(download);
} else if (deleteTaskStatus.anyIs(status)) {
await _clearFailedDownload(download);
}
}).listen((_) {});
}
void _unbindBackgroundIsolate() {
IsolateNameServer.removePortNameMapping('downloader_send_port');
}
@pragma('vm:entry-point')
static void downloadCallback(
String id,
DownloadTaskStatus status,
int progress,
) {
IsolateNameServer.lookupPortByName('downloader_send_port')?.send(
[id, status.value, progress],
);
}
}

View File

@@ -0,0 +1,495 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'download_service.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$DownloadState {
IList<Download> get downloads => throw _privateConstructorUsedError;
IList<SourceId> get deletes => throw _privateConstructorUsedError;
IList<SourceId> get listDownloads => throw _privateConstructorUsedError;
IList<SourceId> get listCancels => throw _privateConstructorUsedError;
String get saveDir => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DownloadStateCopyWith<DownloadState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DownloadStateCopyWith<$Res> {
factory $DownloadStateCopyWith(
DownloadState value, $Res Function(DownloadState) then) =
_$DownloadStateCopyWithImpl<$Res, DownloadState>;
@useResult
$Res call(
{IList<Download> downloads,
IList<SourceId> deletes,
IList<SourceId> listDownloads,
IList<SourceId> listCancels,
String saveDir});
}
/// @nodoc
class _$DownloadStateCopyWithImpl<$Res, $Val extends DownloadState>
implements $DownloadStateCopyWith<$Res> {
_$DownloadStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? downloads = null,
Object? deletes = null,
Object? listDownloads = null,
Object? listCancels = null,
Object? saveDir = null,
}) {
return _then(_value.copyWith(
downloads: null == downloads
? _value.downloads
: downloads // ignore: cast_nullable_to_non_nullable
as IList<Download>,
deletes: null == deletes
? _value.deletes
: deletes // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listDownloads: null == listDownloads
? _value.listDownloads
: listDownloads // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listCancels: null == listCancels
? _value.listCancels
: listCancels // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
saveDir: null == saveDir
? _value.saveDir
: saveDir // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DownloadStateCopyWith<$Res>
implements $DownloadStateCopyWith<$Res> {
factory _$$_DownloadStateCopyWith(
_$_DownloadState value, $Res Function(_$_DownloadState) then) =
__$$_DownloadStateCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{IList<Download> downloads,
IList<SourceId> deletes,
IList<SourceId> listDownloads,
IList<SourceId> listCancels,
String saveDir});
}
/// @nodoc
class __$$_DownloadStateCopyWithImpl<$Res>
extends _$DownloadStateCopyWithImpl<$Res, _$_DownloadState>
implements _$$_DownloadStateCopyWith<$Res> {
__$$_DownloadStateCopyWithImpl(
_$_DownloadState _value, $Res Function(_$_DownloadState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? downloads = null,
Object? deletes = null,
Object? listDownloads = null,
Object? listCancels = null,
Object? saveDir = null,
}) {
return _then(_$_DownloadState(
downloads: null == downloads
? _value.downloads
: downloads // ignore: cast_nullable_to_non_nullable
as IList<Download>,
deletes: null == deletes
? _value.deletes
: deletes // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listDownloads: null == listDownloads
? _value.listDownloads
: listDownloads // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listCancels: null == listCancels
? _value.listCancels
: listCancels // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
saveDir: null == saveDir
? _value.saveDir
: saveDir // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class _$_DownloadState implements _DownloadState {
const _$_DownloadState(
{this.downloads = const IListConst([]),
this.deletes = const IListConst([]),
this.listDownloads = const IListConst([]),
this.listCancels = const IListConst([]),
required this.saveDir});
@override
@JsonKey()
final IList<Download> downloads;
@override
@JsonKey()
final IList<SourceId> deletes;
@override
@JsonKey()
final IList<SourceId> listDownloads;
@override
@JsonKey()
final IList<SourceId> listCancels;
@override
final String saveDir;
@override
String toString() {
return 'DownloadState(downloads: $downloads, deletes: $deletes, listDownloads: $listDownloads, listCancels: $listCancels, saveDir: $saveDir)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_DownloadState &&
const DeepCollectionEquality().equals(other.downloads, downloads) &&
const DeepCollectionEquality().equals(other.deletes, deletes) &&
const DeepCollectionEquality()
.equals(other.listDownloads, listDownloads) &&
const DeepCollectionEquality()
.equals(other.listCancels, listCancels) &&
(identical(other.saveDir, saveDir) || other.saveDir == saveDir));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(downloads),
const DeepCollectionEquality().hash(deletes),
const DeepCollectionEquality().hash(listDownloads),
const DeepCollectionEquality().hash(listCancels),
saveDir);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DownloadStateCopyWith<_$_DownloadState> get copyWith =>
__$$_DownloadStateCopyWithImpl<_$_DownloadState>(this, _$identity);
}
abstract class _DownloadState implements DownloadState {
const factory _DownloadState(
{final IList<Download> downloads,
final IList<SourceId> deletes,
final IList<SourceId> listDownloads,
final IList<SourceId> listCancels,
required final String saveDir}) = _$_DownloadState;
@override
IList<Download> get downloads;
@override
IList<SourceId> get deletes;
@override
IList<SourceId> get listDownloads;
@override
IList<SourceId> get listCancels;
@override
String get saveDir;
@override
@JsonKey(ignore: true)
_$$_DownloadStateCopyWith<_$_DownloadState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$Download {
String get taskId => throw _privateConstructorUsedError;
DownloadTaskStatus get status => throw _privateConstructorUsedError;
int get progress => throw _privateConstructorUsedError;
String get url => throw _privateConstructorUsedError;
String? get filename => throw _privateConstructorUsedError;
String get savedDir => throw _privateConstructorUsedError;
int get timeCreated => throw _privateConstructorUsedError;
bool get allowCellular => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DownloadCopyWith<Download> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DownloadCopyWith<$Res> {
factory $DownloadCopyWith(Download value, $Res Function(Download) then) =
_$DownloadCopyWithImpl<$Res, Download>;
@useResult
$Res call(
{String taskId,
DownloadTaskStatus status,
int progress,
String url,
String? filename,
String savedDir,
int timeCreated,
bool allowCellular});
}
/// @nodoc
class _$DownloadCopyWithImpl<$Res, $Val extends Download>
implements $DownloadCopyWith<$Res> {
_$DownloadCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? taskId = null,
Object? status = null,
Object? progress = null,
Object? url = null,
Object? filename = freezed,
Object? savedDir = null,
Object? timeCreated = null,
Object? allowCellular = null,
}) {
return _then(_value.copyWith(
taskId: null == taskId
? _value.taskId
: taskId // ignore: cast_nullable_to_non_nullable
as String,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as DownloadTaskStatus,
progress: null == progress
? _value.progress
: progress // ignore: cast_nullable_to_non_nullable
as int,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
filename: freezed == filename
? _value.filename
: filename // ignore: cast_nullable_to_non_nullable
as String?,
savedDir: null == savedDir
? _value.savedDir
: savedDir // ignore: cast_nullable_to_non_nullable
as String,
timeCreated: null == timeCreated
? _value.timeCreated
: timeCreated // ignore: cast_nullable_to_non_nullable
as int,
allowCellular: null == allowCellular
? _value.allowCellular
: allowCellular // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DownloadCopyWith<$Res> implements $DownloadCopyWith<$Res> {
factory _$$_DownloadCopyWith(
_$_Download value, $Res Function(_$_Download) then) =
__$$_DownloadCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String taskId,
DownloadTaskStatus status,
int progress,
String url,
String? filename,
String savedDir,
int timeCreated,
bool allowCellular});
}
/// @nodoc
class __$$_DownloadCopyWithImpl<$Res>
extends _$DownloadCopyWithImpl<$Res, _$_Download>
implements _$$_DownloadCopyWith<$Res> {
__$$_DownloadCopyWithImpl(
_$_Download _value, $Res Function(_$_Download) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? taskId = null,
Object? status = null,
Object? progress = null,
Object? url = null,
Object? filename = freezed,
Object? savedDir = null,
Object? timeCreated = null,
Object? allowCellular = null,
}) {
return _then(_$_Download(
taskId: null == taskId
? _value.taskId
: taskId // ignore: cast_nullable_to_non_nullable
as String,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as DownloadTaskStatus,
progress: null == progress
? _value.progress
: progress // ignore: cast_nullable_to_non_nullable
as int,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
filename: freezed == filename
? _value.filename
: filename // ignore: cast_nullable_to_non_nullable
as String?,
savedDir: null == savedDir
? _value.savedDir
: savedDir // ignore: cast_nullable_to_non_nullable
as String,
timeCreated: null == timeCreated
? _value.timeCreated
: timeCreated // ignore: cast_nullable_to_non_nullable
as int,
allowCellular: null == allowCellular
? _value.allowCellular
: allowCellular // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _$_Download extends _Download {
const _$_Download(
{required this.taskId,
required this.status,
required this.progress,
required this.url,
required this.filename,
required this.savedDir,
required this.timeCreated,
required this.allowCellular})
: super._();
@override
final String taskId;
@override
final DownloadTaskStatus status;
@override
final int progress;
@override
final String url;
@override
final String? filename;
@override
final String savedDir;
@override
final int timeCreated;
@override
final bool allowCellular;
@override
String toString() {
return 'Download(taskId: $taskId, status: $status, progress: $progress, url: $url, filename: $filename, savedDir: $savedDir, timeCreated: $timeCreated, allowCellular: $allowCellular)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_Download &&
(identical(other.taskId, taskId) || other.taskId == taskId) &&
(identical(other.status, status) || other.status == status) &&
(identical(other.progress, progress) ||
other.progress == progress) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.filename, filename) ||
other.filename == filename) &&
(identical(other.savedDir, savedDir) ||
other.savedDir == savedDir) &&
(identical(other.timeCreated, timeCreated) ||
other.timeCreated == timeCreated) &&
(identical(other.allowCellular, allowCellular) ||
other.allowCellular == allowCellular));
}
@override
int get hashCode => Object.hash(runtimeType, taskId, status, progress, url,
filename, savedDir, timeCreated, allowCellular);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DownloadCopyWith<_$_Download> get copyWith =>
__$$_DownloadCopyWithImpl<_$_Download>(this, _$identity);
}
abstract class _Download extends Download {
const factory _Download(
{required final String taskId,
required final DownloadTaskStatus status,
required final int progress,
required final String url,
required final String? filename,
required final String savedDir,
required final int timeCreated,
required final bool allowCellular}) = _$_Download;
const _Download._() : super._();
@override
String get taskId;
@override
DownloadTaskStatus get status;
@override
int get progress;
@override
String get url;
@override
String? get filename;
@override
String get savedDir;
@override
int get timeCreated;
@override
bool get allowCellular;
@override
@JsonKey(ignore: true)
_$$_DownloadCopyWith<_$_Download> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$downloadServiceHash() => r'92e963b5c070f4d1edb0cd81899b16393c2b9a70';
/// See also [DownloadService].
@ProviderFor(DownloadService)
final downloadServiceProvider =
NotifierProvider<DownloadService, DownloadState>.internal(
DownloadService.new,
name: r'downloadServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$downloadServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$DownloadService = Notifier<DownloadState>;
// 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

View File

@@ -0,0 +1,123 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../http/client.dart';
import '../models/settings.dart';
import '../sources/subsonic/client.dart';
import '../state/init.dart';
import 'download_service.dart';
part 'settings_service.g.dart';
@Riverpod(keepAlive: true)
class SettingsService extends _$SettingsService {
SubtracksDatabase get _db => ref.read(databaseProvider);
@override
Settings build() {
return const Settings();
}
Future<void> init() async {
final sources = await _db.allSubsonicSources().get();
final settings = await _db.getAppSettings().getSingleOrNull();
state = Settings(
sources: sources
.sorted((a, b) => a.createdAt.compareTo(b.createdAt))
.toIList(),
activeSource: sources.singleWhereOrNull((e) => e.isActive == true),
app: settings ?? const AppSettings(),
);
}
Future<void> createSource(
SourcesCompanion source,
SubsonicSourcesCompanion subsonic,
) async {
final client = SubsonicClient(
SubsonicSettings(
id: 1,
name: source.name.value,
address: source.address.value,
features: IList(),
username: subsonic.username.value,
password: subsonic.password.value,
useTokenAuth: true,
isActive: true,
createdAt: DateTime.now(),
),
ref.read(httpClientProvider),
);
final features = IList([
if (await client.testFeature(SubsonicFeature.emptyQuerySearch))
SubsonicFeature.emptyQuerySearch,
]);
await _db.createSource(
source,
subsonic.copyWith(features: Value(features)),
);
await init();
}
Future<void> updateSource(SubsonicSettings source) async {
await _db.updateSource(source);
await init();
}
Future<void> deleteSource(int sourceId) async {
await ref.read(downloadServiceProvider.notifier).deleteAll(sourceId);
await _db.deleteSource(sourceId);
await init();
}
Future<void> setActiveSource(int id) async {
await _db.setActiveSource(id);
await init();
}
Future<void> addTestSource(String prefix) async {
final env = ref.read(envProvider).requireValue;
await createSource(
SourcesCompanion.insert(
name: env['${prefix}_SERVER_NAME']!,
address: Uri.parse(env['${prefix}_SERVER_URL']!),
),
SubsonicSourcesCompanion.insert(
features: IList(),
username: env['${prefix}_SERVER_USERNAME']!,
password: env['${prefix}_SERVER_PASSWORD']!,
useTokenAuth: const Value(true),
),
);
await init();
}
Future<void> setMaxBitrateWifi(int bitrate) async {
await _db.updateSettings(
state.app.copyWith(maxBitrateWifi: bitrate).toCompanion(),
);
await init();
}
Future<void> setMaxBitrateMobile(int bitrate) async {
await _db.updateSettings(
state.app.copyWith(maxBitrateMobile: bitrate).toCompanion(),
);
await init();
}
Future<void> setStreamFormat(String? streamFormat) async {
await _db.updateSettings(
state.app.copyWith(streamFormat: streamFormat).toCompanion(),
);
await init();
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$settingsServiceHash() => r'85f2bd5eedc3f791fe03a6707748bc95277c6aaf';
/// See also [SettingsService].
@ProviderFor(SettingsService)
final settingsServiceProvider =
NotifierProvider<SettingsService, Settings>.internal(
SettingsService.new,
name: r'settingsServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$settingsServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SettingsService = Notifier<Settings>;
// 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

View File

@@ -0,0 +1,105 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../state/settings.dart';
part 'sync_service.g.dart';
@Riverpod(keepAlive: true)
class SyncService extends _$SyncService {
@override
DateTime build() {
return DateTime.now();
}
Future<void> syncAll() async {
final db = ref.read(databaseProvider);
await db.transaction(() async {
await Future.wait([
db.transaction(_syncAllArtists),
db.transaction(_syncAllAlbums),
db.transaction(_syncAllPlaylists),
db.transaction(_syncAllSongs),
]);
});
state = DateTime.now();
}
Future<void> _syncAllArtists() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var artists in source.allArtists()) {
ids.addAll(artists.map((e) => e.id.value));
await db.saveArtists(artists);
}
await db.deleteArtistsNotIn(source.id, ids);
}
Future<void> _syncAllAlbums() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var albums in source.allAlbums()) {
ids.addAll(albums.map((e) => e.id.value));
await db.saveAlbums(albums);
}
await db.deleteAlbumsNotIn(source.id, ids);
}
Future<void> _syncAllPlaylists() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var playlists in source.allPlaylists()) {
ids.addAll(playlists.map((e) => e.playist.id.value));
await db.savePlaylists(playlists);
}
await db.deletePlaylistsNotIn(source.id, ids);
}
Future<void> _syncAllSongs() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var songs in source.allSongs()) {
ids.addAll(songs.map((e) => e.id.value));
await db.saveSongs(songs);
}
await db.deleteSongsNotIn(source.id, ids);
}
// Future<void> syncArtist(String id) async {
// final source = ref.read(musicSourceProvider);
// final db = ref.read(databaseProvider);
// final artist = await source.artist(id);
// await saveArtist(db, artist);
// }
// Future<void> syncAlbum(String id) async {
// final source = ref.read(musicSourceProvider);
// final db = ref.read(databaseProvider);
// final album = await source.album(id);
// await saveAlbum(db, album);
// }
// Future<void> syncPlaylist(String id) async {
// final source = ref.read(musicSourceProvider);
// final db = ref.read(databaseProvider);
// final playlist = await source.playlist(id);
// await savePlaylist(db, playlist);
// }
}

View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$syncServiceHash() => r'2b8da374c3143bc56f17115440d57bc70468a17e';
/// See also [SyncService].
@ProviderFor(SyncService)
final syncServiceProvider = NotifierProvider<SyncService, DateTime>.internal(
SyncService.new,
name: r'syncServiceProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$syncServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SyncService = Notifier<DateTime>;
// 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

View File

@@ -0,0 +1,91 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../database/database.dart';
import '../state/settings.dart';
abstract class BaseMusicSource {
int get id;
Future<void> ping();
Stream<Iterable<AlbumsCompanion>> allAlbums();
Stream<Iterable<ArtistsCompanion>> allArtists();
Stream<Iterable<PlaylistWithSongsCompanion>> allPlaylists();
Stream<Iterable<SongsCompanion>> allSongs();
Uri streamUri(String songId);
Uri downloadUri(String songId);
Uri coverArtUri(String coverArtId, {bool thumbnail = true});
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true});
}
class OfflineException implements Exception {
@override
String toString() => 'OfflineException';
}
class MusicSource implements BaseMusicSource {
final BaseMusicSource _source;
final Ref _ref;
const MusicSource(this._source, this._ref);
void _testOnline() {
if (_ref.read(offlineModeProvider)) {
throw OfflineException();
}
}
@override
Stream<Iterable<AlbumsCompanion>> allAlbums() {
_testOnline();
return _source.allAlbums();
}
@override
Stream<Iterable<ArtistsCompanion>> allArtists() {
_testOnline();
return _source.allArtists();
}
@override
Stream<Iterable<PlaylistWithSongsCompanion>> allPlaylists() {
_testOnline();
return _source.allPlaylists();
}
@override
Stream<Iterable<SongsCompanion>> allSongs() {
_testOnline();
return _source.allSongs();
}
@override
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true}) {
_testOnline();
return _source.artistArtUri(artistId, thumbnail: thumbnail);
}
@override
Uri coverArtUri(String coverArtId, {bool thumbnail = true}) =>
_source.coverArtUri(coverArtId, thumbnail: thumbnail);
@override
Uri downloadUri(String songId) => _source.downloadUri(songId);
@override
int get id => _source.id;
@override
Future<void> ping() => _source.ping();
@override
Uri streamUri(String songId) => _source.streamUri(songId);
@override
bool operator ==(other) => other is BaseMusicSource && (other.id == id);
@override
int get hashCode => id;
}

View File

@@ -0,0 +1,135 @@
import 'dart:convert';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart';
import 'package:xml/xml.dart';
import '../../models/settings.dart';
import 'xml.dart';
class SubsonicException implements Exception {
final XmlElement xml;
late int code;
late String message;
SubsonicException(this.xml) {
try {
final error = xml.getElement('error')!;
code = int.parse(error.getAttribute('code')!);
message = error.getAttribute('message')!;
} catch (err) {
code = -1;
message = 'Unknown error.';
}
}
@override
String toString() => 'SubsonicException [$code]: $message';
}
class SubsonicClient {
final SubsonicSettings opt;
final BaseClient http;
SubsonicClient(this.opt, this.http);
String _salt() {
final r = Random();
return String.fromCharCodes(
List.generate(4, (index) => r.nextInt(92) + 33),
);
}
Map<String, String> _params() {
final Map<String, String> p = {};
p['v'] = '1.13.0';
p['c'] = 'subtracks';
p['u'] = opt.username;
if (opt.useTokenAuth) {
p['s'] = _salt();
p['t'] = md5.convert(utf8.encode(opt.password + p['s']!)).toString();
} else {
p['p'] = opt.password;
}
return p;
}
Uri uri(
String method, [
Map<String, String?>? extraParams,
]) {
final pathSegments = [...opt.address.pathSegments, 'rest', '$method.view'];
_removeIdPrefix(extraParams);
extraParams?.removeWhere((key, value) => value == null);
final queryParameters = {
..._params(),
...(extraParams ?? {}),
};
return Uri(
scheme: opt.address.scheme,
host: opt.address.host,
port: opt.address.hasPort ? opt.address.port : null,
pathSegments: pathSegments,
queryParameters: queryParameters,
);
}
Future<SubsonicResponse> get(
String method, [
Map<String, String?>? extraParams,
]) async {
final res = await http.get(uri(method, extraParams));
final subsonicResponse =
SubsonicResponse(XmlDocument.parse(utf8.decode(res.bodyBytes)));
if (subsonicResponse.status == Status.failed) {
throw SubsonicException(subsonicResponse.xml);
}
return subsonicResponse;
}
Future<bool> testFeature(SubsonicFeature feature) async {
switch (feature) {
case SubsonicFeature.emptyQuerySearch:
final res = await get(
'search3',
{'query': '""', 'songCount': '1'},
);
return res.xml.findAllElements('song').isNotEmpty;
default:
return false;
}
}
static const _idsWithPrefix = {
'id',
'playlistId',
'songIdToAdd',
'albumId',
'artistId',
};
static const _idPrefixMatch =
r'(artist\.|album\.|playlist\.|song\.|coverArt\.)';
void _removeIdPrefix(Map<String, String?>? params) {
if (params == null) return;
for (var key in params.keys) {
if (!_idsWithPrefix.contains(key)) continue;
if (params[key] == null) continue;
final hasPrefix = params[key]!.startsWith(RegExp(_idPrefixMatch));
if (!hasPrefix) continue;
params[key] = params[key]?.split('.').slice(1).join('');
}
}
}

View File

@@ -0,0 +1,285 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:http/http.dart';
import 'package:pool/pool.dart';
import 'package:xml/xml.dart';
import 'package:xml/xml_events.dart';
import '../../database/database.dart';
import '../../database/util.dart';
import '../../models/settings.dart';
import '../music_source.dart';
import 'client.dart';
class SubsonicSource implements MusicSource {
final SubsonicSettings opt;
final BaseClient http;
final int maxBitrate;
final String? streamFormat;
late final SubsonicClient client;
final _pool = Pool(10, timeout: const Duration(seconds: 60));
SubsonicSource({
required this.opt,
required this.http,
required this.maxBitrate,
this.streamFormat,
}) {
client = SubsonicClient(opt, http);
}
@override
int get id => opt.id;
@override
Future<void> ping() async {
await client.get('ping');
}
@override
Stream<Iterable<ArtistsCompanion>> allArtists() async* {
final res = await client.get('getArtists');
for (var artists in res.xml.findAllElements('artist').slices(200)) {
yield artists.map(_mapArtist);
}
}
@override
Stream<Iterable<AlbumsCompanion>> allAlbums() async* {
final extras = await Future.wait([
_albumList('frequent')
.flatten()
.map((element) => element.getAttribute('id')!)
.toList(),
_albumList('recent')
.flatten()
.map((element) => element.getAttribute('id')!)
.toList(),
]);
final frequentlyPlayed = {
for (var i = 0; i < extras[0].length; i++) extras[0][i]: i
};
final recentlyPlayed = {
for (var i = 0; i < extras[1].length; i++) extras[1][i]: i
};
await for (var albums in _albumList('newest')) {
yield albums.map(
(e) => _mapAlbum(
e,
frequentRank: Value(frequentlyPlayed[e.getAttribute('id')!]),
recentRank: Value(recentlyPlayed[e.getAttribute('id')!]),
),
);
}
}
@override
Stream<Iterable<PlaylistWithSongsCompanion>> allPlaylists() async* {
final allPlaylists = await client.get('getPlaylists');
yield* _pool.forEach(allPlaylists.xml.findAllElements('playlist'),
(playlist) async {
final res = await client.get(
'getPlaylist',
{'id': playlist.getAttribute('id')},
);
return [
PlaylistWithSongsCompanion(
_mapPlaylist(res.xml.getElement('playlist')!),
res.xml.findAllElements('entry').mapIndexed(_mapPlaylistSong),
)
];
});
}
@override
Stream<Iterable<SongsCompanion>> allSongs() async* {
if (opt.features.contains(SubsonicFeature.emptyQuerySearch)) {
await for (var songs in _songSearch()) {
yield songs.map(_mapSong);
}
} else {
await for (var albumsList in _albumList('alphabeticalByName')) {
yield* _pool.forEach(albumsList, (album) async {
final albums = await client.get('getAlbum', {
'id': album.getAttribute('id')!,
});
return albums.xml.findAllElements('song').map(_mapSong);
});
}
}
}
@override
Uri streamUri(String songId) {
return client.uri('stream', {
'id': songId,
'estimateContentLength': true.toString(),
'maxBitRate': maxBitrate.toString(),
'format': streamFormat?.toString(),
});
}
@override
Uri downloadUri(String songId) {
return client.uri('download', {'id': songId});
}
@override
Uri coverArtUri(String id, {bool thumbnail = true}) {
final opts = {'id': id};
if (thumbnail) {
opts['size'] = 256.toString();
}
return client.uri('getCoverArt', opts);
}
@override
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true}) async {
final res = await client.get('getArtistInfo2', {'id': artistId});
return Uri.tryParse(res.xml
.getElement('artistInfo2')
?.getElement(thumbnail ? 'smallImageUrl' : 'largeImageUrl')
?.text ??
'');
}
Stream<Iterable<XmlElement>> _albumList(String type) async* {
const size = 500;
var offset = 0;
while (true) {
final res = await client.get('getAlbumList2', {
'type': type,
'size': size.toString(),
'offset': offset.toString(),
});
final albums = res.xml.findAllElements('album');
offset += albums.length;
yield albums;
if (albums.length < size) {
break;
}
}
}
Stream<Iterable<XmlElement>> _songSearch() async* {
const size = 500;
var offset = 0;
while (true) {
final res = await client.get('search3', {
'query': '""',
'songCount': size.toString(),
'songOffset': offset.toString(),
'artistCount': '0',
'albumCount': '0',
});
final songs = res.xml.findAllElements('song');
offset += songs.length;
yield songs;
if (songs.length < size) {
break;
}
}
}
ArtistsCompanion _mapArtist(XmlElement e) {
return ArtistsCompanion.insert(
sourceId: id,
id: 'artist.${e.getAttribute('id')!}',
name: e.getAttribute('name') ?? 'Artist ${e.getAttribute('id')}',
albumCount: int.parse(e.getAttribute('albumCount')!),
starred: Value(DateTimeExt.tryParseUtc(e.getAttribute('starred'))),
);
}
AlbumsCompanion _mapAlbum(
XmlElement e, {
Value<int?> frequentRank = const Value.absent(),
Value<int?> recentRank = const Value.absent(),
}) {
return AlbumsCompanion.insert(
sourceId: id,
id: 'album.${e.getAttribute('id')!}',
artistId: Value(e.getAttribute('artistId') != null
? 'artist.${e.getAttribute('artistId')}'
: null),
name: e.getAttribute('name') ?? 'Album ${e.getAttribute('id')}',
albumArtist: Value(e.getAttribute('artist')),
created: DateTimeExt.parseUtc(e.getAttribute('created')!),
coverArt: Value(e.getAttribute('coverArt') != null
? 'coverArt.${e.getAttribute('coverArt')}'
: null),
year: e.getAttribute('year') != null
? Value(int.parse(e.getAttribute('year')!))
: const Value(null),
starred: Value(DateTimeExt.tryParseUtc(e.getAttribute('starred'))),
genre: Value(e.getAttribute('genre')),
songCount: int.parse(e.getAttribute('songCount')!),
frequentRank: frequentRank,
recentRank: recentRank,
);
}
PlaylistsCompanion _mapPlaylist(XmlElement e) {
return PlaylistsCompanion.insert(
sourceId: id,
id: 'playlist.${e.getAttribute('id')!}',
name: e.getAttribute('name') ?? 'Playlist ${e.getAttribute('id')}',
comment: Value(e.getAttribute('comment')),
coverArt: Value(e.getAttribute('coverArt') != null
? 'coverArt.${e.getAttribute('coverArt')}'
: null),
songCount: int.parse(e.getAttribute('songCount')!),
created: DateTimeExt.parseUtc(e.getAttribute('created')!),
);
}
SongsCompanion _mapSong(XmlElement e) {
return SongsCompanion.insert(
sourceId: id,
id: 'song.${e.getAttribute('id')!}',
albumId: Value(e.getAttribute('albumId') != null
? 'album.${e.getAttribute('albumId')}'
: null),
artistId: Value(e.getAttribute('artistId') != null
? 'artist.${e.getAttribute('artistId')}'
: null),
title: e.getAttribute('title') ?? 'Song ${e.getAttribute('id')}',
album: Value(e.getAttribute('album')),
artist: Value(e.getAttribute('artist')),
duration: e.getAttribute('duration') != null
? Value(Duration(
seconds: int.parse(e.getAttribute('duration').toString())))
: const Value(null),
track: e.getAttribute('track') != null
? Value(int.parse(e.getAttribute('track')!))
: const Value(null),
disc: e.getAttribute('discNumber') != null
? Value(int.parse(e.getAttribute('discNumber')!))
: const Value(null),
starred: Value(DateTimeExt.tryParseUtc(e.getAttribute('starred'))),
genre: Value(e.getAttribute('genre')),
);
}
PlaylistSongsCompanion _mapPlaylistSong(int index, XmlElement e) {
return PlaylistSongsCompanion.insert(
sourceId: id,
playlistId: 'playlist.${e.parentElement!.getAttribute('id')!}',
songId: 'song.${e.getAttribute('id')!}',
position: index,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:xml/xml.dart';
enum Status {
ok('ok'),
failed('failed');
const Status(this.value);
final String value;
}
class SubsonicResponse {
late Status status;
late XmlElement xml;
SubsonicResponse(XmlDocument xml) {
this.xml = xml.getElement('subsonic-response')!;
status = Status.values.byName(this.xml.getAttribute('status')!);
}
}

143
lib/state/audio.dart Normal file
View File

@@ -0,0 +1,143 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value;
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../models/music.dart';
import '../models/support.dart';
import '../services/audio_service.dart';
import 'settings.dart';
part 'audio.g.dart';
@Riverpod(keepAlive: true)
Stream<MediaItem?> mediaItem(MediaItemRef ref) async* {
final audio = ref.watch(audioControlProvider);
await for (var item in audio.mediaItem) {
yield item;
}
}
@Riverpod(keepAlive: true)
MediaItemData? mediaItemData(MediaItemDataRef ref) {
return ref.watch(mediaItemProvider.select(
(value) => value.valueOrNull?.data,
));
}
@Riverpod(keepAlive: true)
Stream<Song?> mediaItemSong(MediaItemSongRef ref) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final id = ref.watch(mediaItemProvider.select(
(value) => value.valueOrNull?.id,
));
return db.songById(sourceId, id ?? '').watchSingleOrNull();
}
@Riverpod(keepAlive: true)
Stream<PlaybackState> playbackState(PlaybackStateRef ref) async* {
final audio = ref.watch(audioControlProvider);
await for (var state in audio.playbackState) {
yield state;
}
}
@Riverpod(keepAlive: true)
Stream<QueueMode> queueMode(QueueModeRef ref) async* {
final audio = ref.watch(audioControlProvider);
await for (var state in audio.queueMode) {
yield state;
}
}
@Riverpod(keepAlive: true)
Stream<List<MediaItem>> queue(QueueRef ref) async* {
final audio = ref.watch(audioControlProvider);
await for (var queue in audio.queue) {
yield queue;
}
}
@Riverpod(keepAlive: true)
Stream<List<int>?> shuffleIndicies(ShuffleIndiciesRef ref) async* {
final audio = ref.watch(audioControlProvider);
await for (var indicies in audio.shuffleIndicies) {
yield indicies;
}
}
@riverpod
Stream<Duration> positionStream(PositionStreamRef ref) async* {
final audio = ref.watch(audioControlProvider);
await for (var state in audio.position) {
yield state;
}
}
@riverpod
bool playing(PlayingRef ref) {
return ref.watch(playbackStateProvider.select(
(value) => value.valueOrNull?.playing ?? false,
));
}
@riverpod
AudioProcessingState? processingState(ProcessingStateRef ref) {
return ref.watch(playbackStateProvider.select(
(value) => value.valueOrNull?.processingState,
));
}
@riverpod
int position(PositionRef ref) {
return ref.watch(positionStreamProvider.select(
(value) => value.valueOrNull?.inSeconds ?? 0,
));
}
@riverpod
int duration(DurationRef ref) {
return ref.watch(mediaItemProvider.select(
(value) => value.valueOrNull?.duration?.inSeconds ?? 0,
));
}
@Riverpod(keepAlive: true)
AudioServiceShuffleMode? shuffleMode(ShuffleModeRef ref) {
return ref.watch(playbackStateProvider.select(
(value) => value.valueOrNull?.shuffleMode,
));
}
@Riverpod(keepAlive: true)
AudioServiceRepeatMode repeatMode(RepeatModeRef ref) {
return ref.watch(playbackStateProvider.select(
(value) => value.valueOrNull?.repeatMode ?? AudioServiceRepeatMode.none,
));
}
@Riverpod(keepAlive: true)
class LastAudioStateService extends _$LastAudioStateService {
@override
Future<void> build() async {
final db = ref.watch(databaseProvider);
final queueMode = ref.watch(queueModeProvider).valueOrNull;
final shuffleIndicies = ref.watch(shuffleIndiciesProvider).valueOrNull;
final repeat = ref.watch(repeatModeProvider);
await db.saveLastAudioState(LastAudioStateCompanion.insert(
id: const Value(1),
queueMode: queueMode ?? QueueMode.user,
shuffleIndicies: Value(shuffleIndicies?.lock),
repeat: {
AudioServiceRepeatMode.none: RepeatMode.none,
AudioServiceRepeatMode.all: RepeatMode.all,
AudioServiceRepeatMode.group: RepeatMode.all,
AudioServiceRepeatMode.one: RepeatMode.one,
}[repeat]!,
));
}
}

229
lib/state/audio.g.dart Normal file
View File

@@ -0,0 +1,229 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audio.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$mediaItemHash() => r'ca8b6768872f17355f756c95cf85278127b59444';
/// See also [mediaItem].
@ProviderFor(mediaItem)
final mediaItemProvider = StreamProvider<MediaItem?>.internal(
mediaItem,
name: r'mediaItemProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$mediaItemHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MediaItemRef = StreamProviderRef<MediaItem?>;
String _$mediaItemDataHash() => r'8539c02682f0d33b584ea0437dd3774d9f321a2e';
/// See also [mediaItemData].
@ProviderFor(mediaItemData)
final mediaItemDataProvider = Provider<MediaItemData?>.internal(
mediaItemData,
name: r'mediaItemDataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mediaItemDataHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MediaItemDataRef = ProviderRef<MediaItemData?>;
String _$mediaItemSongHash() => r'274f43470cd993f0a2bed3d3da22d7bd41b562f1';
/// See also [mediaItemSong].
@ProviderFor(mediaItemSong)
final mediaItemSongProvider = StreamProvider<Song?>.internal(
mediaItemSong,
name: r'mediaItemSongProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mediaItemSongHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MediaItemSongRef = StreamProviderRef<Song?>;
String _$playbackStateHash() => r'b4a9eb7f802fc8c92666c1318f789865140d6025';
/// See also [playbackState].
@ProviderFor(playbackState)
final playbackStateProvider = StreamProvider<PlaybackState>.internal(
playbackState,
name: r'playbackStateProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$playbackStateHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PlaybackStateRef = StreamProviderRef<PlaybackState>;
String _$queueModeHash() => r'be0b1ff436c367e9be54c6d15fd8bac4f904fdec';
/// See also [queueMode].
@ProviderFor(queueMode)
final queueModeProvider = StreamProvider<QueueMode>.internal(
queueMode,
name: r'queueModeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$queueModeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef QueueModeRef = StreamProviderRef<QueueMode>;
String _$queueHash() => r'94d86c99382f56193a11baf3f13354eab6a39fa8';
/// See also [queue].
@ProviderFor(queue)
final queueProvider = StreamProvider<List<MediaItem>>.internal(
queue,
name: r'queueProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$queueHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef QueueRef = StreamProviderRef<List<MediaItem>>;
String _$shuffleIndiciesHash() => r'e5dc6879b2a7b7a501b58aace717ff36eff59995';
/// See also [shuffleIndicies].
@ProviderFor(shuffleIndicies)
final shuffleIndiciesProvider = StreamProvider<List<int>?>.internal(
shuffleIndicies,
name: r'shuffleIndiciesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$shuffleIndiciesHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ShuffleIndiciesRef = StreamProviderRef<List<int>?>;
String _$positionStreamHash() => r'5f1dc9d11e1bcce649ddb764525cc0dc79bfb6d8';
/// See also [positionStream].
@ProviderFor(positionStream)
final positionStreamProvider = AutoDisposeStreamProvider<Duration>.internal(
positionStream,
name: r'positionStreamProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$positionStreamHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PositionStreamRef = AutoDisposeStreamProviderRef<Duration>;
String _$playingHash() => r'2a40fa275358918b243c8734bbe49bc9d7373f10';
/// See also [playing].
@ProviderFor(playing)
final playingProvider = AutoDisposeProvider<bool>.internal(
playing,
name: r'playingProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$playingHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PlayingRef = AutoDisposeProviderRef<bool>;
String _$processingStateHash() => r'b9e59927b905384a0f1221b2adb9b681091c27d1';
/// See also [processingState].
@ProviderFor(processingState)
final processingStateProvider =
AutoDisposeProvider<AudioProcessingState?>.internal(
processingState,
name: r'processingStateProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$processingStateHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ProcessingStateRef = AutoDisposeProviderRef<AudioProcessingState?>;
String _$positionHash() => r'bfc853fe9e46bf79522fa41374763a7e1c12e739';
/// See also [position].
@ProviderFor(position)
final positionProvider = AutoDisposeProvider<int>.internal(
position,
name: r'positionProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$positionHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PositionRef = AutoDisposeProviderRef<int>;
String _$durationHash() => r'bf9eb316b8401401e5862384deb1a4c1134e6dd2';
/// See also [duration].
@ProviderFor(duration)
final durationProvider = AutoDisposeProvider<int>.internal(
duration,
name: r'durationProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$durationHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef DurationRef = AutoDisposeProviderRef<int>;
String _$shuffleModeHash() => r'fd26d81cb9bd5e0e1a7e9ccf1589c104d9a4eb3a';
/// See also [shuffleMode].
@ProviderFor(shuffleMode)
final shuffleModeProvider = Provider<AudioServiceShuffleMode?>.internal(
shuffleMode,
name: r'shuffleModeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$shuffleModeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ShuffleModeRef = ProviderRef<AudioServiceShuffleMode?>;
String _$repeatModeHash() => r'346248bf08df65f1f69e4cb4b6ef192190d2910c';
/// See also [repeatMode].
@ProviderFor(repeatMode)
final repeatModeProvider = Provider<AudioServiceRepeatMode>.internal(
repeatMode,
name: r'repeatModeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$repeatModeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef RepeatModeRef = ProviderRef<AudioServiceRepeatMode>;
String _$lastAudioStateServiceHash() =>
r'4291b8d4a399f1b192277a3e8a93fbe7096fea32';
/// See also [LastAudioStateService].
@ProviderFor(LastAudioStateService)
final lastAudioStateServiceProvider =
AsyncNotifierProvider<LastAudioStateService, void>.internal(
LastAudioStateService.new,
name: r'lastAudioStateServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$lastAudioStateServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastAudioStateService = 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

82
lib/state/init.dart Normal file
View File

@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../app/app.dart';
import '../app/app_router.dart';
import '../app/pages/bottom_nav_page.dart';
import '../app/pages/library_page.dart';
import '../services/audio_service.dart';
import '../services/download_service.dart';
import '../services/settings_service.dart';
import 'audio.dart';
import 'settings.dart';
part 'init.g.dart';
@Riverpod(keepAlive: true)
FutureOr<Map<String, String>> env(EnvRef ref) async {
await dotenv.load();
return dotenv.env;
}
@Riverpod(keepAlive: true)
AppRouter router(RouterRef ref) {
return AppRouter();
}
@Riverpod(keepAlive: true)
FutureOr<Uri> placeholderImageUri(PlaceholderImageUriRef ref) async {
final byteData = await rootBundle.load('assets/placeholder.png');
final docsDir = await getApplicationDocumentsDirectory();
return (await File('${docsDir.path}/placeholder.png').writeAsBytes(byteData
.buffer
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)))
.uri;
}
@Riverpod(keepAlive: true)
FutureOr<Uri> placeholderThumbImageUri(PlaceholderThumbImageUriRef ref) async {
final byteData = await rootBundle.load('assets/placeholder_thumb.png');
final docsDir = await getApplicationDocumentsDirectory();
return (await File('${docsDir.path}/placeholder_thumb.png').writeAsBytes(
byteData.buffer
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)))
.uri;
}
@Riverpod(keepAlive: true)
FutureOr<PackageInfo> packageInfo(PackageInfoRef ref) async {
return await PackageInfo.fromPlatform();
}
@Riverpod(keepAlive: true)
FutureOr<void> init(InitRef ref) async {
ref.watch(routerProvider);
await ref.watch(envProvider.future);
await ref.read(packageInfoProvider.future);
await ref.watch(placeholderImageUriProvider.future);
await ref.watch(placeholderThumbImageUriProvider.future);
await ref.read(networkModeProvider.future);
await ref.read(maxBitrateProvider.future);
await ref.watch(settingsServiceProvider.notifier).init();
final audio = await ref.watch(audioControlInitProvider.future);
await audio.init();
ref.watch(lastAudioStateServiceProvider.notifier);
await ref.watch(downloadServiceProvider.notifier).init();
await ref.watch(lastPathProvider.notifier).init();
ref.watch(lastBottomNavStateServiceProvider.notifier);
ref.watch(lastLibraryStateServiceProvider.notifier);
await ref.watch(libraryListsProvider.notifier).init();
}

97
lib/state/init.g.dart Normal file
View File

@@ -0,0 +1,97 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'init.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$envHash() => r'183e2c6c10c5a9d3dfcaffca0f1723cd85d67a5c';
/// See also [env].
@ProviderFor(env)
final envProvider = FutureProvider<Map<String, String>>.internal(
env,
name: r'envProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$envHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef EnvRef = FutureProviderRef<Map<String, String>>;
String _$routerHash() => r'3f52af31948ac5942fe4167f84785572458eeacc';
/// See also [router].
@ProviderFor(router)
final routerProvider = Provider<AppRouter>.internal(
router,
name: r'routerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$routerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef RouterRef = ProviderRef<AppRouter>;
String _$placeholderImageUriHash() =>
r'129f37af640421ec12038d161a8b15a67536e570';
/// See also [placeholderImageUri].
@ProviderFor(placeholderImageUri)
final placeholderImageUriProvider = FutureProvider<Uri>.internal(
placeholderImageUri,
name: r'placeholderImageUriProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$placeholderImageUriHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PlaceholderImageUriRef = FutureProviderRef<Uri>;
String _$placeholderThumbImageUriHash() =>
r'8136dd4bbb3dc57fb1e6aa8db556e58620c61c85';
/// See also [placeholderThumbImageUri].
@ProviderFor(placeholderThumbImageUri)
final placeholderThumbImageUriProvider = FutureProvider<Uri>.internal(
placeholderThumbImageUri,
name: r'placeholderThumbImageUriProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$placeholderThumbImageUriHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PlaceholderThumbImageUriRef = FutureProviderRef<Uri>;
String _$packageInfoHash() => r'9a2956f08c2e98b92dd8cce49cb331a127c78670';
/// See also [packageInfo].
@ProviderFor(packageInfo)
final packageInfoProvider = FutureProvider<PackageInfo>.internal(
packageInfo,
name: r'packageInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$packageInfoHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef PackageInfoRef = FutureProviderRef<PackageInfo>;
String _$initHash() => r'10e2945eb65f51e6a904a3811fa88e63771e5b19';
/// See also [init].
@ProviderFor(init)
final initProvider = FutureProvider<void>.internal(
init,
name: r'initProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$initHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef InitRef = FutureProviderRef<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

152
lib/state/music.dart Normal file
View File

@@ -0,0 +1,152 @@
import 'package:fast_immutable_collections/fast_immutable_collections.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 'settings.dart';
part 'music.g.dart';
@riverpod
Stream<Artist> artist(ArtistRef ref, String id) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.artistById(sourceId, id).watchSingle();
}
@riverpod
Stream<Album> album(AlbumRef ref, String id) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.albumById(sourceId, id).watchSingle();
}
@riverpod
Stream<ListDownloadStatus> albumDownloadStatus(
AlbumDownloadStatusRef ref,
String id,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.albumDownloadStatus(sourceId, id).watchSingle();
}
@riverpod
Stream<ListDownloadStatus> playlistDownloadStatus(
PlaylistDownloadStatusRef ref,
String id,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.playlistDownloadStatus(sourceId, id).watchSingle();
}
@riverpod
Stream<Song> song(SongRef ref, String id) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.songById(sourceId, id).watchSingle();
}
@riverpod
Future<List<Song>> albumSongsList(
AlbumSongsListRef ref,
String id,
ListQuery opt,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.albumSongsList(SourceId(sourceId: sourceId, id: id), opt).get();
}
@riverpod
Future<List<Song>> songsByAlbumList(
SongsByAlbumListRef ref,
ListQuery opt,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.songsByAlbumList(sourceId, opt).get();
}
@riverpod
Stream<Playlist> playlist(PlaylistRef ref, String id) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.playlistById(sourceId, id).watchSingle();
}
@riverpod
Future<List<Song>> playlistSongsList(
PlaylistSongsListRef ref,
String id,
ListQuery opt,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.playlistSongsList(SourceId(sourceId: sourceId, id: id), opt).get();
}
@riverpod
Future<List<Album>> albumsInIds(AlbumsInIdsRef ref, IList<String> ids) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.albumsInIds(sourceId, ids.toList()).get();
}
@riverpod
Stream<IList<Album>> albumsByArtistId(AlbumsByArtistIdRef ref, String id) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db
.albumsByArtistId(sourceId, id)
.watch()
.map((event) => event.toIList());
}
@riverpod
Stream<IList<String>> albumGenres(AlbumGenresRef ref, Pagination page) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db
.albumGenres(sourceId, page.limit, page.offset)
.watch()
.map((event) => event.withNullsRemoved().toIList());
}
@riverpod
Stream<IList<Album>> albumsByGenre(
AlbumsByGenreRef ref,
String genre,
Pagination page,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db
.albumsByGenre(sourceId, genre, page.limit, page.offset)
.watch()
.map((event) => event.toIList());
}
@riverpod
Stream<int> songsByGenreCount(SongsByGenreCountRef ref, String genre) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.songsByGenreCount(sourceId, genre).watchSingle();
}

1206
lib/state/music.g.dart Normal file

File diff suppressed because it is too large Load Diff

88
lib/state/settings.dart Normal file
View File

@@ -0,0 +1,88 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../http/client.dart';
import '../models/settings.dart';
import '../services/settings_service.dart';
import '../sources/music_source.dart';
import '../sources/subsonic/source.dart';
part 'settings.g.dart';
@Riverpod(keepAlive: true)
MusicSource musicSource(MusicSourceRef ref) {
final settings = ref.watch(settingsServiceProvider.select(
(value) => value.activeSource,
)) as SubsonicSettings;
final streamFormat = ref.watch(settingsServiceProvider.select(
(value) => value.app.streamFormat,
));
final maxBitrate = ref.watch(maxBitrateProvider).requireValue;
final http = ref.watch(httpClientProvider);
return MusicSource(
SubsonicSource(
opt: settings,
http: http,
maxBitrate: maxBitrate,
streamFormat: streamFormat,
),
ref,
);
}
@Riverpod(keepAlive: true)
Stream<NetworkMode> networkMode(NetworkModeRef ref) async* {
await for (var state in Connectivity().onConnectivityChanged) {
switch (state) {
case ConnectivityResult.wifi:
case ConnectivityResult.ethernet:
yield NetworkMode.wifi;
break;
default:
yield NetworkMode.mobile;
break;
}
}
}
@Riverpod(keepAlive: true)
Future<int> maxBitrate(MaxBitrateRef ref) async {
final settings = ref.watch(settingsServiceProvider.select(
(value) => value.app,
));
final networkMode = ref.watch(networkModeProvider).requireValue;
return networkMode == NetworkMode.wifi
? settings.maxBitrateWifi
: settings.maxBitrateMobile;
}
@Riverpod(keepAlive: true)
int sourceId(SourceIdRef ref) {
return ref.watch(musicSourceProvider.select((value) => value.id));
}
@Riverpod(keepAlive: true)
class OfflineMode extends _$OfflineMode {
@override
bool build() {
return false;
}
Future<void> setMode(bool value) async {
final hasSource = ref.read(settingsServiceProvider.select(
(value) => value.activeSource != null,
));
if (!hasSource) return;
if (value == false && state == true) {
try {
await ref.read(musicSourceProvider).ping();
} catch (err) {
return;
}
}
state = value;
}
}

79
lib/state/settings.g.dart Normal file
View File

@@ -0,0 +1,79 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$musicSourceHash() => r'466e2654eab8518c9e40c8c2c08a2ecb331b0a7f';
/// See also [musicSource].
@ProviderFor(musicSource)
final musicSourceProvider = Provider<MusicSource>.internal(
musicSource,
name: r'musicSourceProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$musicSourceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MusicSourceRef = ProviderRef<MusicSource>;
String _$networkModeHash() => r'813a60a454c6acaefbe3b56bf0152497ab18dcce';
/// See also [networkMode].
@ProviderFor(networkMode)
final networkModeProvider = StreamProvider<NetworkMode>.internal(
networkMode,
name: r'networkModeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$networkModeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef NetworkModeRef = StreamProviderRef<NetworkMode>;
String _$maxBitrateHash() => r'ec02d3ccbc9f3429acfc1b3f191cab791b1191e0';
/// See also [maxBitrate].
@ProviderFor(maxBitrate)
final maxBitrateProvider = FutureProvider<int>.internal(
maxBitrate,
name: r'maxBitrateProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$maxBitrateHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MaxBitrateRef = FutureProviderRef<int>;
String _$sourceIdHash() => r'66ed4717b4a07548f5e25a42aeac2027aeab9b9c';
/// See also [sourceId].
@ProviderFor(sourceId)
final sourceIdProvider = Provider<int>.internal(
sourceId,
name: r'sourceIdProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$sourceIdHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef SourceIdRef = ProviderRef<int>;
String _$offlineModeHash() => r'b84cdece48d97c69e995fbaea97febb128cfc20a';
/// See also [OfflineMode].
@ProviderFor(OfflineMode)
final offlineModeProvider = NotifierProvider<OfflineMode, bool>.internal(
OfflineMode.new,
name: r'offlineModeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$offlineModeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$OfflineMode = Notifier<bool>;
// 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

258
lib/state/theme.dart Normal file
View File

@@ -0,0 +1,258 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker_manager/worker_manager.dart';
import '../cache/image_cache.dart';
import '../models/support.dart';
import '../services/cache_service.dart';
import 'audio.dart';
import 'music.dart';
part 'theme.g.dart';
const kDarkBackgroundValue = 0.28;
const kDarkerBackgroundLightness = 0.13;
const kOnDarkerBackgroundLightness = 0.6;
PaletteColor? _rankedByLuminance(
List<PaletteColor?> colors, [
double minLuminance = 0.0,
double maxLuminance = 1.0,
]) {
for (var color in colors) {
if (color == null) {
continue;
}
final luminance = color.color.computeLuminance();
if (luminance >= minLuminance && luminance <= maxLuminance) {
return color;
}
}
return null;
}
PaletteColor? _rankedWithValue(double value, List<PaletteColor?> colors) {
for (var color in colors) {
if (color == null) {
continue;
}
final hsv = HSVColor.fromColor(color.color);
if (hsv.value > value) {
return PaletteColor(hsv.withValue(value).toColor(), 0);
}
}
return null;
}
@riverpod
ColorTheme _colorTheme(_ColorThemeRef ref, Palette palette) {
final base = ref.watch(baseThemeProvider);
final primary = _rankedByLuminance([
palette.dominantColor,
palette.vibrantColor,
palette.mutedColor,
palette.darkVibrantColor,
], 0.2);
final vibrant = _rankedByLuminance([
palette.vibrantColor,
palette.darkVibrantColor,
palette.dominantColor,
], 0.05);
final secondary = _rankedByLuminance([
palette.lightMutedColor,
palette.mutedColor,
palette.darkMutedColor,
]);
final background = _rankedWithValue(0.5, [
palette.vibrantColor,
palette.darkVibrantColor,
palette.darkMutedColor,
palette.dominantColor,
palette.mutedColor,
palette.lightVibrantColor,
palette.lightMutedColor,
]);
final colorScheme = ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: background?.color ?? Colors.purple[800]!,
background: background?.color,
primaryContainer: primary?.color,
onPrimaryContainer: primary?.bodyTextColor,
secondaryContainer: secondary?.color,
onSecondaryContainer: secondary?.bodyTextColor,
surface: background?.color,
surfaceTint: vibrant?.color,
);
final hsv = HSVColor.fromColor(colorScheme.background);
final hsl = HSLColor.fromColor(colorScheme.background);
return base.copyWith(
theme: ThemeData(
colorScheme: colorScheme,
useMaterial3: base.theme.useMaterial3,
brightness: base.theme.brightness,
cardTheme: base.theme.cardTheme,
),
gradientHigh: colorScheme.background,
darkBackground: hsv.withValue(kDarkBackgroundValue).toColor(),
darkerBackground: hsl.withLightness(kDarkerBackgroundLightness).toColor(),
onDarkerBackground:
hsl.withLightness(kOnDarkerBackgroundLightness).toColor(),
);
}
@riverpod
ColorTheme baseTheme(BaseThemeRef ref) {
final theme = ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.purple[800],
brightness: Brightness.dark,
cardTheme: CardTheme(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2),
),
),
);
final hsv = HSVColor.fromColor(theme.colorScheme.background);
final hsl = HSLColor.fromColor(theme.colorScheme.background);
return ColorTheme(
theme: theme,
gradientHigh: theme.colorScheme.background,
gradientLow: HSLColor.fromColor(theme.colorScheme.background)
.withLightness(0.06)
.toColor(),
darkBackground: hsv.withValue(kDarkBackgroundValue).toColor(),
darkerBackground: hsl.withLightness(kDarkerBackgroundLightness).toColor(),
onDarkerBackground:
hsl.withLightness(kOnDarkerBackgroundLightness).toColor(),
);
}
@riverpod
FutureOr<Palette> albumArtPalette(
AlbumArtPaletteRef ref,
String id,
) async {
final album = await ref.watch(albumProvider(id).future);
final cache = ref.watch(cacheServiceProvider);
final info = cache.albumArt(album, thumbnail: true);
return Palette.fromPaletteGenerator(await _generateFromCache(info));
}
Future<PaletteGenerator> _generateFromCache(
UriCacheInfo cacheInfo,
) async {
var file = await cacheInfo.cacheManager.getSingleFile(
cacheInfo.uri.toString(),
key: cacheInfo.cacheKey,
);
final memoryImage = ResizeImage(
MemoryImage(await file.readAsBytes()),
height: 32,
width: 32,
);
final stream = memoryImage.resolve(const ImageConfiguration(
devicePixelRatio: 1.0,
));
Completer<ui.Image> imageCompleter = Completer<ui.Image>();
late ImageStreamListener listener;
listener = ImageStreamListener((ImageInfo info, bool synchronousCall) {
stream.removeListener(listener);
imageCompleter.complete(info.image);
});
stream.addListener(listener);
final image = await imageCompleter.future;
final byteData = (await image.toByteData())!;
final height = image.height;
final width = image.width;
return await Executor().execute(
arg1: byteData,
arg2: height,
arg3: width,
fun3: _computePalette,
);
}
Future<PaletteGenerator> _computePalette(
ByteData byteData,
int height,
int width,
TypeSendPort port,
) async {
return await PaletteGenerator.fromByteData(
EncodedImage(byteData, height: height, width: width),
);
}
@riverpod
FutureOr<Palette> playlistArtPalette(
PlaylistArtPaletteRef ref,
String id,
) async {
final playlist = await ref.watch(playlistProvider(id).future);
final cache = ref.watch(cacheServiceProvider);
final info = cache.playlistArt(playlist, thumbnail: true);
return Palette.fromPaletteGenerator(await _generateFromCache(info));
}
@riverpod
FutureOr<Palette> mediaItemPalette(MediaItemPaletteRef ref) async {
final item = ref.watch(mediaItemProvider).valueOrNull;
final itemData = ref.watch(mediaItemDataProvider);
final imageCache = ref.watch(imageCacheProvider);
if (item == null || item.artUri == null || itemData == null) {
return const Palette();
}
return Palette.fromPaletteGenerator(await _generateFromCache(
UriCacheInfo(
uri: item.artUri!,
cacheKey: itemData.artCache!.thumbnailArtCacheKey,
cacheManager: imageCache,
),
));
}
@riverpod
FutureOr<ColorTheme> mediaItemTheme(MediaItemThemeRef ref) async {
final palette = await ref.watch(mediaItemPaletteProvider.future);
return ref.watch(_colorThemeProvider(palette));
}
@riverpod
FutureOr<ColorTheme> albumArtTheme(AlbumArtThemeRef ref, String id) async {
final palette = await ref.watch(albumArtPaletteProvider(id).future);
return ref.watch(_colorThemeProvider(palette));
}
@riverpod
FutureOr<ColorTheme> playlistArtTheme(
PlaylistArtThemeRef ref,
String id,
) async {
final palette = await ref.watch(playlistArtPaletteProvider(id).future);
return ref.watch(_colorThemeProvider(palette));
}

485
lib/state/theme.g.dart Normal file
View File

@@ -0,0 +1,485 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$colorThemeHash() => r'f5cc23cb5e2af379c02ae4b9756df72f9f6da5e6';
/// 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 _ColorThemeRef = AutoDisposeProviderRef<ColorTheme>;
/// See also [_colorTheme].
@ProviderFor(_colorTheme)
const _colorThemeProvider = _ColorThemeFamily();
/// See also [_colorTheme].
class _ColorThemeFamily extends Family<ColorTheme> {
/// See also [_colorTheme].
const _ColorThemeFamily();
/// See also [_colorTheme].
_ColorThemeProvider call(
Palette palette,
) {
return _ColorThemeProvider(
palette,
);
}
@override
_ColorThemeProvider getProviderOverride(
covariant _ColorThemeProvider provider,
) {
return call(
provider.palette,
);
}
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'_colorThemeProvider';
}
/// See also [_colorTheme].
class _ColorThemeProvider extends AutoDisposeProvider<ColorTheme> {
/// See also [_colorTheme].
_ColorThemeProvider(
this.palette,
) : super.internal(
(ref) => _colorTheme(
ref,
palette,
),
from: _colorThemeProvider,
name: r'_colorThemeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$colorThemeHash,
dependencies: _ColorThemeFamily._dependencies,
allTransitiveDependencies:
_ColorThemeFamily._allTransitiveDependencies,
);
final Palette palette;
@override
bool operator ==(Object other) {
return other is _ColorThemeProvider && other.palette == palette;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, palette.hashCode);
return _SystemHash.finish(hash);
}
}
String _$baseThemeHash() => r'317a5ef77def208357a54b7938ef3d91666fce70';
/// See also [baseTheme].
@ProviderFor(baseTheme)
final baseThemeProvider = AutoDisposeProvider<ColorTheme>.internal(
baseTheme,
name: r'baseThemeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$baseThemeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef BaseThemeRef = AutoDisposeProviderRef<ColorTheme>;
String _$albumArtPaletteHash() => r'8130b954ee3c67f53207593d4ed3dfbffb00c95d';
typedef AlbumArtPaletteRef = AutoDisposeFutureProviderRef<Palette>;
/// See also [albumArtPalette].
@ProviderFor(albumArtPalette)
const albumArtPaletteProvider = AlbumArtPaletteFamily();
/// See also [albumArtPalette].
class AlbumArtPaletteFamily extends Family<AsyncValue<Palette>> {
/// See also [albumArtPalette].
const AlbumArtPaletteFamily();
/// See also [albumArtPalette].
AlbumArtPaletteProvider call(
String id,
) {
return AlbumArtPaletteProvider(
id,
);
}
@override
AlbumArtPaletteProvider getProviderOverride(
covariant AlbumArtPaletteProvider provider,
) {
return call(
provider.id,
);
}
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'albumArtPaletteProvider';
}
/// See also [albumArtPalette].
class AlbumArtPaletteProvider extends AutoDisposeFutureProvider<Palette> {
/// See also [albumArtPalette].
AlbumArtPaletteProvider(
this.id,
) : super.internal(
(ref) => albumArtPalette(
ref,
id,
),
from: albumArtPaletteProvider,
name: r'albumArtPaletteProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$albumArtPaletteHash,
dependencies: AlbumArtPaletteFamily._dependencies,
allTransitiveDependencies:
AlbumArtPaletteFamily._allTransitiveDependencies,
);
final String id;
@override
bool operator ==(Object other) {
return other is AlbumArtPaletteProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.hashCode);
return _SystemHash.finish(hash);
}
}
String _$playlistArtPaletteHash() =>
r'6bc015688f354ea8d91dde86e2a7191ef1ef6496';
typedef PlaylistArtPaletteRef = AutoDisposeFutureProviderRef<Palette>;
/// See also [playlistArtPalette].
@ProviderFor(playlistArtPalette)
const playlistArtPaletteProvider = PlaylistArtPaletteFamily();
/// See also [playlistArtPalette].
class PlaylistArtPaletteFamily extends Family<AsyncValue<Palette>> {
/// See also [playlistArtPalette].
const PlaylistArtPaletteFamily();
/// See also [playlistArtPalette].
PlaylistArtPaletteProvider call(
String id,
) {
return PlaylistArtPaletteProvider(
id,
);
}
@override
PlaylistArtPaletteProvider getProviderOverride(
covariant PlaylistArtPaletteProvider provider,
) {
return call(
provider.id,
);
}
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'playlistArtPaletteProvider';
}
/// See also [playlistArtPalette].
class PlaylistArtPaletteProvider extends AutoDisposeFutureProvider<Palette> {
/// See also [playlistArtPalette].
PlaylistArtPaletteProvider(
this.id,
) : super.internal(
(ref) => playlistArtPalette(
ref,
id,
),
from: playlistArtPaletteProvider,
name: r'playlistArtPaletteProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$playlistArtPaletteHash,
dependencies: PlaylistArtPaletteFamily._dependencies,
allTransitiveDependencies:
PlaylistArtPaletteFamily._allTransitiveDependencies,
);
final String id;
@override
bool operator ==(Object other) {
return other is PlaylistArtPaletteProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.hashCode);
return _SystemHash.finish(hash);
}
}
String _$mediaItemPaletteHash() => r'2f2744aa735c6056919197c283a367714d7e04e4';
/// See also [mediaItemPalette].
@ProviderFor(mediaItemPalette)
final mediaItemPaletteProvider = AutoDisposeFutureProvider<Palette>.internal(
mediaItemPalette,
name: r'mediaItemPaletteProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mediaItemPaletteHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MediaItemPaletteRef = AutoDisposeFutureProviderRef<Palette>;
String _$mediaItemThemeHash() => r'f43e6f86eeed7aef33fa8bd2695454a760f41afa';
/// See also [mediaItemTheme].
@ProviderFor(mediaItemTheme)
final mediaItemThemeProvider = AutoDisposeFutureProvider<ColorTheme>.internal(
mediaItemTheme,
name: r'mediaItemThemeProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$mediaItemThemeHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef MediaItemThemeRef = AutoDisposeFutureProviderRef<ColorTheme>;
String _$albumArtThemeHash() => r'd3ee71b2df856f1763ec925e158ae2e0f613b9e0';
typedef AlbumArtThemeRef = AutoDisposeFutureProviderRef<ColorTheme>;
/// See also [albumArtTheme].
@ProviderFor(albumArtTheme)
const albumArtThemeProvider = AlbumArtThemeFamily();
/// See also [albumArtTheme].
class AlbumArtThemeFamily extends Family<AsyncValue<ColorTheme>> {
/// See also [albumArtTheme].
const AlbumArtThemeFamily();
/// See also [albumArtTheme].
AlbumArtThemeProvider call(
String id,
) {
return AlbumArtThemeProvider(
id,
);
}
@override
AlbumArtThemeProvider getProviderOverride(
covariant AlbumArtThemeProvider provider,
) {
return call(
provider.id,
);
}
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'albumArtThemeProvider';
}
/// See also [albumArtTheme].
class AlbumArtThemeProvider extends AutoDisposeFutureProvider<ColorTheme> {
/// See also [albumArtTheme].
AlbumArtThemeProvider(
this.id,
) : super.internal(
(ref) => albumArtTheme(
ref,
id,
),
from: albumArtThemeProvider,
name: r'albumArtThemeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$albumArtThemeHash,
dependencies: AlbumArtThemeFamily._dependencies,
allTransitiveDependencies:
AlbumArtThemeFamily._allTransitiveDependencies,
);
final String id;
@override
bool operator ==(Object other) {
return other is AlbumArtThemeProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.hashCode);
return _SystemHash.finish(hash);
}
}
String _$playlistArtThemeHash() => r'1629552e1f3aa2a1e7d223ac1e078893042e5e3b';
typedef PlaylistArtThemeRef = AutoDisposeFutureProviderRef<ColorTheme>;
/// See also [playlistArtTheme].
@ProviderFor(playlistArtTheme)
const playlistArtThemeProvider = PlaylistArtThemeFamily();
/// See also [playlistArtTheme].
class PlaylistArtThemeFamily extends Family<AsyncValue<ColorTheme>> {
/// See also [playlistArtTheme].
const PlaylistArtThemeFamily();
/// See also [playlistArtTheme].
PlaylistArtThemeProvider call(
String id,
) {
return PlaylistArtThemeProvider(
id,
);
}
@override
PlaylistArtThemeProvider getProviderOverride(
covariant PlaylistArtThemeProvider provider,
) {
return call(
provider.id,
);
}
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'playlistArtThemeProvider';
}
/// See also [playlistArtTheme].
class PlaylistArtThemeProvider extends AutoDisposeFutureProvider<ColorTheme> {
/// See also [playlistArtTheme].
PlaylistArtThemeProvider(
this.id,
) : super.internal(
(ref) => playlistArtTheme(
ref,
id,
),
from: playlistArtThemeProvider,
name: r'playlistArtThemeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$playlistArtThemeHash,
dependencies: PlaylistArtThemeFamily._dependencies,
allTransitiveDependencies:
PlaylistArtThemeFamily._allTransitiveDependencies,
);
final String id;
@override
bool operator ==(Object other) {
return other is PlaylistArtThemeProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.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