first commit
Build / Build for Android (push) Has been cancelled
Build / Build for iOS (push) Has been cancelled

This commit is contained in:
Zakaria
2026-05-18 14:15:38 -04:00
commit 1563409cb1
382 changed files with 45347 additions and 0 deletions
@@ -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),
),
),
],
),
);
},
);
}
}
+48
View File
@@ -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();
}
},
),
);
}
}
+111
View File
@@ -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(),
);
}
}
+49
View File
@@ -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(),
],
),
),
);
}
}
+40
View File
@@ -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(),
);
}
}
+46
View File
@@ -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(),
);
}
}
+43
View File
@@ -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),
),
],
),
);
}
}
+26
View File
@@ -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(),
);
}
}
+289
View File
@@ -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(),
),
),
);
},
);
},
);
}
}
+226
View File
@@ -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,
);
});
}
}
+104
View File
@@ -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(),
],
),
),
);
}
}
+26
View File
@@ -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();
}
}
}
+70
View File
@@ -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,
),
),
],
),
),
);
}
}
+28
View File
@@ -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()),
);
}
}
+143
View File
@@ -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);
}
}
}
}