first commit
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../components/AddDownloadLocationScreen/custom_download_location_form.dart';
|
||||
import '../components/AddDownloadLocationScreen/app_directory_location_form.dart';
|
||||
import '../models/finamp_models.dart';
|
||||
import '../services/finamp_settings_helper.dart';
|
||||
|
||||
class AddDownloadLocationScreen extends StatefulWidget {
|
||||
const AddDownloadLocationScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/downloadlocations/add";
|
||||
|
||||
@override
|
||||
State<AddDownloadLocationScreen> createState() =>
|
||||
_AddDownloadLocationScreenState();
|
||||
}
|
||||
|
||||
class _AddDownloadLocationScreenState extends State<AddDownloadLocationScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final customLocationFormKey = GlobalKey<FormState>();
|
||||
final appDirectoryFormKey = GlobalKey<FormState>();
|
||||
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Since we can't initialise tabs before initState we need to awkwardly
|
||||
// provide the length directly
|
||||
_tabController =
|
||||
TabController(vsync: this, length: Platform.isAndroid ? 2 : 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tabs = Platform.isAndroid
|
||||
? [
|
||||
Tab(
|
||||
text: AppLocalizations.of(context)!.customLocation.toUpperCase(),
|
||||
),
|
||||
Tab(
|
||||
text: AppLocalizations.of(context)!.appDirectory.toUpperCase(),
|
||||
),
|
||||
]
|
||||
: [
|
||||
Tab(
|
||||
text: AppLocalizations.of(context)!.customLocation.toUpperCase(),
|
||||
),
|
||||
];
|
||||
return Provider<NewDownloadLocation>(
|
||||
create: (_) => NewDownloadLocation(
|
||||
name: null,
|
||||
deletable: true,
|
||||
path: null,
|
||||
useHumanReadableNames: null,
|
||||
),
|
||||
builder: (context, _) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.addDownloadLocation),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.check),
|
||||
onPressed: () {
|
||||
bool isValidated = false;
|
||||
|
||||
// If _tabController.index is 0, we are on the custom location tab.
|
||||
// If not, we are on the app directory tab.
|
||||
if (_tabController.index == 0) {
|
||||
if (customLocationFormKey.currentState?.validate() == true) {
|
||||
customLocationFormKey.currentState!.save();
|
||||
// If we're saving to a custom location, we want to use human readable names.
|
||||
// With app dir locations, we don't use human readable names.
|
||||
context.read<NewDownloadLocation>().useHumanReadableNames =
|
||||
true;
|
||||
isValidated = true;
|
||||
}
|
||||
} else {
|
||||
if (appDirectoryFormKey.currentState?.validate() == true) {
|
||||
appDirectoryFormKey.currentState!.save();
|
||||
context.read<NewDownloadLocation>().useHumanReadableNames =
|
||||
false;
|
||||
isValidated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// We set a variable called isValidated so that we don't have to copy this logic into each validate()
|
||||
if (isValidated) {
|
||||
final newDownloadLocation = context.read<NewDownloadLocation>();
|
||||
|
||||
// We don't use DownloadLocation when initially getting the
|
||||
// values because DownloadLocation doesn't have nullable values.
|
||||
// At this point, the NewDownloadLocation shouldn't have any
|
||||
// null values.
|
||||
final downloadLocation = DownloadLocation.create(
|
||||
name: newDownloadLocation.name!,
|
||||
path: newDownloadLocation.path!,
|
||||
useHumanReadableNames:
|
||||
newDownloadLocation.useHumanReadableNames!,
|
||||
deletable: newDownloadLocation.deletable,
|
||||
);
|
||||
|
||||
FinampSettingsHelper.addDownloadLocation(downloadLocation);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CustomDownloadLocationForm(
|
||||
formKey: customLocationFormKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child:
|
||||
AppDirectoryLocationForm(formKey: appDirectoryFormKey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/AddToPlaylistScreen/add_to_playlist_list.dart';
|
||||
import '../components/AddToPlaylistScreen/new_playlist_dialog.dart';
|
||||
|
||||
class AddToPlaylistScreen extends StatefulWidget {
|
||||
const AddToPlaylistScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/music/addtoplaylist";
|
||||
|
||||
@override
|
||||
State<AddToPlaylistScreen> createState() => _AddToPlaylistScreenState();
|
||||
}
|
||||
|
||||
class _AddToPlaylistScreenState extends State<AddToPlaylistScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final itemId = ModalRoute.of(context)!.settings.arguments as String;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.addToPlaylistTitle),
|
||||
),
|
||||
body: AddToPlaylistList(
|
||||
itemToAddId: itemId,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
// The dialog returns true if a playlist is created. If this is the
|
||||
// case, we also pop this page. It will return false if the user
|
||||
// cancels the dialog.
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => NewPlaylistDialog(itemToAdd: itemId),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result == true) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../models/finamp_models.dart';
|
||||
import '../services/jellyfin_api_helper.dart';
|
||||
import '../services/finamp_settings_helper.dart';
|
||||
import '../services/downloads_helper.dart';
|
||||
import '../components/now_playing_bar.dart';
|
||||
import '../components/AlbumScreen/album_screen_content.dart';
|
||||
import '../services/music_player_background_task.dart';
|
||||
|
||||
class AlbumScreen extends StatefulWidget {
|
||||
const AlbumScreen({
|
||||
Key? key,
|
||||
this.parent,
|
||||
}) : super(key: key);
|
||||
|
||||
static const routeName = "/music/album";
|
||||
|
||||
/// The album to show. Can also be provided as an argument in a named route
|
||||
final BaseItemDto? parent;
|
||||
|
||||
@override
|
||||
State<AlbumScreen> createState() => _AlbumScreenState();
|
||||
}
|
||||
|
||||
class _AlbumScreenState extends State<AlbumScreen> {
|
||||
Future<List<BaseItemDto>?>? albumScreenContentFuture;
|
||||
JellyfinApiHelper jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final BaseItemDto parent = widget.parent ??
|
||||
ModalRoute.of(context)!.settings.arguments as BaseItemDto;
|
||||
|
||||
return Scaffold(
|
||||
body: ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, widget) {
|
||||
bool isOffline = box.get("FinampSettings")?.isOffline ?? false;
|
||||
|
||||
if (isOffline) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
// The downloadedParent won't be null here if we've already
|
||||
// navigated to it in offline mode
|
||||
final downloadedParent =
|
||||
downloadsHelper.getDownloadedParent(parent.id)!;
|
||||
|
||||
return AlbumScreenContent(
|
||||
parent: downloadedParent.item,
|
||||
children: downloadedParent.downloadedChildren.values.toList(),
|
||||
);
|
||||
} else {
|
||||
albumScreenContentFuture ??= jellyfinApiHelper.getItems(
|
||||
parentItem: parent,
|
||||
sortBy: "ParentIndexNumber,IndexNumber,SortName",
|
||||
includeItemTypes: "Audio",
|
||||
isGenres: false,
|
||||
);
|
||||
|
||||
return FutureBuilder<List<BaseItemDto>?>(
|
||||
future: albumScreenContentFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final List<BaseItemDto> items = snapshot.data!;
|
||||
|
||||
return AlbumScreenContent(parent: parent, children: items);
|
||||
} else if (snapshot.hasError) {
|
||||
return CustomScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: Text(AppLocalizations.of(context)!.error),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(snapshot.error.toString())),
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// We return all of this so that we can have an app bar while loading.
|
||||
// This is especially important for iOS, where there isn't a hardware back button.
|
||||
return CustomScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: Text(parent.name ??
|
||||
AppLocalizations.of(context)!.unknownName),
|
||||
),
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: const NowPlayingBar(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../models/finamp_models.dart';
|
||||
import '../components/ArtistScreen/artist_download_button.dart';
|
||||
import '../components/MusicScreen/music_screen_tab_view.dart';
|
||||
import '../components/now_playing_bar.dart';
|
||||
import '../components/favourite_button.dart';
|
||||
import '../components/ArtistScreen/artist_play_button.dart';
|
||||
import '../components/ArtistScreen/artist_shuffle_button.dart';
|
||||
|
||||
class ArtistScreen extends StatelessWidget {
|
||||
const ArtistScreen({
|
||||
Key? key,
|
||||
this.widgetArtist,
|
||||
}) : super(key: key);
|
||||
|
||||
static const routeName = "/music/artist";
|
||||
|
||||
/// The artist to show. Can also be provided as an argument in a named route
|
||||
final BaseItemDto? widgetArtist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final BaseItemDto artist = widgetArtist ??
|
||||
ModalRoute.of(context)!.settings.arguments as BaseItemDto;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(artist.name ?? "Unknown Name"),
|
||||
actions: [
|
||||
// this screen is also used for genres, which can't be favorited
|
||||
if (artist.type != "MusicGenre") ArtistPlayButton(artist: artist),
|
||||
if (artist.type != "MusicGenre") ArtistShuffleButton(artist: artist),
|
||||
if (artist.type != "MusicGenre") FavoriteButton(item: artist),
|
||||
ArtistDownloadButton(artist: artist)
|
||||
],
|
||||
),
|
||||
body: MusicScreenTabView(
|
||||
tabContentType: TabContentType.albums,
|
||||
parentItem: artist,
|
||||
isFavourite: false,
|
||||
sortBy: SortBy.premiereDate,
|
||||
albumArtist: artist.name,
|
||||
),
|
||||
bottomNavigationBar: const NowPlayingBar(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/AudioServiceSettingsScreen/buffer_duration_list_tile.dart';
|
||||
import '../components/AudioServiceSettingsScreen/stop_foreground_selector.dart';
|
||||
import '../components/AudioServiceSettingsScreen/song_shuffle_item_count_editor.dart';
|
||||
|
||||
class AudioServiceSettingsScreen extends StatelessWidget {
|
||||
const AudioServiceSettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/audioservice";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.audioService),
|
||||
),
|
||||
body: Scrollbar(
|
||||
child: ListView(
|
||||
children: [
|
||||
if (Platform.isAndroid) const StopForegroundSelector(),
|
||||
const SongShuffleItemCountEditor(),
|
||||
const BufferDurationListTile(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../components/DownloadsErrorScreen/download_error_list.dart';
|
||||
import '../services/downloads_helper.dart';
|
||||
|
||||
class DownloadsErrorScreen extends StatelessWidget {
|
||||
const DownloadsErrorScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/downloads/errors";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.downloadErrorsTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () async {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final appLocalizations = AppLocalizations.of(context);
|
||||
|
||||
final redownloaded = await downloadsHelper.redownloadFailed();
|
||||
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
appLocalizations!.redownloadedItems(redownloaded)),
|
||||
),
|
||||
);
|
||||
})
|
||||
]),
|
||||
body: const DownloadErrorList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/DownloadsScreen/downloads_overview.dart';
|
||||
import '../components/DownloadsScreen/downloaded_albums_list.dart';
|
||||
import '../components/DownloadsScreen/download_error_screen_button.dart';
|
||||
import '../components/DownloadsScreen/download_missing_images_button.dart';
|
||||
import '../components/DownloadsScreen/sync_downloaded_playlists.dart';
|
||||
|
||||
class DownloadsScreen extends StatelessWidget {
|
||||
const DownloadsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/downloads";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.downloads),
|
||||
actions: [
|
||||
SyncDownloadedAlbumsOrPlaylistsButton(),
|
||||
const DownloadMissingImagesButton(),
|
||||
const DownloadErrorScreenButton()
|
||||
],
|
||||
),
|
||||
body: Scrollbar(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const Padding(
|
||||
// We don't have bottom padding here since the divider already provides bottom padding
|
||||
padding: EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: DownloadsOverview(),
|
||||
),
|
||||
const Divider(),
|
||||
]),
|
||||
),
|
||||
const DownloadedAlbumsList(),
|
||||
// CurrentDownloadsList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import 'add_download_location_screen.dart';
|
||||
import '../components/DownloadLocationSettingsScreen/download_location_list.dart';
|
||||
|
||||
class DownloadsSettingsScreen extends StatelessWidget {
|
||||
const DownloadsSettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/downloadlocations";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.downloadLocations),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () => Navigator.of(context)
|
||||
.pushNamed(AddDownloadLocationScreen.routeName),
|
||||
),
|
||||
body: const DownloadLocationList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/InteractionSettingsScreen/swipe_insert_queue_next_selector.dart';
|
||||
import '../components/InteractionSettingsScreen/FastScrollSelector.dart';
|
||||
import '../components/InteractionSettingsScreen/disable_gestures.dart';
|
||||
|
||||
class InteractionSettingsScreen extends StatelessWidget {
|
||||
const InteractionSettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/interactions";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.interactions),
|
||||
),
|
||||
body: Scrollbar(
|
||||
child: ListView(
|
||||
children: [
|
||||
const SwipeInsertQueueNextSelector(),
|
||||
const FastScrollSelector(),
|
||||
const DisableGestureSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/LanguageSelectionScreen/language_list.dart';
|
||||
|
||||
class LanguageSelectionScreen extends StatelessWidget {
|
||||
const LanguageSelectionScreen({super.key});
|
||||
|
||||
static const routeName = "/settings/language";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.language),
|
||||
),
|
||||
body: const LanguageList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/LayoutSettingsScreen/theme_selector.dart';
|
||||
import 'tabs_settings_screen.dart';
|
||||
import '../components/LayoutSettingsScreen/content_grid_view_cross_axis_count_list_tile.dart';
|
||||
import '../components/LayoutSettingsScreen/content_view_type_dropdown_list_tile.dart';
|
||||
import '../components/LayoutSettingsScreen/show_text_on_grid_view_selector.dart';
|
||||
import '../components/LayoutSettingsScreen/show_cover_as_player_background_selector.dart';
|
||||
import '../components/LayoutSettingsScreen/hide_song_artists_if_same_as_album_artists_selector.dart';
|
||||
|
||||
class LayoutSettingsScreen extends StatelessWidget {
|
||||
const LayoutSettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/layout";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.layoutAndTheme),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const ContentViewTypeDropdownListTile(),
|
||||
for (final type in ContentGridViewCrossAxisCountType.values)
|
||||
ContentGridViewCrossAxisCountListTile(type: type),
|
||||
const ShowTextOnGridViewSelector(),
|
||||
const ShowCoverAsPlayerBackgroundSelector(),
|
||||
const HideSongArtistsIfSameAsAlbumArtistsSelector(),
|
||||
const ThemeSelector(),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tab),
|
||||
title: Text(AppLocalizations.of(context)!.tabs),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(TabsSettingsScreen.routeName),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/LogsScreen/copy_logs_button.dart';
|
||||
import '../components/LogsScreen/logs_view.dart';
|
||||
import '../components/LogsScreen/share_logs_button.dart';
|
||||
|
||||
class LogsScreen extends StatelessWidget {
|
||||
const LogsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/logs";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.logs),
|
||||
actions: const [
|
||||
ShareLogsButton(),
|
||||
CopyLogsButton(),
|
||||
],
|
||||
),
|
||||
body: const LogsView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../models/finamp_models.dart';
|
||||
import '../services/finamp_settings_helper.dart';
|
||||
import '../services/audio_service_helper.dart';
|
||||
import '../services/finamp_user_helper.dart';
|
||||
import '../components/MusicScreen/music_screen_tab_view.dart';
|
||||
import '../components/MusicScreen/music_screen_drawer.dart';
|
||||
import '../components/MusicScreen/sort_by_menu_button.dart';
|
||||
import '../components/MusicScreen/sort_order_button.dart';
|
||||
import '../components/now_playing_bar.dart';
|
||||
import '../components/error_snackbar.dart';
|
||||
import '../services/jellyfin_api_helper.dart';
|
||||
|
||||
class MusicScreen extends StatefulWidget {
|
||||
const MusicScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/music";
|
||||
|
||||
@override
|
||||
State<MusicScreen> createState() => _MusicScreenState();
|
||||
}
|
||||
|
||||
class _MusicScreenState extends State<MusicScreen>
|
||||
with TickerProviderStateMixin {
|
||||
bool isSearching = false;
|
||||
bool _showShuffleFab = false;
|
||||
TextEditingController textEditingController = TextEditingController();
|
||||
String? searchQuery;
|
||||
final _musicScreenLogger = Logger("MusicScreen");
|
||||
|
||||
TabController? _tabController;
|
||||
|
||||
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
void _stopSearching() {
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
searchQuery = null;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _tabIndexCallback() {
|
||||
var tabKey = FinampSettingsHelper.finampSettings.showTabs.entries
|
||||
.where((element) => element.value)
|
||||
.elementAt(_tabController!.index)
|
||||
.key;
|
||||
if (_tabController != null &&
|
||||
(tabKey == TabContentType.songs ||
|
||||
tabKey == TabContentType.artists ||
|
||||
tabKey == TabContentType.albums)) {
|
||||
setState(() {
|
||||
_showShuffleFab = true;
|
||||
});
|
||||
} else {
|
||||
if (_showShuffleFab) {
|
||||
setState(() {
|
||||
_showShuffleFab = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _buildTabController() {
|
||||
_tabController?.removeListener(_tabIndexCallback);
|
||||
|
||||
_tabController = TabController(
|
||||
length: FinampSettingsHelper.finampSettings.showTabs.entries
|
||||
.where((element) => element.value)
|
||||
.length,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_tabController!.addListener(_tabIndexCallback);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_buildTabController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
FloatingActionButton? getFloatingActionButton() {
|
||||
var tabList = FinampSettingsHelper.finampSettings.showTabs.entries
|
||||
.where((element) => element.value)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
// Show the floating action button only on the albums, artists and songs tab.
|
||||
if (_tabController!.index == tabList.indexOf(TabContentType.songs)) {
|
||||
return FloatingActionButton(
|
||||
tooltip: AppLocalizations.of(context)!.shuffleAll,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await _audioServiceHelper
|
||||
.shuffleAll(FinampSettingsHelper.finampSettings.isFavourite);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.shuffle),
|
||||
);
|
||||
} else if (_tabController!.index ==
|
||||
tabList.indexOf(TabContentType.artists)) {
|
||||
return FloatingActionButton(
|
||||
tooltip: AppLocalizations.of(context)!.startMix,
|
||||
onPressed: () async {
|
||||
try {
|
||||
if (_jellyfinApiHelper.selectedMixArtistsIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.startMixNoSongsArtist)));
|
||||
} else {
|
||||
await _audioServiceHelper.startInstantMixForArtists(
|
||||
_jellyfinApiHelper.selectedMixArtistsIds);
|
||||
}
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.explore));
|
||||
} else if (_tabController!.index ==
|
||||
tabList.indexOf(TabContentType.albums)) {
|
||||
return FloatingActionButton(
|
||||
tooltip: AppLocalizations.of(context)!.startMix,
|
||||
onPressed: () async {
|
||||
try {
|
||||
if (_jellyfinApiHelper.selectedMixAlbumIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.startMixNoSongsAlbum)));
|
||||
} else {
|
||||
await _audioServiceHelper.startInstantMixForAlbums(
|
||||
_jellyfinApiHelper.selectedMixAlbumIds);
|
||||
}
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.explore));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampUser>>(
|
||||
valueListenable: _finampUserHelper.finampUsersListenable,
|
||||
builder: (context, value, _) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, value, _) {
|
||||
final finampSettings = value.get("FinampSettings");
|
||||
|
||||
// Get the tabs from the user's tab order, and filter them to only
|
||||
// include enabled tabs
|
||||
final tabs = finampSettings!.tabOrder.where((e) =>
|
||||
FinampSettingsHelper.finampSettings.showTabs[e] ?? false);
|
||||
|
||||
if (tabs.length != _tabController?.length) {
|
||||
_musicScreenLogger.info(
|
||||
"Rebuilding MusicScreen tab controller (${tabs.length} != ${_tabController?.length})");
|
||||
_buildTabController();
|
||||
}
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
if (isSearching) {
|
||||
_stopSearching();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: isSearching
|
||||
? TextField(
|
||||
controller: textEditingController,
|
||||
autofocus: true,
|
||||
onChanged: (value) => setState(() {
|
||||
searchQuery = value;
|
||||
}),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: MaterialLocalizations.of(context)
|
||||
.searchFieldLabel,
|
||||
),
|
||||
)
|
||||
: Text(_finampUserHelper.currentUser?.currentView?.name ??
|
||||
AppLocalizations.of(context)!.music),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: tabs
|
||||
.map((tabType) => Tab(
|
||||
text: tabType
|
||||
.toLocalisedString(context)
|
||||
.toUpperCase(),
|
||||
))
|
||||
.toList(),
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
),
|
||||
leading: isSearching
|
||||
? BackButton(
|
||||
onPressed: () => _stopSearching(),
|
||||
)
|
||||
: null,
|
||||
actions: isSearching
|
||||
? [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.cancel,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
textEditingController.clear();
|
||||
searchQuery = null;
|
||||
}),
|
||||
tooltip: AppLocalizations.of(context)!.clear,
|
||||
)
|
||||
]
|
||||
: [
|
||||
SortOrderButton(
|
||||
tabs.elementAt(_tabController!.index),
|
||||
),
|
||||
SortByMenuButton(
|
||||
tabs.elementAt(_tabController!.index),
|
||||
),
|
||||
IconButton(
|
||||
icon: finampSettings.isFavourite
|
||||
? const Icon(Icons.favorite)
|
||||
: const Icon(Icons.favorite_outline),
|
||||
onPressed: finampSettings.isOffline
|
||||
? null
|
||||
: () => FinampSettingsHelper.setIsFavourite(
|
||||
!finampSettings.isFavourite),
|
||||
tooltip: AppLocalizations.of(context)!.favourites,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () => setState(() {
|
||||
isSearching = true;
|
||||
}),
|
||||
tooltip: MaterialLocalizations.of(context)
|
||||
.searchFieldLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: const NowPlayingBar(),
|
||||
drawer: const MusicScreenDrawer(),
|
||||
floatingActionButton: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: getFloatingActionButton(),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: tabs
|
||||
.map((tabType) => MusicScreenTabView(
|
||||
tabContentType: tabType,
|
||||
searchTerm: searchQuery,
|
||||
isFavourite: finampSettings.isFavourite,
|
||||
sortBy: finampSettings.getTabSortBy(tabType),
|
||||
sortOrder: finampSettings.getSortOrder(tabType),
|
||||
view: _finampUserHelper.currentUser?.currentView,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
import 'package:simple_gesture_detector/simple_gesture_detector.dart';
|
||||
|
||||
import '../components/favourite_button.dart';
|
||||
import '../services/finamp_settings_helper.dart';
|
||||
import '../services/music_player_background_task.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../components/album_image.dart';
|
||||
import '../components/PlayerScreen/song_name.dart';
|
||||
import '../components/PlayerScreen/progress_slider.dart';
|
||||
import '../components/PlayerScreen/player_buttons.dart';
|
||||
import '../components/PlayerScreen/queue_button.dart';
|
||||
import '../components/PlayerScreen/playback_mode.dart';
|
||||
import '../components/PlayerScreen/add_to_playlist_button.dart';
|
||||
import '../components/PlayerScreen/sleep_timer_button.dart';
|
||||
|
||||
final _albumImageProvider =
|
||||
StateProvider.autoDispose<ImageProvider?>((_) => null);
|
||||
|
||||
class PlayerScreen extends StatelessWidget {
|
||||
const PlayerScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/nowplaying";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return SimpleGestureDetector(
|
||||
onVerticalSwipe: (direction) {
|
||||
if (!FinampSettingsHelper.finampSettings.disableGesture &&
|
||||
direction == SwipeDirection.down) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
onHorizontalSwipe: (direction) {
|
||||
if (!FinampSettingsHelper.finampSettings.disableGesture) {
|
||||
switch (direction) {
|
||||
case SwipeDirection.left:
|
||||
audioHandler.skipToNext();
|
||||
break;
|
||||
case SwipeDirection.right:
|
||||
audioHandler.skipToPrevious();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: const [
|
||||
SleepTimerButton(),
|
||||
AddToPlaylistButton(),
|
||||
],
|
||||
),
|
||||
// Required for sleep timer input
|
||||
resizeToAvoidBottomInset: false,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground)
|
||||
const _BlurredPlayerScreenBackground(),
|
||||
const SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PlayerScreenAlbumImage(),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SongName(),
|
||||
ProgressSlider(),
|
||||
PlayerButtons(),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: PlaybackMode(),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: _PlayerScreenFavoriteButton(),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: QueueButton(),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This widget is just an AlbumImage in a StreamBuilder to get the song id.
|
||||
class _PlayerScreenAlbumImage extends ConsumerWidget {
|
||||
const _PlayerScreenAlbumImage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data?.extras?["itemJson"] == null
|
||||
? null
|
||||
: BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]);
|
||||
|
||||
return item == null
|
||||
? AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: AlbumImage.borderRadius,
|
||||
child: Container(color: Theme.of(context).cardColor),
|
||||
),
|
||||
)
|
||||
: AlbumImage(
|
||||
item: item,
|
||||
imageProviderCallback: (imageProvider) =>
|
||||
// We need a post frame callback because otherwise this
|
||||
// widget rebuilds on the same frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => ref
|
||||
.read(_albumImageProvider.notifier)
|
||||
.state = imageProvider),
|
||||
// Here we awkwardly get the next 3 queue items so that we
|
||||
// can precache them (so that the image is already loaded
|
||||
// when the next song comes on).
|
||||
itemsToPrecache: audioHandler.queue.value
|
||||
.sublist(min(
|
||||
(audioHandler.playbackState.value.queueIndex ?? 0) +
|
||||
1,
|
||||
audioHandler.queue.value.length))
|
||||
.take(3)
|
||||
.map((e) => BaseItemDto.fromJson(e.extras!["itemJson"]))
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [_PlayerScreenAlbumImage], but with a BlurHash instead. We also
|
||||
/// filter the BlurHash so that it works as a background image.
|
||||
class _BlurredPlayerScreenBackground extends ConsumerWidget {
|
||||
const _BlurredPlayerScreenBackground({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final imageProvider = ref.watch(_albumImageProvider);
|
||||
|
||||
return ClipRect(
|
||||
child: imageProvider == null
|
||||
? const SizedBox.shrink()
|
||||
: OctoImage(
|
||||
image: imageProvider,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: (_) => const SizedBox.shrink(),
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
imageBuilder: (context, child) => ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.black.withOpacity(0.35)
|
||||
: Colors.white.withOpacity(0.75),
|
||||
BlendMode.srcOver),
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: 85,
|
||||
sigmaY: 85,
|
||||
tileMode: TileMode.mirror,
|
||||
),
|
||||
child: SizedBox.expand(child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerScreenFavoriteButton extends StatelessWidget {
|
||||
const _PlayerScreenFavoriteButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
return FavoriteButton(
|
||||
item: snapshot.data?.extras?["itemJson"] == null
|
||||
? null
|
||||
: BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]),
|
||||
inPlayer: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:finamp/screens/interaction_settings_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:locale_names/locale_names.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../services/finamp_settings_helper.dart';
|
||||
import '../services/locale_helper.dart';
|
||||
import 'transcoding_settings_screen.dart';
|
||||
import 'downloads_settings_screen.dart';
|
||||
import 'audio_service_settings_screen.dart';
|
||||
import 'layout_settings_screen.dart';
|
||||
import '../components/SettingsScreen/logout_list_tile.dart';
|
||||
import 'view_selector.dart';
|
||||
import 'language_selection_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.settings),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: AppLocalizations.of(context)!.about,
|
||||
icon: const Icon(Icons.info),
|
||||
onPressed: () async {
|
||||
final applicationLegalese =
|
||||
AppLocalizations.of(context)!.applicationLegalese;
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: packageInfo.appName,
|
||||
applicationVersion: packageInfo.version,
|
||||
applicationLegalese: applicationLegalese,
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Scrollbar(
|
||||
child: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.compress),
|
||||
title: Text(AppLocalizations.of(context)!.transcoding),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(TranscodingSettingsScreen.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder),
|
||||
title: Text(AppLocalizations.of(context)!.downloadLocations),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(DownloadsSettingsScreen.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.music_note),
|
||||
title: Text(AppLocalizations.of(context)!.audioService),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(AudioServiceSettingsScreen.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.gesture),
|
||||
title: Text(AppLocalizations.of(context)!.interactions),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(InteractionSettingsScreen.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.widgets),
|
||||
title: Text(AppLocalizations.of(context)!.layoutAndTheme),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(LayoutSettingsScreen.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.library_music),
|
||||
title: Text(AppLocalizations.of(context)!.selectMusicLibraries),
|
||||
subtitle: FinampSettingsHelper.finampSettings.isOffline
|
||||
? Text(
|
||||
AppLocalizations.of(context)!.notAvailableInOfflineMode)
|
||||
: null,
|
||||
enabled: !FinampSettingsHelper.finampSettings.isOffline,
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(ViewSelector.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(AppLocalizations.of(context)!.language),
|
||||
subtitle: Text(LocaleHelper.locale?.nativeDisplayLanguage ??
|
||||
AppLocalizations.of(context)!.system),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(LanguageSelectionScreen.routeName),
|
||||
),
|
||||
const LogoutListTile(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../services/finamp_user_helper.dart';
|
||||
import 'user_selector.dart';
|
||||
import 'music_screen.dart';
|
||||
import 'view_selector.dart';
|
||||
|
||||
class SplashScreen extends StatelessWidget {
|
||||
const SplashScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
if (finampUserHelper.currentUser == null) {
|
||||
return const UserSelector();
|
||||
} else if (finampUserHelper.currentUser!.currentView == null) {
|
||||
return const ViewSelector();
|
||||
} else {
|
||||
return const MusicScreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:finamp/services/finamp_settings_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/TabsSettingsScreen/hide_tab_toggle.dart';
|
||||
|
||||
class TabsSettingsScreen extends StatefulWidget {
|
||||
const TabsSettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/tabs";
|
||||
|
||||
@override
|
||||
State<TabsSettingsScreen> createState() => _TabsSettingsScreenState();
|
||||
}
|
||||
|
||||
class _TabsSettingsScreenState extends State<TabsSettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.tabs),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
FinampSettingsHelper.resetTabs();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: AppLocalizations.of(context)!.resetTabs,
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Scrollbar(
|
||||
child: ReorderableListView.builder(
|
||||
// buildDefaultDragHandles: false,
|
||||
itemCount: FinampSettingsHelper.finampSettings.tabOrder.length,
|
||||
itemBuilder: (context, index) {
|
||||
return HideTabToggle(
|
||||
tabContentType:
|
||||
FinampSettingsHelper.finampSettings.tabOrder[index],
|
||||
key:
|
||||
ValueKey(FinampSettingsHelper.finampSettings.tabOrder[index]),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// It's a bit of a hack to call setState with no actual widget
|
||||
// state, but it saves us from using listeners
|
||||
setState(() {
|
||||
// For some weird reason newIndex is one above what it should be
|
||||
// when oldIndex is lower. This if statement is in Flutter's
|
||||
// ReorderableListView documentation.
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
final oldValue =
|
||||
FinampSettingsHelper.finampSettings.tabOrder[oldIndex];
|
||||
final newValue =
|
||||
FinampSettingsHelper.finampSettings.tabOrder[newIndex];
|
||||
|
||||
FinampSettingsHelper.setTabOrder(oldIndex, newValue);
|
||||
FinampSettingsHelper.setTabOrder(newIndex, oldValue);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../components/TranscodingSettingsScreen/transcode_switch.dart';
|
||||
import '../components/TranscodingSettingsScreen/bitrate_selector.dart';
|
||||
|
||||
class TranscodingSettingsScreen extends StatelessWidget {
|
||||
const TranscodingSettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/transcoding";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.transcoding),
|
||||
),
|
||||
body: Scrollbar(
|
||||
child: ListView(
|
||||
children: [
|
||||
const TranscodeSwitch(),
|
||||
const BitrateSelector(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.jellyfinUsesAACForTranscoding,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../components/UserSelector/private_user_sign_in.dart';
|
||||
import 'language_selection_screen.dart';
|
||||
|
||||
class UserSelector extends StatelessWidget {
|
||||
const UserSelector({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/login/userSelector";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context)
|
||||
.pushNamed(LanguageSelectionScreen.routeName),
|
||||
icon: const Icon(Icons.language),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: const Center(child: PrivateUserSignIn()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../components/ViewSelector/no_music_libraries_message.dart';
|
||||
import '../services/finamp_user_helper.dart';
|
||||
import 'music_screen.dart';
|
||||
import '../services/jellyfin_api_helper.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../components/error_snackbar.dart';
|
||||
|
||||
class ViewSelector extends StatefulWidget {
|
||||
const ViewSelector({Key? key}) : super(key: key);
|
||||
|
||||
static const routeName = "/settings/views";
|
||||
|
||||
@override
|
||||
State<ViewSelector> createState() => _ViewSelectorState();
|
||||
}
|
||||
|
||||
class _ViewSelectorState extends State<ViewSelector> {
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
late Future<List<BaseItemDto>> viewListFuture;
|
||||
final Map<BaseItemDto, bool> _views = {};
|
||||
bool isSubmitButtonEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
viewListFuture = _jellyfinApiHelper.getViews();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<BaseItemDto>>(
|
||||
future: viewListFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
// Finamp only supports music libraries. We used to allow people to
|
||||
// select unsupported libraries, but some people selected "general"
|
||||
// libraries and thought Finamp was broken.
|
||||
if (snapshot.data!.isEmpty ||
|
||||
!snapshot.data!
|
||||
.any((element) => element.collectionType == "music")) {
|
||||
return NoMusicLibrariesMessage(
|
||||
onRefresh: () {
|
||||
setState(() {
|
||||
_views.clear();
|
||||
viewListFuture = _jellyfinApiHelper.getViews();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (_views.isEmpty) {
|
||||
_views.addEntries(snapshot.data!
|
||||
.where((element) => element.collectionType != "playlists")
|
||||
.map((e) => MapEntry(e, e.collectionType == "music")));
|
||||
|
||||
// If only one music library is available and user doesn't have a
|
||||
// view saved (assuming setup is in progress), skip the selector.
|
||||
if (_views.values.where((element) => element == true).length == 1 &&
|
||||
_finampUserHelper.currentUser!.currentView == null) {
|
||||
_submitChoice();
|
||||
} else {
|
||||
if (mounted) {
|
||||
isSubmitButtonEnabled = _views.values.contains(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.selectMusicLibraries),
|
||||
),
|
||||
floatingActionButton: isSubmitButtonEnabled
|
||||
? FloatingActionButton(
|
||||
onPressed: _submitChoice,
|
||||
child: const Icon(Icons.check),
|
||||
)
|
||||
: null,
|
||||
body: Scrollbar(
|
||||
child: ListView.builder(
|
||||
itemCount: _views.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = _views.values.elementAt(index);
|
||||
final view = _views.keys.elementAt(index);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
enabled: view.collectionType == "music",
|
||||
title: Text(_views.keys.elementAt(index).name ??
|
||||
AppLocalizations.of(context)!.unknownName),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_views[_views.keys.elementAt(index)] = value!;
|
||||
isSubmitButtonEnabled = _views.values.contains(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
// TODO: Let the user refresh the page
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error),
|
||||
Text(snapshot.error.toString()),
|
||||
],
|
||||
));
|
||||
} else {
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _submitChoice() {
|
||||
if (_views.values.where((element) => element == true).isEmpty) {
|
||||
// This should no longer be possible since the submit button only shows
|
||||
// when views are selected, but we return just in case
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
_finampUserHelper.setCurrentUserViews(_views.entries
|
||||
.where((element) => element.value == true)
|
||||
.map((e) => e.key)
|
||||
.toList());
|
||||
// allow navigation to music screen while selector is being built
|
||||
Future.microtask(() => Navigator.of(context)
|
||||
.pushNamedAndRemoveUntil(MusicScreen.routeName, (route) => false));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user