refactor: share search field, auth dialog, and list-download plumbing

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.
This commit is contained in:
edde746
2026-07-26 06:09:49 +02:00
parent 4eaf4423a1
commit 9429a76acc
22 changed files with 576 additions and 701 deletions
+40
View File
@@ -29,6 +29,46 @@ Future<void> collectEpisodes(
);
}
/// Walks [items] and collects playable movie/episode/track entries into [out].
/// Shows and seasons are expanded into their episodes; albums and artists are
/// expanded into their tracks (audio playlists/collections). Clips, nested
/// collections/playlists, and unknown types are skipped. [unwatchedOnly] applies
/// the same played-state filter to every kind — for tracks that means
/// Plex/Jellyfin play counts.
///
/// Shared by the one-shot "download this list" queue and the sync rule that
/// keeps the same list downloaded, so both expand a list to the same items.
Future<void> collectListLeaves(
MediaServerClient client,
List<MediaItem> items, {
required bool unwatchedOnly,
required List<MediaItem> out,
}) async {
for (final item in items) {
switch (item.kind) {
case MediaKind.movie:
case MediaKind.episode:
case MediaKind.track:
if (unwatchedOnly && !item.isUnwatchedOrInProgress) break;
out.add(item);
case MediaKind.show:
case MediaKind.season:
await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
case MediaKind.album:
case MediaKind.artist:
// One recursive-leaves call per container on both backends
// (Jellyfin retries tag-only artists by album-artist credit).
for (final track in await client.fetchPlayableDescendants(item.id)) {
if (unwatchedOnly && !track.isUnwatchedOrInProgress) continue;
out.add(track);
}
default:
// Skip clips, nested collections/playlists, unknown types.
break;
}
}
}
/// Fetch just the first episode of a season without walking the entire season.
/// Use this for representative lookups and immediate "play first" actions.
Future<MediaItem?> fetchFirstEpisodeForSeason(
+16
View File
@@ -156,6 +156,22 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
}
}
/// The results list both screens render: padded, without keep-alives or
/// semantic indexes, one child per entry of [searchResults].
Widget buildResultsSliver(NullableIndexedWidgetBuilder itemBuilder) {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
itemBuilder,
childCount: searchResults.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
),
);
}
/// OSK "Search" / hardware Enter on TV: jump to results, or force the
/// pending search to run now.
void handleSearchSubmit() {
+15 -35
View File
@@ -1031,15 +1031,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Queue every playable item from a collection/playlist for download.
///
/// Movies, episodes, and tracks are queued directly. Shows and seasons are
/// expanded into their episodes and albums/artists into their tracks (when
/// [expandShows] is true). Nested collections/playlists and unknown types
/// are skipped.
/// Expansion follows [collectListLeaves] so a one-shot list download queues
/// exactly what a sync rule on the same list would.
Future<int> queueListDownload(
List<MediaItem> items,
MediaServerClient client, {
DownloadFilter filter = DownloadFilter.all,
bool expandShows = true,
}) async {
if (!_downloadManager.downloadsSupported) return 0;
@@ -1053,39 +1050,22 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final relatedContext = _RelatedMetadataDownloadContext();
int count = 0;
Future<void> queueItem(MediaItem item) async {
if (unwatchedOnly && !item.isUnwatchedOrInProgress) return;
final queued = await _queueSingleDownload(item, client, ownership: ownership, relatedContext: relatedContext);
if (queued) count++;
}
for (final item in items) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
if (item.isMovie || item.isEpisode || item.kind == MediaKind.track) {
await queueItem(item);
} else if (item.isShow || item.isSeason) {
if (!expandShows) continue;
// One-shot recursive expansion for both shows and seasons.
final episodes = <MediaItem>[];
await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
// Expand one list entry at a time so a cancelled queue stops before the
// next container is fetched.
final leaves = <MediaItem>[];
await collectListLeaves(client, [item], unwatchedOnly: unwatchedOnly, out: leaves);
if (!_isQueueOwnershipCurrent(ownership)) return count;
for (final leaf in leaves) {
final queued = await _queueSingleDownload(
_ensureServerId(leaf, item.serverId),
client,
ownership: ownership,
relatedContext: relatedContext,
);
if (queued) count++;
if (!_isQueueOwnershipCurrent(ownership)) return count;
for (final ep in episodes) {
await queueItem(ep);
if (!_isQueueOwnershipCurrent(ownership)) return count;
}
} else if (item.kind == MediaKind.album || item.kind == MediaKind.artist) {
if (!expandShows) continue;
// Same one-shot expansion for music containers (album/artist →
// tracks) via the shared recursive-leaves call.
final tracks = await client.fetchPlayableDescendants(item.id);
if (!_isQueueOwnershipCurrent(ownership)) return count;
for (final track in tracks) {
await queueItem(_ensureServerId(track, item.serverId));
if (!_isQueueOwnershipCurrent(ownership)) return count;
}
} else {
// Skip clips, nested collections/playlists, unknown types.
continue;
}
}
return count;
+19 -67
View File
@@ -1,19 +1,16 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_text_field.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/debounced_media_search.dart';
import '../services/catalog/catalog_source.dart';
import '../utils/focus_utils.dart';
import '../utils/platform_detector.dart';
import '../widgets/app_icon.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/focused_scroll_scaffold.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/pill_input_decoration.dart';
import '../widgets/search_input_field.dart';
import 'libraries/state_messages.dart';
/// Free-text search of one catalog source (the Explore tab's active source),
@@ -30,7 +27,6 @@ class CatalogSearchScreen extends StatefulWidget {
}
class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear');
@override
String get searchDebugLabel => 'CatalogSearch';
@@ -46,17 +42,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
}
@override
void dispose() {
_clearFocusNode.dispose();
super.dispose();
}
void _clearSearch() {
searchController.clear();
searchFocusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final sourceName = widget.source.displayName;
@@ -64,36 +49,13 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
title: Text(t.explore.searchHint(source: sourceName)),
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: Stack(
alignment: Alignment.centerRight,
children: [
FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
textInputAction: TextInputAction.search,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
decoration: pillInputDecoration(
context,
hintText: t.explore.searchHint(source: sourceName),
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null,
),
),
if (searchController.text.isNotEmpty)
FocusableButton(
focusNode: _clearFocusNode,
onPressed: _clearSearch,
onNavigateLeft: searchFocusNode.requestFocus,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
autoScroll: false,
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
),
],
),
child: SearchInputField(
controller: searchController,
focusNode: searchFocusNode,
debugLabel: searchDebugLabel,
hintText: t.explore.searchHint(source: sourceName),
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
),
),
if (isSearching)
@@ -125,26 +87,16 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
}
Widget _buildResultsList() {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = searchResults[index];
return FocusableMediaCard(
key: Key(item.globalKey),
item: item,
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? firstResultFocusNode : null,
onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null,
);
},
childCount: searchResults.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
),
);
return buildResultsSliver((context, index) {
final item = searchResults[index];
return FocusableMediaCard(
key: Key(item.globalKey),
item: item,
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? firstResultFocusNode : null,
onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null,
);
});
}
}
+4 -2
View File
@@ -8,6 +8,7 @@ import '../mixins/paginated_item_loader.dart';
import '../mixins/standard_paginated_view.dart';
import '../providers/download_provider.dart';
import '../utils/app_logger.dart';
import '../utils/content_utils.dart';
import '../utils/dialogs.dart';
import '../utils/error_message_utils.dart';
import '../utils/download_utils.dart';
@@ -134,9 +135,10 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
libraryTitle: widget.collection.libraryTitle,
);
if (!mounted) return;
final result = await showCollectionDownloadOptionsAndQueue(
final result = await showListDownloadOptionsAndQueue(
context,
collectionMetadata: widget.collection,
rootMetadata: widget.collection,
targetType: ContentTypes.collection,
items: allItems,
client: mediaClient,
downloadProvider: downloadProvider,
@@ -50,6 +50,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
required LiveTvChannel? channel,
required String? posterThumb,
required String? posterServerId,
ValueChanged<bool>? onRecordingStateChanged,
}) {
final effectiveContext = sheetContext ?? context;
final multiServer = effectiveContext.read<MultiServerProvider>();
@@ -74,6 +75,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
posterUrl: posterUrl,
onTuneChannel: channel != null ? () => tuneChannel(channel) : null,
client: client,
onRecordingStateChanged: onRecordingStateChanged,
);
}
}
+3 -7
View File
@@ -20,6 +20,7 @@ import '../../widgets/settings_builder.dart';
import '../../utils/app_logger.dart';
import '../../utils/error_message_utils.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/live_tv_matching.dart';
import '../../utils/platform_detector.dart';
import '../../utils/serial_future_queue.dart';
import '../../utils/snackbar_helper.dart';
@@ -269,15 +270,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) {
for (final dvr in serverInfo.dvrs) {
if (dvr.key == serverInfo.dvrKey) {
return _nonEmpty(dvr.lineupTitle) ?? _nonEmpty(dvr.lineupURL) ?? _nonEmpty(dvr.lineup);
return liveTvNonEmpty(dvr.lineupTitle) ?? liveTvNonEmpty(dvr.lineupURL) ?? liveTvNonEmpty(dvr.lineup);
}
}
return _nonEmpty(serverInfo.lineup);
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
return liveTvNonEmpty(serverInfo.lineup);
}
Future<void> _loadChannels() {
+15 -40
View File
@@ -22,19 +22,17 @@ import '../../../providers/multi_server_provider.dart';
import '../../../media/media_server_client.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/app_logger.dart';
import '../live_tv_actions_mixin.dart';
import '../live_tv_refresh_lifecycle.dart';
import '../../../utils/formatters.dart';
import '../../../utils/live_tv_grouping.dart';
import '../../../utils/live_tv_matching.dart';
import '../../../utils/media_image_helper.dart';
import '../../../utils/live_tv_player_navigation.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/app_menu.dart';
import '../../../widgets/clickable_cursor.dart';
import '../../../widgets/optimized_media_image.dart';
import '../livetv_styles.dart';
import '../program_details_sheet.dart';
class GuideTab extends StatefulWidget {
final List<LiveTvChannel> channels;
@@ -99,7 +97,8 @@ final class _GuideChannelRow extends _GuideRow {
const _GuideChannelRow({required this.channel, required this.channelIndex});
}
class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBindingObserver {
class GuideTabState extends State<GuideTab>
with LiveTvActionsMixin<GuideTab>, MountedSetStateMixin, WidgetsBindingObserver {
static const _slotWidth = 180.0;
static const _channelColumnWidth = 132.0;
static const _rowHeight = 64.0;
@@ -147,6 +146,9 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
LiveTvProgram? _focusedProgram;
bool _pendingFocus = false;
@override
List<LiveTvChannel> get liveTvChannels => widget.channels;
/// Focus into the guide content (called from tab bar navigation or initial load).
void focusContent() {
if (!InputModeTracker.isKeyboardMode(context)) return;
@@ -494,12 +496,12 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}
Set<String> _recordingKeysForProgram(LiveTvProgram program, {String? fallbackServerId}) {
final serverId = _nonEmpty(program.serverId) ?? _nonEmpty(fallbackServerId);
final serverId = liveTvNonEmpty(program.serverId) ?? liveTvNonEmpty(fallbackServerId);
if (serverId == null) return const <String>{};
final keys = <String>{};
void addMediaId(String? value) {
final normalized = _nonEmpty(value);
final normalized = liveTvNonEmpty(value);
if (normalized != null) keys.add(_recordingKey(ServerId(serverId), 'media', normalized));
}
@@ -507,7 +509,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
addMediaId(program.guid);
addMediaId(program.key);
final channelIdentifier = _nonEmpty(program.channelIdentifier);
final channelIdentifier = liveTvNonEmpty(program.channelIdentifier);
final beginsAt = program.beginsAt;
if (channelIdentifier != null && beginsAt != null) {
keys.add(_recordingKey(ServerId(serverId), 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}'));
@@ -518,11 +520,6 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
String _recordingKey(ServerId serverId, String type, String value) => '$serverId\u0000$type\u0000$value';
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
List<_GuideRow> get _guideRows {
final groups = groupLiveTvChannelsBySource(widget.channels);
if (groups.length <= 1) {
@@ -593,14 +590,9 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
return (totalMinutes / _minutesPerSlot) * _slotWidth;
}
Future<void> _tuneChannel(LiveTvChannel channel) async {
final multiServer = context.read<MultiServerProvider>();
await navigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels);
}
void _activateProgram(LiveTvChannel channel, LiveTvProgram program) {
if (PlatformDetector.isTV() && program.isCurrentlyAiring) {
_tuneChannel(channel);
tuneChannel(channel);
return;
}
@@ -793,7 +785,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) {
final channel = widget.channels[_gridChannelIndex];
if (_gridColumn == 0) {
_tuneChannel(channel);
tuneChannel(channel);
} else if (_focusedProgram != null) {
_activateProgram(channel, _focusedProgram!);
}
@@ -1301,7 +1293,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
client: client,
channel: channel,
theme: theme,
onTap: () => _tuneChannel(channel),
onTap: () => tuneChannel(channel),
onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null,
isFocused: isFocused,
isFavorite: widget.isFavoriteChannel?.call(channel) ?? false,
@@ -1503,28 +1495,11 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
}
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
final multiServer = context.read<MultiServerProvider>();
final serverId = serverIdOrNull(channel.serverId);
final client = serverId == null ? null : multiServer.getClientForServer(serverId);
String? posterUrl;
if (program.thumb != null && client != null) {
posterUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: program.thumb,
maxWidth: 80,
maxHeight: 120,
devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context),
imageType: ImageType.poster,
);
}
showProgramDetailsSheet(
context,
showProgramDetails(
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: () => _tuneChannel(channel),
client: client,
posterThumb: program.thumb,
posterServerId: channel.serverId,
onRecordingStateChanged: (isScheduled) => _handleRecordingStateChanged(program, isScheduled),
);
}
@@ -12,6 +12,7 @@ import '../../services/media_list_playback_launcher.dart';
import '../../services/music/music_playback_service.dart';
import '../../services/playlist_items_loader.dart';
import '../../utils/app_logger.dart';
import '../../utils/content_utils.dart';
import '../../utils/error_message_utils.dart';
import '../../utils/continuation_pagination_coordinator.dart';
import '../../utils/music_navigation.dart';
@@ -302,9 +303,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
try {
final allItems = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
if (!mounted) return;
final result = await showPlaylistDownloadOptionsAndQueue(
final result = await showListDownloadOptionsAndQueue(
context,
playlistMetadata: _playlistAsMetadata(),
rootMetadata: _playlistAsMetadata(),
targetType: ContentTypes.playlist,
items: allItems,
client: mediaClient,
downloadProvider: downloadProvider,
+32 -83
View File
@@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_text_field.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../media/ids.dart';
import '../media/media_item.dart';
@@ -17,7 +15,7 @@ import '../utils/platform_detector.dart';
import '../utils/snackbar_helper.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/pill_input_decoration.dart';
import '../widgets/search_input_field.dart';
import '../widgets/focusable_media_card.dart';
import '../utils/focus_utils.dart';
import 'libraries/state_messages.dart';
@@ -34,7 +32,6 @@ class _SearchScreenState extends State<SearchScreen>
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
String? _focusResultsForQuery;
final _tvKeyboardController = TvKeyboardController();
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
@override
void initState() {
@@ -42,17 +39,6 @@ class _SearchScreenState extends State<SearchScreen>
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
}
@override
void dispose() {
_clearFocusNode.dispose();
super.dispose();
}
void _clearSearch() {
searchController.clear();
searchFocusNode.requestFocus();
}
@override
String get searchDebugLabel => 'Search';
@@ -181,31 +167,21 @@ class _SearchScreenState extends State<SearchScreen>
Widget _buildResultsList(BuildContext context) {
final multiServer = context.watch<MultiServerProvider>();
final showServerName = multiServer.totalServerCount > 1;
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = searchResults[index];
return FocusableMediaCard(
key: Key(item.globalKey),
item: item,
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? firstResultFocusNode : null,
onRefresh: updateItem,
onListRefresh: refresh,
onNavigateLeft: _navigateToSidebar,
onNavigateUp: index == 0 ? focusSearchInput : null,
showServerName: showServerName,
);
},
childCount: searchResults.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
),
);
return buildResultsSliver((context, index) {
final item = searchResults[index];
return FocusableMediaCard(
key: Key(item.globalKey),
item: item,
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? firstResultFocusNode : null,
onRefresh: updateItem,
onListRefresh: refresh,
onNavigateLeft: _navigateToSidebar,
onNavigateUp: index == 0 ? focusSearchInput : null,
showServerName: showServerName,
);
});
}
@override
@@ -217,49 +193,22 @@ class _SearchScreenState extends State<SearchScreen>
slivers: [
DesktopSliverAppBar(title: Text(t.common.search), floating: true),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: Stack(
alignment: Alignment.centerRight,
children: [
FocusableTextField(
controller: searchController,
focusNode: searchFocusNode,
tvKeyboardController: _tvKeyboardController,
textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
onNavigateDown: searchResults.isNotEmpty && !isSearching
? firstResultFocusNode.requestFocus
: null,
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
onBack: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
} else {
_navigateToSidebar();
}
},
decoration: pillInputDecoration(
context,
hintText: t.search.hint,
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null,
),
),
if (searchController.text.isNotEmpty)
FocusableButton(
focusNode: _clearFocusNode,
onPressed: _clearSearch,
onNavigateLeft: searchFocusNode.requestFocus,
onNavigateDown: searchResults.isNotEmpty && !isSearching
? firstResultFocusNode.requestFocus
: null,
autoScroll: false,
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
),
],
),
child: SearchInputField(
controller: searchController,
focusNode: searchFocusNode,
debugLabel: searchDebugLabel,
hintText: t.search.hint,
tvKeyboardController: _tvKeyboardController,
onNavigateLeft: _navigateToSidebar,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
onBack: () {
if (searchController.text.isNotEmpty) {
searchController.clear();
} else {
_navigateToSidebar();
}
},
),
),
if (isSearching)
+1 -41
View File
@@ -1,10 +1,8 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart';
import '../database/app_database.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../models/download_models.dart';
import '../utils/app_logger.dart';
@@ -352,7 +350,7 @@ class SyncRuleExecutor {
final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched;
final collected = <MediaItem>[];
await collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected);
await collectListLeaves(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected);
final candidates = unwatchedOnly
? await _excludeLocallyWatched(
@@ -409,44 +407,6 @@ class SyncRuleExecutor {
libraryTitle: source?.libraryTitle,
);
/// Walks [items] and collects playable movie/episode/track entries into
/// [out]. Shows and seasons are expanded into their episodes; albums and
/// artists are expanded into their tracks (audio playlists/collections in
/// sync rules). Clips, nested collections/playlists, and unknown types are
/// skipped. [unwatchedOnly] applies the same played-state filter to every
/// kind — for tracks that means Plex/Jellyfin play counts.
@visibleForTesting
Future<void> collectItemsForList(
MediaServerClient client,
List<MediaItem> items, {
required bool unwatchedOnly,
required List<MediaItem> out,
}) async {
for (final item in items) {
switch (item.kind) {
case MediaKind.movie:
case MediaKind.episode:
case MediaKind.track:
if (unwatchedOnly && !item.isUnwatchedOrInProgress) break;
out.add(item);
case MediaKind.show:
case MediaKind.season:
await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
case MediaKind.album:
case MediaKind.artist:
// One recursive-leaves call per container on both backends
// (Jellyfin retries tag-only artists by album-artist credit).
for (final track in await client.fetchPlayableDescendants(item.id)) {
if (unwatchedOnly && !track.isUnwatchedOrInProgress) continue;
out.add(track);
}
default:
// Skip clips, nested collections/playlists, unknown types.
break;
}
}
}
/// Drop items the user already marked watched locally — the server response
/// still shows them as unwatched until the next bidirectional-sync push
/// drains the OfflineWatchProgress queue, which can be many seconds away.
+17 -52
View File
@@ -148,14 +148,7 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
}
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
final syncChoice = await 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),
],
);
final syncChoice = await _showSyncChoiceDialog(context);
if (syncChoice == null || !context.mounted) return null;
keepSynced = syncChoice == _SyncChoice.keepSynced;
}
@@ -226,22 +219,12 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
final selectedFilter = await showOptionPickerDialog<DownloadFilter>(
context,
title: t.downloads.downloadNow,
options: [
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: DownloadFilter.all),
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: DownloadFilter.unwatched),
],
options: _filterOptions(DownloadFilter.all, DownloadFilter.unwatched),
);
if (selectedFilter == null || !context.mounted) return null;
final syncChoice = await 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),
],
);
final syncChoice = await _showSyncChoiceDialog(context);
if (syncChoice == null || !context.mounted) return null;
final serverId = rootMetadata.serverId ?? client.serverId;
@@ -279,36 +262,21 @@ Future<DownloadResult?> showListDownloadOptionsAndQueue(
);
}
/// Shows the shared list-download dialog for a playlist.
Future<DownloadResult?> showPlaylistDownloadOptionsAndQueue(
BuildContext context, {
required MediaItem playlistMetadata,
required List<MediaItem> items,
required MediaServerClient client,
required DownloadProvider downloadProvider,
}) => showListDownloadOptionsAndQueue(
context,
rootMetadata: playlistMetadata,
targetType: ContentTypes.playlist,
items: items,
client: client,
downloadProvider: downloadProvider,
);
/// 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),
];
/// Shows the shared list-download dialog for a collection.
Future<DownloadResult?> showCollectionDownloadOptionsAndQueue(
BuildContext context, {
required MediaItem collectionMetadata,
required List<MediaItem> items,
required MediaServerClient client,
required DownloadProvider downloadProvider,
}) => showListDownloadOptionsAndQueue(
/// Asks whether to download once or keep the target synced.
Future<_SyncChoice?> _showSyncChoiceDialog(BuildContext context) => showOptionPickerDialog<_SyncChoice>(
context,
rootMetadata: collectionMetadata,
targetType: ContentTypes.collection,
items: items,
client: client,
downloadProvider: downloadProvider,
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(
@@ -375,10 +343,7 @@ Future<bool> editSyncRuleFilter(
final selected = await showOptionPickerDialog<String>(
context,
title: t.downloads.editSyncFilter,
options: [
(icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: SyncRuleFilter.all),
(icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: SyncRuleFilter.unwatched),
],
options: _filterOptions(SyncRuleFilter.all, SyncRuleFilter.unwatched),
);
if (selected == null || selected == currentFilter || !context.mounted) return false;
+7 -12
View File
@@ -45,15 +45,15 @@ List<LiveTvChannelGroup> groupLiveTvChannelsBySource(List<LiveTvChannel> channel
}
String liveTvChannelSourceKey(LiveTvChannel channel) {
final serverId = _nonEmpty(channel.serverId) ?? '';
final providerSource = _nonEmpty(channel.favoriteSource) ?? _nonEmpty(channel.lineup) ?? '';
final dvrSource = _nonEmpty(channel.liveDvrKey) ?? '';
final serverId = liveTvNonEmpty(channel.serverId) ?? '';
final providerSource = liveTvNonEmpty(channel.favoriteSource) ?? liveTvNonEmpty(channel.lineup) ?? '';
final dvrSource = liveTvNonEmpty(channel.liveDvrKey) ?? '';
return '$serverId\u0000$providerSource\u0000$dvrSource';
}
String liveTvChannelSourceLabel(LiveTvChannel channel) {
final serverLabel = _nonEmpty(channel.serverName) ?? _nonEmpty(channel.serverId) ?? 'Live TV';
final sourceTitle = _nonEmpty(channel.liveTvSourceTitle);
final serverLabel = liveTvNonEmpty(channel.serverName) ?? liveTvNonEmpty(channel.serverId) ?? 'Live TV';
final sourceTitle = liveTvNonEmpty(channel.liveTvSourceTitle);
if (sourceTitle == null || sourceTitle == serverLabel) return serverLabel;
return '$serverLabel - $sourceTitle';
}
@@ -62,8 +62,8 @@ String _deduplicatedLabel(LiveTvChannelGroup group) {
if (group.channels.isEmpty) return group.label;
final first = group.channels.first;
final suffixes = [
_nonEmpty(first.liveTvSourceTitle),
_nonEmpty(first.liveDvrKey),
liveTvNonEmpty(first.liveTvSourceTitle),
liveTvNonEmpty(first.liveDvrKey),
liveTvProviderIdentifierForChannel(first),
];
String? suffix;
@@ -76,8 +76,3 @@ String _deduplicatedLabel(LiveTvChannelGroup group) {
if (suffix == null || group.label.contains(suffix)) return group.label;
return '${group.label} - $suffix';
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
+10 -9
View File
@@ -2,14 +2,14 @@ import '../models/livetv_channel.dart';
import '../models/livetv_program.dart';
bool liveTvProgramMatchesChannel(LiveTvProgram program, LiveTvChannel channel) {
final programChannel = _nonEmpty(program.channelIdentifier);
final programChannel = liveTvNonEmpty(program.channelIdentifier);
if (programChannel == null) return false;
if (programChannel != channel.key && programChannel != channel.identifier) return false;
if (!_nullableIdsMatch(program.serverId, channel.serverId)) return false;
if (!_nullableIdsMatch(program.liveDvrKey, channel.liveDvrKey)) return false;
final programProvider = _nonEmpty(program.providerIdentifier);
final programProvider = liveTvNonEmpty(program.providerIdentifier);
final channelProvider = liveTvProviderIdentifierForChannel(channel);
if (programProvider != null && channelProvider != null && programProvider != channelProvider) return false;
@@ -17,26 +17,27 @@ bool liveTvProgramMatchesChannel(LiveTvProgram program, LiveTvChannel channel) {
}
String? liveTvProviderIdentifierForChannel(LiveTvChannel channel) {
final source = _nonEmpty(channel.favoriteSource);
final source = liveTvNonEmpty(channel.favoriteSource);
if (source != null) {
final uri = Uri.tryParse(source);
if (uri != null && uri.pathSegments.isNotEmpty) return _nonEmpty(uri.pathSegments.last);
if (uri != null && uri.pathSegments.isNotEmpty) return liveTvNonEmpty(uri.pathSegments.last);
final slashIndex = source.lastIndexOf('/');
if (slashIndex >= 0 && slashIndex < source.length - 1) {
return _nonEmpty(source.substring(slashIndex + 1));
return liveTvNonEmpty(source.substring(slashIndex + 1));
}
}
return _nonEmpty(channel.lineup);
return liveTvNonEmpty(channel.lineup);
}
bool _nullableIdsMatch(String? a, String? b) {
final left = _nonEmpty(a);
final right = _nonEmpty(b);
final left = liveTvNonEmpty(a);
final right = liveTvNonEmpty(b);
return left == null || right == null || left == right;
}
String? _nonEmpty(String? value) {
/// Trimmed [value], or null when it is null, empty, or whitespace-only.
String? liveTvNonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
+21 -71
View File
@@ -1,16 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import '../models/trackers/device_code.dart';
import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'dialog_action_button.dart';
import 'loading_indicator_box.dart';
import 'pending_auth_dialog.dart';
/// Shared device-code activation dialog for Trakt and Simkl (RFC 8628).
///
@@ -25,11 +19,6 @@ class DeviceCodeDialog extends StatelessWidget {
const DeviceCodeDialog({super.key, required this.code, required this.serviceName, required this.onCancel});
Future<void> _open() async {
final url = code.verificationUrlComplete ?? code.verificationUrl;
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
Future<void> _copy(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: code.userCode));
if (!context.mounted) return;
@@ -39,70 +28,31 @@ class DeviceCodeDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text(t.services.deviceCode.title(service: serviceName)),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
Center(
child: FocusableWrapper(
onSelect: () => _copy(context),
semanticLabel: t.services.deviceCode.copyCode,
descendantsAreFocusable: false,
useBackgroundFocus: true,
borderRadius: 8,
child: InkWell(
canRequestFocus: false,
onTap: () => _copy(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
code.userCode,
style: theme.textTheme.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 4,
fontWeight: .w600,
),
),
return PendingAuthDialog(
title: t.services.deviceCode.title(service: serviceName),
body: t.services.deviceCode.body(url: code.verificationUrl),
url: code.verificationUrlComplete ?? code.verificationUrl,
openLabel: t.services.deviceCode.openToActivate(service: serviceName),
onCancel: onCancel,
children: [
Center(
child: CopyTapRegion(
onCopy: () => _copy(context),
semanticLabel: t.services.deviceCode.copyCode,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
code.userCode,
style: theme.textTheme.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 4,
fontWeight: .w600,
),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FocusableButton(
onPressed: _open,
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const AppIcon(Symbols.open_in_new_rounded),
label: Text(t.services.deviceCode.openToActivate(service: serviceName)),
onPressed: _open,
),
),
),
const SizedBox(height: 16),
Row(
children: [
const LoadingIndicatorBox(size: 16),
const SizedBox(width: 12),
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
],
),
],
),
actions: [
DialogActionButton(
onPressed: () {
onCancel();
Navigator.of(context).pop();
},
label: t.common.cancel,
),
const SizedBox(height: 16),
],
);
}
+97 -134
View File
@@ -648,8 +648,7 @@ class _MediaCardList extends StatelessWidget {
if (mi.kind == MediaKind.track) return mi.trackArtistTitle;
if (mi.parentIndex != null && mi.index != null) {
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}';
return 'S${mi.parentIndex}${_episodeNumberSuffix(mi)}';
}
if (mi.displaySubtitle != null) {
@@ -677,33 +676,10 @@ class _MediaCardList extends StatelessWidget {
return '';
}
Widget _buildEpisodeSubtitle(BuildContext context, MediaItem mi) {
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
);
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : '';
return Row(
children: [
if (enableDetailLinks)
_ClickableText(
text: 'S${mi.parentIndex}',
style: style,
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
)
else
ExcludeSemantics(child: Text('S${mi.parentIndex}', style: style)),
ExcludeSemantics(child: Text('$episodeNum · ', style: style)),
Expanded(
child: ExcludeSemantics(
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: style),
),
),
],
);
}
TextStyle? _subtitleStyle(BuildContext context) => Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
);
@override
Widget build(BuildContext context) {
@@ -792,19 +768,17 @@ class _MediaCardList extends StatelessWidget {
(item as MediaItem).isEpisode &&
(item as MediaItem).parentIndex != null &&
(item as MediaItem).parentId != null) ...[
_buildEpisodeSubtitle(context, item as MediaItem),
_buildEpisodeSubtitleRow(
context,
item as MediaItem,
style: _subtitleStyle(context),
enableDetailLinks: enableDetailLinks,
isOffline: isOffline,
),
const SizedBox(height: 4),
] else if (subtitle != null) ...[
ExcludeSemantics(
child: Text(
subtitle,
maxLines: 1,
overflow: .ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
child: Text(subtitle, maxLines: 1, overflow: .ellipsis, style: _subtitleStyle(context)),
),
const SizedBox(height: 4),
],
@@ -909,32 +883,16 @@ Widget _buildPosterImage(
double? knownWidth,
double? knownHeight,
}) {
String? posterUrl;
if (item is MediaPlaylist) {
posterUrl = item.displayImagePath;
if (cardShapeOverride == CardShape.square) {
return OptimizedMediaImage(
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: Symbols.playlist_play_rounded,
imageType: ImageType.square,
localFilePath: localPosterPath,
);
}
return OptimizedMediaImage.playlist(
return OptimizedMediaImage(
client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)),
imagePath: posterUrl,
imagePath: item.displayImagePath,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: Symbols.playlist_play_rounded,
imageType: cardShapeOverride == CardShape.square ? ImageType.square : ImageType.poster,
localFilePath: localPosterPath,
);
} else if (item is MediaItem) {
@@ -946,7 +904,7 @@ Widget _buildPosterImage(
final primaryPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext);
final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl);
posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
final posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl;
final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId));
final fallbackIcon = _mediaPosterFallbackIcon(item);
final imageType = switch (cardShapeOverride) {
@@ -956,72 +914,52 @@ Widget _buildPosterImage(
null => MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext),
};
OptimizedMediaImage buildImage(
String? path,
ImageType type, {
String? localFilePath,
Widget Function(BuildContext, String, dynamic)? errorWidget,
}) => OptimizedMediaImage(
client: mediaClient,
imagePath: path,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
errorWidget: errorWidget,
imageType: type,
localFilePath: localFilePath,
);
// Remember the dead primary URL so later builds go straight to the fallback.
Widget Function(BuildContext, String, dynamic)? retryWithFallback(ImageType type) {
if (posterFallbackUrl == null || useRememberedFallback) return null;
return (_, _, _) {
_rememberFailedPosterUrl(primaryPosterUrl);
return buildImage(posterFallbackUrl, type);
};
}
Widget image;
// Square 1:1 artwork for music (artists/albums/tracks)
if (imageType == ImageType.square) {
image = OptimizedMediaImage(
client: mediaClient,
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
errorWidget: posterFallbackUrl == null || useRememberedFallback
? null
: (_, _, _) {
_rememberFailedPosterUrl(primaryPosterUrl);
return OptimizedMediaImage(
client: mediaClient,
imagePath: posterFallbackUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
imageType: ImageType.square,
);
},
imageType: ImageType.square,
image = buildImage(
posterUrl,
ImageType.square,
localFilePath: localPosterPath,
errorWidget: retryWithFallback(ImageType.square),
);
} else if (imageType == ImageType.thumb) {
// Use thumb image type for 16:9 content (episodes, or movies in mixed hubs)
image = OptimizedMediaImage.thumb(
client: mediaClient,
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
localFilePath: localPosterPath,
);
image = buildImage(posterUrl, ImageType.thumb, localFilePath: localPosterPath);
} else {
image = OptimizedMediaImage.poster(
client: mediaClient,
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
errorWidget: posterFallbackUrl == null || useRememberedFallback
? null
: (_, _, _) {
_rememberFailedPosterUrl(primaryPosterUrl);
return OptimizedMediaImage.poster(
client: mediaClient,
imagePath: posterFallbackUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon,
);
},
image = buildImage(
posterUrl,
ImageType.poster,
localFilePath: localPosterPath,
errorWidget: retryWithFallback(ImageType.poster),
);
}
@@ -1100,29 +1038,19 @@ class _MediaCardHelpers {
// For episodes, show "S# · Episode Title" with clickable season link
if (mi.isEpisode && mi.parentIndex != null) {
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : '';
if (enableDetailLinks && mi.parentId != null) {
return Row(
children: [
_ClickableText(
text: 'S${mi.parentIndex}',
style: subtitleStyle,
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
),
ExcludeSemantics(child: Text('$episodeSuffix · ', style: subtitleStyle)),
Expanded(
child: ExcludeSemantics(
child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
),
),
],
return _buildEpisodeSubtitleRow(
context,
mi,
style: subtitleStyle,
enableDetailLinks: true,
isOffline: isOffline,
);
}
final episodeTitle = mi.displaySubtitle ?? mi.displayTitle;
return ExcludeSemantics(
child: Text(
'S${mi.parentIndex}$episodeSuffix · $episodeTitle',
'S${mi.parentIndex}${_episodeNumberSuffix(mi)} · $episodeTitle',
maxLines: 1,
overflow: .ellipsis,
style: subtitleStyle,
@@ -1155,6 +1083,41 @@ class _MediaCardHelpers {
}
}
/// "S# E# · Episode title" with the season number linking to the season.
Widget _buildEpisodeSubtitleRow(
BuildContext context,
MediaItem mi, {
required TextStyle? style,
required bool enableDetailLinks,
required bool isOffline,
}) {
final seasonLabel = 'S${mi.parentIndex}';
return Row(
children: [
if (enableDetailLinks)
_ClickableText(
text: seasonLabel,
style: style,
onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline),
)
else
ExcludeSemantics(child: Text(seasonLabel, style: style)),
ExcludeSemantics(child: Text('${_episodeNumberSuffix(mi)} · ', style: style)),
Expanded(
child: ExcludeSemantics(
child: Text(mi.displaySubtitle ?? mi.displayTitle, maxLines: 1, overflow: .ellipsis, style: style),
),
),
],
);
}
/// Empty unless [SettingsService.showEpisodeNumberOnCards] is on.
String _episodeNumberSuffix(MediaItem mi) {
final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards);
return (showEp && mi.index != null) ? ' E${mi.index}' : '';
}
/// Whether the card renders any pointer detail link for this item.
bool _hasPointerDetailLinks(MediaItem mi) {
if (_hasClickableTitle(mi)) return true;
+8 -5
View File
@@ -23,6 +23,7 @@ import '../services/offline_watch_sync_service.dart';
import '../services/playlist_items_loader.dart';
import '../services/watch_actions.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/content_utils.dart';
import '../utils/download_version_utils.dart';
import '../utils/download_utils.dart';
import '../utils/quality_preset_labels.dart';
@@ -1502,7 +1503,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
/// Handle download collection action — opens the same sync/one-time dialog
/// as playlists, wired to [showCollectionDownloadOptionsAndQueue].
/// as playlists, wired to [showListDownloadOptionsAndQueue].
Future<void> _handleDownloadCollection(BuildContext context) async {
final collection = _mediaItem!;
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
@@ -1517,9 +1518,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
);
if (!context.mounted) return;
final result = await showCollectionDownloadOptionsAndQueue(
final result = await showListDownloadOptionsAndQueue(
context,
collectionMetadata: collection,
rootMetadata: collection,
targetType: ContentTypes.collection,
items: items,
client: client,
downloadProvider: downloadProvider,
@@ -1561,9 +1563,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
serverName: playlist.serverName,
);
final result = await showPlaylistDownloadOptionsAndQueue(
final result = await showListDownloadOptionsAndQueue(
context,
playlistMetadata: playlistMetadata,
rootMetadata: playlistMetadata,
targetType: ContentTypes.playlist,
items: items,
client: client,
downloadProvider: downloadProvider,
+32 -81
View File
@@ -1,17 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../services/trackers/oauth_proxy_client.dart';
import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'dialog_action_button.dart';
import 'loading_indicator_box.dart';
import 'pending_auth_dialog.dart';
/// Sign-in dialog for OAuth-proxy flows (MAL, AniList).
///
@@ -25,10 +19,6 @@ class OAuthProxyDialog extends StatelessWidget {
const OAuthProxyDialog({super.key, required this.start, required this.serviceName, required this.onCancel});
Future<void> _open() async {
await launchUrl(Uri.parse(start.url), mode: LaunchMode.externalApplication);
}
Future<void> _copyUrl(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: start.url));
if (!context.mounted) return;
@@ -38,80 +28,41 @@ class OAuthProxyDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text(t.services.oauthProxy.title(service: serviceName)),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(t.services.oauthProxy.body, style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
// QrImageView doesn't support intrinsic sizing; wrap in SizedBox so
// AlertDialog's IntrinsicWidth walk sees a concrete width.
Center(
child: SizedBox.square(
dimension: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white),
),
),
),
const SizedBox(height: 16),
FocusableWrapper(
onSelect: () => _copyUrl(context),
semanticLabel: t.services.oauthProxy.copyUrl,
descendantsAreFocusable: false,
borderRadius: 8,
useBackgroundFocus: true,
child: InkWell(
canRequestFocus: false,
onTap: () => _copyUrl(context),
return PendingAuthDialog(
title: t.services.oauthProxy.title(service: serviceName),
body: t.services.oauthProxy.body,
url: start.url,
openLabel: t.services.oauthProxy.openToSignIn(service: serviceName),
onCancel: onCancel,
children: [
// QrImageView doesn't support intrinsic sizing; wrap in SizedBox so
// AlertDialog's IntrinsicWidth walk sees a concrete width.
Center(
child: SizedBox.square(
dimension: 220,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
start.url,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FocusableButton(
onPressed: _open,
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const AppIcon(Symbols.open_in_new_rounded),
label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)),
onPressed: _open,
),
),
),
const SizedBox(height: 16),
Row(
children: [
const LoadingIndicatorBox(size: 16),
const SizedBox(width: 12),
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
],
),
],
),
actions: [
DialogActionButton(
onPressed: () {
onCancel();
Navigator.of(context).pop();
},
label: t.common.cancel,
),
const SizedBox(height: 16),
CopyTapRegion(
onCopy: () => _copyUrl(context),
semanticLabel: t.services.oauthProxy.copyUrl,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
start.url,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
),
const SizedBox(height: 8),
],
);
}
+23 -53
View File
@@ -99,7 +99,15 @@ class OverlaySheetController {
/// Re-focus the first focusable descendant within the sheet.
/// Useful after internal page changes via setState.
void refocus() {
_state._refocus();
_state._autoFocus(clearSelectSuppression: false);
}
/// Sizing applied when a caller supplies no explicit constraints: capped
/// width on desktop, three quarters of the screen height everywhere.
static BoxConstraints _defaultSheetConstraints(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final isDesktop = size.width > 600;
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
}
/// Show a sheet using the overlay system if available, otherwise fall back
@@ -129,13 +137,7 @@ class OverlaySheetController {
}
// Apply the same default constraints the overlay system uses so sheets
// shown without an OverlaySheetHost still have sensible sizing on desktop.
final effectiveConstraints =
constraints ??
() {
final size = MediaQuery.sizeOf(context);
final isDesktop = size.width > 600;
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
}();
final effectiveConstraints = constraints ?? _defaultSheetConstraints(context);
openSheetCount.value++;
try {
return await showModalBottomSheet<T>(
@@ -146,6 +148,7 @@ class OverlaySheetController {
constraints: effectiveConstraints,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
barrierColor: Colors.black54,
isDismissible: barrierDismissible,
isScrollControlled: isScrollControlled,
showDragHandle: showDragHandle,
);
@@ -188,28 +191,16 @@ class OverlaySheetController {
showDragHandle: showDragHandle,
);
}
final effectiveConstraints =
constraints ??
() {
final size = MediaQuery.sizeOf(context);
final isDesktop = size.width > 600;
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
}();
BackKeyCoordinator.clear();
openSheetCount.value++;
try {
return await showModalBottomSheet<T>(
context: context,
builder: (context) => SafeArea(top: false, child: builder(context)),
constraints: effectiveConstraints,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
isDismissible: barrierDismissible,
isScrollControlled: isScrollControlled,
showDragHandle: showDragHandle,
);
} finally {
openSheetCount.value--;
}
return showAdaptive<T>(
context,
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
isScrollControlled: isScrollControlled,
showDragHandle: showDragHandle,
);
}
/// Close the sheet entirely. Uses overlay controller if available,
@@ -457,7 +448,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
return _lastPointerPosition?.dx;
}
void _autoFocus() {
void _autoFocus({bool clearSelectSuppression = true}) {
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
// First post-frame: the FocusScope is now built and the node is attached.
@@ -486,33 +477,13 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
// select inside the sheet from being eaten).
// - Long press: key still held → keep flag so KeyRepeat/KeyUp events
// from the long press are correctly suppressed.
if (!HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) {
if (clearSelectSuppression && !HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) {
SelectKeyUpSuppressor.clearSuppression();
}
});
});
}
void _refocus() {
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
_sheetFocusScopeNode.requestFocus();
if (!focusDescendant) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
final initialNode = topEntry?.initialFocusNode;
if (initialNode != null && initialNode.context != null) {
initialNode.requestFocus();
} else {
_focusFirstDescendant();
}
});
});
}
void _focusFirstDescendant() {
final descendants = _sheetFocusScopeNode.traversalDescendants.toList();
if (descendants.isNotEmpty) {
@@ -634,8 +605,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
final isTV = PlatformDetector.isTV();
final showHandle = _showDragHandle && !isTV && !isTop;
final effectiveConstraints =
_constraints ?? BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
final effectiveConstraints = _constraints ?? OverlaySheetController._defaultSheetConstraints(context);
// Slide direction depends on alignment: bottom sheets slide up, top sheets slide down.
// Use a pixel transform instead of FractionalTranslation so mouse-tracker
+108
View File
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import 'app_icon.dart';
import 'dialog_action_button.dart';
import 'loading_indicator_box.dart';
/// Shell for the "waiting for out-of-band authorization" dialogs.
///
/// Shows [body], the service-specific [children], a button that launches [url]
/// in the browser, and a "waiting for authorization…" spinner while the poll
/// loop runs. Dismissing calls [onCancel] so the provider can abort the poll.
class PendingAuthDialog extends StatelessWidget {
final String title;
final String body;
/// Sits between the body text and the launch button, and carries its own
/// trailing spacing.
final List<Widget> children;
final String url;
final String openLabel;
final VoidCallback onCancel;
const PendingAuthDialog({
super.key,
required this.title,
required this.body,
required this.children,
required this.url,
required this.openLabel,
required this.onCancel,
});
Future<void> _open() async {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(body, style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
...children,
SizedBox(
width: double.infinity,
child: FocusableButton(
onPressed: _open,
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const AppIcon(Symbols.open_in_new_rounded),
label: Text(openLabel),
onPressed: _open,
),
),
),
const SizedBox(height: 16),
Row(
children: [
const LoadingIndicatorBox(size: 16),
const SizedBox(width: 12),
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
],
),
],
),
actions: [
DialogActionButton(
onPressed: () {
onCancel();
Navigator.of(context).pop();
},
label: t.common.cancel,
),
],
);
}
}
/// Tap/D-pad target that copies the value it displays to the clipboard.
class CopyTapRegion extends StatelessWidget {
final VoidCallback onCopy;
final String semanticLabel;
final Widget child;
const CopyTapRegion({super.key, required this.onCopy, required this.semanticLabel, required this.child});
@override
Widget build(BuildContext context) {
return FocusableWrapper(
onSelect: onCopy,
semanticLabel: semanticLabel,
descendantsAreFocusable: false,
useBackgroundFocus: true,
borderRadius: 8,
child: InkWell(canRequestFocus: false, onTap: onCopy, borderRadius: BorderRadius.circular(8), child: child),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import 'app_icon.dart';
import 'pill_input_decoration.dart';
/// The pill search field the search screens put above their results, with the
/// clear affordance that appears once there is text: RIGHT out of the field
/// lands on it, LEFT goes back, and both escape down into the results.
///
/// [onBack] stays null unless the host wants the back key — a pushed route
/// needs it for its own pop.
class SearchInputField extends StatefulWidget {
final TextEditingController controller;
final FocusNode focusNode;
final String hintText;
/// Names the clear button's focus node.
final String debugLabel;
final TvKeyboardController? tvKeyboardController;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateDown;
final VoidCallback? onEditingComplete;
final VoidCallback? onBack;
const SearchInputField({
super.key,
required this.controller,
required this.focusNode,
required this.hintText,
required this.debugLabel,
this.tvKeyboardController,
this.onNavigateLeft,
this.onNavigateDown,
this.onEditingComplete,
this.onBack,
});
@override
State<SearchInputField> createState() => _SearchInputFieldState();
}
class _SearchInputFieldState extends State<SearchInputField> {
late final FocusNode _clearFocusNode = FocusNode(debugLabel: '${widget.debugLabel}.clear');
@override
void dispose() {
_clearFocusNode.dispose();
super.dispose();
}
void _clearSearch() {
widget.controller.clear();
widget.focusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final hasText = widget.controller.text.isNotEmpty;
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: Stack(
alignment: Alignment.centerRight,
children: [
FocusableTextField(
controller: widget.controller,
focusNode: widget.focusNode,
tvKeyboardController: widget.tvKeyboardController,
textInputAction: TextInputAction.search,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: hasText ? _clearFocusNode.requestFocus : null,
onNavigateDown: widget.onNavigateDown,
onEditingComplete: widget.onEditingComplete,
onBack: widget.onBack,
decoration: pillInputDecoration(
context,
hintText: widget.hintText,
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
suffixIcon: hasText ? const SizedBox(width: 48) : null,
),
),
if (hasText)
FocusableButton(
focusNode: _clearFocusNode,
onPressed: _clearSearch,
onNavigateLeft: widget.focusNode.requestFocus,
onNavigateDown: widget.onNavigateDown,
autoScroll: false,
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
),
],
),
);
}
}
+4 -7
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/episode_collection.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -397,11 +398,7 @@ void main() {
expect(client.fetchChildrenCalled, isFalse);
});
test('collectItemsForList accepts tracks and expands albums/artists', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
final executor = SyncRuleExecutor(database: db);
test('collectListLeaves accepts tracks and expands albums/artists', () async {
final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)];
final client = _PlayableDescendantsClient(albumTracks);
@@ -414,14 +411,14 @@ void main() {
];
final out = <MediaItem>[];
await executor.collectItemsForList(client, items, unwatchedOnly: false, out: out);
await collectListLeaves(client, items, unwatchedOnly: false, out: out);
expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']);
expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']);
// unwatchedOnly applies the play-count filter to tracks too.
final unwatched = <MediaItem>[];
await executor.collectItemsForList(
await collectListLeaves(
client,
[_track('played-track', played: true), items[1]],
unwatchedOnly: true,