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

This commit is contained in:
Zakaria
2026-05-18 14:15:38 -04:00
commit 1563409cb1
382 changed files with 45347 additions and 0 deletions
@@ -0,0 +1,207 @@
import 'package:finamp/components/AlbumScreen/sync_album_or_playlist_button.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/finamp_settings_helper.dart';
import '../../components/favourite_button.dart';
import 'album_screen_content_flexible_space_bar.dart';
import 'delete_button.dart';
import 'song_list_tile.dart';
import 'playlist_name_edit_button.dart';
typedef BaseItemDtoCallback = void Function(BaseItemDto item);
class AlbumScreenContent extends StatefulWidget {
const AlbumScreenContent({
Key? key,
required this.parent,
required this.children,
}) : super(key: key);
final BaseItemDto parent;
final List<BaseItemDto> children;
@override
State<AlbumScreenContent> createState() => _AlbumScreenContentState();
}
class _AlbumScreenContentState extends State<AlbumScreenContent> {
@override
Widget build(BuildContext context) {
void onDelete(BaseItemDto item) {
// This is pretty inefficient (has to search through whole list) but
// SongsSliverList gets passed some weird split version of children to
// handle multi-disc albums and it's 00:35 so I can't be bothered to get
// it to return an index
setState(() {
widget.children.remove(item);
});
}
List<List<BaseItemDto>> childrenPerDisc = [];
// if not in playlist, try splitting up tracks by disc numbers
// if first track has a disc number, let's assume the rest has it too
if (widget.parent.type != "Playlist" &&
widget.children[0].parentIndexNumber != null) {
int? lastDiscNumber;
for (var child in widget.children) {
if (child.parentIndexNumber != null &&
child.parentIndexNumber != lastDiscNumber) {
lastDiscNumber = child.parentIndexNumber;
childrenPerDisc.add([]);
}
childrenPerDisc.last.add(child);
}
}
return Scrollbar(
child: CustomScrollView(
slivers: [
SliverAppBar(
title: Text(widget.parent.name ??
AppLocalizations.of(context)!.unknownName),
// 125 + 64 is the total height of the widget we use as a
// FlexibleSpaceBar. We add the toolbar height since the widget
// should appear below the appbar.
// TODO: This height is affected by platform density.
expandedHeight: kToolbarHeight + 125 + 64,
pinned: true,
flexibleSpace: AlbumScreenContentFlexibleSpaceBar(
album: widget.parent,
items: widget.children,
),
actions: [
if (widget.parent.type == "Playlist" &&
!FinampSettingsHelper.finampSettings.isOffline)
PlaylistNameEditButton(playlist: widget.parent),
FavoriteButton(item: widget.parent),
if (GetIt.instance<DownloadsHelper>().isAlbumDownloaded(widget.parent.id))
DeleteButton(parent: widget.parent, items: widget.children),
if (!FinampSettingsHelper.finampSettings.isOffline)
SyncAlbumOrPlaylistButton(parent: widget.parent, items: widget.children)
],
),
if (widget.children.length > 1 &&
childrenPerDisc.length >
1) // show headers only for multi disc albums
for (var childrenOfThisDisc in childrenPerDisc)
SliverStickyHeader(
header: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 16.0,
),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Text(
AppLocalizations.of(context)!
.discNumber(childrenOfThisDisc[0].parentIndexNumber!),
style: const TextStyle(fontSize: 20.0),
),
),
sliver: SongsSliverList(
childrenForList: childrenOfThisDisc,
childrenForQueue: widget.children,
parent: widget.parent,
onDelete: onDelete,
),
)
else if (widget.children.isNotEmpty)
SongsSliverList(
childrenForList: widget.children,
childrenForQueue: widget.children,
parent: widget.parent,
onDelete: onDelete,
),
],
),
);
}
}
class SongsSliverList extends StatefulWidget {
const SongsSliverList({
Key? key,
required this.childrenForList,
required this.childrenForQueue,
required this.parent,
this.onDelete,
}) : super(key: key);
final List<BaseItemDto> childrenForList;
final List<BaseItemDto> childrenForQueue;
final BaseItemDto parent;
final BaseItemDtoCallback? onDelete;
@override
State<SongsSliverList> createState() => _SongsSliverListState();
}
class _SongsSliverListState extends State<SongsSliverList> {
final GlobalKey<SliverAnimatedListState> sliverListKey =
GlobalKey<SliverAnimatedListState>();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
// When user selects song from disc other than first, index number is
// incorrect and song with the same index on first disc is played instead.
// Adding this offset ensures playback starts for nth song on correct disc.
final int indexOffset =
widget.childrenForQueue.indexOf(widget.childrenForList[0]);
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final BaseItemDto item = widget.childrenForList[index];
BaseItemDto removeItem() {
late BaseItemDto item;
setState(() {
item = widget.childrenForList.removeAt(index);
});
return item;
}
return SongListTile(
item: item,
children: widget.childrenForQueue,
index: index + indexOffset,
parentId: widget.parent.id,
onDelete: () {
final item = removeItem();
if (widget.onDelete != null) {
widget.onDelete!(item);
}
},
isInPlaylist: widget.parent.type == "Playlist",
// show artists except for this one scenario
showArtists: !(
// we're on album screen
widget.parent.type == "MusicAlbum"
// "hide song artists if they're the same as album artists" == true
&&
FinampSettingsHelper
.finampSettings.hideSongArtistsIfSameAsAlbumArtists
// song artists == album artists
&&
setEquals(
widget.parent.albumArtists?.map((e) => e.name).toSet(),
item.artists?.toSet())),
);
},
childCount: widget.childrenForList.length,
),
);
}
}
@@ -0,0 +1,92 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/audio_service_helper.dart';
import '../album_image.dart';
import 'item_info.dart';
class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget {
const AlbumScreenContentFlexibleSpaceBar({
Key? key,
required this.album,
required this.items,
}) : super(key: key);
final BaseItemDto album;
final List<BaseItemDto> items;
@override
Widget build(BuildContext context) {
AudioServiceHelper audioServiceHelper =
GetIt.instance<AudioServiceHelper>();
return FlexibleSpaceBar(
background: SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
height: 125,
child: AlbumImage(item: album),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
),
Expanded(
flex: 2,
child: ItemInfo(
item: album,
itemSongs: items.length,
),
)
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
audioServiceHelper.replaceQueueWithItem(
itemList: items,
),
icon: const Icon(Icons.play_arrow),
label:
Text(AppLocalizations.of(context)!.playButtonLabel),
),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 8)),
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
audioServiceHelper.replaceQueueWithItem(
itemList: items,
shuffle: true,
initialIndex: Random().nextInt(items.length),
),
icon: const Icon(Icons.shuffle),
label: Text(
AppLocalizations.of(context)!.shuffleButtonLabel),
),
),
]),
)
],
),
),
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import '../../services/downloads_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../models/jellyfin_models.dart';
import '../../models/finamp_models.dart';
import '../confirmation_prompt_dialog.dart';
import '../error_snackbar.dart';
class DeleteButton extends StatefulWidget {
const DeleteButton({
Key? key,
required this.parent,
required this.items,
}) : super(key: key);
final BaseItemDto parent;
final List<BaseItemDto> items;
@override
State<DeleteButton> createState() => _DeleteButtonState();
}
class _DeleteButtonState extends State<DeleteButton> {
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
void checkIfDownloaded() {
if (!mounted) return;
}
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, box, child) {
bool? isOffline = box.get("FinampSettings")?.isOffline;
return IconButton(
tooltip: AppLocalizations.of(context)!.deleteFromDevice,
icon: const Icon(Icons.delete),
// If offline, we don't allow the user to delete items.
// If we did, we'd have to implement listeners for MusicScreenTabView so that the user can't delete a parent, go back, and select the same parent.
// If they did, AlbumScreen would show an error since the item no longer exists.
// Also, the user could delete the parent and immediately re-download it, which will either cause unwanted network usage or cause more errors because the user is offline.
onPressed: isOffline ?? false
? null
: () {
showDialog(
context: context,
builder: (context) => ConfirmationPromptDialog(
promptText: AppLocalizations.of(context)!
.deleteDownloadsPrompt(
widget.parent.name ?? "",
widget.parent.type == "Playlist"
? "playlist"
: "album"),
confirmButtonText: AppLocalizations.of(context)!
.deleteDownloadsConfirmButtonText,
abortButtonText: AppLocalizations.of(context)!
.deleteDownloadsAbortButtonText,
onConfirmed: () async {
final messenger = ScaffoldMessenger.of(context);
try {
await _downloadsHelper
.deleteParentAndChildDownloads(
jellyfinItemIds:
widget.items.map((e) => e.id).toList(),
deletedFor: widget.parent.id,
);
checkIfDownloaded();
messenger.showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!
.downloadsDeleted)));
} catch (error) {
errorSnackbar(error, context);
}
},
onAborted: () {},
),
);
});
},
);
}
}
@@ -0,0 +1,101 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/downloads_helper.dart';
import '../../models/finamp_models.dart';
import '../../models/jellyfin_models.dart';
import '../error_snackbar.dart';
class DownloadDialog extends StatefulWidget {
const DownloadDialog({
Key? key,
required this.parents,
required this.items,
required this.viewId,
}) : assert(parents.length == items.length),
super(key: key);
final List<BaseItemDto> parents;
final List<List<BaseItemDto>> items;
final String viewId;
@override
State<DownloadDialog> createState() => _DownloadDialogState();
}
class _DownloadDialogState extends State<DownloadDialog> {
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
DownloadLocation? selectedDownloadLocation;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.addDownloads),
content: DropdownButton<DownloadLocation>(
hint: Text(AppLocalizations.of(context)!.location),
isExpanded: true,
onChanged: (value) => setState(() {
selectedDownloadLocation = value;
}),
value: selectedDownloadLocation,
items: FinampSettingsHelper.finampSettings.downloadLocationsMap.values
.map((e) => DropdownMenuItem<DownloadLocation>(
value: e,
child: Text(e.name),
))
.toList()),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
onPressed: selectedDownloadLocation == null
? null
: () async {
Navigator.of(context).pop(selectedDownloadLocation);
},
child: Text(AppLocalizations.of(context)!.addButtonLabel),
)
],
);
}
}
/// This function is used by DownloadDialog to check/add downloads.
Future<void> checkedAddDownloads(
BuildContext context, {
required DownloadLocation downloadLocation,
required List<BaseItemDto> parents,
required List<List<BaseItemDto>> items,
required String viewId,
}) async {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
final checkedAddDownloadsLogger = Logger("CheckedAddDownloads");
// If the default "internal storage" path is set and doesn't
// exist, it may have been moved by an iOS update.
if (downloadLocation.useHumanReadableNames == false &&
!await Directory(downloadLocation.path).exists()) {
checkedAddDownloadsLogger
.warning("Internal storage path doesn't exist! Resetting.");
await FinampSettingsHelper.resetDefaultDownloadLocation();
}
for (int i = 0; i < parents.length; i++) {
downloadsHelper
.addDownloads(
parent: parents[i],
items: items[i],
useHumanReadableNames: downloadLocation.useHumanReadableNames,
viewId: viewId,
downloadLocation: downloadLocation,
)
.onError((error, stackTrace) => errorSnackbar(error, context));
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import '../../models/finamp_models.dart';
import '../../services/downloads_helper.dart';
import '../../services/download_update_stream.dart';
import '../../models/jellyfin_models.dart';
import '../error_snackbar.dart';
class DownloadedIndicator extends StatefulWidget {
const DownloadedIndicator({
Key? key,
required this.item,
this.size,
}) : super(key: key);
final BaseItemDto item;
final double? size;
@override
State<DownloadedIndicator> createState() => _DownloadedIndicatorState();
}
class _DownloadedIndicatorState extends State<DownloadedIndicator> {
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
final _downloadUpdateStream = GetIt.instance<DownloadUpdateStream>();
late Future<List<DownloadTask>?> _downloadedIndicatorFuture;
String? _downloadTaskId;
DownloadTaskStatus? _currentStatus;
@override
void initState() {
super.initState();
_downloadedIndicatorFuture =
_downloadsHelper.getDownloadStatus([widget.item.id]);
// We do this instead of using a StreamBuilder because the StreamBuilder
// kept dropping events. With this, we can also make it so that the widget
// only rebuilds when it actually has to.
_downloadUpdateStream.stream.listen((event) {
if (event.id == _downloadTaskId && event.status != _currentStatus) {
setState(() {
_currentStatus = event.status;
});
}
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<DownloadTask>?>(
future: _downloadedIndicatorFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data != null && snapshot.data!.isNotEmpty) {
_downloadTaskId = snapshot.data?[0].taskId;
_currentStatus = snapshot.data?[0].status;
}
// This ValueListenable is used to get the download task ID of new
// downloads. It also clears the task ID and status when the download
// is deleted. This only rebuilds when the item with key
// widget.item.id is changed, so this should only rebuild when the
// item is first downloaded and when it is deleted.
return ValueListenableBuilder<Box<DownloadedSong>>(
valueListenable: _downloadsHelper
.getDownloadedItemsListenable(keys: [widget.item.id]),
builder: (context, box, _) {
if (_downloadTaskId == null && box.get(widget.item.id) != null) {
_downloadTaskId = box.get(widget.item.id)!.downloadId;
} else if (box.get(widget.item.id) == null) {
_downloadTaskId = null;
_currentStatus = null;
}
// return StreamBuilder<DownloadUpdate>(
// stream: _downloadUpdateStream.stream,
// builder: (context, snapshot) {
// print("Streambuilder rebuild ${snapshot.data}");
// if (snapshot.hasData &&
// snapshot.data!.id == _downloadTask?.taskId) {
// print("Change $_currentStatus to ${snapshot.data?.status}");
// if (snapshot.data?.status == DownloadTaskStatus.complete) {
// print(
// "COMPLETE IN STREAMBUILDER ${widget.item.name}!!!!");
// }
// _currentStatus = snapshot.data?.status;
// }
if (_currentStatus == null) {
return const SizedBox.shrink();
} else if (_currentStatus == DownloadTaskStatus.complete) {
return Icon(
Icons.download,
color: Theme.of(context).colorScheme.secondary,
size: widget.size,
);
} else if (_currentStatus == DownloadTaskStatus.failed ||
_currentStatus == DownloadTaskStatus.undefined) {
return Icon(
Icons.error,
color: Colors.red,
size: widget.size,
);
} else if (_currentStatus == DownloadTaskStatus.paused) {
return Icon(
Icons.pause,
color: Colors.yellow,
size: widget.size,
);
} else if (_currentStatus == DownloadTaskStatus.enqueued ||
_currentStatus == DownloadTaskStatus.running) {
return Icon(
Icons.download_outlined,
color: Colors.white.withOpacity(0.5),
size: widget.size,
);
} else {
return const SizedBox.shrink();
}
// },
// );
},
);
} else if (snapshot.hasError) {
errorSnackbar(snapshot.error, context);
return const SizedBox.shrink();
} else {
return const SizedBox.shrink();
}
},
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:finamp/components/artists_text_spans.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import '../print_duration.dart';
class ItemInfo extends StatelessWidget {
const ItemInfo({
Key? key,
required this.item,
required this.itemSongs,
}) : super(key: key);
final BaseItemDto item;
final int itemSongs;
// TODO: see if there's a way to expand this column to the row that it's in
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (item.type != "Playlist")
_IconAndText(
iconData: Icons.person,
textSpan: TextSpan(
children: ArtistsTextSpans(
item,
Theme.of(context).colorScheme.onSurface,
context,
false,
),
)),
_IconAndText(
iconData: Icons.music_note,
textSpan: _buildStyledText(
context: context,
text: AppLocalizations.of(context)!.songCount(itemSongs),
),
),
_IconAndText(
iconData: Icons.timer,
textSpan: _buildStyledText(
context: context,
text: printDuration(Duration(
microseconds:
item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10,
)),
),
),
if (item.type != "Playlist")
_IconAndText(
iconData: Icons.event,
textSpan: _buildStyledText(
context: context,
text: item.productionYearString,
),
)
],
);
}
TextSpan _buildStyledText({required BuildContext context, String? text}) {
return TextSpan(
text: text,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
);
}
}
class _IconAndText extends StatelessWidget {
const _IconAndText({
Key? key,
required this.iconData,
required this.textSpan,
}) : super(key: key);
final IconData iconData;
final TextSpan textSpan;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
iconData,
// Inactive icons have an opacity of 50% with dark theme and 38%
// with bright theme
// https://material.io/design/iconography/system-icons.html#color
color: Theme.of(context).disabledColor,
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
Expanded(
child: RichText(
text: textSpan,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../models/jellyfin_models.dart';
import 'playlist_name_edit_dialog.dart';
class PlaylistNameEditButton extends StatelessWidget {
const PlaylistNameEditButton({
Key? key,
required this.playlist,
}) : super(key: key);
final BaseItemDto playlist;
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.edit),
tooltip: AppLocalizations.of(context)!.editPlaylistNameTooltip,
onPressed: () => showDialog(
context: context,
builder: (context) => PlaylistNameEditDialog(playlist: playlist),
),
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import '../../models/jellyfin_models.dart';
import '../../services/jellyfin_api_helper.dart';
import '../error_snackbar.dart';
class PlaylistNameEditDialog extends StatefulWidget {
const PlaylistNameEditDialog({
Key? key,
required this.playlist,
}) : super(key: key);
final BaseItemDto playlist;
@override
State<PlaylistNameEditDialog> createState() => _PlaylistNameEditDialogState();
}
class _PlaylistNameEditDialogState extends State<PlaylistNameEditDialog> {
String? _name;
bool _isUpdating = false;
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_name = widget.playlist.name;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.editPlaylistNameTitle),
content: Form(
key: _formKey,
child: TextFormField(
initialValue: _name,
decoration:
InputDecoration(labelText: AppLocalizations.of(context)!.name),
textInputAction: TextInputAction.done,
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)!.required;
}
return null;
},
onFieldSubmitted: (_) async => await _submit(),
onSaved: (newValue) => _name = newValue,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: _isUpdating ? null : () async => await _submit(),
child: Text(AppLocalizations.of(context)!.updateButtonLabel),
),
],
);
}
Future<void> _submit() async {
if (_formKey.currentState != null && _formKey.currentState!.validate()) {
setState(() {
_isUpdating = true;
});
_formKey.currentState!.save();
try {
BaseItemDto playlistTemp = widget.playlist;
playlistTemp.name = _name;
await _jellyfinApiHelper.updateItem(
itemId: widget.playlist.id,
newItem: playlistTemp,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.playlistNameUpdated),
));
Navigator.of(context).pop();
} catch (e) {
errorSnackbar(e, context);
setState(() {
_isUpdating = false;
});
return;
}
}
}
}
@@ -0,0 +1,512 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:mini_music_visualizer/mini_music_visualizer.dart';
import '../../models/jellyfin_models.dart';
import '../../screens/add_to_playlist_screen.dart';
import '../../screens/album_screen.dart';
import '../../services/audio_service_helper.dart';
import '../../services/downloads_helper.dart';
import '../../services/finamp_settings_helper.dart';
import '../../services/jellyfin_api_helper.dart';
import '../../services/media_state_stream.dart';
import '../../services/process_artist.dart';
import '../album_image.dart';
import '../error_snackbar.dart';
import '../favourite_button.dart';
import '../print_duration.dart';
import 'downloaded_indicator.dart';
enum SongListTileMenuItems {
addToQueue,
playNext,
replaceQueueWithItem,
addToPlaylist,
removeFromPlaylist,
instantMix,
goToAlbum,
addFavourite,
removeFavourite,
}
class SongListTile extends StatefulWidget {
const SongListTile({
Key? key,
required this.item,
/// Children that are related to this list tile, such as the other songs in
/// the album. This is used to give the audio service all the songs for the
/// item. If null, only this song will be given to the audio service.
this.children,
/// Index of the song in whatever parent this widget is in. Used to start
/// the audio service at a certain index, such as when selecting the middle
/// song in an album.
this.index,
this.parentId,
this.isSong = false,
this.showArtists = true,
this.onDelete,
/// Whether this widget is being displayed in a playlist. If true, will show
/// the remove from playlist button.
this.isInPlaylist = false,
}) : super(key: key);
final BaseItemDto item;
final List<BaseItemDto>? children;
final int? index;
final bool isSong;
final String? parentId;
final bool showArtists;
final VoidCallback? onDelete;
final bool isInPlaylist;
@override
State<SongListTile> createState() => _SongListTileState();
}
class _SongListTileState extends State<SongListTile> {
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
/// Sets the item's favourite on the Jellyfin server.
Future<void> setFavourite() async {
try {
// We switch the widget state before actually doing the request to
// make the app feel faster (without, there is a delay from the
// user adding the favourite and the icon showing)
setState(() {
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
});
// Since we flipped the favourite state already, we can use the flipped
// state to decide which API call to make
final newUserData = widget.item.userData!.isFavorite
? await _jellyfinApiHelper.addFavourite(widget.item.id)
: await _jellyfinApiHelper.removeFavourite(widget.item.id);
if (!mounted) return;
setState(() {
widget.item.userData = newUserData;
});
} catch (e) {
setState(() {
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
});
errorSnackbar(e, context);
}
}
final listTile = StreamBuilder<MediaState>(
stream: mediaStateStream,
builder: (context, snapshot) {
// I think past me did this check directly from the JSON for
// performance. It works for now, apologies if you're debugging it
// years in the future.
final isCurrentlyPlaying =
snapshot.data?.mediaItem?.extras?["itemJson"]["Id"] ==
widget.item.id &&
snapshot.data?.mediaItem?.extras?["itemJson"]["AlbumId"] ==
widget.parentId;
return ListTile(
leading: AlbumImage(item: widget.item),
title: RichText(
text: TextSpan(
children: [
// third condition checks if the item is viewed from its album (instead of e.g. a playlist)
// same horrible check as in canGoToAlbum in GestureDetector below
if (widget.item.indexNumber != null &&
!widget.isSong &&
widget.item.albumId == widget.parentId)
TextSpan(
text: "${widget.item.indexNumber}. ",
style:
TextStyle(color: Theme.of(context).disabledColor)),
TextSpan(
text: widget.item.name ??
AppLocalizations.of(context)!.unknownName,
style: TextStyle(
color: isCurrentlyPlaying
? Theme.of(context).colorScheme.secondary
: null,
),
),
],
style: Theme.of(context).textTheme.titleMedium,
),
),
subtitle: RichText(
text: TextSpan(
children: [
WidgetSpan(
child: Transform.translate(
offset: const Offset(-3, 0),
child: DownloadedIndicator(
item: widget.item,
size:
Theme.of(context).textTheme.bodyMedium!.fontSize! +
3,
),
),
alignment: PlaceholderAlignment.top,
),
TextSpan(
text: printDuration(Duration(
microseconds: (widget.item.runTimeTicks == null
? 0
: widget.item.runTimeTicks! ~/ 10))),
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyMedium
?.color
?.withOpacity(0.7)),
),
if (widget.showArtists)
TextSpan(
text:
" · ${processArtist(widget.item.artists?.join(", ") ?? widget.item.albumArtist, context)}",
style: TextStyle(color: Theme.of(context).disabledColor),
)
],
),
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isCurrentlyPlaying &&
(snapshot.data?.playbackState.playing ?? false))
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: MiniMusicVisualizer(
color: Theme.of(context).colorScheme.secondary,
width: 4,
height: 15,
),
),
FavoriteButton(
item: widget.item,
onlyIfFav: true,
),
],
),
onTap: () {
_audioServiceHelper.replaceQueueWithItem(
itemList: widget.children ?? [widget.item],
initialIndex: widget.index ?? 0,
);
},
);
});
return GestureDetector(
onLongPressStart: (details) async {
Feedback.forLongPress(context);
// This horrible check does 2 things:
// - Checks if the item's album is not the same as the parent item
// that created the widget. The ids will be different if the
// SongListTile is in a playlist, but they will be the same if viewed
// in the item's album. We don't want to show this menu item if we're
// already in the item's album.
//
// - Checks if the album is downloaded if in offline mode. If we're in
// offline mode, we need the album to actually be downloaded to show
// its metadata. This function also checks if widget.item.parentId is
// null.
final canGoToAlbum = widget.item.albumId != widget.parentId &&
_isAlbumDownloadedIfOffline(widget.item.parentId);
// Some options are disabled in offline mode
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
final selection = await showMenu<SongListTileMenuItems>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
screenSize.width - details.globalPosition.dx,
screenSize.height - details.globalPosition.dy,
),
items: [
if (_audioServiceHelper.hasQueueItems()) ...[
PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.addToQueue,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.addToQueue),
),
),
PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.playNext,
child: ListTile(
leading: const Icon(Icons.queue_music),
title: Text(AppLocalizations.of(context)!.playNext),
),
),
],
PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.replaceQueueWithItem,
child: ListTile(
leading: const Icon(Icons.play_circle),
title: Text(AppLocalizations.of(context)!.replaceQueue),
),
),
if (widget.isInPlaylist)
PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.addToPlaylist,
child: ListTile(
leading: const Icon(Icons.playlist_add),
title: Text(AppLocalizations.of(context)!.addToPlaylistTitle),
enabled: !isOffline,
),
),
widget.isInPlaylist
? PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.removeFromPlaylist,
child: ListTile(
leading: const Icon(Icons.playlist_remove),
title: Text(AppLocalizations.of(context)!
.removeFromPlaylistTitle),
enabled: !isOffline && widget.parentId != null,
),
)
: PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.addToPlaylist,
child: ListTile(
leading: const Icon(Icons.playlist_add),
title: Text(
AppLocalizations.of(context)!.addToPlaylistTitle),
enabled: !isOffline,
),
),
PopupMenuItem<SongListTileMenuItems>(
enabled: !isOffline,
value: SongListTileMenuItems.instantMix,
child: ListTile(
leading: const Icon(Icons.explore),
title: Text(AppLocalizations.of(context)!.instantMix),
enabled: !isOffline,
),
),
PopupMenuItem<SongListTileMenuItems>(
enabled: canGoToAlbum,
value: SongListTileMenuItems.goToAlbum,
child: ListTile(
leading: const Icon(Icons.album),
title: Text(AppLocalizations.of(context)!.goToAlbum),
enabled: canGoToAlbum,
),
),
widget.item.userData!.isFavorite
? PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.removeFavourite,
child: ListTile(
leading: const Icon(Icons.favorite_border),
title:
Text(AppLocalizations.of(context)!.removeFavourite),
),
)
: PopupMenuItem<SongListTileMenuItems>(
value: SongListTileMenuItems.addFavourite,
child: ListTile(
leading: const Icon(Icons.favorite),
title: Text(AppLocalizations.of(context)!.addFavourite),
),
),
],
);
if (!mounted) return;
switch (selection) {
case SongListTileMenuItems.addToQueue:
await _audioServiceHelper.addQueueItems([widget.item]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.addedToQueue),
));
break;
case SongListTileMenuItems.playNext:
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.insertedIntoQueue),
));
break;
case SongListTileMenuItems.replaceQueueWithItem:
await _audioServiceHelper
.replaceQueueWithItem(itemList: [widget.item]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.queueReplaced),
));
break;
case SongListTileMenuItems.addToPlaylist:
Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName,
arguments: widget.item.id);
break;
case SongListTileMenuItems.removeFromPlaylist:
try {
await _jellyfinApiHelper.removeItemsFromPlaylist(
playlistId: widget.parentId!,
entryIds: [widget.item.playlistItemId!]);
if (!mounted) return;
await _jellyfinApiHelper.getItems(
parentItem:
await _jellyfinApiHelper.getItemById(widget.item.parentId!),
sortBy: "ParentIndexNumber,IndexNumber,SortName",
includeItemTypes: "Audio",
isGenres: false,
);
if (!mounted) return;
if (widget.onDelete != null) widget.onDelete!();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text(AppLocalizations.of(context)!.removedFromPlaylist),
));
} catch (e) {
errorSnackbar(e, context);
}
break;
case SongListTileMenuItems.instantMix:
await _audioServiceHelper.startInstantMixForItem(widget.item);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.startingInstantMix),
));
break;
case SongListTileMenuItems.goToAlbum:
late BaseItemDto album;
if (FinampSettingsHelper.finampSettings.isOffline) {
// If offline, load the album's BaseItemDto from DownloadHelper.
final downloadsHelper = GetIt.instance<DownloadsHelper>();
// downloadedParent won't be null here since the menu item already
// checks if the DownloadedParent exists.
album = downloadsHelper
.getDownloadedParent(widget.item.parentId!)!
.item;
} else {
// If online, get the album's BaseItemDto from the server.
try {
album =
await _jellyfinApiHelper.getItemById(widget.item.parentId!);
} catch (e) {
errorSnackbar(e, context);
break;
}
}
if (!mounted) return;
Navigator.of(context)
.pushNamed(AlbumScreen.routeName, arguments: album);
break;
case SongListTileMenuItems.addFavourite:
case SongListTileMenuItems.removeFavourite:
await setFavourite();
break;
case null:
break;
}
},
child: widget.isSong
? listTile
: Dismissible(
key: Key(widget.index.toString()),
direction: FinampSettingsHelper.finampSettings.disableGesture
? DismissDirection.none
: DismissDirection.horizontal,
background: Container(
color: Theme.of(context).colorScheme.secondaryContainer,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
AspectRatio(
aspectRatio: 1,
child: FittedBox(
fit: BoxFit.fitHeight,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Icon(
Icons.queue_music,
color: Theme.of(context)
.colorScheme
.onSecondaryContainer,
),
),
),
)
],
),
),
),
confirmDismiss: (direction) async {
if (!FinampSettingsHelper.finampSettings.swipeInsertQueueNext) {
await _audioServiceHelper.addQueueItems([widget.item]);
} else {
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
}
if (!mounted) return false;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
FinampSettingsHelper.finampSettings.swipeInsertQueueNext
? AppLocalizations.of(context)!.insertedIntoQueue
: AppLocalizations.of(context)!.addedToQueue),
));
return false;
},
child: listTile,
),
);
}
}
/// If offline, check if an album is downloaded. Always returns true if online.
/// Returns false if albumId is null.
bool _isAlbumDownloadedIfOffline(String? albumId) {
if (albumId == null) {
return false;
} else if (FinampSettingsHelper.finampSettings.isOffline) {
final downloadsHelper = GetIt.instance<DownloadsHelper>();
return downloadsHelper.isAlbumDownloaded(albumId);
} else {
return true;
}
}
@@ -0,0 +1,52 @@
import 'package:finamp/models/jellyfin_models.dart';
import 'package:finamp/services/downloads_helper.dart';
import 'package:finamp/services/sync_helper.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class SyncAlbumOrPlaylistButton extends StatefulWidget {
const SyncAlbumOrPlaylistButton({
Key? key,
required this.parent,
required this.items,
}) : super(key: key);
final BaseItemDto parent;
final List<BaseItemDto> items;
@override
State<SyncAlbumOrPlaylistButton> createState() =>
_SyncAlbumOrPlaylistButtonState();
}
class _SyncAlbumOrPlaylistButtonState
extends State<SyncAlbumOrPlaylistButton> {
final _syncLogger = Logger("SyncPlaylistButton");
final _downloadHelper = GetIt.instance<DownloadsHelper>();
bool isAlbumDownloaded = false;
void syncAlbumOrPlaylist(BuildContext context) async {
_syncLogger.info("Syncing playlist");
var syncHelper = DownloadsSyncHelper(_syncLogger);
syncHelper.sync(context, widget.parent, widget.items);
setState(() {
isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id);
});
}
@override
Widget build(BuildContext context) {
isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id);
return IconButton(
tooltip: isAlbumDownloaded
? AppLocalizations.of(context)!.sync
: AppLocalizations.of(context)!.download,
onPressed: () => syncAlbumOrPlaylist(context),
icon:
isAlbumDownloaded ?
const Icon(Icons.sync) :
const Icon(Icons.download));
}
}