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,107 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import '../../models/finamp_models.dart';
class AppDirectoryLocationForm extends StatefulWidget {
const AppDirectoryLocationForm({Key? key, required this.formKey})
: super(key: key);
final Key formKey;
@override
State<AppDirectoryLocationForm> createState() =>
_AppDirectoryLocationFormState();
}
class _AppDirectoryLocationFormState extends State<AppDirectoryLocationForm> {
Directory? selectedDirectory;
late Future<List<Directory>?> externalStorageListFuture;
@override
void initState() {
super.initState();
externalStorageListFuture = getExternalStorageDirectories();
}
@override
Widget build(BuildContext context) {
return Form(
key: widget.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder<List<Directory>?>(
future: externalStorageListFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return const Text("No external directories.");
}
List<DropdownMenuItem<Directory>> dropdownButtonItems =
snapshot.data!
.map((e) => DropdownMenuItem(
value: e,
child: Text(
e.path,
overflow: TextOverflow.ellipsis,
),
))
.toList();
return DropdownButtonFormField<Directory>(
items: dropdownButtonItems,
hint: const Text("Location"),
isExpanded: true,
value: selectedDirectory,
onChanged: (value) {
setState(() {
selectedDirectory = value;
});
},
validator: (value) {
if (value == null) {
return "Required";
}
return null;
},
onSaved: (newValue) {
if (newValue != null) {
context.read<NewDownloadLocation>().path = newValue.path;
}
},
);
} else if (snapshot.hasError) {
return Text(snapshot.error.toString());
} else {
return const CircularProgressIndicator.adaptive();
}
},
),
TextFormField(
decoration: const InputDecoration(labelText: "Name (required)"),
validator: (value) {
if (value == null || value.isEmpty) {
return "Required";
}
return null;
},
onSaved: (newValue) {
if (newValue != null) {
context.read<NewDownloadLocation>().name = newValue;
}
},
),
const Padding(padding: EdgeInsets.all(8.0)),
Text(
"If the path doesn't contain \"emulated\", it is proably external storage.",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
@@ -0,0 +1,152 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import '../../models/finamp_models.dart';
class CustomDownloadLocationForm extends StatefulWidget {
const CustomDownloadLocationForm({Key? key, required this.formKey})
: super(key: key);
final Key formKey;
@override
State<CustomDownloadLocationForm> createState() =>
_CustomDownloadLocationFormState();
}
class _CustomDownloadLocationFormState
extends State<CustomDownloadLocationForm> {
Directory? selectedDirectory;
@override
Widget build(BuildContext context) {
return Form(
key: widget.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FormField<Directory>(
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
selectedDirectory == null
? AppLocalizations.of(context)!
.selectDirectory
: selectedDirectory!.path.replaceFirst(
"${selectedDirectory!.parent.path}/",
""),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: selectedDirectory == null
? Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
color: Theme.of(context).hintColor,
)
: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.folder),
onPressed: () async {
String? newPath = await FilePicker.platform
.getDirectoryPath();
if (newPath != null) {
setState(() {
selectedDirectory = Directory(newPath);
});
}
}),
],
),
),
),
),
if (field.hasError)
Padding(
padding: const EdgeInsets.fromLTRB(0, 4, 0, 0),
child: Text(
field.errorText ??
AppLocalizations.of(context)!.unknownError,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme.of(context).colorScheme.error),
),
),
],
);
},
validator: (_) {
if (selectedDirectory == null) {
return AppLocalizations.of(context)!.required;
}
// There are a load of null checks here, but since we've already
// checked if selectedDirectory is null we should be fine.
if (selectedDirectory!.path == "/") {
return AppLocalizations.of(context)!
.pathReturnSlashErrorMessage;
}
// This checks if the chosen directory is empty
if (selectedDirectory!
.listSync()
.where((event) => !event.path
.replaceFirst(selectedDirectory!.path, "")
.contains("."))
.isNotEmpty) {
return AppLocalizations.of(context)!.directoryMustBeEmpty;
}
return null;
},
onSaved: (_) {
if (selectedDirectory != null) {
context.read<NewDownloadLocation>().path =
selectedDirectory!.path;
}
},
),
TextFormField(
decoration:
InputDecoration(labelText: AppLocalizations.of(context)!.name),
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)!.required;
}
return null;
},
onSaved: (newValue) {
if (newValue != null) {
context.read<NewDownloadLocation>().name = newValue;
}
},
),
const Padding(padding: EdgeInsets.all(8.0)),
Text(AppLocalizations.of(context)!.customLocationsBuggy,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red)),
],
),
);
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../MusicScreen/album_item.dart';
import '../error_snackbar.dart';
class AddToPlaylistList extends StatefulWidget {
const AddToPlaylistList({
Key? key,
required this.itemToAddId,
}) : super(key: key);
final String itemToAddId;
@override
State<AddToPlaylistList> createState() => _AddToPlaylistListState();
}
class _AddToPlaylistListState extends State<AddToPlaylistList> {
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
late Future<List<BaseItemDto>?> addToPlaylistListFuture;
@override
void initState() {
super.initState();
addToPlaylistListFuture = jellyfinApiHelper.getItems(
includeItemTypes: "Playlist",
sortBy: "SortName",
isGenres: false,
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<BaseItemDto>?>(
future: addToPlaylistListFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Scrollbar(
child: ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return AlbumItem(
album: snapshot.data![index],
parentType: snapshot.data![index].type,
onTap: () async {
try {
await jellyfinApiHelper.addItemstoPlaylist(
playlistId: snapshot.data![index].id,
ids: [widget.itemToAddId],
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Added to playlist."),
// action: SnackBarAction(
// label: "OPEN",
// onPressed: () {
// Navigator.of(context).pushNamed(
// "/music/albumscreen",
// arguments: snapshot.data![index]);
// },
// ),
),
);
Navigator.pop(context);
} catch (e) {
errorSnackbar(e, context);
return;
}
},
);
},
),
);
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return const Center(
child: Icon(Icons.error, size: 64),
);
} else {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
},
);
}
}
@@ -0,0 +1,93 @@
import 'package:finamp/models/jellyfin_models.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/finamp_user_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../error_snackbar.dart';
class NewPlaylistDialog extends StatefulWidget {
const NewPlaylistDialog({
Key? key,
required this.itemToAdd,
}) : super(key: key);
final String itemToAdd;
@override
State<NewPlaylistDialog> createState() => _NewPlaylistDialogState();
}
class _NewPlaylistDialogState extends State<NewPlaylistDialog> {
final _formKey = GlobalKey<FormState>();
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
bool _isSubmitting = false;
String? _name;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.newPlaylist),
content: Form(
key: _formKey,
child: TextFormField(
decoration:
InputDecoration(labelText: AppLocalizations.of(context)!.name),
textInputAction: TextInputAction.done,
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)!.required;
}
return null;
},
onFieldSubmitted: (_) async => await _submit(),
onSaved: (newValue) => _name = newValue,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop<bool>(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: _isSubmitting ? null : () async => await _submit(),
child: Text(AppLocalizations.of(context)!.createButtonLabel),
),
],
);
}
Future<void> _submit() async {
if (_formKey.currentState != null && _formKey.currentState!.validate()) {
setState(() {
_isSubmitting = true;
});
_formKey.currentState!.save();
try {
await _jellyfinApiHelper.createNewPlaylist(NewPlaylist(
name: _name,
ids: [widget.itemToAdd],
userId: _finampUserHelper.currentUser!.id,
));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.playlistCreated),
));
Navigator.of(context).pop<bool>(true);
} catch (e) {
errorSnackbar(e, context);
setState(() {
_isSubmitting = false;
});
return;
}
}
}
}
@@ -0,0 +1,207 @@
import 'package:finamp/components/AlbumScreen/sync_album_or_playlist_button.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/finamp_settings_helper.dart';
import '../../components/favourite_button.dart';
import 'album_screen_content_flexible_space_bar.dart';
import 'delete_button.dart';
import 'song_list_tile.dart';
import 'playlist_name_edit_button.dart';
typedef BaseItemDtoCallback = void Function(BaseItemDto item);
class AlbumScreenContent extends StatefulWidget {
const AlbumScreenContent({
Key? key,
required this.parent,
required this.children,
}) : super(key: key);
final BaseItemDto parent;
final List<BaseItemDto> children;
@override
State<AlbumScreenContent> createState() => _AlbumScreenContentState();
}
class _AlbumScreenContentState extends State<AlbumScreenContent> {
@override
Widget build(BuildContext context) {
void onDelete(BaseItemDto item) {
// This is pretty inefficient (has to search through whole list) but
// SongsSliverList gets passed some weird split version of children to
// handle multi-disc albums and it's 00:35 so I can't be bothered to get
// it to return an index
setState(() {
widget.children.remove(item);
});
}
List<List<BaseItemDto>> childrenPerDisc = [];
// if not in playlist, try splitting up tracks by disc numbers
// if first track has a disc number, let's assume the rest has it too
if (widget.parent.type != "Playlist" &&
widget.children[0].parentIndexNumber != null) {
int? lastDiscNumber;
for (var child in widget.children) {
if (child.parentIndexNumber != null &&
child.parentIndexNumber != lastDiscNumber) {
lastDiscNumber = child.parentIndexNumber;
childrenPerDisc.add([]);
}
childrenPerDisc.last.add(child);
}
}
return Scrollbar(
child: CustomScrollView(
slivers: [
SliverAppBar(
title: Text(widget.parent.name ??
AppLocalizations.of(context)!.unknownName),
// 125 + 64 is the total height of the widget we use as a
// FlexibleSpaceBar. We add the toolbar height since the widget
// should appear below the appbar.
// TODO: This height is affected by platform density.
expandedHeight: kToolbarHeight + 125 + 64,
pinned: true,
flexibleSpace: AlbumScreenContentFlexibleSpaceBar(
album: widget.parent,
items: widget.children,
),
actions: [
if (widget.parent.type == "Playlist" &&
!FinampSettingsHelper.finampSettings.isOffline)
PlaylistNameEditButton(playlist: widget.parent),
FavoriteButton(item: widget.parent),
if (GetIt.instance<DownloadsHelper>().isAlbumDownloaded(widget.parent.id))
DeleteButton(parent: widget.parent, items: widget.children),
if (!FinampSettingsHelper.finampSettings.isOffline)
SyncAlbumOrPlaylistButton(parent: widget.parent, items: widget.children)
],
),
if (widget.children.length > 1 &&
childrenPerDisc.length >
1) // show headers only for multi disc albums
for (var childrenOfThisDisc in childrenPerDisc)
SliverStickyHeader(
header: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 16.0,
),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Text(
AppLocalizations.of(context)!
.discNumber(childrenOfThisDisc[0].parentIndexNumber!),
style: const TextStyle(fontSize: 20.0),
),
),
sliver: SongsSliverList(
childrenForList: childrenOfThisDisc,
childrenForQueue: widget.children,
parent: widget.parent,
onDelete: onDelete,
),
)
else if (widget.children.isNotEmpty)
SongsSliverList(
childrenForList: widget.children,
childrenForQueue: widget.children,
parent: widget.parent,
onDelete: onDelete,
),
],
),
);
}
}
class SongsSliverList extends StatefulWidget {
const SongsSliverList({
Key? key,
required this.childrenForList,
required this.childrenForQueue,
required this.parent,
this.onDelete,
}) : super(key: key);
final List<BaseItemDto> childrenForList;
final List<BaseItemDto> childrenForQueue;
final BaseItemDto parent;
final BaseItemDtoCallback? onDelete;
@override
State<SongsSliverList> createState() => _SongsSliverListState();
}
class _SongsSliverListState extends State<SongsSliverList> {
final GlobalKey<SliverAnimatedListState> sliverListKey =
GlobalKey<SliverAnimatedListState>();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
// When user selects song from disc other than first, index number is
// incorrect and song with the same index on first disc is played instead.
// Adding this offset ensures playback starts for nth song on correct disc.
final int indexOffset =
widget.childrenForQueue.indexOf(widget.childrenForList[0]);
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final BaseItemDto item = widget.childrenForList[index];
BaseItemDto removeItem() {
late BaseItemDto item;
setState(() {
item = widget.childrenForList.removeAt(index);
});
return item;
}
return SongListTile(
item: item,
children: widget.childrenForQueue,
index: index + indexOffset,
parentId: widget.parent.id,
onDelete: () {
final item = removeItem();
if (widget.onDelete != null) {
widget.onDelete!(item);
}
},
isInPlaylist: widget.parent.type == "Playlist",
// show artists except for this one scenario
showArtists: !(
// we're on album screen
widget.parent.type == "MusicAlbum"
// "hide song artists if they're the same as album artists" == true
&&
FinampSettingsHelper
.finampSettings.hideSongArtistsIfSameAsAlbumArtists
// song artists == album artists
&&
setEquals(
widget.parent.albumArtists?.map((e) => e.name).toSet(),
item.artists?.toSet())),
);
},
childCount: widget.childrenForList.length,
),
);
}
}
@@ -0,0 +1,92 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/audio_service_helper.dart';
import '../album_image.dart';
import 'item_info.dart';
class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget {
const AlbumScreenContentFlexibleSpaceBar({
Key? key,
required this.album,
required this.items,
}) : super(key: key);
final BaseItemDto album;
final List<BaseItemDto> items;
@override
Widget build(BuildContext context) {
AudioServiceHelper audioServiceHelper =
GetIt.instance<AudioServiceHelper>();
return FlexibleSpaceBar(
background: SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
height: 125,
child: AlbumImage(item: album),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
),
Expanded(
flex: 2,
child: ItemInfo(
item: album,
itemSongs: items.length,
),
)
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
audioServiceHelper.replaceQueueWithItem(
itemList: items,
),
icon: const Icon(Icons.play_arrow),
label:
Text(AppLocalizations.of(context)!.playButtonLabel),
),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 8)),
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
audioServiceHelper.replaceQueueWithItem(
itemList: items,
shuffle: true,
initialIndex: Random().nextInt(items.length),
),
icon: const Icon(Icons.shuffle),
label: Text(
AppLocalizations.of(context)!.shuffleButtonLabel),
),
),
]),
)
],
),
),
),
),
);
}
}
@@ -0,0 +1,93 @@
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 '../../services/downloads_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../confirmation_prompt_dialog.dart';
import '../error_snackbar.dart';
class DeleteButton extends StatefulWidget {
const DeleteButton({
Key? key,
required this.parent,
required this.items,
}) : super(key: key);
final BaseItemDto parent;
final List<BaseItemDto> items;
@override
State<DeleteButton> createState() => _DeleteButtonState();
}
class _DeleteButtonState extends State<DeleteButton> {
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
void checkIfDownloaded() {
if (!mounted) return;
}
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, child) {
bool? isOffline = box.get("FinampSettings")?.isOffline;
return IconButton(
tooltip: AppLocalizations.of(context)!.deleteFromDevice,
icon: const Icon(Icons.delete),
// If offline, we don't allow the user to delete items.
// If we did, we'd have to implement listeners for MusicScreenTabView so that the user can't delete a parent, go back, and select the same parent.
// If they did, AlbumScreen would show an error since the item no longer exists.
// Also, the user could delete the parent and immediately re-download it, which will either cause unwanted network usage or cause more errors because the user is offline.
onPressed: isOffline ?? false
? null
: () {
showDialog(
context: context,
builder: (context) => ConfirmationPromptDialog(
promptText: AppLocalizations.of(context)!
.deleteDownloadsPrompt(
widget.parent.name ?? "",
widget.parent.type == "Playlist"
? "playlist"
: "album"),
confirmButtonText: AppLocalizations.of(context)!
.deleteDownloadsConfirmButtonText,
abortButtonText: AppLocalizations.of(context)!
.deleteDownloadsAbortButtonText,
onConfirmed: () async {
final messenger = ScaffoldMessenger.of(context);
try {
await _downloadsHelper
.deleteParentAndChildDownloads(
jellyfinItemIds:
widget.items.map((e) => e.id).toList(),
deletedFor: widget.parent.id,
);
checkIfDownloaded();
messenger.showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!
.downloadsDeleted)));
} catch (error) {
errorSnackbar(error, context);
}
},
onAborted: () {},
),
);
});
},
);
}
}
@@ -0,0 +1,101 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/downloads_helper.dart';
import '../../models/finamp_models.dart';
import '../../models/jellyfin_models.dart';
import '../error_snackbar.dart';
class DownloadDialog extends StatefulWidget {
const DownloadDialog({
Key? key,
required this.parents,
required this.items,
required this.viewId,
}) : assert(parents.length == items.length),
super(key: key);
final List<BaseItemDto> parents;
final List<List<BaseItemDto>> items;
final String viewId;
@override
State<DownloadDialog> createState() => _DownloadDialogState();
}
class _DownloadDialogState extends State<DownloadDialog> {
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
DownloadLocation? selectedDownloadLocation;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.addDownloads),
content: DropdownButton<DownloadLocation>(
hint: Text(AppLocalizations.of(context)!.location),
isExpanded: true,
onChanged: (value) => setState(() {
selectedDownloadLocation = value;
}),
value: selectedDownloadLocation,
items: FinampSettingsHelper.finampSettings.downloadLocationsMap.values
.map((e) => DropdownMenuItem<DownloadLocation>(
value: e,
child: Text(e.name),
))
.toList()),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
onPressed: selectedDownloadLocation == null
? null
: () async {
Navigator.of(context).pop(selectedDownloadLocation);
},
child: Text(AppLocalizations.of(context)!.addButtonLabel),
)
],
);
}
}
/// This function is used by DownloadDialog to check/add downloads.
Future<void> checkedAddDownloads(
BuildContext context, {
required DownloadLocation downloadLocation,
required List<BaseItemDto> parents,
required List<List<BaseItemDto>> items,
required String viewId,
}) async {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
final checkedAddDownloadsLogger = Logger("CheckedAddDownloads");
// If the default "internal storage" path is set and doesn't
// exist, it may have been moved by an iOS update.
if (downloadLocation.useHumanReadableNames == false &&
!await Directory(downloadLocation.path).exists()) {
checkedAddDownloadsLogger
.warning("Internal storage path doesn't exist! Resetting.");
await FinampSettingsHelper.resetDefaultDownloadLocation();
}
for (int i = 0; i < parents.length; i++) {
downloadsHelper
.addDownloads(
parent: parents[i],
items: items[i],
useHumanReadableNames: downloadLocation.useHumanReadableNames,
viewId: viewId,
downloadLocation: downloadLocation,
)
.onError((error, stackTrace) => errorSnackbar(error, context));
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/downloads_helper.dart';
import '../../services/download_update_stream.dart';
import '../../models/jellyfin_models.dart';
import '../error_snackbar.dart';
class DownloadedIndicator extends StatefulWidget {
const DownloadedIndicator({
Key? key,
required this.item,
this.size,
}) : super(key: key);
final BaseItemDto item;
final double? size;
@override
State<DownloadedIndicator> createState() => _DownloadedIndicatorState();
}
class _DownloadedIndicatorState extends State<DownloadedIndicator> {
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
final _downloadUpdateStream = GetIt.instance<DownloadUpdateStream>();
late Future<List<DownloadTask>?> _downloadedIndicatorFuture;
String? _downloadTaskId;
DownloadTaskStatus? _currentStatus;
@override
void initState() {
super.initState();
_downloadedIndicatorFuture =
_downloadsHelper.getDownloadStatus([widget.item.id]);
// We do this instead of using a StreamBuilder because the StreamBuilder
// kept dropping events. With this, we can also make it so that the widget
// only rebuilds when it actually has to.
_downloadUpdateStream.stream.listen((event) {
if (event.id == _downloadTaskId && event.status != _currentStatus) {
setState(() {
_currentStatus = event.status;
});
}
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<DownloadTask>?>(
future: _downloadedIndicatorFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data != null && snapshot.data!.isNotEmpty) {
_downloadTaskId = snapshot.data?[0].taskId;
_currentStatus = snapshot.data?[0].status;
}
// This ValueListenable is used to get the download task ID of new
// downloads. It also clears the task ID and status when the download
// is deleted. This only rebuilds when the item with key
// widget.item.id is changed, so this should only rebuild when the
// item is first downloaded and when it is deleted.
return ValueListenableBuilder<Box<DownloadedSong>>(
valueListenable: _downloadsHelper
.getDownloadedItemsListenable(keys: [widget.item.id]),
builder: (context, box, _) {
if (_downloadTaskId == null && box.get(widget.item.id) != null) {
_downloadTaskId = box.get(widget.item.id)!.downloadId;
} else if (box.get(widget.item.id) == null) {
_downloadTaskId = null;
_currentStatus = null;
}
// return StreamBuilder<DownloadUpdate>(
// stream: _downloadUpdateStream.stream,
// builder: (context, snapshot) {
// print("Streambuilder rebuild ${snapshot.data}");
// if (snapshot.hasData &&
// snapshot.data!.id == _downloadTask?.taskId) {
// print("Change $_currentStatus to ${snapshot.data?.status}");
// if (snapshot.data?.status == DownloadTaskStatus.complete) {
// print(
// "COMPLETE IN STREAMBUILDER ${widget.item.name}!!!!");
// }
// _currentStatus = snapshot.data?.status;
// }
if (_currentStatus == null) {
return const SizedBox.shrink();
} else if (_currentStatus == DownloadTaskStatus.complete) {
return Icon(
Icons.download,
color: Theme.of(context).colorScheme.secondary,
size: widget.size,
);
} else if (_currentStatus == DownloadTaskStatus.failed ||
_currentStatus == DownloadTaskStatus.undefined) {
return Icon(
Icons.error,
color: Colors.red,
size: widget.size,
);
} else if (_currentStatus == DownloadTaskStatus.paused) {
return Icon(
Icons.pause,
color: Colors.yellow,
size: widget.size,
);
} else if (_currentStatus == DownloadTaskStatus.enqueued ||
_currentStatus == DownloadTaskStatus.running) {
return Icon(
Icons.download_outlined,
color: Colors.white.withOpacity(0.5),
size: widget.size,
);
} else {
return const SizedBox.shrink();
}
// },
// );
},
);
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return const SizedBox.shrink();
} else {
return const SizedBox.shrink();
}
},
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:finamp/components/artists_text_spans.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import '../print_duration.dart';
class ItemInfo extends StatelessWidget {
const ItemInfo({
Key? key,
required this.item,
required this.itemSongs,
}) : super(key: key);
final BaseItemDto item;
final int itemSongs;
// TODO: see if there's a way to expand this column to the row that it's in
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (item.type != "Playlist")
_IconAndText(
iconData: Icons.person,
textSpan: TextSpan(
children: ArtistsTextSpans(
item,
Theme.of(context).colorScheme.onSurface,
context,
false,
),
)),
_IconAndText(
iconData: Icons.music_note,
textSpan: _buildStyledText(
context: context,
text: AppLocalizations.of(context)!.songCount(itemSongs),
),
),
_IconAndText(
iconData: Icons.timer,
textSpan: _buildStyledText(
context: context,
text: printDuration(Duration(
microseconds:
item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10,
)),
),
),
if (item.type != "Playlist")
_IconAndText(
iconData: Icons.event,
textSpan: _buildStyledText(
context: context,
text: item.productionYearString,
),
)
],
);
}
TextSpan _buildStyledText({required BuildContext context, String? text}) {
return TextSpan(
text: text,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
);
}
}
class _IconAndText extends StatelessWidget {
const _IconAndText({
Key? key,
required this.iconData,
required this.textSpan,
}) : super(key: key);
final IconData iconData;
final TextSpan textSpan;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
iconData,
// Inactive icons have an opacity of 50% with dark theme and 38%
// with bright theme
// https://material.io/design/iconography/system-icons.html#color
color: Theme.of(context).disabledColor,
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
Expanded(
child: RichText(
text: textSpan,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import 'playlist_name_edit_dialog.dart';
class PlaylistNameEditButton extends StatelessWidget {
const PlaylistNameEditButton({
Key? key,
required this.playlist,
}) : super(key: key);
final BaseItemDto playlist;
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.edit),
tooltip: AppLocalizations.of(context)!.editPlaylistNameTooltip,
onPressed: () => showDialog(
context: context,
builder: (context) => PlaylistNameEditDialog(playlist: playlist),
),
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../error_snackbar.dart';
class PlaylistNameEditDialog extends StatefulWidget {
const PlaylistNameEditDialog({
Key? key,
required this.playlist,
}) : super(key: key);
final BaseItemDto playlist;
@override
State<PlaylistNameEditDialog> createState() => _PlaylistNameEditDialogState();
}
class _PlaylistNameEditDialogState extends State<PlaylistNameEditDialog> {
String? _name;
bool _isUpdating = false;
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_name = widget.playlist.name;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.editPlaylistNameTitle),
content: Form(
key: _formKey,
child: TextFormField(
initialValue: _name,
decoration:
InputDecoration(labelText: AppLocalizations.of(context)!.name),
textInputAction: TextInputAction.done,
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)!.required;
}
return null;
},
onFieldSubmitted: (_) async => await _submit(),
onSaved: (newValue) => _name = newValue,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: _isUpdating ? null : () async => await _submit(),
child: Text(AppLocalizations.of(context)!.updateButtonLabel),
),
],
);
}
Future<void> _submit() async {
if (_formKey.currentState != null && _formKey.currentState!.validate()) {
setState(() {
_isUpdating = true;
});
_formKey.currentState!.save();
try {
BaseItemDto playlistTemp = widget.playlist;
playlistTemp.name = _name;
await _jellyfinApiHelper.updateItem(
itemId: widget.playlist.id,
newItem: playlistTemp,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.playlistNameUpdated),
));
Navigator.of(context).pop();
} catch (e) {
errorSnackbar(e, context);
setState(() {
_isUpdating = false;
});
return;
}
}
}
}
@@ -0,0 +1,512 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:mini_music_visualizer/mini_music_visualizer.dart';
import '../../models/jellyfin_models.dart';
import '../../screens/add_to_playlist_screen.dart';
import '../../screens/album_screen.dart';
import '../../services/audio_service_helper.dart';
import '../../services/downloads_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/media_state_stream.dart';
import '../../services/process_artist.dart';
import '../album_image.dart';
import '../error_snackbar.dart';
import '../favourite_button.dart';
import '../print_duration.dart';
import 'downloaded_indicator.dart';
enum SongListTileMenuItems {
addToQueue,
playNext,
replaceQueueWithItem,
addToPlaylist,
removeFromPlaylist,
instantMix,
goToAlbum,
addFavourite,
removeFavourite,
}
class SongListTile extends StatefulWidget {
const SongListTile({
Key? key,
required this.item,
/// Children that are related to this list tile, such as the other songs in
/// the album. This is used to give the audio service all the songs for the
/// item. If null, only this song will be given to the audio service.
this.children,
/// Index of the song in whatever parent this widget is in. Used to start
/// the audio service at a certain index, such as when selecting the middle
/// song in an album.
this.index,
this.parentId,
this.isSong = false,
this.showArtists = true,
this.onDelete,
/// Whether this widget is being displayed in a playlist. If true, will show
/// the remove from playlist button.
this.isInPlaylist = false,
}) : super(key: key);
final BaseItemDto item;
final List<BaseItemDto>? children;
final int? index;
final bool isSong;
final String? parentId;
final bool showArtists;
final VoidCallback? onDelete;
final bool isInPlaylist;
@override
State<SongListTile> createState() => _SongListTileState();
}
class _SongListTileState extends State<SongListTile> {
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
/// Sets the item's favourite on the Jellyfin server.
Future<void> setFavourite() async {
try {
// We switch the widget state before actually doing the request to
// make the app feel faster (without, there is a delay from the
// user adding the favourite and the icon showing)
setState(() {
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
});
// Since we flipped the favourite state already, we can use the flipped
// state to decide which API call to make
final newUserData = widget.item.userData!.isFavorite
? await _jellyfinApiHelper.addFavourite(widget.item.id)
: await _jellyfinApiHelper.removeFavourite(widget.item.id);
if (!mounted) return;
setState(() {
widget.item.userData = newUserData;
});
} catch (e) {
setState(() {
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
});
errorSnackbar(e, context);
}
}
final listTile = StreamBuilder<MediaState>(
stream: mediaStateStream,
builder: (context, snapshot) {
// I think past me did this check directly from the JSON for
// performance. It works for now, apologies if you're debugging it
// years in the future.
final isCurrentlyPlaying =
snapshot.data?.mediaItem?.extras?["itemJson"]["Id"] ==
widget.item.id &&
snapshot.data?.mediaItem?.extras?["itemJson"]["AlbumId"] ==
widget.parentId;
return ListTile(
leading: AlbumImage(item: widget.item),
title: RichText(
text: TextSpan(
children: [
// third condition checks if the item is viewed from its album (instead of e.g. a playlist)
// same horrible check as in canGoToAlbum in GestureDetector below
if (widget.item.indexNumber != null &&
!widget.isSong &&
widget.item.albumId == widget.parentId)
TextSpan(
text: "${widget.item.indexNumber}. ",
style:
TextStyle(color: Theme.of(context).disabledColor)),
TextSpan(
text: widget.item.name ??
AppLocalizations.of(context)!.unknownName,
style: TextStyle(
color: isCurrentlyPlaying
? Theme.of(context).colorScheme.secondary
: null,
),
),
],
style: Theme.of(context).textTheme.titleMedium,
),
),
subtitle: RichText(
text: TextSpan(
children: [
WidgetSpan(
child: Transform.translate(
offset: const Offset(-3, 0),
child: DownloadedIndicator(
item: widget.item,
size:
Theme.of(context).textTheme.bodyMedium!.fontSize! +
3,
),
),
alignment: PlaceholderAlignment.top,
),
TextSpan(
text: printDuration(Duration(
microseconds: (widget.item.runTimeTicks == null
? 0
: widget.item.runTimeTicks! ~/ 10))),
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyMedium
?.color
?.withOpacity(0.7)),
),
if (widget.showArtists)
TextSpan(
text:
" · ${processArtist(widget.item.artists?.join(", ") ?? widget.item.albumArtist, context)}",
style: TextStyle(color: Theme.of(context).disabledColor),
)
],
),
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isCurrentlyPlaying &&
(snapshot.data?.playbackState.playing ?? false))
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: MiniMusicVisualizer(
color: Theme.of(context).colorScheme.secondary,
width: 4,
height: 15,
),
),
FavoriteButton(
item: widget.item,
onlyIfFav: true,
),
],
),
onTap: () {
_audioServiceHelper.replaceQueueWithItem(
itemList: widget.children ?? [widget.item],
initialIndex: widget.index ?? 0,
);
},
);
});
return GestureDetector(
onLongPressStart: (details) async {
Feedback.forLongPress(context);
// This horrible check does 2 things:
// - Checks if the item's album is not the same as the parent item
// that created the widget. The ids will be different if the
// SongListTile is in a playlist, but they will be the same if viewed
// in the item's album. We don't want to show this menu item if we're
// already in the item's album.
//
// - Checks if the album is downloaded if in offline mode. If we're in
// offline mode, we need the album to actually be downloaded to show
// its metadata. This function also checks if widget.item.parentId is
// null.
final canGoToAlbum = widget.item.albumId != widget.parentId &&
_isAlbumDownloadedIfOffline(widget.item.parentId);
// Some options are disabled in offline mode
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
final selection = await showMenu<SongListTileMenuItems>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
screenSize.width - details.globalPosition.dx,
screenSize.height - details.globalPosition.dy,
),
items: [
if (_audioServiceHelper.hasQueueItems()) ...[
PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.addToQueue,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.addToQueue),
),
),
PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.playNext,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.playNext),
),
),
],
PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.replaceQueueWithItem,
child: ListTile(
leading: const Icon(Icons.play_circle),
title: Text(AppLocalizations.of(context)!.replaceQueue),
),
),
if (widget.isInPlaylist)
PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.addToPlaylist,
child: ListTile(
leading: const Icon(Icons.playlist_add),
title: Text(AppLocalizations.of(context)!.addToPlaylistTitle),
enabled: !isOffline,
),
),
widget.isInPlaylist
? PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.removeFromPlaylist,
child: ListTile(
leading: const Icon(Icons.playlist_remove),
title: Text(AppLocalizations.of(context)!
.removeFromPlaylistTitle),
enabled: !isOffline && widget.parentId != null,
),
)
: PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.addToPlaylist,
child: ListTile(
leading: const Icon(Icons.playlist_add),
title: Text(
AppLocalizations.of(context)!.addToPlaylistTitle),
enabled: !isOffline,
),
),
PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.instantMix,
child: ListTile(
leading: const Icon(Icons.explore),
title: Text(AppLocalizations.of(context)!.instantMix),
enabled: !isOffline,
),
),
PopupMenuItem<SongListTileMenuItems>(
enabled: canGoToAlbum,
value: SongListTileMenuItems.goToAlbum,
child: ListTile(
leading: const Icon(Icons.album),
title: Text(AppLocalizations.of(context)!.goToAlbum),
enabled: canGoToAlbum,
),
),
widget.item.userData!.isFavorite
? PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.removeFavourite,
child: ListTile(
leading: const Icon(Icons.favorite_border),
title:
Text(AppLocalizations.of(context)!.removeFavourite),
),
)
: PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.addFavourite,
child: ListTile(
leading: const Icon(Icons.favorite),
title: Text(AppLocalizations.of(context)!.addFavourite),
),
),
],
);
if (!mounted) return;
switch (selection) {
case SongListTileMenuItems.addToQueue:
await _audioServiceHelper.addQueueItems([widget.item]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.addedToQueue),
));
break;
case SongListTileMenuItems.playNext:
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.insertedIntoQueue),
));
break;
case SongListTileMenuItems.replaceQueueWithItem:
await _audioServiceHelper
.replaceQueueWithItem(itemList: [widget.item]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.queueReplaced),
));
break;
case SongListTileMenuItems.addToPlaylist:
Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName,
arguments: widget.item.id);
break;
case SongListTileMenuItems.removeFromPlaylist:
try {
await _jellyfinApiHelper.removeItemsFromPlaylist(
playlistId: widget.parentId!,
entryIds: [widget.item.playlistItemId!]);
if (!mounted) return;
await _jellyfinApiHelper.getItems(
parentItem:
await _jellyfinApiHelper.getItemById(widget.item.parentId!),
sortBy: "ParentIndexNumber,IndexNumber,SortName",
includeItemTypes: "Audio",
isGenres: false,
);
if (!mounted) return;
if (widget.onDelete != null) widget.onDelete!();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context)!.removedFromPlaylist),
));
} catch (e) {
errorSnackbar(e, context);
}
break;
case SongListTileMenuItems.instantMix:
await _audioServiceHelper.startInstantMixForItem(widget.item);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.startingInstantMix),
));
break;
case SongListTileMenuItems.goToAlbum:
late BaseItemDto album;
if (FinampSettingsHelper.finampSettings.isOffline) {
// If offline, load the album's BaseItemDto from DownloadHelper.
final downloadsHelper = GetIt.instance<DownloadsHelper>();
// downloadedParent won't be null here since the menu item already
// checks if the DownloadedParent exists.
album = downloadsHelper
.getDownloadedParent(widget.item.parentId!)!
.item;
} else {
// If online, get the album's BaseItemDto from the server.
try {
album =
await _jellyfinApiHelper.getItemById(widget.item.parentId!);
} catch (e) {
errorSnackbar(e, context);
break;
}
}
if (!mounted) return;
Navigator.of(context)
.pushNamed(AlbumScreen.routeName, arguments: album);
break;
case SongListTileMenuItems.addFavourite:
case SongListTileMenuItems.removeFavourite:
await setFavourite();
break;
case null:
break;
}
},
child: widget.isSong
? listTile
: Dismissible(
key: Key(widget.index.toString()),
direction: FinampSettingsHelper.finampSettings.disableGesture
? DismissDirection.none
: DismissDirection.horizontal,
background: Container(
color: Theme.of(context).colorScheme.secondaryContainer,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
AspectRatio(
aspectRatio: 1,
child: FittedBox(
fit: BoxFit.fitHeight,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Icon(
Icons.queue_music,
color: Theme.of(context)
.colorScheme
.onSecondaryContainer,
),
),
),
)
],
),
),
),
confirmDismiss: (direction) async {
if (!FinampSettingsHelper.finampSettings.swipeInsertQueueNext) {
await _audioServiceHelper.addQueueItems([widget.item]);
} else {
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
}
if (!mounted) return false;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
FinampSettingsHelper.finampSettings.swipeInsertQueueNext
? AppLocalizations.of(context)!.insertedIntoQueue
: AppLocalizations.of(context)!.addedToQueue),
));
return false;
},
child: listTile,
),
);
}
}
/// If offline, check if an album is downloaded. Always returns true if online.
/// Returns false if albumId is null.
bool _isAlbumDownloadedIfOffline(String? albumId) {
if (albumId == null) {
return false;
} else if (FinampSettingsHelper.finampSettings.isOffline) {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
return downloadsHelper.isAlbumDownloaded(albumId);
} else {
return true;
}
}
@@ -0,0 +1,52 @@
import 'package:finamp/models/jellyfin_models.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:finamp/services/sync_helper.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class SyncAlbumOrPlaylistButton extends StatefulWidget {
const SyncAlbumOrPlaylistButton({
Key? key,
required this.parent,
required this.items,
}) : super(key: key);
final BaseItemDto parent;
final List<BaseItemDto> items;
@override
State<SyncAlbumOrPlaylistButton> createState() =>
_SyncAlbumOrPlaylistButtonState();
}
class _SyncAlbumOrPlaylistButtonState
extends State<SyncAlbumOrPlaylistButton> {
final _syncLogger = Logger("SyncPlaylistButton");
final _downloadHelper = GetIt.instance<DownloadsHelper>();
bool isAlbumDownloaded = false;
void syncAlbumOrPlaylist(BuildContext context) async {
_syncLogger.info("Syncing playlist");
var syncHelper = DownloadsSyncHelper(_syncLogger);
syncHelper.sync(context, widget.parent, widget.items);
setState(() {
isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id);
});
}
@override
Widget build(BuildContext context) {
isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id);
return IconButton(
tooltip: isAlbumDownloaded
? AppLocalizations.of(context)!.sync
: AppLocalizations.of(context)!.download,
onPressed: () => syncAlbumOrPlaylist(context),
icon:
isAlbumDownloaded ?
const Icon(Icons.sync) :
const Icon(Icons.download));
}
}
@@ -0,0 +1,164 @@
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/finamp_settings_helper.dart';
import '../../services/finamp_user_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/downloads_helper.dart';
import '../AlbumScreen/download_dialog.dart';
import '../confirmation_prompt_dialog.dart';
import '../error_snackbar.dart';
class ArtistDownloadButton extends StatefulWidget {
const ArtistDownloadButton({
Key? key,
required this.artist,
}) : super(key: key);
final BaseItemDto artist;
@override
State<ArtistDownloadButton> createState() => _ArtistDownloadButtonState();
}
class _ArtistDownloadButtonState extends State<ArtistDownloadButton> {
static const _disabledButton = IconButton(
icon: Icon(Icons.download),
onPressed: null,
);
Future<List<BaseItemDto>?>? _artistDownloadButtonFuture;
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
List<BaseItemDto> _getUndownloadedAlbums(List<BaseItemDto> albums) {
return albums
.where((element) => !_downloadsHelper.isAlbumDownloaded(element.id))
.toList();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, _) {
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
bool deleteAlbums = false;
if (isOffline) {
return _disabledButton;
} else {
// We only want to get album data if we're online
_artistDownloadButtonFuture ??= _jellyfinApiHelper.getItems(
parentItem: widget.artist,
includeItemTypes: "MusicAlbum",
isGenres: false,
);
return FutureBuilder<List<BaseItemDto>?>(
future: _artistDownloadButtonFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
final undownloadedAlbums =
_getUndownloadedAlbums(snapshot.data!);
deleteAlbums = undownloadedAlbums.isEmpty;
return IconButton(
tooltip: AppLocalizations.of(context)!
.downloadArtist(widget.artist.name ?? "Unknown Artist"),
icon: deleteAlbums
? const Icon(Icons.delete)
: const Icon(Icons.download),
onPressed:() async {
if (deleteAlbums) {
showDialog(
context: context,
builder: (context) => ConfirmationPromptDialog(
promptText: AppLocalizations.of(context)!
.deleteDownloadsPrompt(
widget.artist.name ?? "",
widget.artist.type == "MusicArtist" ? "artist" : "genre"),
confirmButtonText: AppLocalizations.of(context)!
.deleteDownloadsConfirmButtonText,
abortButtonText: AppLocalizations.of(context)!
.deleteDownloadsAbortButtonText,
onConfirmed: () async {
try {
final deleteFutures = snapshot.data!.map((e) =>
_downloadsHelper.deleteParentAndChildDownloads(
jellyfinItemIds: _downloadsHelper
.getDownloadedParent(e.id)!
.downloadedChildren
.keys
.toList(),
deletedFor: e.id));
await Future.wait(deleteFutures);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Downloads deleted.")));
final undownloadedAlbums =
_getUndownloadedAlbums(snapshot.data!);
setState(() {
deleteAlbums = undownloadedAlbums.isEmpty;
});
} catch (error) {
errorSnackbar(error, context);
}
},
onAborted: () {},
),
);
} else {
List<Future<List<BaseItemDto>?>> albumInfoFutures = [];
for (var element in undownloadedAlbums) {
albumInfoFutures.add(_jellyfinApiHelper.getItems(
parentItem: element,
sortBy: "SortName",
includeItemTypes: "Audio",
isGenres: false,
));
}
List<List<BaseItemDto>?> albumInfo;
try {
albumInfo = await Future.wait(albumInfoFutures);
} catch (e) {
errorSnackbar(e, context);
return;
}
await showDialog(
context: context,
builder: (context) => DownloadDialog(
parents: undownloadedAlbums,
// getItems returns null so we have to null check
// each element
items: albumInfo.map((e) => e!).toList(),
viewId: _finampUserHelper.currentUser!.currentViewId!,
),
);
}
// We call a setState so that the downloaded albums are
// checked again (so that the download icon turns into a
// delete icon and vice-versa)
setState(() {});
},
);
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return _disabledButton;
} else {
return _disabledButton;
}
},
);
}
},
);
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/audio_service_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/downloads_helper.dart';
class ArtistPlayButton extends StatefulWidget {
const ArtistPlayButton({
Key? key,
required this.artist,
}) : super(key: key);
final BaseItemDto artist;
@override
State<ArtistPlayButton> createState() => _ArtistPlayButtonState();
}
class _ArtistPlayButtonState extends State<ArtistPlayButton> {
static const _disabledButton = IconButton(
onPressed: null,
icon: Icon(Icons.play_arrow)
);
Future<List<BaseItemDto>?>? artistPlayButtonFuture;
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, _) {
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
if (isOffline) {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
final List<BaseItemDto>artistsSongs = [];
for (DownloadedSong item in downloadsHelper.downloadedItems) {
if (item.song.albumArtist == widget.artist.name) {
artistsSongs.add(item.song);
}
}
// We have to sort by hand in offline mode because a downloadedParent for artists hasn't been implemented
Map<String, List<BaseItemDto>> groupedSongs = {};
for (BaseItemDto song in artistsSongs) {
groupedSongs.putIfAbsent((song.albumId ?? 'unknown'), () => []);
groupedSongs[song.albumId]!.add(song);
}
final List<BaseItemDto> sortedSongs = [];
groupedSongs.forEach((album, albumSongs) {
albumSongs.sort((a, b) => (a.indexNumber ?? 0).compareTo(b.indexNumber ?? 0));
sortedSongs.addAll(albumSongs);
});
return IconButton(
tooltip: AppLocalizations.of(context)!
.playArtist(widget.artist.name ?? "Unknown Artist"),
onPressed: () async {
await _audioServiceHelper
.replaceQueueWithItem(itemList: sortedSongs);
},
icon: const Icon(Icons.play_arrow),
);
} else {
artistPlayButtonFuture ??= _jellyfinApiHelper.getItems(
parentItem: widget.artist,
includeItemTypes: "Audio",
sortBy: 'PremiereDate,Album,SortName',
isGenres: false,
);
return FutureBuilder<List<BaseItemDto>?>(
future: artistPlayButtonFuture,
builder: (context, snapshot) {
if (snapshot.hasData){
final List<BaseItemDto> items = snapshot.data!;
return IconButton(
tooltip: AppLocalizations.of(context)!
.playArtist(widget.artist.name ?? "Unknown Artist"),
onPressed: () async {
await _audioServiceHelper
.replaceQueueWithItem(itemList: items);
},
icon: const Icon(Icons.play_arrow),
);
} else {
return _disabledButton;
}
},
);
}
},
);
}
}
@@ -0,0 +1,99 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/audio_service_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/downloads_helper.dart';
class ArtistShuffleButton extends StatefulWidget {
const ArtistShuffleButton({
Key? key,
required this.artist,
}) : super(key: key);
final BaseItemDto artist;
@override
State<ArtistShuffleButton> createState() => _ArtistShuffleButtonState();
}
class _ArtistShuffleButtonState extends State<ArtistShuffleButton> {
static const _disabledButton = IconButton(
onPressed: null,
icon: Icon(Icons.play_arrow)
);
Future<List<BaseItemDto>?>? artistShuffleButtonFuture;
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, _) {
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
if (isOffline) {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
final List<BaseItemDto>artistsSongs = [];
for (DownloadedSong item in downloadsHelper.downloadedItems) {
if (item.song.albumArtist == widget.artist.name) {
artistsSongs.add(item.song);
}
}
return IconButton(
tooltip: AppLocalizations.of(context)!
.shuffleArtist(widget.artist.name ?? "Unknown Artist"),
onPressed: () async {
await _audioServiceHelper
.replaceQueueWithItem(itemList: artistsSongs, shuffle: true);
},
icon: const Icon(Icons.shuffle),
);
} else {
artistShuffleButtonFuture ??= _jellyfinApiHelper.getItems(
parentItem: widget.artist,
includeItemTypes: "Audio",
sortBy: 'PremiereDate,Album,SortName',
isGenres: false,
);
return FutureBuilder<List<BaseItemDto>?>(
future: artistShuffleButtonFuture,
builder: (context, snapshot) {
if (snapshot.hasData){
final List<BaseItemDto> items = snapshot.data!;
return IconButton(
tooltip: AppLocalizations.of(context)!
.shuffleArtist(widget.artist.name ?? "Unknown Artist"),
onPressed: () async {
await _audioServiceHelper
.replaceQueueWithItem(itemList: items, shuffle: true, initialIndex: Random().nextInt(items.length));
},
icon: const Icon(Icons.shuffle),
);
} else {
return _disabledButton;
}
},
);
}
},
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../services/finamp_settings_helper.dart';
class BufferDurationListTile extends StatefulWidget {
const BufferDurationListTile({super.key});
@override
State<BufferDurationListTile> createState() => _BufferDurationListTileState();
}
class _BufferDurationListTileState extends State<BufferDurationListTile> {
final _controller = TextEditingController(
text:
FinampSettingsHelper.finampSettings.bufferDurationSeconds.toString());
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(AppLocalizations.of(context)!.bufferDuration),
subtitle: Text(AppLocalizations.of(context)!.bufferDurationSubtitle),
trailing: SizedBox(
width: 50 * MediaQuery.of(context).textScaleFactor,
child: TextField(
controller: _controller,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
onChanged: (value) {
final valueInt = int.tryParse(value);
if (valueInt != null && !valueInt.isNegative) {
FinampSettingsHelper.setBufferDuration(
Duration(seconds: valueInt));
}
},
),
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../services/finamp_settings_helper.dart';
class SongShuffleItemCountEditor extends StatefulWidget {
const SongShuffleItemCountEditor({Key? key}) : super(key: key);
@override
State<SongShuffleItemCountEditor> createState() =>
_SongShuffleItemCountEditorState();
}
class _SongShuffleItemCountEditorState
extends State<SongShuffleItemCountEditor> {
final _controller = TextEditingController(
text:
FinampSettingsHelper.finampSettings.songShuffleItemCount.toString());
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(AppLocalizations.of(context)!.shuffleAllSongCount),
subtitle: Text(AppLocalizations.of(context)!.shuffleAllSongCountSubtitle),
trailing: SizedBox(
width: 50 * MediaQuery.of(context).textScaleFactor,
child: TextField(
controller: _controller,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
onChanged: (value) {
final valueInt = int.tryParse(value);
if (valueInt != null) {
FinampSettingsHelper.setSongShuffleItemCount(valueInt);
}
},
),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class StopForegroundSelector extends StatelessWidget {
const StopForegroundSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title:
Text(AppLocalizations.of(context)!.enterLowPriorityStateOnPause),
subtitle: Text(AppLocalizations.of(context)!
.enterLowPriorityStateOnPauseSubtitle),
value:
FinampSettingsHelper.finampSettings.androidStopForegroundOnPause,
onChanged: (value) =>
FinampSettingsHelper.setAndroidStopForegroundOnPause(value),
);
},
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../../services/finamp_settings_helper.dart';
class DownloadLocationDeleteDialog extends StatelessWidget {
const DownloadLocationDeleteDialog({
Key? key,
required this.id,
}) : super(key: key);
final String id;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Are you sure?"),
content: const Text(
"Deleting a download location doesn't actually delete any downloads. It just removes the menu entry."),
actions: [
TextButton(
child: const Text("CANCEL"),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: const Text("DELETE"),
onPressed: () {
FinampSettingsHelper.deleteDownloadLocation(id);
Navigator.of(context).pop();
},
),
],
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
import 'download_location_list_tile.dart';
class DownloadLocationList extends StatefulWidget {
const DownloadLocationList({Key? key}) : super(key: key);
@override
State<DownloadLocationList> createState() => _DownloadLocationListState();
}
class _DownloadLocationListState extends State<DownloadLocationList> {
late Iterable<DownloadLocation> downloadLocationsIterable;
@override
void initState() {
super.initState();
downloadLocationsIterable =
FinampSettingsHelper.finampSettings.downloadLocationsMap.values;
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, child) {
return ListView.builder(
itemCount: downloadLocationsIterable.length,
itemBuilder: (context, index) {
return DownloadLocationListTile(
downloadLocation: downloadLocationsIterable.elementAt(index),
);
},
);
},
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../../models/finamp_models.dart';
import 'download_location_delete_dialog.dart';
class DownloadLocationListTile extends StatelessWidget {
const DownloadLocationListTile({
Key? key,
required this.downloadLocation,
}) : super(key: key);
final DownloadLocation downloadLocation;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(downloadLocation.name),
subtitle: Text(
downloadLocation.path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: downloadLocation.deletable
? () => showDialog(
context: context,
builder: (context) => DownloadLocationDeleteDialog(
id: downloadLocation.id,
),
)
: null,
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/downloads_helper.dart';
import '../error_snackbar.dart';
import 'download_error_list_tile.dart';
class DownloadErrorList extends StatefulWidget {
const DownloadErrorList({Key? key}) : super(key: key);
@override
State<DownloadErrorList> createState() => _DownloadErrorListState();
}
class _DownloadErrorListState extends State<DownloadErrorList> {
List<DownloadTask>? loadedDownloadTasks;
late Future<List<DownloadTask>?> downloadErrorListFuture;
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
@override
void initState() {
super.initState();
downloadErrorListFuture =
downloadsHelper.getDownloadsWithStatus(DownloadTaskStatus.failed);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<DownloadTask>?>(
future: downloadErrorListFuture,
builder: (context, snapshot) {
loadedDownloadTasks = snapshot.data;
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check,
size: 64,
// Inactive icons have an opacity of 50% with dark theme and 38%
// with bright theme
// https://material.io/design/iconography/system-icons.html#color
color: Theme.of(context).iconTheme.color?.withOpacity(
Theme.of(context).brightness == Brightness.light
? 0.38
: 0.5)),
const Padding(padding: EdgeInsets.all(8.0)),
Text(AppLocalizations.of(context)!.noErrors),
],
),
);
} else {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return DownloadErrorListTile(
downloadTask: snapshot.data![index]);
},
);
}
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(AppLocalizations.of(context)!.errorScreenError),
),
);
} else {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
},
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/finamp_models.dart';
import '../../services/downloads_helper.dart';
import '../../services/process_artist.dart';
import '../album_image.dart';
class DownloadErrorListTile extends StatelessWidget {
const DownloadErrorListTile({Key? key, required this.downloadTask})
: super(key: key);
final DownloadTask downloadTask;
@override
Widget build(BuildContext context) {
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
DownloadedSong? downloadedSong =
downloadsHelper.getJellyfinItemFromDownloadId(downloadTask.taskId);
if (downloadedSong == null) {
return ListTile(
title: Text(downloadTask.taskId),
subtitle:
Text(AppLocalizations.of(context)!.failedToGetSongFromDownloadId),
);
}
return ListTile(
leading: AlbumImage(item: downloadedSong.song),
title: Text(downloadedSong.song.name == null
? AppLocalizations.of(context)!.unknownName
: downloadedSong.song.name!),
subtitle: Text(processArtist(downloadedSong.song.albumArtist, context)),
// trailing: IconButton(
// icon: Icon(Icons.refresh),
// onPressed: () {},
// tooltip: "Retry",
// ),
);
}
}
@@ -0,0 +1,30 @@
import 'package:file_sizes/file_sizes.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/finamp_models.dart';
import '../../services/downloads_helper.dart';
class AlbumFileSize extends StatelessWidget {
const AlbumFileSize({Key? key, required this.downloadedParent})
: super(key: key);
final DownloadedParent downloadedParent;
@override
Widget build(BuildContext context) {
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
int totalSize = 0;
for (final item in downloadedParent.downloadedChildren.values) {
DownloadedSong? downloadedSong =
downloadsHelper.getDownloadedSong(item.id);
if (downloadedSong?.mediaSourceInfo.size != null) {
totalSize += downloadedSong!.mediaSourceInfo.size!;
}
}
return Text(FileSize.getSize(totalSize));
}
}
@@ -0,0 +1,108 @@
import 'dart:isolate';
import 'dart:ui';
import 'package:finamp/services/downloads_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:get_it/get_it.dart';
import '../../models/finamp_models.dart';
import '../error_snackbar.dart';
import '../album_image.dart';
class CurrentDownloadsList extends StatefulWidget {
const CurrentDownloadsList({Key? key}) : super(key: key);
@override
State<CurrentDownloadsList> createState() => _CurrentDownloadsListState();
}
class _CurrentDownloadsListState extends State<CurrentDownloadsList> {
final ReceivePort _port = ReceivePort();
final DownloadsHelper _downloadsHelper = GetIt.instance<DownloadsHelper>();
@override
void initState() {
super.initState();
IsolateNameServer.registerPortWithName(
_port.sendPort, 'downloader_send_port');
_port.listen((dynamic data) {
setState(() {});
});
FlutterDownloader.registerCallback(downloadCallback);
}
@override
void dispose() {
IsolateNameServer.removePortNameMapping('downloader_send_port');
super.dispose();
}
// https://github.com/fluttercommunity/flutter_downloader/issues/629
@pragma('vm:entry-point')
static void downloadCallback(String id, int status, int progress) {
final SendPort? send =
IsolateNameServer.lookupPortByName('downloader_send_port');
send?.send([id, status, progress]);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<DownloadTask>?>(
future: _downloadsHelper.getIncompleteDownloads(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return SliverList(
delegate:
SliverChildBuilderDelegate((BuildContext context, int index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: CurrentDownloadListTile(
downloadTask: snapshot.data![index],
));
}, childCount: snapshot.data!.length),
);
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return SliverList(
delegate: SliverChildListDelegate([
const Text("An error occured while getting current downloads")
]));
} else {
return SliverList(delegate: SliverChildListDelegate([]));
}
},
);
}
}
class CurrentDownloadListTile extends StatelessWidget {
const CurrentDownloadListTile({Key? key, required this.downloadTask})
: super(key: key);
final DownloadTask downloadTask;
@override
Widget build(BuildContext context) {
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
DownloadedSong? item =
downloadsHelper.getJellyfinItemFromDownloadId(downloadTask.taskId);
return Stack(
alignment: Alignment.bottomCenter,
children: [
LinearProgressIndicator(
value: downloadTask.progress / 100,
backgroundColor: Colors.transparent,
),
ListTile(
leading: AlbumImage(item: item?.song),
title: Text(item?.song.name ?? "???"),
subtitle: Text(item?.song.albumArtist ?? "???"),
)
],
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../screens/downloads_error_screen.dart';
import '../../services/downloads_helper.dart';
class DownloadErrorScreenButton extends StatefulWidget {
const DownloadErrorScreenButton({Key? key}) : super(key: key);
@override
State<DownloadErrorScreenButton> createState() =>
_DownloadErrorScreenButtonState();
}
class _DownloadErrorScreenButtonState extends State<DownloadErrorScreenButton> {
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
late Future<List<DownloadTask>?> downloadErrorScreenButtonFuture;
@override
void initState() {
super.initState();
downloadErrorScreenButtonFuture =
_downloadsHelper.getDownloadsWithStatus(DownloadTaskStatus.failed);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<DownloadTask>?>(
future: downloadErrorScreenButtonFuture,
builder: (context, snapshot) {
return IconButton(
onPressed: () =>
Navigator.of(context).pushNamed(DownloadsErrorScreen.routeName),
icon: Icon(
Icons.error,
color: snapshot.data?.isNotEmpty ?? false
? Theme.of(context).colorScheme.error
: null,
),
tooltip: AppLocalizations.of(context)!.downloadErrors,
);
},
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/downloads_helper.dart';
import '../../services/finamp_settings_helper.dart';
class DownloadMissingImagesButton extends StatefulWidget {
const DownloadMissingImagesButton({Key? key}) : super(key: key);
@override
State<DownloadMissingImagesButton> createState() =>
_DownloadMissingImagesButtonState();
}
class _DownloadMissingImagesButtonState
extends State<DownloadMissingImagesButton> {
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
// The user shouldn't be able to check for missing downloads while offline
bool _enabled = !FinampSettingsHelper.finampSettings.isOffline;
void downloadImages() async {
// We don't want two checks to happen at once, so we disable the
// button while a check is running (it shouldn't take long enough
// for the user to be able to press twice, but you never know)
setState(() {
_enabled = false;
});
final imagesDownloaded = await _downloadsHelper.downloadMissingImages();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!
.downloadedMissingImages(imagesDownloaded)),
));
setState(() {
_enabled = true;
});
}
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: _enabled ? () => downloadImages() : null,
icon: const Icon(Icons.image),
tooltip: AppLocalizations.of(context)!.downloadMissingImages,
);
}
}
@@ -0,0 +1,147 @@
import 'package:finamp/services/jellyfin_api_helper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/finamp_models.dart';
import '../../services/downloads_helper.dart';
import '../../models/jellyfin_models.dart';
import '../album_image.dart';
import '../confirmation_prompt_dialog.dart';
import 'item_media_source_info.dart';
import 'album_file_size.dart';
class DownloadedAlbumsList extends StatefulWidget {
const DownloadedAlbumsList({Key? key}) : super(key: key);
@override
State<DownloadedAlbumsList> createState() => _DownloadedAlbumsListState();
}
class _DownloadedAlbumsListState extends State<DownloadedAlbumsList> {
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
final JellyfinApiHelper jellyfinApiHelper = JellyfinApiHelper();
Future<void> deleteAlbum(
BuildContext context, DownloadedParent downloadedParent) async {
List<String> itemIds = [];
for (BaseItemDto item in downloadedParent.downloadedChildren.values) {
itemIds.add(item.id);
}
await downloadsHelper.deleteParentAndChildDownloads(
jellyfinItemIds: itemIds, deletedFor: downloadedParent.item.id);
}
@override
Widget build(BuildContext context) {
final Iterable<DownloadedParent> downloadedParents =
downloadsHelper.downloadedParents;
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
DownloadedParent album = downloadedParents.elementAt(index);
return ExpansionTile(
key: PageStorageKey(album.item.id),
leading: AlbumImage(item: album.item),
title: Text(album.item.name ?? "Unknown Name"),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => showDialog(
context: context,
builder: (context) => ConfirmationPromptDialog(
promptText: AppLocalizations.of(context)!
.deleteDownloadsPrompt(
album.item.name ?? "",
album.item.type == "Playlist"
? "playlist"
: "album"),
confirmButtonText: AppLocalizations.of(context)!
.deleteDownloadsConfirmButtonText,
abortButtonText: AppLocalizations.of(context)!
.deleteDownloadsAbortButtonText,
onConfirmed: () async {
await deleteAlbum(context, album);
setState(() {});
},
onAborted: () {},
),
),
),
subtitle: AlbumFileSize(
downloadedParent: album,
),
children: [
DownloadedSongsInAlbumList(
children: album.downloadedChildren.values,
parent: album,
)
],
);
},
childCount: downloadedParents.length,
),
);
}
}
class DownloadedSongsInAlbumList extends StatefulWidget {
const DownloadedSongsInAlbumList(
{Key? key, required this.children, required this.parent})
: super(key: key);
final Iterable<BaseItemDto> children;
final DownloadedParent parent;
@override
State<DownloadedSongsInAlbumList> createState() =>
_DownloadedSongsInAlbumListState();
}
class _DownloadedSongsInAlbumListState
extends State<DownloadedSongsInAlbumList> {
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
@override
Widget build(BuildContext context) {
return Column(children: [
//TODO use a list builder here
for (final song in widget.children)
ListTile(
title: Text(song.name ?? "Unknown Name"),
leading: AlbumImage(item: song),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => showDialog(
context: context,
builder: (context) => ConfirmationPromptDialog(
promptText: AppLocalizations.of(context)!
.deleteDownloadsPrompt(
song.name ?? "",
"track"),
confirmButtonText: AppLocalizations.of(context)!
.deleteDownloadsConfirmButtonText,
abortButtonText: AppLocalizations.of(context)!
.deleteDownloadsAbortButtonText,
onConfirmed: () async {
await deleteSong(context, song);
setState(() {});
},
onAborted: () {},
),
),
),
subtitle: ItemMediaSourceInfo(
songId: song.id,
),
)
]);
}
Future<void> deleteSong(BuildContext context, BaseItemDto itemDto) async {
widget.parent.downloadedChildren
.removeWhere((key, value) => value == itemDto);
await downloadsHelper.deleteSong(jellyfinItemId: itemDto.id);
}
}
@@ -0,0 +1,56 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:file_sizes/file_sizes.dart';
import '../../services/downloads_helper.dart';
import '../error_snackbar.dart';
class DownloadsFileSize extends StatefulWidget {
const DownloadsFileSize({Key? key, required this.directory})
: super(key: key);
final Directory directory;
@override
State<DownloadsFileSize> createState() => _DownloadsFileSizeState();
}
class _DownloadsFileSizeState extends State<DownloadsFileSize> {
late Future<int> _downloadsFileSizeFuture;
final DownloadsHelper _downloadsHelper = GetIt.instance<DownloadsHelper>();
@override
void initState() {
super.initState();
_downloadsFileSizeFuture = _downloadsHelper.getDirSize(widget.directory);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _downloadsFileSizeFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(
FileSize.getSize(snapshot.data),
style: const TextStyle(color: Colors.grey),
);
}
if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return const Text(
"??? MB",
style: TextStyle(color: Colors.red),
);
} else {
return const Text(
"...",
style: TextStyle(color: Colors.grey),
);
}
},
);
}
}
@@ -0,0 +1,185 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/download_update_stream.dart';
import '../error_snackbar.dart';
const double downloadsOverviewCardLoadingHeight = 120;
class DownloadsOverview extends StatefulWidget {
const DownloadsOverview({Key? key}) : super(key: key);
@override
State<DownloadsOverview> createState() => _DownloadsOverviewState();
}
class _DownloadsOverviewState extends State<DownloadsOverview> {
late Future<List<DownloadTask>?> _downloadsOverviewFuture;
final _downloadUpdateStream = GetIt.instance<DownloadUpdateStream>();
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
Map<String, DownloadTaskStatus>? _downloadTaskStatuses;
final Map<DownloadTaskStatus, int> _downloadCount = {
DownloadTaskStatus.undefined: 0,
DownloadTaskStatus.enqueued: 0,
DownloadTaskStatus.running: 0,
DownloadTaskStatus.complete: 0,
DownloadTaskStatus.failed: 0,
DownloadTaskStatus.canceled: 0,
DownloadTaskStatus.paused: 0,
};
bool _initialCountDone = false;
@override
void initState() {
super.initState();
_downloadsOverviewFuture = FlutterDownloader.loadTasks();
// Like in DownloadedIndicator, we use our own listener instead of a
// StreamBuilder to ensure that we capture all events.
_downloadUpdateStream.stream.listen((event) {
if (_downloadTaskStatuses != null &&
_downloadTaskStatuses!.containsKey(event.id) &&
_downloadTaskStatuses![event.id] != event.status) {
setState(() {
_downloadCount[_downloadTaskStatuses![event.id]!] =
_downloadCount[_downloadTaskStatuses![event.id]]! - 1;
_downloadCount[event.status] = _downloadCount[event.status]! + 1;
_downloadTaskStatuses![event.id] = event.status;
});
}
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<DownloadTask>?>(
future: _downloadsOverviewFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
_downloadTaskStatuses ??= Map.fromEntries(
snapshot.data!.map((e) => MapEntry(e.taskId, e.status)));
if (!_initialCountDone) {
// Switch cases don't work for some reason
for (var element in snapshot.data!) {
if (element.status == DownloadTaskStatus.undefined) {
_downloadCount[DownloadTaskStatus.undefined] =
_downloadCount[DownloadTaskStatus.undefined]! + 1;
} else if (element.status == DownloadTaskStatus.enqueued) {
_downloadCount[DownloadTaskStatus.enqueued] =
_downloadCount[DownloadTaskStatus.enqueued]! + 1;
} else if (element.status == DownloadTaskStatus.running) {
_downloadCount[DownloadTaskStatus.running] =
_downloadCount[DownloadTaskStatus.running]! + 1;
} else if (element.status == DownloadTaskStatus.complete) {
_downloadCount[DownloadTaskStatus.complete] =
_downloadCount[DownloadTaskStatus.complete]! + 1;
} else if (element.status == DownloadTaskStatus.failed) {
_downloadCount[DownloadTaskStatus.failed] =
_downloadCount[DownloadTaskStatus.failed]! + 1;
} else if (element.status == DownloadTaskStatus.canceled) {
_downloadCount[DownloadTaskStatus.canceled] =
_downloadCount[DownloadTaskStatus.canceled]! + 1;
} else if (element.status == DownloadTaskStatus.paused) {
_downloadCount[DownloadTaskStatus.paused] =
_downloadCount[DownloadTaskStatus.paused]! + 1;
}
}
_initialCountDone = true;
}
// We have to awkwardly get two strings like this because Flutter's
// internationalisation stuff doesn't support multiple plurals.
// https://github.com/flutter/flutter/issues/86906
final downloadedItemsString = AppLocalizations.of(context)!
.downloadedItemsCount(_downloadsHelper.downloadedItems.length);
final downloadedImagesString = AppLocalizations.of(context)!
.downloadedImagesCount(_downloadsHelper.downloadedImages.length);
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
AppLocalizations.of(context)!
.downloadCount(snapshot.data!.length),
style: const TextStyle(fontSize: 28),
maxLines: 1,
),
Text(
AppLocalizations.of(context)!
.downloadedItemsImagesCount(
downloadedItemsString,
downloadedImagesString),
style: const TextStyle(color: Colors.grey),
)
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
AppLocalizations.of(context)!.dlComplete(
_downloadCount[DownloadTaskStatus.complete]!),
style: const TextStyle(color: Colors.green),
),
Text(
AppLocalizations.of(context)!.dlFailed(
_downloadCount[DownloadTaskStatus.failed]!),
style: const TextStyle(color: Colors.red),
),
Text(
AppLocalizations.of(context)!.dlEnqueued(
_downloadCount[DownloadTaskStatus.enqueued]!),
style: const TextStyle(color: Colors.grey),
),
Text(
AppLocalizations.of(context)!.dlRunning(
_downloadCount[DownloadTaskStatus.running]!),
style: const TextStyle(color: Colors.grey),
),
],
),
)
],
),
),
),
);
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return const SizedBox(
height: downloadsOverviewCardLoadingHeight,
child: Card(
child: Icon(Icons.error),
),
);
} else {
return const SizedBox(
height: downloadsOverviewCardLoadingHeight,
child: Card(
child: Center(child: CircularProgressIndicator.adaptive()),
),
);
}
},
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:file_sizes/file_sizes.dart';
import 'package:get_it/get_it.dart';
import '../../services/downloads_helper.dart';
import '../../models/jellyfin_models.dart';
class ItemMediaSourceInfo extends StatelessWidget {
const ItemMediaSourceInfo({Key? key, required this.songId}) : super(key: key);
final String songId;
@override
Widget build(BuildContext context) {
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
MediaSourceInfo? mediaSourceInfo =
downloadsHelper.getDownloadedSong(songId)?.mediaSourceInfo;
if (mediaSourceInfo == null) {
return const Text("??? MB Unknown");
} else {
return Text(
"${FileSize.getSize(mediaSourceInfo.size)} ${mediaSourceInfo.container?.toUpperCase()}");
}
}
}
@@ -0,0 +1,45 @@
import 'package:finamp/models/finamp_models.dart';
import 'package:finamp/models/jellyfin_models.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:finamp/services/jellyfin_api_helper.dart';
import 'package:finamp/services/sync_helper.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
class SyncDownloadedAlbumsOrPlaylistsButton extends StatelessWidget {
SyncDownloadedAlbumsOrPlaylistsButton({super.key});
final _syncLogger = Logger("SyncDownloadedPlaylistsButton");
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
final _jellyfinApiData = GetIt.instance<JellyfinApiHelper>();
void syncPlaylists(BuildContext context) async {
_syncLogger.info("Syncing downloaded playlists");
var syncHelper = DownloadsSyncHelper(_syncLogger);
List<DownloadedParent> parents = downloadsHelper.downloadedParents.toList();
for (DownloadedParent parent in parents) {
List<BaseItemDto>? items = await _jellyfinApiData.getItems(
isGenres: false, parentItem: parent.item);
if (items == null) {
_syncLogger.warning("Could not find any items for album or playlist id ${parent.item.id}");
continue;
}
syncHelper.sync(context, parent.item, items);
}
}
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => syncPlaylists(context),
icon: const Icon(Icons.sync),
tooltip: AppLocalizations.of(context)!.syncDownloadedPlaylists,
);
}
}
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class FastScrollSelector extends StatelessWidget {
const FastScrollSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.showFastScroller),
value: FinampSettingsHelper.finampSettings.showFastScroller,
onChanged: (value) => FinampSettingsHelper.setShowFastScroller(value),
);
},
);
}
}
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class DisableGestureSelector extends StatelessWidget {
const DisableGestureSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.disableGesture),
subtitle: Text(AppLocalizations.of(context)!.disableGestureSubtitle),
value: FinampSettingsHelper.finampSettings.disableGesture,
onChanged: (value) => FinampSettingsHelper.setDisableGesture(value),
);
},
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class SwipeInsertQueueNextSelector extends StatelessWidget {
const SwipeInsertQueueNextSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.swipeInsertQueueNext),
subtitle:
Text(AppLocalizations.of(context)!.swipeInsertQueueNextSubtitle),
value: FinampSettingsHelper.finampSettings.swipeInsertQueueNext,
onChanged: (value) =>
FinampSettingsHelper.setSwipeInsertQueueNext(value),
);
},
);
}
}
@@ -0,0 +1,93 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:locale_names/locale_names.dart';
import '../../services/locale_helper.dart';
class LanguageList extends StatefulWidget {
const LanguageList({super.key});
@override
State<LanguageList> createState() => _LanguageListState();
}
class _LanguageListState extends State<LanguageList> {
// yeah I'm a computer science student how could you tell
// (sorts locales without having to copy them into a list first)
final locales = SplayTreeMap<String?, Locale>.fromIterable(
AppLocalizations.supportedLocales,
key: (element) => (element as Locale).defaultDisplayLanguage,
value: (element) => element,
);
@override
Widget build(BuildContext context) {
return Scrollbar(
// We have a ValueListenableBuilder here to rebuild all the ListTiles when
// the language changes
child: ValueListenableBuilder(
valueListenable: LocaleHelper.localeListener,
builder: (_, __, ___) {
return CustomScrollView(
slivers: [
// For some reason, setting the null (system) LanguageListTile to
// const stops it from switching when going to/from the same
// language as the system language (e.g., system to English on a
// device set to English)
// ignore: prefer_const_constructors
SliverList(
// ignore: prefer_const_constructors
delegate: SliverChildListDelegate.fixed([
// ignore: prefer_const_constructors
LanguageListTile(),
const Divider(),
]),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final locale = locales.values.elementAt(index);
return LanguageListTile(locale: locale);
},
childCount: locales.length,
),
)
],
);
},
),
);
}
}
class LanguageListTile extends StatelessWidget {
const LanguageListTile({
super.key,
this.locale,
});
final Locale? locale;
@override
Widget build(BuildContext context) {
return RadioListTile<Locale?>(
title: Text(locale?.nativeDisplayLanguage ??
AppLocalizations.of(context)!.system),
subtitle: locale == null
? null
: Text((LocaleHelper.locale == null
? locale!.defaultDisplayLanguage
: locale!.displayLanguageIn(LocaleHelper.locale!)) ??
"???"),
value: locale,
groupValue: LocaleHelper.locale,
onChanged: (_) {
LocaleHelper.setLocale(locale);
},
);
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../services/finamp_settings_helper.dart';
enum ContentGridViewCrossAxisCountType {
portrait,
landscape;
/// Human-readable version of the [ContentGridViewCrossAxisCountType]. For
/// example, toString() on [ContentGridViewCrossAxisCountType.portrait],
/// toString() would return "ContentGridViewCrossAxisCountType.portrait". With
/// this function, the same input would return "Portrait".
@override
@Deprecated("Use toLocalisedString when possible")
String toString() => _humanReadableName(this);
String toLocalisedString(BuildContext context) =>
_humanReadableLocalisedName(this, context);
String _humanReadableName(
ContentGridViewCrossAxisCountType contentGridViewCrossAxisCountType) {
switch (contentGridViewCrossAxisCountType) {
case ContentGridViewCrossAxisCountType.portrait:
return "Portrait";
case ContentGridViewCrossAxisCountType.landscape:
return "Landscape";
}
}
String _humanReadableLocalisedName(
ContentGridViewCrossAxisCountType contentGridViewCrossAxisCountType,
BuildContext context) {
switch (contentGridViewCrossAxisCountType) {
case ContentGridViewCrossAxisCountType.portrait:
return AppLocalizations.of(context)!.portrait;
case ContentGridViewCrossAxisCountType.landscape:
return AppLocalizations.of(context)!.landscape;
}
}
}
class ContentGridViewCrossAxisCountListTile extends StatefulWidget {
const ContentGridViewCrossAxisCountListTile({
Key? key,
required this.type,
}) : super(key: key);
final ContentGridViewCrossAxisCountType type;
@override
State<ContentGridViewCrossAxisCountListTile> createState() =>
_ContentGridViewCrossAxisCountListTileState();
}
class _ContentGridViewCrossAxisCountListTileState
extends State<ContentGridViewCrossAxisCountListTile> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
switch (widget.type) {
case ContentGridViewCrossAxisCountType.portrait:
_controller.text = FinampSettingsHelper
.finampSettings.contentGridViewCrossAxisCountPortrait
.toString();
break;
case ContentGridViewCrossAxisCountType.landscape:
_controller.text = FinampSettingsHelper
.finampSettings.contentGridViewCrossAxisCountLandscape
.toString();
break;
}
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(AppLocalizations.of(context)!
.gridCrossAxisCount(widget.type.toLocalisedString(context))),
subtitle: Text(
AppLocalizations.of(context)!.gridCrossAxisCountSubtitle(
widget.type.toLocalisedString(context).toLowerCase()),
),
trailing: SizedBox(
width: 50 * MediaQuery.of(context).textScaleFactor,
child: TextField(
controller: _controller,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
onChanged: (value) {
final valueInt = int.tryParse(value);
if (valueInt != null && valueInt > 0) {
switch (widget.type) {
case ContentGridViewCrossAxisCountType.portrait:
FinampSettingsHelper.setContentGridViewCrossAxisCountPortrait(
valueInt);
break;
case ContentGridViewCrossAxisCountType.landscape:
FinampSettingsHelper
.setContentGridViewCrossAxisCountLandscape(valueInt);
break;
}
}
},
),
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class ContentViewTypeDropdownListTile extends StatelessWidget {
const ContentViewTypeDropdownListTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return ListTile(
title: Text(AppLocalizations.of(context)!.viewType),
subtitle: Text(AppLocalizations.of(context)!.viewTypeSubtitle),
trailing: DropdownButton<ContentViewType>(
value: box.get("FinampSettings")?.contentViewType,
items: ContentViewType.values
.map((e) => DropdownMenuItem<ContentViewType>(
value: e,
child: Text(e.toLocalisedString(context)),
))
.toList(),
onChanged: (value) {
if (value != null) {
FinampSettingsHelper.setContentViewType(value);
}
},
),
);
},
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class HideSongArtistsIfSameAsAlbumArtistsSelector extends StatelessWidget {
const HideSongArtistsIfSameAsAlbumArtistsSelector({Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!
.hideSongArtistsIfSameAsAlbumArtists),
subtitle: Text(AppLocalizations.of(context)!
.hideSongArtistsIfSameAsAlbumArtistsSubtitle),
value: FinampSettingsHelper
.finampSettings.hideSongArtistsIfSameAsAlbumArtists,
onChanged: (value) =>
FinampSettingsHelper.setHideSongArtistsIfSameAsAlbumArtists(
value),
);
},
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class ShowCoverAsPlayerBackgroundSelector extends StatelessWidget {
const ShowCoverAsPlayerBackgroundSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title:
Text(AppLocalizations.of(context)!.showCoverAsPlayerBackground),
subtitle: Text(AppLocalizations.of(context)!
.showCoverAsPlayerBackgroundSubtitle),
value:
FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground,
onChanged: (value) =>
FinampSettingsHelper.setShowCoverAsPlayerBackground(value),
);
},
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class ShowTextOnGridViewSelector extends StatelessWidget {
const ShowTextOnGridViewSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.showTextOnGridView),
subtitle:
Text(AppLocalizations.of(context)!.showTextOnGridViewSubtitle),
value: FinampSettingsHelper.finampSettings.showTextOnGridView,
onChanged: (value) =>
FinampSettingsHelper.setShowTextOnGridView(value),
);
},
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/theme_mode_helper.dart';
extension LocalisedName on ThemeMode {
String toLocalisedString(BuildContext context) =>
_humanReadableLocalisedName(this, context);
String _humanReadableLocalisedName(
ThemeMode themeMode, BuildContext context) {
switch (themeMode) {
case ThemeMode.system:
return AppLocalizations.of(context)!.system;
case ThemeMode.light:
return AppLocalizations.of(context)!.light;
case ThemeMode.dark:
return AppLocalizations.of(context)!.dark;
}
}
}
class ThemeSelector extends StatelessWidget {
const ThemeSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<ThemeMode>>(
valueListenable: ThemeModeHelper.themeModeListener,
builder: (_, box, __) {
return ListTile(
title: const Text("Theme"),
trailing: DropdownButton<ThemeMode>(
value: box.get("ThemeMode"),
items: ThemeMode.values
.map((e) => DropdownMenuItem<ThemeMode>(
value: e,
child: Text(e.toLocalisedString(context)),
))
.toList(),
onChanged: (value) {
if (value != null) {
ThemeModeHelper.setThemeMode(value);
}
},
),
);
},
);
}
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/finamp_logs_helper.dart';
class CopyLogsButton extends StatefulWidget {
const CopyLogsButton({Key? key}) : super(key: key);
@override
State<CopyLogsButton> createState() => _CopyLogsButtonState();
}
class _CopyLogsButtonState extends State<CopyLogsButton> {
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.copy),
onPressed: () async {
final finampLogsHelper = GetIt.instance<FinampLogsHelper>();
await finampLogsHelper.copyLogs();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.logsCopied),
));
},
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'package:clipboard/clipboard.dart';
import 'package:finamp/services/censored_log.dart';
import 'package:finamp/services/contains_login.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import '../error_snackbar.dart';
class LogTile extends StatefulWidget {
const LogTile({Key? key, required this.logRecord}) : super(key: key);
final LogRecord logRecord;
@override
State<LogTile> createState() => _LogTileState();
}
class _LogTileState extends State<LogTile> {
final _controller = ExpansionTileController();
/// Whether the user has confirmed. This is used to stop onExpansionChanged
/// from infinitely asking the user to confirm.
bool hasConfirmed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPress: () async {
try {
await FlutterClipboard.copy(widget.logRecord.censoredMessage);
} catch (e) {
errorSnackbar(e, context);
}
},
child: Card(
color: _logColor(widget.logRecord.level, context),
child: ExpansionTile(
controller: _controller,
key: PageStorageKey(widget.logRecord.time),
leading: _LogIcon(level: widget.logRecord.level),
title: RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text:
"[${widget.logRecord.loggerName}]\n${widget.logRecord.time}",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
subtitle: RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: widget.logRecord.loginCensoredMessage,
style: Theme.of(context).textTheme.bodyMedium,
),
),
expandedAlignment: Alignment.centerLeft,
expandedCrossAxisAlignment: CrossAxisAlignment.start,
textColor: _logTextColor(widget.logRecord.level, context),
collapsedTextColor: _logTextColor(widget.logRecord.level, context),
iconColor: _logTextColor(widget.logRecord.level, context),
collapsedIconColor: _logTextColor(widget.logRecord.level, context),
// Remove the border when expanded
shape: const Border(),
childrenPadding: const EdgeInsets.all(8.0),
children: [
Text(
AppLocalizations.of(context)!.message,
style: Theme.of(context).textTheme.titleLarge,
),
_LogMessageContent(widget.logRecord.message),
const SizedBox(height: 16.0),
if (widget.logRecord.stackTrace != null)
Text(
AppLocalizations.of(context)!.stackTrace,
style: Theme.of(context).textTheme.titleLarge,
),
if (widget.logRecord.stackTrace != null)
_LogMessageContent(widget.logRecord.stackTrace.toString()),
],
onExpansionChanged: (value) async {
if (value && !hasConfirmed && widget.logRecord.containsLogin) {
_controller.collapse();
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.confirm),
content: Text(
AppLocalizations.of(context)!.showUncensoredLogMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(MaterialLocalizations.of(context)
.cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(AppLocalizations.of(context)!.confirm),
),
],
);
},
);
hasConfirmed = confirmed!;
if (confirmed) {
_controller.expand();
}
} else if (hasConfirmed) {
hasConfirmed = false;
}
},
),
),
);
}
Color _logColor(Level level, BuildContext context) {
if (level == Level.WARNING) {
return Theme.of(context).colorScheme.tertiaryContainer;
} else if (level == Level.SEVERE) {
return Theme.of(context).colorScheme.errorContainer;
} else {
return Theme.of(context).colorScheme.primaryContainer;
}
}
Color _logTextColor(Level level, BuildContext context) {
if (level == Level.WARNING) {
return Theme.of(context).colorScheme.onTertiaryContainer;
} else if (level == Level.SEVERE) {
return Theme.of(context).colorScheme.onErrorContainer;
} else {
return Theme.of(context).colorScheme.onPrimaryContainer;
}
}
}
class _LogIcon extends StatelessWidget {
const _LogIcon({Key? key, required this.level}) : super(key: key);
final Level level;
@override
Widget build(BuildContext context) {
if (level == Level.INFO) {
return const Icon(Icons.info);
} else if (level == Level.WARNING) {
return const Icon(Icons.warning);
} else if (level == Level.SEVERE) {
return const Icon(Icons.error);
} else {
return const Icon(Icons.info);
}
}
}
class _LogMessageContent extends StatelessWidget {
const _LogMessageContent(this.content, {Key? key}) : super(key: key);
final String content;
@override
Widget build(BuildContext context) {
return Text(
content,
style: const TextStyle(
fontSize: 12.0,
fontFamily: "monospace",
),
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'log_tile.dart';
import '../../services/finamp_logs_helper.dart';
class LogsView extends StatelessWidget {
const LogsView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
FinampLogsHelper finampLogsHelper = GetIt.instance<FinampLogsHelper>();
return Scrollbar(
child: ListView.builder(
itemCount: finampLogsHelper.logs.length,
reverse: true,
itemBuilder: (context, index) {
return LogTile(
logRecord: finampLogsHelper.logs.reversed.elementAt(index));
},
),
);
}
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/finamp_logs_helper.dart';
class ShareLogsButton extends StatelessWidget {
const ShareLogsButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(Icons.adaptive.share),
tooltip: AppLocalizations.of(context)!.shareLogs,
onPressed: () async {
final finampLogsHelper = GetIt.instance<FinampLogsHelper>();
await finampLogsHelper.shareLogs();
},
);
}
}
+311
View File
@@ -0,0 +1,311 @@
import 'package:finamp/components/MusicScreen/album_item_list_tile.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:finamp/services/finamp_settings_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/audio_service_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../screens/artist_screen.dart';
import '../../screens/album_screen.dart';
import '../error_snackbar.dart';
import 'album_item_card.dart';
enum _AlbumListTileMenuItems {
addToQueue,
playNext,
addFavourite,
removeFavourite,
addToMixList,
removeFromMixList,
}
/// This widget is kind of a shell around AlbumItemCard and AlbumItemListTile.
/// Depending on the values given, a list tile or a card will be returned. This
/// widget exists to handle the dropdown stuff and other stuff shared between
/// the two widgets.
class AlbumItem extends StatefulWidget {
const AlbumItem({
Key? key,
required this.album,
this.parentType,
this.onTap,
this.isGrid = false,
this.gridAddSettingsListener = false,
}) : super(key: key);
/// The album (or item, I just used to call items albums before Finamp
/// supported other types) to show in the widget.
final BaseItemDto album;
/// The parent type of the item. Used to change onTap functionality for stuff
/// like artists.
final String? parentType;
/// A custom onTap can be provided to override the default value, which is to
/// open the item's album/artist screen.
final void Function()? onTap;
/// If specified, use cards instead of list tiles. Use this if you want to use
/// this widget in a grid view.
final bool isGrid;
/// If true, the grid item will use a ValueListenableBuilder to check whether
/// or not to show the text. You'll want to set this to false if the
/// [AlbumItem] would be rebuilt by FinampSettings anyway.
final bool gridAddSettingsListener;
@override
State<AlbumItem> createState() => _AlbumItemState();
}
class _AlbumItemState extends State<AlbumItem> {
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
late BaseItemDto mutableAlbum;
late Function() onTap;
@override
void initState() {
super.initState();
mutableAlbum = widget.album;
// this is jank lol
onTap = widget.onTap ??
() {
if (mutableAlbum.type == "MusicArtist" ||
mutableAlbum.type == "MusicGenre") {
Navigator.of(context)
.pushNamed(ArtistScreen.routeName, arguments: mutableAlbum);
} else {
Navigator.of(context)
.pushNamed(AlbumScreen.routeName, arguments: mutableAlbum);
}
};
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
return Padding(
padding: widget.isGrid
? Theme.of(context).cardTheme.margin ?? const EdgeInsets.all(4.0)
: EdgeInsets.zero,
child: GestureDetector(
onLongPressStart: (details) async {
Feedback.forLongPress(context);
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final selection = await showMenu<_AlbumListTileMenuItems>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
screenSize.width - details.globalPosition.dx,
screenSize.height - details.globalPosition.dy,
),
items: [
if (_audioServiceHelper.hasQueueItems()) ...[
PopupMenuItem<_AlbumListTileMenuItems>(
value: _AlbumListTileMenuItems.addToQueue,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.addToQueue),
),
),
PopupMenuItem<_AlbumListTileMenuItems>(
value: _AlbumListTileMenuItems.playNext,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.playNext),
),
),
],
mutableAlbum.userData!.isFavorite
? PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.removeFavourite,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.favorite_border),
title:
Text(AppLocalizations.of(context)!.removeFavourite),
),
)
: PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.addFavourite,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.favorite),
title: Text(AppLocalizations.of(context)!.addFavourite),
),
),
jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id)
? PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.removeFromMixList,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.explore_off),
title:
Text(AppLocalizations.of(context)!.removeFromMix),
),
)
: PopupMenuItem<_AlbumListTileMenuItems>(
enabled: !isOffline,
value: _AlbumListTileMenuItems.addToMixList,
child: ListTile(
enabled: !isOffline,
leading: const Icon(Icons.explore),
title: Text(AppLocalizations.of(context)!.addToMix),
),
),
],
);
if (!mounted) return;
switch (selection) {
case _AlbumListTileMenuItems.addToQueue:
List<BaseItemDto>? children;
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(widget.album.id)!;
children = downloadedParent.downloadedChildren.values.toList();
} else {
children = await jellyfinApiHelper.getItems(
parentItem: widget.album,
sortBy: "ParentIndexNumber,IndexNumber,SortName",
includeItemTypes: "Audio",
isGenres: false,
);
}
if (children != null) {
await _audioServiceHelper.addQueueItems(children);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.addedToQueue),
));
}
break;
case _AlbumListTileMenuItems.playNext:
List<BaseItemDto>? children;
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(widget.album.id)!;
children = downloadedParent.downloadedChildren.values.toList();
} else {
children = await jellyfinApiHelper.getItems(
parentItem: widget.album,
sortBy: "ParentIndexNumber,IndexNumber,SortName",
includeItemTypes: "Audio",
isGenres: false,
);
}
if (children != null) {
await _audioServiceHelper.insertQueueItemsNext(children);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context)!.insertedIntoQueue),
));
}
break;
case _AlbumListTileMenuItems.addFavourite:
try {
final newUserData =
await jellyfinApiHelper.addFavourite(mutableAlbum.id);
if (!mounted) return;
setState(() {
mutableAlbum.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite added.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case _AlbumListTileMenuItems.removeFavourite:
try {
final newUserData =
await jellyfinApiHelper.removeFavourite(mutableAlbum.id);
if (!mounted) return;
setState(() {
mutableAlbum.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite removed.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case _AlbumListTileMenuItems.addToMixList:
try {
jellyfinApiHelper.addAlbumToMixBuilderList(mutableAlbum);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case _AlbumListTileMenuItems.removeFromMixList:
try {
jellyfinApiHelper.removeAlbumFromBuilderList(mutableAlbum);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case null:
break;
}
},
child: widget.isGrid
? AlbumItemCard(
item: mutableAlbum,
onTap: onTap,
parentType: widget.parentType,
addSettingsListener: widget.gridAddSettingsListener,
)
: AlbumItemListTile(
item: mutableAlbum,
onTap: onTap,
parentType: widget.parentType,
),
),
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/generate_subtitle.dart';
import '../album_image.dart';
/// Card content for AlbumItem. You probably shouldn't use this widget directly,
/// use AlbumItem instead.
class AlbumItemCard extends StatelessWidget {
const AlbumItemCard({
Key? key,
required this.item,
this.parentType,
this.onTap,
this.addSettingsListener = false,
}) : super(key: key);
final BaseItemDto item;
final String? parentType;
final void Function()? onTap;
final bool addSettingsListener;
@override
Widget build(BuildContext context) {
return Card(
// In AlbumItem, the OpenContainer handles padding.
margin: EdgeInsets.zero,
child: ClipRRect(
borderRadius: AlbumImage.borderRadius,
child: Stack(
children: [
AlbumImage(item: item),
addSettingsListener
? // We need this ValueListenableBuilder to react to changes to
// showTextOnGridView. When shown in a MusicScreen, this widget
// would refresh anyway since MusicScreen also listens to
// FinampSettings, but there may be cases where this widget is used
// elsewhere.
ValueListenableBuilder<Box<FinampSettings>>(
valueListenable:
FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
if (box.get("FinampSettings")!.showTextOnGridView) {
return _AlbumItemCardText(
item: item, parentType: parentType);
} else {
// ValueListenableBuilder doesn't let us return null, so we
// return a 0-sized SizedBox.
return const SizedBox.shrink();
}
},
)
: FinampSettingsHelper.finampSettings.showTextOnGridView
? _AlbumItemCardText(item: item, parentType: parentType)
: const SizedBox.shrink(),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
),
),
)
],
),
),
);
}
}
class _AlbumItemCardText extends StatelessWidget {
const _AlbumItemCardText({
Key? key,
required this.item,
required this.parentType,
}) : super(key: key);
final BaseItemDto item;
final String? parentType;
@override
Widget build(BuildContext context) {
final subtitle = generateSubtitle(item, parentType, context);
return Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
// We fade from half transparent black to transparent so that text is visible on bright images
Colors.black.withOpacity(0.5),
Colors.transparent,
],
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Align(
alignment: Alignment.bottomLeft,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? "Unknown Name",
overflow: TextOverflow.ellipsis,
maxLines: 3,
style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(color: Colors.white),
),
if (subtitle != null)
Text(
subtitle,
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(color: Colors.white.withOpacity(0.7)),
)
],
),
),
),
),
);
}
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/generate_subtitle.dart';
import '../album_image.dart';
/// ListTile content for AlbumItem. You probably shouldn't use this widget
/// directly, use AlbumItem instead.
class AlbumItemListTile extends StatelessWidget {
const AlbumItemListTile({
Key? key,
required this.item,
this.parentType,
this.onTap,
}) : super(key: key);
final BaseItemDto item;
final String? parentType;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final subtitle = generateSubtitle(item, parentType, context);
return ListTile(
// This widget is used on the add to playlist screen, so we allow a custom
// onTap to be passed as an argument.
onTap: onTap,
leading: AlbumImage(item: item),
title: Text(
item.name ?? AppLocalizations.of(context)!.unknownName,
overflow: TextOverflow.ellipsis,
),
subtitle: subtitle == null ? null : Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id)
? const Icon(Icons.explore)
: null,
);
}
}
@@ -0,0 +1,71 @@
import 'package:finamp/models/jellyfin_models.dart';
import 'package:flutter/material.dart';
class AlphabetList extends StatefulWidget {
final Function(String) callback;
final String sortOrder;
const AlphabetList({super.key, required this.callback, required this.sortOrder});
@override
State<AlphabetList> createState() => _AlphabetListState();
}
class _AlphabetListState extends State<AlphabetList> {
List<String> alphabet = ['#'] +
List.generate(26, (int index) {
return String.fromCharCode('A'.codeUnitAt(0) + index);
});
List<String> get getAlphabet => alphabet;
@override
void initState() {
orderTheList(alphabet);
super.initState();
}
@override
void didUpdateWidget(AlphabetList oldWidget) {
orderTheList(alphabet);
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
alphabet.length,
(x) => InkWell(
onTap: () {
widget.callback(alphabet[x]);
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
child: Text(
alphabet[x].toUpperCase(),
),
),
),
)),
),
),
);
}
void orderTheList(List<String> list) {
widget.sortOrder == "Ascending"
? list.sort()
: list.sort((a, b) => b.compareTo(a));
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../screens/artist_screen.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../album_image.dart';
import '../error_snackbar.dart';
enum ArtistListTileMenuItems {
addToFavourite,
removeFromFavourite,
addToMixList,
removeFromMixList,
}
class ArtistListTile extends StatefulWidget {
const ArtistListTile({
Key? key,
required this.item,
this.index,
this.parentId,
}) : super(key: key);
final BaseItemDto item;
final int? index;
final String? parentId;
@override
State<ArtistListTile> createState() => _ArtistListTileState();
}
class _ArtistListTileState extends State<ArtistListTile> {
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
late BaseItemDto mutableItem = widget.item;
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final listTile = ListTile(
onTap: () {
Navigator.of(context)
.pushNamed(ArtistScreen.routeName, arguments: mutableItem);
},
leading: AlbumImage(item: mutableItem),
title: Text(
mutableItem.name ?? "Unknown Name",
overflow: TextOverflow.ellipsis,
),
subtitle: null,
trailing:
_jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id)
? const Icon(Icons.explore)
: null,
);
return GestureDetector(
onLongPressStart: (details) async {
Feedback.forLongPress(context);
// Some options are disabled in offline mode
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
final selection = await showMenu<ArtistListTileMenuItems>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
screenSize.width - details.globalPosition.dx,
screenSize.height - details.globalPosition.dy,
),
items: [
mutableItem.userData!.isFavorite
? const PopupMenuItem<ArtistListTileMenuItems>(
value: ArtistListTileMenuItems.removeFromFavourite,
child: ListTile(
leading: Icon(Icons.favorite_border),
title: Text("Remove Favourite"),
),
)
: const PopupMenuItem<ArtistListTileMenuItems>(
value: ArtistListTileMenuItems.addToFavourite,
child: ListTile(
leading: Icon(Icons.favorite),
title: Text("Add Favourite"),
),
),
_jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id)
? PopupMenuItem<ArtistListTileMenuItems>(
enabled: !isOffline,
value: ArtistListTileMenuItems.removeFromMixList,
child: ListTile(
leading: const Icon(Icons.explore_off),
title: const Text("Remove From Mix"),
enabled: isOffline ? false : true,
),
)
: PopupMenuItem<ArtistListTileMenuItems>(
value: ArtistListTileMenuItems.addToMixList,
enabled: !isOffline,
child: ListTile(
leading: const Icon(Icons.explore),
title: const Text("Add To Mix"),
enabled: !isOffline,
),
),
],
);
if (!mounted) return;
switch (selection) {
case ArtistListTileMenuItems.addToFavourite:
try {
final newUserData =
await _jellyfinApiHelper.addFavourite(mutableItem.id);
if (!mounted) return;
setState(() {
mutableItem.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite added.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case ArtistListTileMenuItems.removeFromFavourite:
try {
final newUserData =
await _jellyfinApiHelper.removeFavourite(mutableItem.id);
if (!mounted) return;
setState(() {
mutableItem.userData = newUserData;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Favourite removed.")));
} catch (e) {
errorSnackbar(e, context);
}
break;
case ArtistListTileMenuItems.addToMixList:
try {
_jellyfinApiHelper.addArtistToMixBuilderList(mutableItem);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case ArtistListTileMenuItems.removeFromMixList:
try {
_jellyfinApiHelper.removeArtistFromBuilderList(mutableItem);
setState(() {});
} catch (e) {
errorSnackbar(e, context);
}
break;
case null:
break;
}
},
child: listTile);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../services/finamp_user_helper.dart';
import '../../screens/downloads_screen.dart';
import '../../screens/logs_screen.dart';
import '../../screens/settings_screen.dart';
import 'offline_mode_switch_list_tile.dart';
import 'view_list_tile.dart';
class MusicScreenDrawer extends StatelessWidget {
const MusicScreenDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final finampUserHelper = GetIt.instance<FinampUserHelper>();
return Drawer(
child: Scrollbar(
child: CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildListDelegate.fixed(
[
DrawerHeader(
child: Stack(
children: [
const Align(
alignment: Alignment.topCenter,
child: CircleAvatar(
backgroundColor: Colors.transparent,
backgroundImage: AssetImage(
'images/finamp.png',
),
radius: 50.0,
),
),
Align(
alignment:
Alignment.bottomCenter - const Alignment(0, 0.2),
child: Text(
AppLocalizations.of(context)!.finamp,
style: const TextStyle(fontSize: 20),
)),
],
)),
ListTile(
leading: const Icon(Icons.file_download),
title: Text(AppLocalizations.of(context)!.downloads),
onTap: () => Navigator.of(context)
.pushNamed(DownloadsScreen.routeName),
),
const OfflineModeSwitchListTile(),
const Divider(),
],
),
),
// This causes an error when logging out if we show this widget
if (finampUserHelper.currentUser != null)
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return ViewListTile(
view: finampUserHelper.currentUser!.views.values
.elementAt(index));
}, childCount: finampUserHelper.currentUser!.views.length),
),
SliverFillRemaining(
hasScrollBody: false,
child: SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
ListTile(
leading: const Icon(Icons.science),
title: Text(AppLocalizations.of(context)!.redesignBeta),
onTap: () async => await launchUrl(Uri.parse(
"https://github.com/jmshrv/finamp/releases/tag/0.9.2-beta")),
),
ListTile(
leading: const Icon(Icons.warning),
title: Text(AppLocalizations.of(context)!.logs),
onTap: () => Navigator.of(context)
.pushNamed(LogsScreen.routeName),
),
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () => Navigator.of(context)
.pushNamed(SettingsScreen.routeName),
),
],
),
),
),
)
],
),
),
);
}
}
@@ -0,0 +1,614 @@
import 'dart:async';
import 'dart:math';
import 'package:finamp/components/MusicScreen/artist_item_list_tile.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../models/finamp_models.dart';
import '../../models/jellyfin_models.dart';
import '../../services/downloads_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/finamp_user_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../AlbumScreen/song_list_tile.dart';
import '../error_snackbar.dart';
import '../first_page_progress_indicator.dart';
import '../new_page_progress_indicator.dart';
import 'album_item.dart';
import 'alphabet_item_list.dart';
class MusicScreenTabView extends StatefulWidget {
const MusicScreenTabView({
Key? key,
required this.tabContentType,
this.parentItem,
this.searchTerm,
required this.isFavourite,
this.sortBy,
this.sortOrder,
this.view,
this.albumArtist,
}) : super(key: key);
final TabContentType tabContentType;
final BaseItemDto? parentItem;
final String? searchTerm;
final bool isFavourite;
final SortBy? sortBy;
final SortOrder? sortOrder;
final BaseItemDto? view;
final String? albumArtist;
@override
State<MusicScreenTabView> createState() => _MusicScreenTabViewState();
}
// We use AutomaticKeepAliveClientMixin so that the view keeps its position after the tab is changed.
// https://stackoverflow.com/questions/49439047/how-to-preserve-widget-states-in-flutter-when-navigating-using-bottomnavigation
class _MusicScreenTabViewState extends State<MusicScreenTabView>
with AutomaticKeepAliveClientMixin<MusicScreenTabView> {
// If parentItem is null, we assume that this view is actually in a tab.
// If it isn't null, this view is being used as an artist detail screen and shouldn't be kept alive.
@override
bool get wantKeepAlive => widget.parentItem == null;
static const _pageSize = 100;
final PagingController<int, BaseItemDto> _pagingController =
PagingController(firstPageKey: 0);
List<BaseItemDto>? offlineSortedItems;
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
String? _lastSearch;
bool? _oldIsFavourite;
SortBy? _oldSortBy;
SortOrder? _oldSortOrder;
BaseItemDto? _oldView;
ScrollController? controller;
String? letterToSearch;
String lastSortOrder = SortOrder.ascending.toString();
Timer? timer;
// This function just lets us easily set stuff to the getItems call we want.
Future<void> _getPage(int pageKey) async {
try {
final sortOrder =
widget.sortOrder?.toString() ?? SortOrder.ascending.toString();
final newItems = await _jellyfinApiHelper.getItems(
// starting with Jellyfin 10.9, only automatically created playlists will have a specific library as parent. user-created playlists will not be returned anymore
// this condition fixes this by not providing a parentId when fetching playlists
parentItem: widget.tabContentType == TabContentType.playlists
? null
: widget.parentItem ??
widget.view ??
_finampUserHelper.currentUser?.currentView,
includeItemTypes: _includeItemTypes(widget.tabContentType),
// If we're on the songs tab, sort by "Album,SortName". This is what the
// Jellyfin web client does. If this isn't the case, check if parentItem
// is null. parentItem will be null when this widget is not used in an
// artist view. If it's null, sort by "SortName". If it isn't null, check
// if the parentItem is a MusicArtist. If it is, sort by year. Otherwise,
// sort by SortName. If widget.sortBy is set, it is used instead.
sortBy: widget.sortBy?.jellyfinName(widget.tabContentType) ??
(widget.tabContentType == TabContentType.songs
? "Album,SortName"
: widget.parentItem == null
? "SortName"
: widget.parentItem?.type == "MusicArtist"
? "ProductionYear,PremiereDate"
: "SortName"),
sortOrder: sortOrder,
searchTerm: widget.searchTerm?.trim(),
// If this is the genres tab, tell getItems to get genres.
isGenres: widget.tabContentType == TabContentType.genres,
filters: widget.isFavourite ? "IsFavorite" : null,
startIndex: pageKey,
limit: _pageSize,
);
if (newItems!.length < _pageSize) {
_pagingController.appendLastPage(newItems);
} else {
_pagingController.appendPage(newItems, pageKey + newItems.length);
}
if (letterToSearch != null) {
scrollToLetter(letterToSearch);
timer?.cancel();
timer = Timer(const Duration(seconds: 2, milliseconds: 500), () {
scrollToNearbyLetter();
});
}
setState(() {
lastSortOrder = sortOrder;
});
} catch (e) {
errorSnackbar(e, context);
}
}
String _getParentType() =>
widget.parentItem?.type! ??
_finampUserHelper.currentUser!.currentView!.type!;
@override
void initState() {
_pagingController.addPageRequestListener((pageKey) {
_getPage(pageKey);
});
lastSortOrder =
widget.sortOrder?.toString() ?? SortOrder.ascending.toString();
controller = ScrollController();
super.initState();
}
@override
void didUpdateWidget(oldWidget) {
setState(() {
lastSortOrder =
widget.sortOrder?.toString() ?? SortOrder.ascending.toString();
});
super.didUpdateWidget(oldWidget);
}
// Scrolls the list to the first occurrence of the letter in the list
// If clicked in the # element, it goes to the first one ( pixels = 0 )
void scrollToLetter(String? clickedLetter) async {
String? letter = clickedLetter ?? letterToSearch;
if (letter == null) return;
letterToSearch = letter;
if (letter == '#') {
double targetScroll = lastSortOrder == SortOrder.ascending.toString()
? -(controller!.position.maxScrollExtent * 10)
: controller!.position.maxScrollExtent * 10;
await controller?.animateTo(targetScroll,
duration: const Duration(milliseconds: 200), curve: Curves.ease);
} else {
final indexWhere = _pagingController.itemList!.indexWhere((element) {
final name = element.name!;
final firstLetter =
name.startsWith(RegExp(r'^the', caseSensitive: false))
? name.split(RegExp(r'^the', caseSensitive: false))[1].trim()[0]
: name[0].toUpperCase();
return firstLetter == letter;
});
if (indexWhere >= 0) {
final scrollTo = (indexWhere * 72).toDouble();
await controller?.animateTo(scrollTo,
duration: const Duration(milliseconds: 200), curve: Curves.ease);
letterToSearch = null;
} else {
await controller?.animateTo(controller!.position.maxScrollExtent * 100,
duration: const Duration(milliseconds: 200), curve: Curves.ease);
}
}
}
void scrollToNearbyLetter() {
if (letterToSearch != null) {
const standardAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
final closestLetterIndex = standardAlphabet.indexOf(letterToSearch!);
if (closestLetterIndex != -1) {
for (int offset = 0; offset <= standardAlphabet.length; offset++) {
for (final direction in [1, -1]) {
final nextIndex = closestLetterIndex + offset * direction;
if (nextIndex >= 0 && nextIndex < standardAlphabet.length) {
final nextLetter = standardAlphabet[nextIndex];
final nextLetterIndex =
_pagingController.itemList!.indexWhere((element) {
final firstLetter = element.name![0].toUpperCase();
return firstLetter == nextLetter;
});
if (nextLetterIndex >= 0) {
final scrollTo = (nextLetterIndex * 72).toDouble();
controller?.animateTo(scrollTo,
duration: const Duration(milliseconds: 200),
curve: Curves.ease);
letterToSearch = null;
return;
}
}
}
}
}
}
}
@override
void dispose() {
_pagingController.dispose();
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, _) {
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
if (isOffline) {
// We do the same checks we do when online to ensure that the list is
// not resorted when it doesn't have to be.
if (widget.searchTerm != _lastSearch ||
offlineSortedItems == null ||
widget.isFavourite != _oldIsFavourite ||
widget.sortBy != _oldSortBy ||
widget.sortOrder != _oldSortOrder ||
widget.view != _oldView) {
_lastSearch = widget.searchTerm;
_oldIsFavourite = widget.isFavourite;
_oldSortBy = widget.sortBy;
_oldSortOrder = widget.sortOrder;
_oldView = widget.view;
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
if (widget.tabContentType == TabContentType.artists) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 64,
color: Colors.white.withOpacity(0.5),
),
const Padding(padding: EdgeInsets.all(8.0)),
const Text("Offline artists view hasn't been implemented")
],
),
);
}
if (widget.searchTerm == null) {
if (widget.tabContentType == TabContentType.songs) {
// If we're on the songs tab, just get all of the downloaded items
offlineSortedItems = downloadsHelper.downloadedItems
.where((element) =>
element.viewId ==
_finampUserHelper.currentUser!.currentViewId)
.map((e) => e.song)
.toList();
} else {
String? albumArtist = widget.albumArtist;
offlineSortedItems = downloadsHelper.downloadedParents
.where((element) =>
element.item.type ==
_includeItemTypes(widget.tabContentType) &&
element.viewId ==
_finampUserHelper.currentUser!.currentViewId &&
(albumArtist == null ||
element.item.albumArtist?.toLowerCase() ==
albumArtist.toLowerCase()))
.map((e) => e.item)
.toList();
}
} else {
if (widget.tabContentType == TabContentType.songs) {
offlineSortedItems = downloadsHelper.downloadedItems
.where(
(element) {
return _offlineSearch(
item: element.song,
searchTerm: widget.searchTerm!,
tabContentType: widget.tabContentType);
},
)
.map((e) => e.song)
.toList();
} else {
offlineSortedItems = downloadsHelper.downloadedParents
.where(
(element) {
return _offlineSearch(
item: element.item,
searchTerm: widget.searchTerm!,
tabContentType: widget.tabContentType);
},
)
.map((e) => e.item)
.toList();
}
}
offlineSortedItems!.sort((a, b) {
// if (a.name == null || b.name == null) {
// // Returning 0 is the same as both being the same
// return 0;
// } else {
// return a.name!.compareTo(b.name!);
// }
if (a.nameForSorting == null || b.nameForSorting == null) {
// Returning 0 is the same as both being the same
return 0;
} else {
switch (widget.sortBy) {
case SortBy.sortName:
if (a.nameForSorting == null || b.nameForSorting == null) {
// Returning 0 is the same as both being the same
return 0;
} else {
return a.nameForSorting!.compareTo(b.nameForSorting!);
}
case SortBy.albumArtist:
if (a.albumArtist == null || b.albumArtist == null) {
return 0;
} else {
return a.albumArtist!.compareTo(b.albumArtist!);
}
case SortBy.communityRating:
if (a.communityRating == null ||
b.communityRating == null) {
return 0;
} else {
return a.communityRating!.compareTo(b.communityRating!);
}
case SortBy.criticRating:
if (a.criticRating == null || b.criticRating == null) {
return 0;
} else {
return a.criticRating!.compareTo(b.criticRating!);
}
case SortBy.dateCreated:
if (a.dateCreated == null || b.dateCreated == null) {
return 0;
} else {
return a.dateCreated!.compareTo(b.dateCreated!);
}
case SortBy.premiereDate:
if (a.premiereDate == null || b.premiereDate == null) {
return 0;
} else {
return a.premiereDate!.compareTo(b.premiereDate!);
}
case SortBy.random:
// We subtract the result by one so that we can get -1 values
// (see comareTo documentation)
return Random().nextInt(2) - 1;
default:
throw UnimplementedError(
"Unimplemented offline sort mode ${widget.sortBy}");
}
}
});
if (widget.sortOrder == SortOrder.descending) {
// The above sort functions sort in ascending order, so we swap them
// when sorting in descending order.
offlineSortedItems = offlineSortedItems!.reversed.toList();
}
}
return Scrollbar(
controller: controller,
child: Stack(
children: [
box.get("FinampSettings")!.contentViewType ==
ContentViewType.list
? ListView.builder(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: offlineSortedItems!.length,
key: UniqueKey(),
controller: controller,
itemBuilder: (context, index) {
if (widget.tabContentType == TabContentType.songs) {
return SongListTile(
item: offlineSortedItems![index],
isSong: true,
);
} else {
return AlbumItem(
album: offlineSortedItems![index],
parentType: _getParentType(),
);
}
},
)
: GridView.builder(
itemCount: offlineSortedItems!.length,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
controller: controller,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).size.width >
MediaQuery.of(context).size.height
? box
.get("FinampSettings")!
.contentGridViewCrossAxisCountLandscape
: box
.get("FinampSettings")!
.contentGridViewCrossAxisCountPortrait,
),
itemBuilder: (context, index) {
if (widget.tabContentType == TabContentType.songs) {
return SongListTile(
item: offlineSortedItems![index],
isSong: true,
);
} else {
return AlbumItem(
album: offlineSortedItems![index],
parentType: _getParentType(),
isGrid: true,
gridAddSettingsListener: false,
);
}
},
),
box.get("FinampSettings")!.showFastScroller &&
widget.sortBy == SortBy.sortName
? AlphabetList(
callback: scrollToLetter, sortOrder: lastSortOrder)
: const SizedBox.shrink(),
],
),
);
} else {
// If the searchTerm argument is different to lastSearch, the user has changed their search input.
// This makes albumViewFuture search again so that results with the search are shown.
// This also means we don't redo a search unless we actaully need to.
if (widget.searchTerm != _lastSearch ||
_pagingController.itemList == null ||
widget.isFavourite != _oldIsFavourite ||
widget.sortBy != _oldSortBy ||
widget.sortOrder != _oldSortOrder ||
widget.view != _oldView) {
_lastSearch = widget.searchTerm;
_oldIsFavourite = widget.isFavourite;
_oldSortBy = widget.sortBy;
_oldSortOrder = widget.sortOrder;
_oldView = widget.view;
_pagingController.refresh();
}
return RefreshIndicator(
// RefreshIndicator wants an async function, so we use Future.sync()
// to run refresh() inside an async function
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: Scrollbar(
controller: controller,
child: Stack(
children: [
box.get("FinampSettings")!.contentViewType ==
ContentViewType.list
? PagedListView<int, BaseItemDto>.separated(
pagingController: _pagingController,
scrollController: controller,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
builderDelegate:
PagedChildBuilderDelegate<BaseItemDto>(
itemBuilder: (context, item, index) {
if (widget.tabContentType ==
TabContentType.songs) {
return SongListTile(
item: item,
isSong: true,
);
} else if (widget.tabContentType ==
TabContentType.artists) {
return ArtistListTile(item: item);
} else {
return AlbumItem(
album: item,
parentType: _getParentType(),
);
}
},
firstPageProgressIndicatorBuilder: (_) =>
const FirstPageProgressIndicator(),
newPageProgressIndicatorBuilder: (_) =>
const NewPageProgressIndicator(),
),
separatorBuilder: (context, index) => SizedBox(
height: widget.tabContentType ==
TabContentType.artists ||
widget.tabContentType ==
TabContentType.genres
? 16.0
: 0.0,
),
)
: PagedGridView(
pagingController: _pagingController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
scrollController: controller,
builderDelegate:
PagedChildBuilderDelegate<BaseItemDto>(
itemBuilder: (context, item, index) {
if (widget.tabContentType ==
TabContentType.songs) {
return SongListTile(
item: item,
isSong: true,
);
} else {
return AlbumItem(
album: item,
parentType: _getParentType(),
isGrid: true,
gridAddSettingsListener: false,
);
}
},
firstPageProgressIndicatorBuilder: (_) =>
const FirstPageProgressIndicator(),
newPageProgressIndicatorBuilder: (_) =>
const NewPageProgressIndicator(),
),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).size.width >
MediaQuery.of(context).size.height
? box
.get("FinampSettings")!
.contentGridViewCrossAxisCountLandscape
: box
.get("FinampSettings")!
.contentGridViewCrossAxisCountPortrait,
),
),
box.get("FinampSettings")!.showFastScroller &&
widget.sortBy == SortBy.sortName
? AlphabetList(
callback: scrollToLetter, sortOrder: lastSortOrder)
: const SizedBox.shrink(),
],
),
),
);
}
},
);
}
}
String _includeItemTypes(TabContentType tabContentType) {
switch (tabContentType) {
case TabContentType.songs:
return "Audio";
case TabContentType.albums:
return "MusicAlbum";
case TabContentType.artists:
return "MusicArtist";
case TabContentType.genres:
return "MusicGenre";
case TabContentType.playlists:
return "Playlist";
default:
throw const FormatException("Unsupported TabContentType");
}
}
bool _offlineSearch(
{required BaseItemDto item,
required String searchTerm,
required TabContentType tabContentType}) {
late bool containsName;
// This horrible thing is for null safety
if (item.name == null) {
containsName = false;
} else {
containsName = item.name!.toLowerCase().contains(searchTerm.toLowerCase());
}
return item.type == _includeItemTypes(tabContentType) && containsName;
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class OfflineModeSwitchListTile extends StatelessWidget {
const OfflineModeSwitchListTile({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, widget) {
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.offlineMode),
secondary: const Icon(Icons.cloud_off),
value: box.get("FinampSettings")?.isOffline ?? false,
onChanged: (value) {
FinampSettingsHelper.setIsOffline(value);
},
);
},
);
}
}
@@ -0,0 +1,37 @@
import 'package:finamp/models/finamp_models.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import '../../services/finamp_settings_helper.dart';
class SortByMenuButton extends StatelessWidget {
const SortByMenuButton(this.tabType, {Key? key}) : super(key: key);
final TabContentType tabType;
@override
Widget build(BuildContext context) {
return PopupMenuButton<SortBy>(
icon: const Icon(Icons.sort),
tooltip: AppLocalizations.of(context)!.sortBy,
itemBuilder: (context) => [
for (SortBy sortBy in SortBy.defaults)
PopupMenuItem(
value: sortBy,
child: Text(
sortBy.toLocalisedString(context),
style: TextStyle(
color:
FinampSettingsHelper.finampSettings.getTabSortBy(tabType) ==
sortBy
? Theme.of(context).colorScheme.secondary
: null,
),
),
)
],
onSelected: (value) => FinampSettingsHelper.setSortBy(tabType, value),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../../services/finamp_settings_helper.dart';
class SortOrderButton extends StatelessWidget {
const SortOrderButton(this.tabType, {Key? key}) : super(key: key);
final TabContentType tabType;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, _) {
final finampSettings = box.get("FinampSettings");
return IconButton(
tooltip: AppLocalizations.of(context)!.sortOrder,
icon: finampSettings!.getSortOrder(tabType) == SortOrder.ascending
? const Icon(Icons.arrow_downward)
: const Icon(Icons.arrow_upward),
onPressed: () {
if (finampSettings.getSortOrder(tabType) == SortOrder.ascending) {
FinampSettingsHelper.setSortOrder(tabType,SortOrder.descending);
} else {
FinampSettingsHelper.setSortOrder(tabType, SortOrder.ascending);
}
},
);
},
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/finamp_user_helper.dart';
import '../view_icon.dart';
class ViewListTile extends StatelessWidget {
const ViewListTile({Key? key, required this.view}) : super(key: key);
final BaseItemDto view;
@override
Widget build(BuildContext context) {
final finampUserHelper = GetIt.instance<FinampUserHelper>();
return ListTile(
leading: ViewIcon(
collectionType: view.collectionType,
color: finampUserHelper.currentUser!.currentViewId == view.id
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
view.name ?? "Unknown Name",
style: TextStyle(
color: finampUserHelper.currentUser!.currentViewId == view.id
? Theme.of(context).colorScheme.primary
: null,
),
),
onTap: () => finampUserHelper.setCurrentUserCurrentViewId(view.id),
);
}
}
@@ -0,0 +1,40 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/music_player_background_task.dart';
import '../../screens/add_to_playlist_screen.dart';
class AddToPlaylistButton extends StatelessWidget {
const AddToPlaylistButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
return StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (snapshot.hasData) {
return IconButton(
onPressed: () => Navigator.of(context).pushReplacementNamed(
AddToPlaylistScreen.routeName,
arguments:
BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"])
.id),
icon: const Icon(Icons.playlist_add),
tooltip: AppLocalizations.of(context)!.addToPlaylistTooltip,
);
} else {
return IconButton(
icon: const Icon(Icons.playlist_add),
onPressed: null,
tooltip: AppLocalizations.of(context)!.addToPlaylistTooltip,
);
}
},
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:audio_service/audio_service.dart';
import 'package:get_it/get_it.dart';
import '../../services/music_player_background_task.dart';
class PlaybackMode extends StatelessWidget {
const PlaybackMode({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
return StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (snapshot.hasData) {
late String onlineOrOffline;
late String transcodeOrDirect;
if (snapshot.data!.extras!["downloadedSongJson"] == null) {
onlineOrOffline = AppLocalizations.of(context)!.streaming;
} else {
onlineOrOffline = AppLocalizations.of(context)!.downloaded;
}
if (snapshot.data!.extras!["shouldTranscode"] &&
snapshot.data!.extras!["downloadedSongJson"] == null) {
transcodeOrDirect = AppLocalizations.of(context)!.transcode;
} else {
transcodeOrDirect = AppLocalizations.of(context)!.direct;
}
return Text(
"$onlineOrOffline\n$transcodeOrDirect",
style: Theme.of(context).textTheme.bodySmall,
);
} else if (snapshot.hasError) {
return Text(
AppLocalizations.of(context)!.statusError,
style: Theme.of(context).textTheme.bodySmall,
);
} else {
return Text(
AppLocalizations.of(context)!.noItem.toUpperCase(),
style: Theme.of(context).textTheme.bodySmall,
);
}
},
);
}
}
@@ -0,0 +1,146 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../services/music_player_background_task.dart';
class PlayerButtons extends StatelessWidget {
const PlayerButtons({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
return StreamBuilder<PlaybackState>(
stream: audioHandler.playbackState,
builder: (context, snapshot) {
final PlaybackState? playbackState = snapshot.data;
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
textDirection: TextDirection.ltr,
children: [
IconButton(
tooltip: playbackState?.shuffleMode == AudioServiceShuffleMode.all
? AppLocalizations.of(context)!.playbackOrderShuffledTooltip
: AppLocalizations.of(context)!.playbackOrderLinearTooltip,
icon: _getShufflingIcon(
playbackState == null
? AudioServiceShuffleMode.none
: playbackState.shuffleMode,
Theme.of(context).colorScheme.secondary,
),
onPressed: playbackState != null
? () async {
if (playbackState.shuffleMode ==
AudioServiceShuffleMode.all) {
await audioHandler
.setShuffleMode(AudioServiceShuffleMode.none);
} else {
await audioHandler
.setShuffleMode(AudioServiceShuffleMode.all);
}
}
: null,
iconSize: 20,
),
IconButton(
tooltip: AppLocalizations.of(context)!.skipToPrevious,
icon: const Icon(Icons.skip_previous),
onPressed: playbackState != null
? () async => await audioHandler.skipToPrevious()
: null,
iconSize: 36,
),
SizedBox(
height: 56,
width: 56,
child: FloatingActionButton(
tooltip: AppLocalizations.of(context)!.togglePlayback,
// We set a heroTag because otherwise the play button on AlbumScreenContent will do hero widget stuff
heroTag: "PlayerScreenFAB",
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
splashColor:
Theme.of(context).colorScheme.onPrimary.withOpacity(0.24),
onPressed: playbackState != null
? () async {
if (playbackState.playing) {
await audioHandler.pause();
} else {
await audioHandler.play();
}
}
: null,
child: Icon(
playbackState == null || playbackState.playing
? Icons.pause
: Icons.play_arrow,
size: 36,
),
),
),
IconButton(
tooltip: AppLocalizations.of(context)!.skipToNext,
icon: const Icon(Icons.skip_next),
onPressed: playbackState != null
? () async => audioHandler.skipToNext()
: null,
iconSize: 36),
IconButton(
tooltip: playbackState?.repeatMode == AudioServiceRepeatMode.all
? AppLocalizations.of(context)!.loopModeAllTooltip
: playbackState?.repeatMode == AudioServiceRepeatMode.one
? AppLocalizations.of(context)!.loopModeOneTooltip
: AppLocalizations.of(context)!.loopModeNoneTooltip,
icon: _getRepeatingIcon(
playbackState == null
? AudioServiceRepeatMode.none
: playbackState.repeatMode,
Theme.of(context).colorScheme.secondary,
),
onPressed: playbackState != null
? () async {
// Cyles from none -> all -> one
if (playbackState.repeatMode ==
AudioServiceRepeatMode.none) {
await audioHandler
.setRepeatMode(AudioServiceRepeatMode.all);
} else if (playbackState.repeatMode ==
AudioServiceRepeatMode.all) {
await audioHandler
.setRepeatMode(AudioServiceRepeatMode.one);
} else {
await audioHandler
.setRepeatMode(AudioServiceRepeatMode.none);
}
}
: null,
iconSize: 20,
),
],
);
},
);
}
Widget _getRepeatingIcon(
AudioServiceRepeatMode repeatMode, Color iconColour) {
if (repeatMode == AudioServiceRepeatMode.all) {
return Icon(Icons.repeat, color: iconColour);
} else if (repeatMode == AudioServiceRepeatMode.one) {
return Icon(Icons.repeat_one, color: iconColour);
} else {
return const Icon(Icons.repeat);
}
}
Icon _getShufflingIcon(
AudioServiceShuffleMode shuffleMode, Color iconColour) {
if (shuffleMode == AudioServiceShuffleMode.all) {
return Icon(Icons.shuffle, color: iconColour);
} else {
return const Icon(Icons.shuffle);
}
}
}
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../services/music_player_background_task.dart';
import '../../services/progress_state_stream.dart';
import '../print_duration.dart';
class ProgressSlider extends StatefulWidget {
const ProgressSlider({
Key? key,
this.allowSeeking = true,
this.showBuffer = true,
this.showDuration = true,
this.showPlaceholder = true,
}) : super(key: key);
final bool allowSeeking;
final bool showBuffer;
final bool showDuration;
final bool showPlaceholder;
@override
State<ProgressSlider> createState() => _ProgressSliderState();
}
class _ProgressSliderState extends State<ProgressSlider> {
/// Value used to hold the slider's value when dragging.
double? _dragValue;
late SliderThemeData _sliderThemeData;
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
@override
void didChangeDependencies() {
super.didChangeDependencies();
_sliderThemeData = SliderTheme.of(context).copyWith(
trackHeight: 4.0,
inactiveTrackColor:
Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
);
}
@override
Widget build(BuildContext context) {
// The slider always needs to be LTR, so we use Directionality to save
// putting TextDirection.ltr all over the place
return Directionality(
textDirection: TextDirection.ltr,
// The slider can refresh up to 60 times per second, so we wrap it in a
// RepaintBoundary to avoid more areas being repainted than necessary
child: RepaintBoundary(
child: StreamBuilder<ProgressState>(
stream: progressStateStream,
builder: (context, snapshot) {
if (snapshot.data?.mediaItem == null) {
// If nothing is playing or the AudioService isn't connected, return a
// greyed out slider with some fake numbers. We also do this if
// currentPosition is null, which sometimes happens when the app is
// closed and reopened.
return widget.showPlaceholder
? Column(
children: [
SliderTheme(
data: _sliderThemeData.copyWith(
trackShape: CustomTrackShape(),
),
child: const Slider(
value: 0,
max: 1,
onChanged: null,
),
),
if (widget.showDuration)
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"00:00",
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color),
),
Text(
"00:00",
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color),
),
],
),
],
)
: const SizedBox.shrink();
} else if (snapshot.hasData) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Slider displaying playback and buffering progress.
SliderTheme(
data: widget.allowSeeking
? _sliderThemeData.copyWith(
trackShape: CustomTrackShape(),
)
: _sliderThemeData.copyWith(
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 0),
// gets rid of both horizontal and vertical padding
overlayShape:
const RoundSliderOverlayShape(overlayRadius: 0),
trackShape: const RectangularSliderTrackShape(),
),
child: Slider(
min: 0.0,
max: snapshot.data!.mediaItem?.duration == null
? snapshot.data!.playbackState.bufferedPosition
.inMicroseconds
.toDouble()
: snapshot.data!.mediaItem!.duration!.inMicroseconds
.toDouble(),
value: (_dragValue ??
snapshot.data!.position.inMicroseconds)
.clamp(
0,
snapshot.data!.mediaItem!.duration!.inMicroseconds
.toDouble())
.toDouble(),
secondaryTrackValue: widget.showBuffer &&
snapshot.data!.mediaItem
?.extras?["downloadedSongJson"] ==
null
? snapshot.data!.playbackState.bufferedPosition
.inMicroseconds
.clamp(
0.0,
snapshot.data!.mediaItem!.duration == null
? snapshot.data!.playbackState
.bufferedPosition.inMicroseconds
: snapshot.data!.mediaItem!.duration!
.inMicroseconds,
)
.toDouble()
: 0,
onChanged: widget.allowSeeking
? (newValue) async {
// We don't actually tell audio_service to seek here
// because it would get flooded with seek requests
setState(() {
_dragValue = newValue;
});
}
: (_) {},
onChangeStart: widget.allowSeeking
? (value) {
setState(() {
_dragValue = value;
});
}
: (_) {},
onChangeEnd: widget.allowSeeking
? (newValue) async {
// Seek to the new position
await _audioHandler.seek(
Duration(microseconds: newValue.toInt()));
// Clear drag value so that the slider uses the play
// duration again.
setState(() {
_dragValue = null;
});
}
: (_) {},
),
),
if (widget.showDuration)
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
printDuration(
Duration(
microseconds: _dragValue?.toInt() ??
snapshot.data!.position.inMicroseconds),
),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color),
),
Text(
printDuration(snapshot.data!.mediaItem?.duration),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.textTheme
.bodySmall
?.color),
),
],
),
],
);
} else {
return const Text(
"Snapshot doesn't have data and MediaItem isn't null and AudioService is connected?");
}
},
),
),
);
}
}
class PositionData {
final Duration position;
final Duration bufferedPosition;
PositionData(this.position, this.bufferedPosition);
}
/// Track shape used to remove horizontal padding.
/// https://github.com/flutter/flutter/issues/37057
class CustomTrackShape extends RoundedRectSliderTrackShape {
@override
Rect getPreferredRect({
required RenderBox parentBox,
Offset offset = Offset.zero,
required SliderThemeData sliderTheme,
bool isEnabled = false,
bool isDiscrete = false,
}) {
final double trackHeight = sliderTheme.trackHeight!;
final double trackLeft = offset.dx;
final double trackTop =
offset.dy + (parentBox.size.height - trackHeight) / 2;
final double trackWidth = parentBox.size.width;
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
}
/// Disable additionalActiveTrackHeight
@override
void paint(
PaintingContext context,
Offset offset, {
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required Animation<double> enableAnimation,
required TextDirection textDirection,
required Offset thumbCenter,
Offset? secondaryOffset,
bool isDiscrete = false,
bool isEnabled = false,
double additionalActiveTrackHeight = 0,
}) {
super.paint(
context,
offset,
parentBox: parentBox,
sliderTheme: sliderTheme,
enableAnimation: enableAnimation,
textDirection: textDirection,
thumbCenter: thumbCenter,
secondaryOffset: secondaryOffset,
isDiscrete: isDiscrete,
isEnabled: isEnabled,
additionalActiveTrackHeight: additionalActiveTrackHeight,
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'queue_list.dart';
class QueueButton extends StatelessWidget {
const QueueButton({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.queue_music),
tooltip: AppLocalizations.of(context)!.queue,
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24.0)),
),
context: context,
builder: (context) {
return DraggableScrollableSheet(
expand: false,
builder: (context, scrollController) {
return QueueList(
scrollController: scrollController,
);
},
);
},
);
});
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:rxdart/rxdart.dart';
import '../../services/finamp_settings_helper.dart';
import '../album_image.dart';
import '../../models/jellyfin_models.dart';
import '../../services/process_artist.dart';
import '../../services/media_state_stream.dart';
import '../../services/music_player_background_task.dart';
class _QueueListStreamState {
_QueueListStreamState(
this.queue,
this.mediaState,
);
final List<MediaItem>? queue;
final MediaState mediaState;
}
class QueueList extends StatefulWidget {
const QueueList({Key? key, required this.scrollController}) : super(key: key);
final ScrollController scrollController;
@override
State<QueueList> createState() => _QueueListState();
}
class _QueueListState extends State<QueueList> {
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
List<MediaItem>? _queue;
@override
Widget build(BuildContext context) {
return StreamBuilder<_QueueListStreamState>(
// stream: AudioService.queueStream,
stream: Rx.combineLatest2<List<MediaItem>?, MediaState,
_QueueListStreamState>(_audioHandler.queue, mediaStateStream,
(a, b) => _QueueListStreamState(a, b)),
builder: (context, snapshot) {
if (snapshot.hasData) {
_queue ??= snapshot.data!.queue;
return PrimaryScrollController(
controller: widget.scrollController,
child: ReorderableListView.builder(
itemCount: snapshot.data!.queue?.length ?? 0,
onReorder: (oldIndex, newIndex) async {
setState(() {
// _queue?.insert(newIndex, _queue![oldIndex]);
// _queue?.removeAt(oldIndex);
int? smallerThanNewIndex;
if (oldIndex < newIndex) {
// When we're moving an item backwards, we need to reduce
// newIndex by 1 to account for there being a new item added
// before newIndex.
smallerThanNewIndex = newIndex - 1;
}
final item = _queue?.removeAt(oldIndex);
_queue?.insert(smallerThanNewIndex ?? newIndex, item!);
});
await _audioHandler.reorderQueue(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final actualIndex =
_audioHandler.playbackState.valueOrNull?.shuffleMode ==
AudioServiceShuffleMode.all
? _audioHandler.shuffleIndices![index]
: index;
return Dismissible(
key: ValueKey(snapshot.data!.queue![actualIndex].id),
direction: FinampSettingsHelper.finampSettings.disableGesture
? DismissDirection.none
: DismissDirection.horizontal,
onDismissed: (direction) async {
await _audioHandler.removeQueueItemAt(actualIndex);
},
child: ListTile(
leading: AlbumImage(
item: snapshot.data!.queue?[actualIndex]
.extras?["itemJson"] ==
null
? null
: BaseItemDto.fromJson(snapshot
.data!.queue?[actualIndex].extras?["itemJson"]),
),
title: Text(
snapshot.data!.queue?[actualIndex].title ??
AppLocalizations.of(context)!.unknownName,
style: snapshot.data!.mediaState.mediaItem ==
snapshot.data!.queue?[actualIndex]
? TextStyle(
color: Theme.of(context).colorScheme.secondary)
: null),
subtitle: Text(processArtist(
snapshot.data!.queue?[actualIndex].artist, context)),
onTap: () async =>
await _audioHandler.skipToIndex(actualIndex),
),
);
},
),
);
} else {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
},
);
}
}
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/music_player_background_task.dart';
import 'sleep_timer_dialog.dart';
import 'sleep_timer_cancel_dialog.dart';
class SleepTimerButton extends StatelessWidget {
const SleepTimerButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
return ValueListenableBuilder<Timer?>(
valueListenable: audioHandler.sleepTimer,
builder: (context, value, child) {
return IconButton(
icon: value == null
? const Icon(Icons.mode_night_outlined)
: const Icon(Icons.mode_night),
tooltip: AppLocalizations.of(context)!.sleepTimerTooltip,
onPressed: () async {
if (value != null) {
showDialog(
context: context,
builder: (context) => const SleepTimerCancelDialog(),
);
} else {
await showDialog(
context: context,
builder: (context) => const SleepTimerDialog(),
);
}
});
},
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/music_player_background_task.dart';
class SleepTimerCancelDialog extends StatelessWidget {
const SleepTimerCancelDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
return AlertDialog(
title: Text(AppLocalizations.of(context)!.cancelSleepTimer),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.noButtonLabel),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text(AppLocalizations.of(context)!.yesButtonLabel),
onPressed: () {
audioHandler.clearSleepTimer();
Navigator.of(context).pop();
},
)
],
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../services/music_player_background_task.dart';
import '../../services/finamp_settings_helper.dart';
class SleepTimerDialog extends StatefulWidget {
const SleepTimerDialog({Key? key}) : super(key: key);
@override
State<SleepTimerDialog> createState() => _SleepTimerDialogState();
}
class _SleepTimerDialogState extends State<SleepTimerDialog> {
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
final _textController = TextEditingController(
text: (FinampSettingsHelper.finampSettings.sleepTimerSeconds ~/ 60)
.toString());
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.setSleepTimer),
content: Form(
key: _formKey,
child: Row(
children: [
Expanded(
child: TextFormField(
controller: _textController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.minutes),
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)!.required;
}
if (int.tryParse(value) == null) {
return AppLocalizations.of(context)!.invalidNumber;
}
return null;
},
onSaved: (value) {
final valueInt = int.parse(value!);
_audioHandler.setSleepTimer(Duration(minutes: valueInt));
FinampSettingsHelper.setSleepTimerSeconds(valueInt * 60);
},
),
),
],
),
),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text(MaterialLocalizations.of(context).okButtonLabel),
onPressed: () {
if (_formKey.currentState?.validate() == true) {
_formKey.currentState!.save();
Navigator.of(context).pop();
}
},
)
],
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:audio_service/audio_service.dart';
import 'package:finamp/models/jellyfin_models.dart';
import 'package:finamp/screens/artist_screen.dart';
import 'package:finamp/services/finamp_settings_helper.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../screens/album_screen.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/music_player_background_task.dart';
import '../artists_text_spans.dart';
/// Creates some text that shows the song's name, album and the artist.
class SongName extends StatelessWidget {
const SongName({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
final JellyfinApiHelper jellyfinApiHelper =
GetIt.instance<JellyfinApiHelper>();
final textColour =
Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6);
return StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (snapshot.hasData) {
final MediaItem mediaItem = snapshot.data!;
BaseItemDto songBaseItemDto =
BaseItemDto.fromJson(mediaItem.extras!["itemJson"]);
List<TextSpan> separatedArtistTextSpans = [];
if (songBaseItemDto.artistItems?.isEmpty ?? true) {
separatedArtistTextSpans = [
TextSpan(
text: AppLocalizations.of(context)!.unknownArtist,
style: TextStyle(color: textColour),
)
];
} else {
songBaseItemDto.artistItems
?.map((e) => TextSpan(
text: e.name,
style: TextStyle(color: textColour),
recognizer: TapGestureRecognizer()
..onTap = () {
// Offline artists aren't implemented yet so we return if
// offline
if (FinampSettingsHelper.finampSettings.isOffline) {
return;
}
jellyfinApiHelper.getItemById(e.id).then((artist) =>
Navigator.of(context).popAndPushNamed(
ArtistScreen.routeName,
arguments: artist));
}))
.forEach((artistTextSpan) {
separatedArtistTextSpans.add(artistTextSpan);
separatedArtistTextSpans.add(TextSpan(
text: ", ",
style: TextStyle(color: textColour),
));
});
separatedArtistTextSpans.removeLast();
}
return SongNameContent(
songBaseItemDto: songBaseItemDto,
mediaItem: mediaItem,
separatedArtistTextSpans: ArtistsTextSpans(
songBaseItemDto,
textColour,
context,
true,
));
}
return const SongNameContent(
songBaseItemDto: null,
mediaItem: null,
separatedArtistTextSpans: [],
);
},
);
}
}
class SongNameContent extends StatelessWidget {
const SongNameContent({
Key? key,
required this.songBaseItemDto,
required this.mediaItem,
required this.separatedArtistTextSpans,
}) : super(key: key);
final BaseItemDto? songBaseItemDto;
final MediaItem? mediaItem;
final List<TextSpan> separatedArtistTextSpans;
@override
Widget build(BuildContext context) {
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final textColour =
Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6);
return Padding(
// I don't know why but 12 is the magic number that lines up with the
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: songBaseItemDto == null
? null
: () => jellyfinApiHelper
.getItemById(songBaseItemDto!.albumId as String)
.then((album) => Navigator.of(context).popAndPushNamed(
AlbumScreen.routeName,
arguments: album)),
child: Text(
mediaItem == null
? AppLocalizations.of(context)!.noAlbum
: mediaItem!.album ?? AppLocalizations.of(context)!.noAlbum,
style: TextStyle(color: textColour),
),
),
const Padding(padding: EdgeInsets.symmetric(vertical: 2)),
Text(
mediaItem == null
? AppLocalizations.of(context)!.noItem
: mediaItem!.title,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
const Padding(padding: EdgeInsets.symmetric(vertical: 2)),
RichText(
text: TextSpan(
children: mediaItem == null || mediaItem!.artist == null
? [
TextSpan(
text: AppLocalizations.of(context)!.noArtist,
style: TextStyle(color: textColour),
)
]
: separatedArtistTextSpans,
),
),
],
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../screens/splash_screen.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/music_player_background_task.dart';
import '../error_snackbar.dart';
class LogoutListTile extends StatefulWidget {
const LogoutListTile({Key? key}) : super(key: key);
@override
State<LogoutListTile> createState() => _LogoutListTileState();
}
class _LogoutListTileState extends State<LogoutListTile> {
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
Icons.logout,
color:
FinampSettingsHelper.finampSettings.isOffline ? null : Colors.red,
),
title: Text(
AppLocalizations.of(context)!.logOut,
style: FinampSettingsHelper.finampSettings.isOffline
? null
: const TextStyle(
color: Colors.red,
),
),
subtitle: FinampSettingsHelper.finampSettings.isOffline
? Text(AppLocalizations.of(context)!.notAvailableInOfflineMode)
: Text(
AppLocalizations.of(context)!.downloadedSongsWillNotBeDeleted,
style: const TextStyle(color: Colors.red),
),
enabled: !FinampSettingsHelper.finampSettings.isOffline,
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.areYouSure),
actions: [
TextButton(
child:
Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text(MaterialLocalizations.of(context).okButtonLabel),
onPressed: () async {
try {
final audioHandler =
GetIt.instance<MusicPlayerBackgroundTask>();
// We don't want audio to be playing after we log out.
// We check if the audio service is running on iOS because
// stop() never completes if the service is not running.
if (audioHandler.playbackState.valueOrNull?.playing ==
true) {
await audioHandler.stop();
}
final jellyfinApiHelper =
GetIt.instance<JellyfinApiHelper>();
await jellyfinApiHelper
.logoutCurrentUser()
.onError((_, __) {});
if (!mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil(
SplashScreen.routeName, (route) => false);
} catch (e) {
errorSnackbar(e, context);
return;
}
},
),
],
),
);
},
);
}
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class HideTabToggle extends StatelessWidget {
const HideTabToggle({
Key? key,
required this.tabContentType,
}) : super(key: key);
final TabContentType tabContentType;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (_, box, __) {
return SwitchListTile.adaptive(
title: Text(tabContentType.toLocalisedString(context)),
// secondary: const Icon(Icons.drag_handle),
// This should never be null, but it gets set to true if it is.
value: FinampSettingsHelper.finampSettings.showTabs[tabContentType] ??
true,
onChanged: (value) =>
FinampSettingsHelper.setShowTab(tabContentType, value),
);
},
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class BitrateSelector extends StatelessWidget {
const BitrateSelector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
ListTile(
title: Text(AppLocalizations.of(context)!.bitrate),
subtitle: Text(AppLocalizations.of(context)!.bitrateSubtitle),
),
ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, child) {
final finampSettings = box.get("FinampSettings")!;
// We do all of this division/multiplication because Jellyfin wants us to specify bitrates in bits, not kilobits.
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Slider(
min: 64,
max: 320,
value: finampSettings.transcodeBitrate / 1000,
divisions: 8,
label: "${finampSettings.transcodeBitrate ~/ 1000}kbps",
onChanged: (value) {
FinampSettingsHelper.setTranscodeBitrate(
(value * 1000).toInt());
},
),
Text(
"${finampSettings.transcodeBitrate ~/ 1000}kbps",
style: Theme.of(context).textTheme.titleLarge,
)
],
);
},
),
],
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hive/hive.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/finamp_models.dart';
class TranscodeSwitch extends StatelessWidget {
const TranscodeSwitch({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, child) {
bool? shouldTranscode = box.get("FinampSettings")?.shouldTranscode;
return SwitchListTile.adaptive(
title: Text(AppLocalizations.of(context)!.enableTranscoding),
subtitle:
Text(AppLocalizations.of(context)!.enableTranscodingSubtitle),
value: shouldTranscode ?? false,
onChanged: shouldTranscode == null
? null
: (value) {
FinampSettings finampSettingsTemp =
box.get("FinampSettings")!;
finampSettingsTemp.shouldTranscode = value;
box.put("FinampSettings", finampSettingsTemp);
},
);
},
);
}
}
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../screens/logs_screen.dart';
import '../../screens/view_selector.dart';
import '../../services/jellyfin_api_helper.dart';
import '../error_snackbar.dart';
class PrivateUserSignIn extends StatefulWidget {
const PrivateUserSignIn({Key? key}) : super(key: key);
@override
State<PrivateUserSignIn> createState() => _PrivateUserSignInState();
}
class _PrivateUserSignInState extends State<PrivateUserSignIn> {
bool isAuthenticating = false;
String? baseUrl;
String? username;
String? password;
final formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
// This variable is for handling shifting focus when the user presses submit.
// https://stackoverflow.com/questions/52150677/how-to-shift-focus-to-next-textfield-in-flutter
final node = FocusScope.of(context);
return SafeArea(
child: Stack(
children: [
Form(
key: formKey,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.serverUrl,
hintText: "http://0.0.0.0:8096",
border: const OutlineInputBorder(),
suffixIcon: IconButton(
color: Theme.of(context).iconTheme.color,
icon: const Icon(Icons.info),
onPressed: () => showDialog(
context: context,
builder: (context) => AlertDialog(
content: Text(AppLocalizations.of(context)!
.internalExternalIpExplanation),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context)
.okButtonLabel),
)
],
),
),
),
),
textInputAction: TextInputAction.next,
onEditingComplete: () => node.nextFocus(),
validator: (value) {
if (value?.isEmpty == true) {
return AppLocalizations.of(context)!.emptyServerUrl;
}
if (!value!.trim().startsWith("http://") &&
!value.trim().startsWith("https://")) {
return AppLocalizations.of(context)!
.urlStartWithHttps;
}
if (value.trim().endsWith("/")) {
return AppLocalizations.of(context)!.urlTrailingSlash;
}
return null;
},
onSaved: (newValue) => baseUrl = newValue,
),
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
autocorrect: false,
keyboardType: TextInputType.visiblePassword,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
textInputAction: TextInputAction.next,
onEditingComplete: () => node.nextFocus(),
onSaved: (newValue) => username = newValue,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
autocorrect: false,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.password,
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) async => await sendForm(),
onSaved: (newValue) => password = newValue,
),
),
),
],
),
],
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextButton(
onPressed: () =>
Navigator.of(context).pushNamed(LogsScreen.routeName),
child:
Text(AppLocalizations.of(context)!.logs.toUpperCase()),
),
ElevatedButton(
onPressed:
isAuthenticating ? null : () async => await sendForm(),
child:
Text(AppLocalizations.of(context)!.next.toUpperCase()),
),
],
),
),
)
],
),
);
}
/// Function to handle logging in for Widgets, including a snackbar for errors.
Future<void> loginHelper(
{required String username,
String? password,
required String baseUrl,
required BuildContext context}) async {
JellyfinApiHelper jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
// We trim the base url in case the user accidentally added some trailing whitespce
baseUrl = baseUrl.trim();
jellyfinApiHelper.baseUrlTemp = Uri.parse(baseUrl);
try {
if (password == null) {
await jellyfinApiHelper.authenticateViaName(username: username);
} else {
await jellyfinApiHelper.authenticateViaName(
username: username,
password: password,
);
}
if (!mounted) return;
Navigator.of(context).pushNamed(ViewSelector.routeName);
} catch (e) {
errorSnackbar(e, context);
// We return here to stop the function from continuing.
return;
}
}
Future<void> sendForm() async {
if (formKey.currentState?.validate() == true) {
formKey.currentState!.save();
setState(() {
isAuthenticating = true;
});
await loginHelper(
username: username!,
password: password,
baseUrl: baseUrl!,
context: context,
);
setState(() {
isAuthenticating = false;
});
}
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class NoMusicLibrariesMessage extends StatelessWidget {
const NoMusicLibrariesMessage({
super.key,
this.onRefresh,
});
final VoidCallback? onRefresh;
@override
Widget build(BuildContext context) {
return Center(
child: Scrollbar(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(
AppLocalizations.of(context)!.noMusicLibrariesTitle,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
AppLocalizations.of(context)!.noMusicLibrariesBody,
textAlign: TextAlign.center,
),
ElevatedButton(
onPressed: onRefresh,
child: Text(AppLocalizations.of(context)!.refresh))
],
),
),
),
),
);
}
}
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:octo_image/octo_image.dart';
import '../models/jellyfin_models.dart';
import '../services/album_image_provider.dart';
typedef ImageProviderCallback = void Function(ImageProvider? imageProvider);
/// This widget provides the default look for album images throughout Finamp -
/// Aspect ratio 1 with a circular border radius of 4. If you don't want these
/// customisations, use [BareAlbumImage] or get an [ImageProvider] directly
/// through [AlbumImageProvider.init].
class AlbumImage extends StatelessWidget {
const AlbumImage({
Key? key,
this.item,
this.imageProviderCallback,
this.itemsToPrecache,
}) : super(key: key);
/// The item to get an image for.
final BaseItemDto? item;
/// A callback to get the image provider once it has been fetched.
final ImageProviderCallback? imageProviderCallback;
/// A list of items to precache
final List<BaseItemDto>? itemsToPrecache;
static final BorderRadius borderRadius = BorderRadius.circular(4);
@override
Widget build(BuildContext context) {
if (item == null || item!.imageId == null) {
if (imageProviderCallback != null) {
imageProviderCallback!(null);
}
return ClipRRect(
borderRadius: borderRadius,
child: const AspectRatio(
aspectRatio: 1,
child: _AlbumImageErrorPlaceholder(),
),
);
}
return ClipRRect(
borderRadius: borderRadius,
child: AspectRatio(
aspectRatio: 1,
child: LayoutBuilder(builder: (context, constraints) {
// LayoutBuilder (and other pixel-related stuff in Flutter) returns logical pixels instead of physical pixels.
// While this is great for doing layout stuff, we want to get images that are the right size in pixels.
// Logical pixels aren't the same as the physical pixels on the device, they're quite a bit bigger.
// If we use logical pixels for the image request, we'll get a smaller image than we want.
// Because of this, we convert the logical pixels to physical pixels by multiplying by the device's DPI.
final MediaQueryData mediaQuery = MediaQuery.of(context);
final int physicalWidth =
(constraints.maxWidth * mediaQuery.devicePixelRatio).toInt();
final int physicalHeight =
(constraints.maxHeight * mediaQuery.devicePixelRatio).toInt();
return BareAlbumImage(
item: item!,
maxWidth: physicalWidth,
maxHeight: physicalHeight,
imageProviderCallback: imageProviderCallback,
itemsToPrecache: itemsToPrecache,
);
}),
),
);
}
}
/// An [AlbumImage] without any of the padding or media size detection.
class BareAlbumImage extends StatefulWidget {
const BareAlbumImage({
Key? key,
required this.item,
this.maxWidth,
this.maxHeight,
this.errorBuilder,
this.placeholderBuilder,
this.imageProviderCallback,
this.itemsToPrecache,
}) : super(key: key);
final BaseItemDto item;
final int? maxWidth;
final int? maxHeight;
final WidgetBuilder? placeholderBuilder;
final OctoErrorBuilder? errorBuilder;
final ImageProviderCallback? imageProviderCallback;
/// A list of items to precache
final List<BaseItemDto>? itemsToPrecache;
@override
State<BareAlbumImage> createState() => _BareAlbumImageState();
}
class _BareAlbumImageState extends State<BareAlbumImage> {
late Future<ImageProvider?> _albumImageContentFuture;
late WidgetBuilder _placeholderBuilder;
late OctoErrorBuilder _errorBuilder;
@override
void initState() {
super.initState();
_albumImageContentFuture = AlbumImageProvider.init(
widget.item,
maxWidth: widget.maxWidth,
maxHeight: widget.maxHeight,
itemsToPrecache: widget.itemsToPrecache,
context: context,
);
_placeholderBuilder = widget.placeholderBuilder ??
(context) => Container(
color: Theme.of(context).cardColor,
);
_errorBuilder = widget.errorBuilder ??
(context, _, __) => const _AlbumImageErrorPlaceholder();
}
// We need to do this so that the image changes when dependencies change, such
// as when used in the player screen.
@override
void didUpdateWidget(BareAlbumImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.item.imageId != oldWidget.item.imageId ||
widget.maxWidth != oldWidget.maxWidth ||
widget.maxHeight != oldWidget.maxHeight ||
widget.itemsToPrecache != oldWidget.itemsToPrecache) {
_albumImageContentFuture = AlbumImageProvider.init(
widget.item,
maxWidth: widget.maxWidth,
maxHeight: widget.maxHeight,
itemsToPrecache: widget.itemsToPrecache,
context: context,
);
}
_placeholderBuilder = widget.placeholderBuilder ??
(context) => Container(
color: Theme.of(context).cardColor,
);
_errorBuilder = widget.errorBuilder ??
(context, _, __) => const _AlbumImageErrorPlaceholder();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<ImageProvider?>(
future: _albumImageContentFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (widget.imageProviderCallback != null) {
widget.imageProviderCallback!(snapshot.data!);
}
return OctoImage(
image: snapshot.data!,
fit: BoxFit.cover,
placeholderBuilder: _placeholderBuilder,
errorBuilder: _errorBuilder,
);
}
if (snapshot.hasError) {
if (widget.imageProviderCallback != null) {
widget.imageProviderCallback!(null);
}
return const _AlbumImageErrorPlaceholder();
}
if (widget.imageProviderCallback != null) {
widget.imageProviderCallback!(null);
}
return Builder(builder: _placeholderBuilder);
},
);
}
}
class _AlbumImageErrorPlaceholder extends StatelessWidget {
const _AlbumImageErrorPlaceholder({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).cardColor,
child: const Icon(Icons.album),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:finamp/services/finamp_settings_helper.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../screens/artist_screen.dart';
List<TextSpan> ArtistsTextSpans(
BaseItemDto item,
Color? textColour,
BuildContext context,
bool popRoutes
) {
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
List<TextSpan> separatedArtistTextSpans = [];
List<NameIdPair>? artists =
item.type == "MusicAlbum"
? item.albumArtists
: item.artistItems;
if (artists?.isEmpty ?? true) {
separatedArtistTextSpans = [
TextSpan(
text: "Unknown Artist",
style: TextStyle(color: textColour),
)
];
} else {
artists
?.map((e) => TextSpan(
text: e.name,
style: TextStyle(color: textColour),
recognizer: TapGestureRecognizer()
..onTap = () {
// Offline artists aren't implemented yet so we return if offline
if (FinampSettingsHelper.finampSettings.isOffline) return;
jellyfinApiHelper.getItemById(e.id).then((artist) =>
popRoutes
? Navigator.of(context).popAndPushNamed(
ArtistScreen.routeName,
arguments: artist)
: Navigator.of(context).pushNamed(
ArtistScreen.routeName,
arguments: artist)
);
}))
.forEach((artistTextSpan) {
separatedArtistTextSpans.add(artistTextSpan);
separatedArtistTextSpans.add(TextSpan(
text: ", ",
style: TextStyle(color: textColour),
));
});
separatedArtistTextSpans.removeLast();
}
return separatedArtistTextSpans;
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class BlurredImage extends StatelessWidget {
const BlurredImage(this.image, {Key? key}) : super(key: key);
final ImageProvider image;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: image,
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
),
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../models/jellyfin_models.dart';
import '../services/downloads_helper.dart';
import 'error_snackbar.dart';
class ConfirmationPromptDialog extends AlertDialog {
const ConfirmationPromptDialog({
Key? key,
required this.promptText,
required this.confirmButtonText,
required this.abortButtonText,
required this.onConfirmed,
required this.onAborted,
}) : super(key: key);
final String promptText;
final String confirmButtonText;
final String abortButtonText;
final void Function()? onConfirmed;
final void Function()? onAborted;
@override
Widget build(BuildContext context) {
return AlertDialog(
buttonPadding: const EdgeInsets.all(0.0),
contentPadding: const EdgeInsets.all(0.0),
insetPadding: const EdgeInsets.all(32.0),
actionsPadding: const EdgeInsets.all(0.0),
actionsAlignment: MainAxisAlignment.spaceAround,
actionsOverflowAlignment: OverflowBarAlignment.center,
actionsOverflowDirection: VerticalDirection.up,
title: Text(
promptText,
style: const TextStyle(fontSize: 18),
),
actions: [
Container(
constraints: const BoxConstraints(
maxWidth: 150.0,
),
child: TextButton(
child: Text(abortButtonText,
textAlign: TextAlign.center,
),
onPressed: () {
Navigator.of(context).pop();
onAborted?.call();
},
),
),
Container(
constraints: const BoxConstraints(
maxWidth: 150.0,
),
child: TextButton(
child: Text(confirmButtonText,
textAlign: TextAlign.center,
softWrap: true,
),
onPressed: () {
Navigator.of(context).pop(); // Close the dialog
onConfirmed?.call();
},
),
),
],
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:chopper/chopper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
/// Snackbar with error icon for displaying errors
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> errorSnackbar(
dynamic error, BuildContext context) {
return ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.anErrorHasOccured),
action: SnackBarAction(
label: MaterialLocalizations.of(context).moreButtonTooltip,
onPressed: () => showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.error),
content: Text(_errorText(error, context)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
)
],
),
),
),
),
);
}
String _errorText(dynamic error, BuildContext context) {
if (error.runtimeType == Response) {
error = error as Response;
if (error.statusCode == 401) {
return AppLocalizations.of(context)!
.responseError401(error.error.toString(), error.statusCode);
} else {
return AppLocalizations.of(context)!
.responseError(error.error.toString(), error.statusCode);
}
} else {
return error.toString();
}
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:finamp/components/error_snackbar.dart';
import 'package:finamp/models/jellyfin_models.dart';
import 'package:finamp/services/jellyfin_api_helper.dart';
import 'package:finamp/services/music_player_background_task.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
class FavoriteButton extends StatefulWidget {
const FavoriteButton(
{Key? key,
required this.item,
this.onlyIfFav = false,
this.inPlayer = false})
: super(key: key);
final BaseItemDto? item;
final bool onlyIfFav;
final bool inPlayer;
@override
State<FavoriteButton> createState() => _FavoriteButtonState();
}
class _FavoriteButtonState extends State<FavoriteButton> {
@override
Widget build(BuildContext context) {
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
if (widget.item == null) {
return const SizedBox.shrink();
}
bool isFav = widget.item!.userData!.isFavorite;
if (widget.onlyIfFav) {
if (isFav) {
return Icon(
Icons.favorite,
color: Colors.red,
size: 24.0,
semanticLabel: AppLocalizations.of(context)!.favourite,
);
} else {
return const SizedBox.shrink();
}
} else {
return IconButton(
icon: Icon(
isFav ? Icons.favorite : Icons.favorite_outline,
color: isFav ? Colors.red : null,
size: 24.0,
),
tooltip: AppLocalizations.of(context)!.favourite,
onPressed: () async {
try {
UserItemDataDto? newUserData;
if (isFav) {
newUserData =
await jellyfinApiHelper.removeFavourite(widget.item!.id);
} else {
newUserData =
await jellyfinApiHelper.addFavourite(widget.item!.id);
}
setState(() {
widget.item!.userData = newUserData;
if (widget.inPlayer) {
audioHandler.mediaItem.valueOrNull!.extras!['itemJson'] =
widget.item!.toJson();
}
});
} catch (e) {
errorSnackbar(e, context);
}
},
);
}
}
}
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
// Taken from https://github.com/EdsonBueno/infinite_scroll_pagination/blob/983763669fe7247682cd64f0f8f5d6c6f96d75d3/lib/src/ui/default_indicators/first_page_progress_indicator.dart
// Made CircularProgressIndicator adaptive
class FirstPageProgressIndicator extends StatelessWidget {
const FirstPageProgressIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.all(32),
child: Center(
child: CircularProgressIndicator.adaptive(),
),
);
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/footer_tile.dart';
// Taken from https://github.com/EdsonBueno/infinite_scroll_pagination/blob/e1a826d5a06d8cfb6c2146658d59465ced4140cd/lib/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart
// Made CircularProgressIndicator adaptive
class NewPageProgressIndicator extends StatelessWidget {
const NewPageProgressIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => const FooterTile(
child: CircularProgressIndicator.adaptive(),
);
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:simple_gesture_detector/simple_gesture_detector.dart';
import '../services/finamp_settings_helper.dart';
import '../services/media_state_stream.dart';
import 'album_image.dart';
import '../models/jellyfin_models.dart';
import '../services/process_artist.dart';
import '../services/music_player_background_task.dart';
import '../screens/player_screen.dart';
import 'PlayerScreen/progress_slider.dart';
class NowPlayingBar extends StatelessWidget {
const NowPlayingBar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// BottomNavBar's default elevation is 8 (https://api.flutter.dev/flutter/material/BottomNavigationBar/elevation.html)
const elevation = 8.0;
final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor;
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
return SimpleGestureDetector(
onVerticalSwipe: (direction) {
if (direction == SwipeDirection.up) {
Navigator.of(context).pushNamed(PlayerScreen.routeName);
}
},
child: StreamBuilder<MediaState>(
stream: mediaStateStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
final playing = snapshot.data!.playbackState.playing;
// If we have a media item and the player hasn't finished, show
// the now playing bar.
if (snapshot.data!.mediaItem != null) {
final item = BaseItemDto.fromJson(
snapshot.data!.mediaItem!.extras!["itemJson"]);
return Material(
color: color,
elevation: elevation,
child: SafeArea(
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Stack(
children: [
const ProgressSlider(
allowSeeking: false,
showBuffer: false,
showDuration: false,
showPlaceholder: false,
),
Dismissible(
key: const Key("NowPlayingBar"),
direction: FinampSettingsHelper.finampSettings.disableGesture ? DismissDirection.none : DismissDirection.horizontal,
confirmDismiss: (direction) async {
if (direction == DismissDirection.endToStart) {
audioHandler.skipToNext();
} else {
audioHandler.skipToPrevious();
}
return false;
},
background: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AspectRatio(
aspectRatio: 1,
child: FittedBox(
fit: BoxFit.fitHeight,
child: Padding(
padding:
EdgeInsets.symmetric(vertical: 8.0),
child: Icon(Icons.skip_previous),
),
),
),
AspectRatio(
aspectRatio: 1,
child: FittedBox(
fit: BoxFit.fitHeight,
child: Padding(
padding:
EdgeInsets.symmetric(vertical: 8.0),
child: Icon(Icons.skip_next),
),
),
),
],
),
),
child: ListTile(
onTap: () => Navigator.of(context)
.pushNamed(PlayerScreen.routeName),
leading: AlbumImage(item: item),
title: Text(
snapshot.data!.mediaItem!.title,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.fade,
),
subtitle: Text(
processArtist(
snapshot.data!.mediaItem!.artist, context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (snapshot
.data!.playbackState.processingState !=
AudioProcessingState.idle)
IconButton(
// We have a key here because otherwise the
// InkWell moves over to the play/pause button
key: const ValueKey("StopButton"),
icon: const Icon(Icons.stop),
onPressed: () => audioHandler.stop(),
),
playing
? IconButton(
icon: const Icon(Icons.pause),
onPressed: () => audioHandler.pause(),
)
: IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: () => audioHandler.play(),
),
],
),
),
),
],
),
),
),
);
} else {
return const SizedBox.shrink();
}
} else {
return const SizedBox.shrink();
}
},
),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
/// Flutter doesn't have a nice way of formatting durations for some reason so I stole this code from StackOverflow
String printDuration(Duration? duration) {
if (duration == null) {
return "00:00";
}
String twoDigits(int n) => n.toString().padLeft(2, "0");
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
if (duration.inHours >= 1) {
String twoDigitHours = twoDigits(duration.inHours);
return "$twoDigitHours:$twoDigitMinutes:$twoDigitSeconds";
}
return "$twoDigitMinutes:$twoDigitSeconds";
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
class ViewIcon extends StatelessWidget {
const ViewIcon({
Key? key,
required this.collectionType,
this.color,
}) : super(key: key);
final String? collectionType;
final Color? color;
@override
Widget build(BuildContext context) {
switch (collectionType) {
case "movies":
return Icon(
Icons.movie,
color: color,
);
case "tvshows":
return Icon(
Icons.tv,
color: color,
);
case "music":
return Icon(
Icons.music_note,
color: color,
);
case "games":
return Icon(
Icons.games,
color: color,
);
case "books":
return Icon(
Icons.book,
color: color,
);
case "musicvideos":
return Icon(
Icons.music_video,
color: color,
);
case "homevideos":
return Icon(
Icons.videocam,
color: color,
);
case "livetv":
return Icon(
Icons.live_tv,
color: color,
);
case "channels":
return Icon(
Icons.settings_remote,
color: color,
);
case "playlists":
return Icon(
Icons.queue_music,
color: color,
);
default:
return Icon(
Icons.warning,
color: color,
);
}
}
}