first commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+112
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+31
@@ -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),
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user