The search screens, the out-of-band auth dialogs, the live TV guide and the list-download paths each carried their own copy of the same shell. Extracts SearchInputField and PendingAuthDialog and routes the duplicated download and guide helpers through one implementation.
473 lines
16 KiB
Dart
473 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../media/ids.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../focus/focusable_action_bar.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_kind.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../database/app_database.dart';
|
|
import '../providers/download_provider.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../services/sync_rule_executor.dart';
|
|
import 'content_utils.dart';
|
|
import 'dialogs.dart';
|
|
import 'download_version_utils.dart';
|
|
import 'platform_detector.dart';
|
|
import 'snackbar_helper.dart';
|
|
|
|
@visibleForTesting
|
|
String? validateEpisodeCountInput(String text, {required bool allowZero}) {
|
|
final count = int.tryParse(text);
|
|
if (count == null || count < 0 || (!allowZero && count == 0)) {
|
|
return t.downloads.invalidEpisodeCount;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Dialog option for the download picker. Typed to avoid stringly-typed values.
|
|
enum _DownloadChoice { all, unwatched, next5, next10, custom, delete }
|
|
|
|
/// Whether the user chose a one-time download or a persistent sync rule.
|
|
enum _SyncChoice { downloadOnce, keepSynced }
|
|
|
|
/// Result of the download dialog + queue operation.
|
|
class DownloadResult {
|
|
final int count;
|
|
final bool syncRuleCreated;
|
|
final bool syncRuleUpdated;
|
|
|
|
/// `true` when the rule targets a collection/playlist — affects the
|
|
/// "created" snackbar wording (no "unwatched episodes" suffix).
|
|
final bool isListRule;
|
|
|
|
/// `true` when the queued leaves are tracks (album/artist/track download)
|
|
/// — picks "tracks queued" over "episodes queued" wording.
|
|
final bool isMusic;
|
|
|
|
const DownloadResult({
|
|
required this.count,
|
|
this.syncRuleCreated = false,
|
|
this.syncRuleUpdated = false,
|
|
this.isListRule = false,
|
|
this.isMusic = false,
|
|
});
|
|
|
|
String toSnackBarMessage() {
|
|
if (syncRuleUpdated) return t.downloads.syncRuleUpdated;
|
|
if (syncRuleCreated) {
|
|
return isListRule ? t.downloads.syncRuleListCreated : t.downloads.syncRuleCreated(count: count.toString());
|
|
}
|
|
if (count > 1) return isMusic ? t.downloads.tracksQueued(count: count) : t.downloads.episodesQueued(count: count);
|
|
return t.downloads.downloadQueued;
|
|
}
|
|
}
|
|
|
|
/// Shows download options dialog for shows/seasons, then queues the download.
|
|
/// For movies/episodes, queues directly without a dialog.
|
|
/// Returns a [DownloadResult], or null if cancelled.
|
|
///
|
|
/// When [onDelete] is provided (i.e. the item already has downloads), a
|
|
/// "Delete download" row is appended to the show/season options dialog so the
|
|
/// completed-download button can double as a "download more / delete" menu.
|
|
/// Selecting it runs [onDelete] and returns null.
|
|
Future<DownloadResult?> showDownloadOptionsAndQueue(
|
|
BuildContext context, {
|
|
required MediaItem metadata,
|
|
required MediaServerClient client,
|
|
required DownloadProvider downloadProvider,
|
|
Future<void> Function()? onDelete,
|
|
}) async {
|
|
final kind = metadata.kind;
|
|
|
|
var filter = DownloadFilter.all;
|
|
int? maxCount;
|
|
bool keepSynced = false;
|
|
// Remembered "Include Specials" choice; the toggle is only shown for whole
|
|
// shows (a single season has no Specials to drop).
|
|
final settings = SettingsService.instanceOrNull;
|
|
bool includeSpecials = settings?.read(SettingsService.downloadIncludeSpecials) ?? true;
|
|
|
|
if (kind == MediaKind.show || kind == MediaKind.season) {
|
|
int? customCount;
|
|
final options = <({IconData? icon, String label, _DownloadChoice value})>[
|
|
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: _DownloadChoice.all),
|
|
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: _DownloadChoice.unwatched),
|
|
(icon: Symbols.filter_5_rounded, label: t.downloads.nextNUnwatched(count: 5), value: _DownloadChoice.next5),
|
|
(
|
|
icon: Symbols.filter_9_plus_rounded,
|
|
label: t.downloads.nextNUnwatched(count: 10),
|
|
value: _DownloadChoice.next10,
|
|
),
|
|
(icon: Symbols.tune_rounded, label: t.downloads.customAmount, value: _DownloadChoice.custom),
|
|
];
|
|
// Already-downloaded show/season: offer deletion as the last row.
|
|
if (onDelete != null) {
|
|
options.add((icon: Symbols.delete_rounded, label: t.downloads.deleteDownload, value: _DownloadChoice.delete));
|
|
}
|
|
final selected = await showOptionPickerDialog<_DownloadChoice>(
|
|
context,
|
|
title: t.downloads.downloadNow,
|
|
options: options,
|
|
toggle: kind == MediaKind.show
|
|
? (
|
|
label: t.downloads.includeSpecials,
|
|
icon: Symbols.star_rounded,
|
|
value: includeSpecials,
|
|
onChanged: (value) => includeSpecials = value,
|
|
)
|
|
: null,
|
|
onBeforeClose: (value) async {
|
|
if (value != _DownloadChoice.custom) return value;
|
|
customCount = await _showEpisodeCountDialog(context);
|
|
return customCount != null ? value : null;
|
|
},
|
|
);
|
|
|
|
if (selected == null || !context.mounted) return null;
|
|
|
|
switch (selected) {
|
|
case _DownloadChoice.all:
|
|
break;
|
|
case _DownloadChoice.unwatched:
|
|
filter = DownloadFilter.unwatched;
|
|
case _DownloadChoice.next5:
|
|
filter = DownloadFilter.unwatched;
|
|
maxCount = 5;
|
|
case _DownloadChoice.next10:
|
|
filter = DownloadFilter.unwatched;
|
|
maxCount = 10;
|
|
case _DownloadChoice.custom:
|
|
filter = DownloadFilter.unwatched;
|
|
maxCount = customCount;
|
|
case _DownloadChoice.delete:
|
|
if (onDelete != null) await onDelete();
|
|
return null;
|
|
}
|
|
|
|
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
|
|
final syncChoice = await _showSyncChoiceDialog(context);
|
|
if (syncChoice == null || !context.mounted) return null;
|
|
keepSynced = syncChoice == _SyncChoice.keepSynced;
|
|
}
|
|
}
|
|
|
|
if (!context.mounted) return null;
|
|
|
|
final versionConfig = await resolveDownloadVersion(context, metadata, client);
|
|
if (versionConfig == null || !context.mounted) return null;
|
|
|
|
// Create or update sync rule before queueing (so the rule exists even if queue fails)
|
|
bool syncRuleUpdated = false;
|
|
if (keepSynced) {
|
|
final syncCount = maxCount ?? 0; // 0 means "all unwatched" for the rule
|
|
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(metadata.serverId ?? client.serverId), metadata.id);
|
|
syncRuleUpdated = downloadProvider.hasSyncRule(ruleKey);
|
|
|
|
await downloadProvider.createSyncRule(
|
|
serverId: ServerId(metadata.serverId ?? client.serverId),
|
|
ratingKey: metadata.id,
|
|
targetType: metadata.kind.id.isNotEmpty ? metadata.kind.id : ContentTypes.show,
|
|
episodeCount: syncCount,
|
|
mediaIndex: versionConfig.mediaIndex,
|
|
includeSpecials: includeSpecials,
|
|
targetMetadata: metadata,
|
|
);
|
|
}
|
|
|
|
// Remember the toggle for next time (only shown, and thus meaningful, for shows).
|
|
if (kind == MediaKind.show) {
|
|
await settings?.write(SettingsService.downloadIncludeSpecials, includeSpecials);
|
|
}
|
|
|
|
final count = await downloadProvider.queueDownload(
|
|
metadata,
|
|
client,
|
|
versionConfig: versionConfig,
|
|
filter: filter,
|
|
maxCount: maxCount,
|
|
includeSpecials: includeSpecials,
|
|
);
|
|
|
|
return DownloadResult(
|
|
count: count,
|
|
syncRuleCreated: keepSynced && !syncRuleUpdated,
|
|
syncRuleUpdated: syncRuleUpdated,
|
|
isMusic: kind.isMusic,
|
|
);
|
|
}
|
|
|
|
/// Shows download options dialog for a collection or playlist, then queues
|
|
/// the download. Offers both one-time download and "Keep Synced" (creates or
|
|
/// updates a sync rule for the target).
|
|
///
|
|
/// [rootMetadata] is the collection or playlist itself — used to persist the
|
|
/// title/thumb for the sync rule and build the rule's global key.
|
|
/// [targetType] must be [ContentTypes.collection] or [ContentTypes.playlist].
|
|
Future<DownloadResult?> showListDownloadOptionsAndQueue(
|
|
BuildContext context, {
|
|
required MediaItem rootMetadata,
|
|
required String targetType,
|
|
required List<MediaItem> items,
|
|
required MediaServerClient client,
|
|
required DownloadProvider downloadProvider,
|
|
}) async {
|
|
assert(targetType == ContentTypes.collection || targetType == ContentTypes.playlist);
|
|
|
|
final selectedFilter = await showOptionPickerDialog<DownloadFilter>(
|
|
context,
|
|
title: t.downloads.downloadNow,
|
|
options: _filterOptions(DownloadFilter.all, DownloadFilter.unwatched),
|
|
);
|
|
|
|
if (selectedFilter == null || !context.mounted) return null;
|
|
|
|
final syncChoice = await _showSyncChoiceDialog(context);
|
|
if (syncChoice == null || !context.mounted) return null;
|
|
|
|
final serverId = rootMetadata.serverId ?? client.serverId;
|
|
final filterString = selectedFilter == DownloadFilter.unwatched ? SyncRuleFilter.unwatched : SyncRuleFilter.all;
|
|
|
|
bool syncRuleCreated = false;
|
|
bool syncRuleUpdated = false;
|
|
|
|
if (syncChoice == _SyncChoice.keepSynced) {
|
|
final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id);
|
|
if (downloadProvider.hasSyncRule(ruleKey)) {
|
|
await downloadProvider.updateSyncRuleFilter(ruleKey, filterString);
|
|
syncRuleUpdated = true;
|
|
} else {
|
|
await downloadProvider.createSyncRule(
|
|
serverId: ServerId(serverId),
|
|
ratingKey: rootMetadata.id,
|
|
targetType: targetType,
|
|
episodeCount: 0,
|
|
mediaIndex: 0,
|
|
downloadFilter: filterString,
|
|
targetMetadata: rootMetadata,
|
|
);
|
|
syncRuleCreated = true;
|
|
}
|
|
}
|
|
|
|
final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter);
|
|
|
|
return DownloadResult(
|
|
count: count,
|
|
syncRuleCreated: syncRuleCreated,
|
|
syncRuleUpdated: syncRuleUpdated,
|
|
isListRule: true,
|
|
);
|
|
}
|
|
|
|
/// The all/unwatched option rows, shared by the pickers that differ only in
|
|
/// how they spell those two values.
|
|
List<({IconData? icon, String label, T value})> _filterOptions<T>(T all, T unwatched) => [
|
|
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: all),
|
|
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: unwatched),
|
|
];
|
|
|
|
/// Asks whether to download once or keep the target synced.
|
|
Future<_SyncChoice?> _showSyncChoiceDialog(BuildContext context) => showOptionPickerDialog<_SyncChoice>(
|
|
context,
|
|
title: t.downloads.downloadNow,
|
|
options: [
|
|
(icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce),
|
|
(icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced),
|
|
],
|
|
);
|
|
|
|
Future<int?> _showEpisodeCountDialog(
|
|
BuildContext context, {
|
|
String? title,
|
|
String? hintText,
|
|
bool allowZero = false,
|
|
}) async {
|
|
final result = await showTextInputDialog(
|
|
context,
|
|
title: title ?? t.downloads.howManyEpisodes,
|
|
labelText: '',
|
|
hintText: hintText ?? '',
|
|
confirmText: t.common.ok,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
validator: (text) => validateEpisodeCountInput(text, allowZero: allowZero),
|
|
);
|
|
if (result == null) return null;
|
|
return int.tryParse(result);
|
|
}
|
|
|
|
/// Shows a dialog to edit a sync rule's episode count. Returns true if updated.
|
|
Future<bool> editSyncRuleCount(
|
|
BuildContext context, {
|
|
required DownloadProvider downloadProvider,
|
|
required String globalKey,
|
|
required int currentCount,
|
|
String? displayTitle,
|
|
}) async {
|
|
final count = await _showEpisodeCountDialog(
|
|
context,
|
|
title: t.downloads.editEpisodeCount,
|
|
hintText: currentCount.toString(),
|
|
allowZero: true,
|
|
);
|
|
if (count == null || !context.mounted) return false;
|
|
|
|
if (count == 0) {
|
|
final removed = await confirmAndRemoveSyncRule(
|
|
context,
|
|
downloadProvider: downloadProvider,
|
|
globalKey: globalKey,
|
|
displayTitle: displayTitle ?? globalKey,
|
|
);
|
|
if (removed && context.mounted) {
|
|
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
await downloadProvider.updateSyncRuleCount(globalKey, count);
|
|
return true;
|
|
}
|
|
|
|
/// Shows a dialog to edit a collection/playlist sync rule's filter. Returns
|
|
/// true if the filter changed.
|
|
Future<bool> editSyncRuleFilter(
|
|
BuildContext context, {
|
|
required DownloadProvider downloadProvider,
|
|
required String globalKey,
|
|
required String currentFilter,
|
|
}) async {
|
|
final selected = await showOptionPickerDialog<String>(
|
|
context,
|
|
title: t.downloads.editSyncFilter,
|
|
options: _filterOptions(SyncRuleFilter.all, SyncRuleFilter.unwatched),
|
|
);
|
|
if (selected == null || selected == currentFilter || !context.mounted) return false;
|
|
|
|
await downloadProvider.updateSyncRuleFilter(globalKey, selected);
|
|
return true;
|
|
}
|
|
|
|
/// Shows a confirmation dialog to remove a sync rule. Returns true if removed.
|
|
Future<bool> confirmAndRemoveSyncRule(
|
|
BuildContext context, {
|
|
required DownloadProvider downloadProvider,
|
|
required String globalKey,
|
|
required String displayTitle,
|
|
}) async {
|
|
final confirmed = await showConfirmDialog(
|
|
context,
|
|
title: t.downloads.removeSyncRule,
|
|
message: t.downloads.removeSyncRuleConfirm(title: displayTitle),
|
|
confirmText: t.downloads.removeSyncRule,
|
|
);
|
|
if (!confirmed || !context.mounted) return false;
|
|
|
|
await downloadProvider.deleteSyncRule(globalKey);
|
|
return true;
|
|
}
|
|
|
|
/// Whether this rule targets a collection or playlist (as opposed to a
|
|
/// show/season). Shared by detail screens, the sync rules screen, and the
|
|
/// context menu to dispatch between count vs. filter editing.
|
|
extension SyncRuleItemDispatch on SyncRuleItem {
|
|
bool get isListRule => targetType == ContentTypes.collection || targetType == ContentTypes.playlist;
|
|
}
|
|
|
|
/// Open the right sync-rule edit dialog for [globalKey] and show a success
|
|
/// snackbar when anything changed. Used by both detail screens and the
|
|
/// context menu so they don't each reimplement the get-rule / edit / snack
|
|
/// dance.
|
|
Future<void> manageSyncRule(
|
|
BuildContext context, {
|
|
required DownloadProvider downloadProvider,
|
|
required String globalKey,
|
|
String? displayTitle,
|
|
}) async {
|
|
final rule = downloadProvider.getSyncRule(globalKey);
|
|
if (rule == null) return;
|
|
|
|
final bool updated;
|
|
if (rule.isListRule) {
|
|
updated = await editSyncRuleFilter(
|
|
context,
|
|
downloadProvider: downloadProvider,
|
|
globalKey: globalKey,
|
|
currentFilter: rule.downloadFilter,
|
|
);
|
|
} else {
|
|
updated = await editSyncRuleCount(
|
|
context,
|
|
downloadProvider: downloadProvider,
|
|
globalKey: globalKey,
|
|
currentCount: rule.episodeCount,
|
|
displayTitle: displayTitle ?? rule.ratingKey,
|
|
);
|
|
}
|
|
if (updated && context.mounted) {
|
|
showSuccessSnackBar(context, t.downloads.syncRuleUpdated);
|
|
}
|
|
}
|
|
|
|
/// Confirm + remove a sync rule and show a success snackbar.
|
|
Future<void> removeSyncRuleAndSnack(
|
|
BuildContext context, {
|
|
required DownloadProvider downloadProvider,
|
|
required String globalKey,
|
|
required String displayTitle,
|
|
}) async {
|
|
final removed = await confirmAndRemoveSyncRule(
|
|
context,
|
|
downloadProvider: downloadProvider,
|
|
globalKey: globalKey,
|
|
displayTitle: displayTitle,
|
|
);
|
|
if (removed && context.mounted) {
|
|
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
|
}
|
|
}
|
|
|
|
/// The download / manage-sync-rule app-bar pair shared by the collection and
|
|
/// playlist detail screens: one entry that downloads (or edits the existing
|
|
/// rule) and, when a rule exists, one that removes it. Both are hidden on
|
|
/// Apple TV, which has no downloads UI.
|
|
///
|
|
/// [hasRule] stays caller-computed so each screen keeps its own
|
|
/// `context.select` short-circuit, and [showDownload] carries the screen's
|
|
/// own visibility predicate for the first entry.
|
|
List<FocusableAction> buildSyncRuleActions(
|
|
BuildContext context, {
|
|
required String ruleKey,
|
|
required String displayTitle,
|
|
required bool hasRule,
|
|
required bool showDownload,
|
|
required VoidCallback onDownload,
|
|
}) {
|
|
if (PlatformDetector.isAppleTV()) return const [];
|
|
return [
|
|
if (showDownload)
|
|
FocusableAction(
|
|
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
|
|
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
|
|
onPressed: hasRule
|
|
? () => manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: ruleKey)
|
|
: onDownload,
|
|
iconColor: hasRule ? Colors.teal : null,
|
|
),
|
|
if (hasRule)
|
|
FocusableAction(
|
|
icon: Symbols.sync_disabled_rounded,
|
|
tooltip: t.downloads.removeSyncRule,
|
|
onPressed: () => removeSyncRuleAndSnack(
|
|
context,
|
|
downloadProvider: context.read<DownloadProvider>(),
|
|
globalKey: ruleKey,
|
|
displayTitle: displayTitle,
|
|
),
|
|
),
|
|
];
|
|
}
|