first commit
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user