diff --git a/assets/localization/en.json b/assets/localization/en.json index aae857d9c..284f819fa 100644 --- a/assets/localization/en.json +++ b/assets/localization/en.json @@ -703,14 +703,17 @@ "sonarr.Episode": "Episode", "sonarr.Episodes": "Episodes", "sonarr.EpisodesAvailable": "Episodes Available", + "sonarr.EpisodesCount": "{} Episodes", "sonarr.EpisodeFileDeleted": "Episode File Deleted", "sonarr.EpisodeFileRenamed": "Episode File Renamed", + "sonarr.EpisodeFilesDeleted": "Episode Files Deleted", "sonarr.EpisodeImported": "Episode Imported ({})", "sonarr.EpisodeNumber": "Episode {}", "sonarr.FailedToAddSeries": "Failed to Add Series", "sonarr.FailedToAddTag": "Failed to Add Tag", "sonarr.FailedToBackupDatabase": "Failed to Backup Database", "sonarr.FailedToDeleteEpisodeFile": "Failed to Delete Episode File", + "sonarr.FailedToDeleteEpisodeFiles": "Failed to Delete Episode Files", "sonarr.FailedToDownloadRelease": "Failed to Download Release", "sonarr.FailedToMonitorEpisode": "Failed to Monitor Episode", "sonarr.FailedToMonitorSeason": "Failed to Monitor Season", @@ -719,6 +722,7 @@ "sonarr.FailedToRemoveFromQueue": "Failed to Remove From Queue", "sonarr.FailedToRemoveSeries": "Failed to Remove Series", "sonarr.FailedToRunRSSSync": "Failed to Run RSS Sync", + "sonarr.FailedToSearchForEpisodes": "Failed to Search for Episodes", "sonarr.FailedToSearchForMonitoredEpisodes": "Failed to Search for Monitored Episodes", "sonarr.FailedToSeasonSearch": "Failed to Season Search", "sonarr.FailedToSearch": "Failed to Search", @@ -763,6 +767,8 @@ "sonarr.More": "More", "sonarr.Name": "Name", "sonarr.NoEpisodesFound": "No Episodes Found", + "sonarr.NoEpisodeFilesFound": "No Episode Files Found", + "sonarr.NoEpisodeFilesFoundDeleteMessage": "No selected episodes have files to delete", "sonarr.NoHistoryFound": "No History Found", "sonarr.NoLongerMonitoring": "No Longer Monitoring", "sonarr.NoMessagesFound": "No Messages Found", @@ -772,6 +778,7 @@ "sonarr.NoSeriesFound": "No Series Found", "sonarr.NoSummaryAvailable": "No Summary Available", "sonarr.NoTagsFound": "No Tags Found", + "sonarr.OneEpisode": "1 Episode", "sonarr.OneSeason": "1 Season", "sonarr.Other": "Other", "sonarr.Overview": "Overview", @@ -808,6 +815,7 @@ "sonarr.Search": "Search", "sonarr.Searching": "Searching{}", "sonarr.SearchingForEpisode": "Searching for Episode…", + "sonarr.SearchingForEpisodes": "Searching for Episodes…", "sonarr.SearchingForSeason": "Searching for Season…", "sonarr.SearchingForMonitoredEpisodes": "Searching for Monitored Episodes…", "sonarr.SearchingDescription": "Searching for all missing episodes", diff --git a/lib/api/sonarr/controllers.dart b/lib/api/sonarr/controllers.dart index d1c31f1bf..aaee3f925 100644 --- a/lib/api/sonarr/controllers.dart +++ b/lib/api/sonarr/controllers.dart @@ -26,6 +26,7 @@ part 'controllers/command/series_search.dart'; // Episode File part 'controllers/episode_file.dart'; part 'controllers/episode_file/delete_episode_file.dart'; +part 'controllers/episode_file/delete_episode_files.dart'; part 'controllers/episode_file/get_episode_file.dart'; part 'controllers/episode_file/get_series_episode_files.dart'; diff --git a/lib/api/sonarr/controllers/episode_file.dart b/lib/api/sonarr/controllers/episode_file.dart index 4f1c9900b..a04a63898 100644 --- a/lib/api/sonarr/controllers/episode_file.dart +++ b/lib/api/sonarr/controllers/episode_file.dart @@ -20,6 +20,12 @@ class SonarrControllerEpisodeFile { }) async => _commandDeleteEpisodeFile(_client, episodeFileId: episodeFileId); + /// Delete the given episode files. + Future deleteBulk({ + required List episodeFileIds, + }) async => + _commandDeleteEpisodeFiles(_client, episodeFileIds: episodeFileIds); + /// Handler for [episodefile/{id}](https://github.com/Sonarr/Sonarr/wiki/EpisodeFile#get). /// /// Returns the episode file with the matching episode ID. diff --git a/lib/api/sonarr/controllers/episode_file/delete_episode_files.dart b/lib/api/sonarr/controllers/episode_file/delete_episode_files.dart new file mode 100644 index 000000000..e0c4b2183 --- /dev/null +++ b/lib/api/sonarr/controllers/episode_file/delete_episode_files.dart @@ -0,0 +1,10 @@ +part of sonarr_commands; + +Future _commandDeleteEpisodeFiles( + Dio client, { + required List episodeFileIds, +}) async { + await client.delete('episodefile/bulk', data: { + 'episodeFileIds': episodeFileIds, + }); +} diff --git a/lib/modules/sonarr/core/api_controller.dart b/lib/modules/sonarr/core/api_controller.dart index f4510fe04..b6260c83c 100644 --- a/lib/modules/sonarr/core/api_controller.dart +++ b/lib/modules/sonarr/core/api_controller.dart @@ -127,6 +127,54 @@ class SonarrAPIController { return false; } + Future deleteEpisodes({ + required BuildContext context, + required List episodeFileIds, + bool showSnackbar = true, + }) async { + if (episodeFileIds.isEmpty) { + showLunaInfoSnackBar( + title: 'sonarr.NoEpisodeFilesFound'.tr(), + message: 'sonarr.NoEpisodeFilesFoundDeleteMessage'.tr(), + ); + return true; + } + + if (context.read().enabled) { + return context + .read() + .api! + .episodeFile + .deleteBulk(episodeFileIds: episodeFileIds) + .then((response) { + if (showSnackbar) { + showLunaSuccessSnackBar( + title: 'sonarr.EpisodeFilesDeleted'.tr(), + message: episodeFileIds.length > 1 + ? 'sonarr.EpisodesCount' + .tr(args: [episodeFileIds.length.toString()]) + : 'sonarr.OneEpisode'.tr(), + ); + } + return true; + }).catchError((error, stack) { + LunaLogger().error( + 'Failed to delete episodes (${episodeFileIds.join(',')})', + error, + stack, + ); + if (showSnackbar) { + showLunaErrorSnackBar( + title: 'sonarr.FailedToDeleteEpisodeFiles'.tr(), + error: error, + ); + } + return false; + }); + } + return false; + } + Future episodeSearch({ required BuildContext context, required SonarrEpisode episode, @@ -163,6 +211,46 @@ class SonarrAPIController { return false; } + Future multiEpisodeSearch({ + required BuildContext context, + required List episodeIds, + bool showSnackbar = true, + }) async { + if (context.read().enabled) { + return context + .read() + .api! + .command + .episodeSearch(episodeIds: episodeIds) + .then((response) { + if (showSnackbar) { + showLunaSuccessSnackBar( + title: 'sonarr.SearchingForEpisodes'.tr(), + message: episodeIds.length > 1 + ? 'sonarr.EpisodesCount' + .tr(args: [episodeIds.length.toString()]) + : 'sonarr.OneEpisode'.tr(), + ); + } + return true; + }).catchError((error, stack) { + LunaLogger().error( + 'Failed to search for episode: ${episodeIds.join(',')}', + error, + stack, + ); + if (showSnackbar) { + showLunaErrorSnackBar( + title: 'sonarr.FailedToSearchForEpisodes'.tr(), + error: error, + ); + } + return false; + }); + } + return false; + } + Future toggleSeasonMonitored({ required BuildContext context, required SonarrSeriesSeason season, diff --git a/lib/modules/sonarr/core/dialogs.dart b/lib/modules/sonarr/core/dialogs.dart index da3c7a5c2..8111b4a36 100644 --- a/lib/modules/sonarr/core/dialogs.dart +++ b/lib/modules/sonarr/core/dialogs.dart @@ -129,6 +129,40 @@ class SonarrDialogs { return Tuple2(_flag, _value); } + Future> episodeMultiSettings( + BuildContext context, + int episodes, + ) async { + bool _flag = false; + SonarrEpisodeMultiSettingsType? _value; + + void _setValues(bool flag, SonarrEpisodeMultiSettingsType value) { + _flag = flag; + _value = value; + Navigator.of(context, rootNavigator: true).pop(); + } + + await LunaDialog.dialog( + context: context, + title: episodes > 1 + ? 'sonarr.EpisodesCount'.tr(args: [episodes.toString()]) + : 'sonarr.OneEpisode'.tr(), + content: List.generate( + SonarrEpisodeMultiSettingsType.values.length, + (idx) => LunaDialog.tile( + text: SonarrEpisodeMultiSettingsType.values[idx].name, + icon: SonarrEpisodeMultiSettingsType.values[idx].icon, + iconColor: LunaColours().byListIndex(idx), + onTap: () { + _setValues(true, SonarrEpisodeMultiSettingsType.values[idx]); + }, + ), + ), + contentPadding: LunaDialog.listDialogContentPadding(), + ); + return Tuple2(_flag, _value); + } + static Future> setDefaultPage( BuildContext context, { required List titles, diff --git a/lib/modules/sonarr/core/types.dart b/lib/modules/sonarr/core/types.dart index 27d0938a7..3c9336a93 100644 --- a/lib/modules/sonarr/core/types.dart +++ b/lib/modules/sonarr/core/types.dart @@ -1,6 +1,7 @@ export 'types/filter_releases.dart'; export 'types/filter_series.dart'; export 'types/monitor_status.dart'; +export 'types/settings_episode_multi.dart'; export 'types/settings_episode.dart'; export 'types/settings_global.dart'; export 'types/settings_season.dart'; diff --git a/lib/modules/sonarr/core/types/settings_episode_multi.dart b/lib/modules/sonarr/core/types/settings_episode_multi.dart new file mode 100644 index 000000000..7cc1061a7 --- /dev/null +++ b/lib/modules/sonarr/core/types/settings_episode_multi.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:lunasea/modules/sonarr.dart'; +import 'package:lunasea/vendor.dart'; +import 'package:lunasea/widgets/ui.dart'; + +enum SonarrEpisodeMultiSettingsType { + AUTOMATIC_SEARCH, + DELETE_FILES; + + IconData get icon { + switch (this) { + case SonarrEpisodeMultiSettingsType.AUTOMATIC_SEARCH: + return LunaIcons.SEARCH; + case SonarrEpisodeMultiSettingsType.DELETE_FILES: + return LunaIcons.DELETE; + } + } + + String get name { + switch (this) { + case SonarrEpisodeMultiSettingsType.AUTOMATIC_SEARCH: + return 'sonarr.AutomaticSearch'.tr(); + case SonarrEpisodeMultiSettingsType.DELETE_FILES: + return 'sonarr.DeleteFiles'.tr(); + } + } + + Future execute( + BuildContext context, + List episodes, + ) async { + switch (this) { + case SonarrEpisodeMultiSettingsType.AUTOMATIC_SEARCH: + final episodeIds = episodes.map((ep) => ep.id!).toList(); + await SonarrAPIController().multiEpisodeSearch( + context: context, + episodeIds: episodeIds, + ); + break; + case SonarrEpisodeMultiSettingsType.DELETE_FILES: + final episodeIds = episodes + .filter((ep) => ep.episodeFileId != null && ep.episodeFileId != 0) + .map((ep) => ep.episodeFileId!) + .toList(); + await SonarrAPIController().deleteEpisodes( + context: context, + episodeFileIds: episodeIds, + ); + break; + } + + context.read().fetchState(context); + } +} diff --git a/lib/modules/sonarr/routes/season_details/state.dart b/lib/modules/sonarr/routes/season_details/state.dart index 26cac8627..08c6d0523 100644 --- a/lib/modules/sonarr/routes/season_details/state.dart +++ b/lib/modules/sonarr/routes/season_details/state.dart @@ -181,4 +181,38 @@ class SonarrSeasonDetailsState extends ChangeNotifier { } notifyListeners(); } + + final Set selectedEpisodes = {}; + + void toggleSelectedEpisode(SonarrEpisode episode) { + final id = episode.id!; + if (selectedEpisodes.contains(id)) { + selectedEpisodes.remove(id); + } else { + selectedEpisodes.add(id); + } + + notifyListeners(); + } + + void clearSelectedEpisodes() { + selectedEpisodes.clear(); + notifyListeners(); + } + + Future toggleSeasonEpisodes(int seasonNumber) async { + final eps = (await episodes)! + .filter((ep) => ep.value.seasonNumber == seasonNumber) + .map((ep) => ep.value.id!) + .toList(); + final allSelected = eps.every(selectedEpisodes.contains); + + if (allSelected) { + selectedEpisodes.removeAll(eps); + } else { + selectedEpisodes.addAll(eps); + } + + notifyListeners(); + } } diff --git a/lib/modules/sonarr/routes/season_details/widgets/episode_tile.dart b/lib/modules/sonarr/routes/season_details/widgets/episode_tile.dart index dc8e28466..6c6216990 100644 --- a/lib/modules/sonarr/routes/season_details/widgets/episode_tile.dart +++ b/lib/modules/sonarr/routes/season_details/widgets/episode_tile.dart @@ -32,6 +32,12 @@ class _State extends State { trailing: _trailing(), onTap: _onTap, onLongPress: _onLongPress, + backgroundColor: context + .read() + .selectedEpisodes + .contains(widget.episode.id) + ? LunaColours.accent.selected() + : null, ); } @@ -83,6 +89,11 @@ class _State extends State { return LunaIconButton( text: widget.episode.episodeNumber.toString(), textSize: LunaUI.FONT_SIZE_H4, + onPressed: () { + context + .read() + .toggleSelectedEpisode(widget.episode); + }, ); } diff --git a/lib/modules/sonarr/routes/season_details/widgets/page_episodes.dart b/lib/modules/sonarr/routes/season_details/widgets/page_episodes.dart index 67e5bbd70..f44b4c41d 100644 --- a/lib/modules/sonarr/routes/season_details/widgets/page_episodes.dart +++ b/lib/modules/sonarr/routes/season_details/widgets/page_episodes.dart @@ -12,20 +12,79 @@ class SonarrSeasonDetailsEpisodesPage extends StatefulWidget { } class _State extends State - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { final GlobalKey _scaffoldKey = GlobalKey(); final GlobalKey _refreshKey = GlobalKey(); + late AnimationController _hideController; @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + _hideController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: LunaUI.ANIMATION_SPEED), + ); + context.read().addListener(_updateFabListener); + } + + @override + void dispose() { + _hideController.dispose(); + super.dispose(); + } + + void _updateFabListener() { + final state = context.read(); + if (state.selectedEpisodes.isNotEmpty) { + _hideController.forward(); + } else { + _hideController.reverse(); + } + } + @override Widget build(BuildContext context) { super.build(context); return LunaScaffold( scaffoldKey: _scaffoldKey, body: _body(), + floatingActionButton: _floatingActionButton(), + ); + } + + Widget? _floatingActionButton() { + final state = context.watch(); + return ScaleTransition( + scale: _hideController, + child: LunaFloatingActionButton( + icon: LunaIcons.EDIT, + label: state.selectedEpisodes.length > 1 + ? 'sonarr.EpisodesCount' + .tr(args: [state.selectedEpisodes.length.toString()]) + : 'sonarr.OneEpisode'.tr(), + onPressed: () async { + final result = await SonarrDialogs().episodeMultiSettings( + context, + state.selectedEpisodes.length, + ); + + if (result.item1) { + final eps = (await state.episodes)! + .values + .filter((ep) => state.selectedEpisodes.contains(ep.id!)) + .toList(); + result.item2!.execute( + context, + eps, + ); + state.clearSelectedEpisodes(); + } + }, + ), ); } diff --git a/lib/modules/sonarr/routes/season_details/widgets/season_header.dart b/lib/modules/sonarr/routes/season_details/widgets/season_header.dart index fbf272c46..fe0ae8520 100644 --- a/lib/modules/sonarr/routes/season_details/widgets/season_header.dart +++ b/lib/modules/sonarr/routes/season_details/widgets/season_header.dart @@ -23,6 +23,9 @@ class SonarrSeasonHeader extends StatelessWidget { args: [seasonNumber.toString()], ), ), + onTap: () => context + .read() + .toggleSeasonEpisodes(seasonNumber!), onLongPress: () async { HapticFeedback.heavyImpact(); Tuple2 result = diff --git a/lib/widgets/ui.dart b/lib/widgets/ui.dart index 42697de5c..1f5fc21b1 100644 --- a/lib/widgets/ui.dart +++ b/lib/widgets/ui.dart @@ -82,6 +82,7 @@ class LunaUI { static const double OPACITY_DIMMED = 0.75; static const double OPACITY_DISABLED = 0.50; static const double OPACITY_SPLASH = 0.25; + static const double OPACITY_SELECTED = 0.35; static const double ELEVATION = 0.0; static const FontWeight FONT_WEIGHT_BOLD = FontWeight.w600; diff --git a/lib/widgets/ui/colors.dart b/lib/widgets/ui/colors.dart index 309cbb929..12bb05e39 100644 --- a/lib/widgets/ui/colors.dart +++ b/lib/widgets/ui/colors.dart @@ -67,5 +67,10 @@ extension LunaColor on Color { return this.withOpacity(LunaUI.OPACITY_DISABLED); } + Color selected([bool condition = true]) { + if (condition) return this.withOpacity(LunaUI.OPACITY_SELECTED); + return this; + } + Color dimmed() => this.withOpacity(LunaUI.OPACITY_DIMMED); } diff --git a/lib/widgets/ui/floating_action_button.dart b/lib/widgets/ui/floating_action_button.dart index 2c7cfc41e..83aae7855 100644 --- a/lib/widgets/ui/floating_action_button.dart +++ b/lib/widgets/ui/floating_action_button.dart @@ -1,2 +1,2 @@ export 'floating_action_button/floating_action_button_animated.dart'; -export 'floating_action_button/floating_action_button_extended.dart'; +export 'floating_action_button/floating_action_button.dart'; diff --git a/lib/widgets/ui/floating_action_button/floating_action_button.dart b/lib/widgets/ui/floating_action_button/floating_action_button.dart new file mode 100644 index 000000000..84e471c7e --- /dev/null +++ b/lib/widgets/ui/floating_action_button/floating_action_button.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:lunasea/core.dart'; + +class LunaFloatingActionButton extends StatelessWidget { + final Color color; + final Color backgroundColor; + final IconData icon; + final String? label; + final void Function() onPressed; + final Object? heroTag; + + const LunaFloatingActionButton({ + Key? key, + required this.icon, + this.label, + required this.onPressed, + this.backgroundColor = LunaColours.accent, + this.color = Colors.white, + this.heroTag, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (label?.isNotEmpty ?? false) { + return FloatingActionButton.extended( + icon: Icon(icon, color: color), + onPressed: onPressed, + label: Text( + label!, + style: TextStyle( + color: color, + fontWeight: LunaUI.FONT_WEIGHT_BOLD, + fontSize: LunaUI.FONT_SIZE_H3, + letterSpacing: 0.35, + ), + ), + backgroundColor: backgroundColor, + heroTag: heroTag, + ); + } + + return FloatingActionButton( + child: Icon(icon, color: color), + onPressed: onPressed, + backgroundColor: backgroundColor, + heroTag: heroTag, + ); + } +} diff --git a/lib/widgets/ui/floating_action_button/floating_action_button_extended.dart b/lib/widgets/ui/floating_action_button/floating_action_button_extended.dart deleted file mode 100644 index 9823c3423..000000000 --- a/lib/widgets/ui/floating_action_button/floating_action_button_extended.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lunasea/core.dart'; - -class LunaFloatingActionButtonExtended extends StatelessWidget { - final Color color; - final Color backgroundColor; - final IconData icon; - final String label; - final void Function() onPressed; - final Object? heroTag; - - const LunaFloatingActionButtonExtended({ - Key? key, - required this.icon, - required this.label, - required this.onPressed, - this.backgroundColor = LunaColours.accent, - this.color = Colors.white, - this.heroTag, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return FloatingActionButton.extended( - icon: Icon(icon, color: color), - onPressed: onPressed, - label: Text( - label, - style: TextStyle( - color: color, - fontWeight: LunaUI.FONT_WEIGHT_BOLD, - fontSize: LunaUI.FONT_SIZE_H3, - letterSpacing: 0.35, - ), - ), - backgroundColor: backgroundColor, - heroTag: heroTag, - ); - } -} diff --git a/localization/sonarr/en.json b/localization/sonarr/en.json index cd97272c6..b0d079d2e 100644 --- a/localization/sonarr/en.json +++ b/localization/sonarr/en.json @@ -51,14 +51,17 @@ "sonarr.Episode": "Episode", "sonarr.Episodes": "Episodes", "sonarr.EpisodesAvailable": "Episodes Available", + "sonarr.EpisodesCount": "{} Episodes", "sonarr.EpisodeFileDeleted": "Episode File Deleted", "sonarr.EpisodeFileRenamed": "Episode File Renamed", + "sonarr.EpisodeFilesDeleted": "Episode Files Deleted", "sonarr.EpisodeImported": "Episode Imported ({})", "sonarr.EpisodeNumber": "Episode {}", "sonarr.FailedToAddSeries": "Failed to Add Series", "sonarr.FailedToAddTag": "Failed to Add Tag", "sonarr.FailedToBackupDatabase": "Failed to Backup Database", "sonarr.FailedToDeleteEpisodeFile": "Failed to Delete Episode File", + "sonarr.FailedToDeleteEpisodeFiles": "Failed to Delete Episode Files", "sonarr.FailedToDownloadRelease": "Failed to Download Release", "sonarr.FailedToMonitorEpisode": "Failed to Monitor Episode", "sonarr.FailedToMonitorSeason": "Failed to Monitor Season", @@ -67,6 +70,7 @@ "sonarr.FailedToRemoveFromQueue": "Failed to Remove From Queue", "sonarr.FailedToRemoveSeries": "Failed to Remove Series", "sonarr.FailedToRunRSSSync": "Failed to Run RSS Sync", + "sonarr.FailedToSearchForEpisodes": "Failed to Search for Episodes", "sonarr.FailedToSearchForMonitoredEpisodes": "Failed to Search for Monitored Episodes", "sonarr.FailedToSeasonSearch": "Failed to Season Search", "sonarr.FailedToSearch": "Failed to Search", @@ -111,6 +115,8 @@ "sonarr.More": "More", "sonarr.Name": "Name", "sonarr.NoEpisodesFound": "No Episodes Found", + "sonarr.NoEpisodeFilesFound": "No Episode Files Found", + "sonarr.NoEpisodeFilesFoundDeleteMessage": "No selected episodes have files to delete", "sonarr.NoHistoryFound": "No History Found", "sonarr.NoLongerMonitoring": "No Longer Monitoring", "sonarr.NoMessagesFound": "No Messages Found", @@ -120,6 +126,7 @@ "sonarr.NoSeriesFound": "No Series Found", "sonarr.NoSummaryAvailable": "No Summary Available", "sonarr.NoTagsFound": "No Tags Found", + "sonarr.OneEpisode": "1 Episode", "sonarr.OneSeason": "1 Season", "sonarr.Other": "Other", "sonarr.Overview": "Overview", @@ -156,6 +163,7 @@ "sonarr.Search": "Search", "sonarr.Searching": "Searching{}", "sonarr.SearchingForEpisode": "Searching for Episode…", + "sonarr.SearchingForEpisodes": "Searching for Episodes…", "sonarr.SearchingForSeason": "Searching for Season…", "sonarr.SearchingForMonitoredEpisodes": "Searching for Monitored Episodes…", "sonarr.SearchingDescription": "Searching for all missing episodes",