diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index cb081fa4..7bfd890d 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -29,6 +29,46 @@ Future 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 collectListLeaves( + MediaServerClient client, + List items, { + required bool unwatchedOnly, + required List 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 fetchFirstEpisodeForSeason( diff --git a/lib/mixins/debounced_media_search.dart b/lib/mixins/debounced_media_search.dart index 1fd7ce19..d533b4be 100644 --- a/lib/mixins/debounced_media_search.dart +++ b/lib/mixins/debounced_media_search.dart @@ -156,6 +156,22 @@ mixin DebouncedMediaSearch on State { } } + /// 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() { diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index a1834bd7..3f7b1104 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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 queueListDownload( List 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 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 = []; - 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 = []; + 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; diff --git a/lib/screens/catalog_search_screen.dart b/lib/screens/catalog_search_screen.dart index 072801a1..9ce54d8d 100644 --- a/lib/screens/catalog_search_screen.dart +++ b/lib/screens/catalog_search_screen.dart @@ -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 with DebouncedMediaSearch { - final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear'); @override String get searchDebugLabel => 'CatalogSearch'; @@ -46,17 +42,6 @@ class _CatalogSearchScreenState extends State 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 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 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, + ); + }); } } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index e8be7c13..f010bedf 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -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 on State { required LiveTvChannel? channel, required String? posterThumb, required String? posterServerId, + ValueChanged? onRecordingStateChanged, }) { final effectiveContext = sheetContext ?? context; final multiServer = effectiveContext.read(); @@ -74,6 +75,7 @@ mixin LiveTvActionsMixin on State { posterUrl: posterUrl, onTuneChannel: channel != null ? () => tuneChannel(channel) : null, client: client, + onRecordingStateChanged: onRecordingStateChanged, ); } } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 7502f9bc..e1961a5c 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -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 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 _loadChannels() { diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index a26fab6c..63c8891d 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -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 channels; @@ -99,7 +97,8 @@ final class _GuideChannelRow extends _GuideRow { const _GuideChannelRow({required this.channel, required this.channelIndex}); } -class GuideTabState extends State with MountedSetStateMixin, WidgetsBindingObserver { +class GuideTabState extends State + with LiveTvActionsMixin, 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 with MountedSetStateMixin, WidgetsBi LiveTvProgram? _focusedProgram; bool _pendingFocus = false; + @override + List 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 with MountedSetStateMixin, WidgetsBi } Set _recordingKeysForProgram(LiveTvProgram program, {String? fallbackServerId}) { - final serverId = _nonEmpty(program.serverId) ?? _nonEmpty(fallbackServerId); + final serverId = liveTvNonEmpty(program.serverId) ?? liveTvNonEmpty(fallbackServerId); if (serverId == null) return const {}; final keys = {}; 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 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 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 with MountedSetStateMixin, WidgetsBi return (totalMinutes / _minutesPerSlot) * _slotWidth; } - Future _tuneChannel(LiveTvChannel channel) async { - final multiServer = context.read(); - 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 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 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 with MountedSetStateMixin, WidgetsBi } void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { - final multiServer = context.read(); - 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), ); } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 061c70a7..8be3f5f6 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -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 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 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 Widget _buildResultsList(BuildContext context) { final multiServer = context.watch(); 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 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) diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 0362abd3..f929c00c 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -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 = []; - 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 collectItemsForList( - MediaServerClient client, - List items, { - required bool unwatchedOnly, - required List 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. diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 757c78fc..88facb18 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -148,14 +148,7 @@ Future 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 showListDownloadOptionsAndQueue( final selectedFilter = await showOptionPickerDialog( 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 showListDownloadOptionsAndQueue( ); } -/// Shows the shared list-download dialog for a playlist. -Future showPlaylistDownloadOptionsAndQueue( - BuildContext context, { - required MediaItem playlistMetadata, - required List 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 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 showCollectionDownloadOptionsAndQueue( - BuildContext context, { - required MediaItem collectionMetadata, - required List 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 _showEpisodeCountDialog( @@ -375,10 +343,7 @@ Future editSyncRuleFilter( final selected = await showOptionPickerDialog( 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; diff --git a/lib/utils/live_tv_grouping.dart b/lib/utils/live_tv_grouping.dart index 38d61801..6866ac8d 100644 --- a/lib/utils/live_tv_grouping.dart +++ b/lib/utils/live_tv_grouping.dart @@ -45,15 +45,15 @@ List groupLiveTvChannelsBySource(List 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; -} diff --git a/lib/utils/live_tv_matching.dart b/lib/utils/live_tv_matching.dart index 69f4dc8f..e1d48c03 100644 --- a/lib/utils/live_tv_matching.dart +++ b/lib/utils/live_tv_matching.dart @@ -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; } diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index d35d59bf..846acd7f 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -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 _open() async { - final url = code.verificationUrlComplete ?? code.verificationUrl; - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - Future _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), ], ); } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index a406d294..5c4e9db3 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 6ce43f00..df209341 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { } /// Handle download collection action — opens the same sync/one-time dialog - /// as playlists, wired to [showCollectionDownloadOptionsAndQueue]. + /// as playlists, wired to [showListDownloadOptionsAndQueue]. Future _handleDownloadCollection(BuildContext context) async { final collection = _mediaItem!; final downloadProvider = Provider.of(context, listen: false); @@ -1517,9 +1518,10 @@ class MediaContextMenuState extends State { ); 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 { 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, diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index 6ec125ae..3843ba81 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -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 _open() async { - await launchUrl(Uri.parse(start.url), mode: LaunchMode.externalApplication); - } - Future _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), ], ); } diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 78470c5e..a14db069 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -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( @@ -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( - 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( + 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 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 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 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 diff --git a/lib/widgets/pending_auth_dialog.dart b/lib/widgets/pending_auth_dialog.dart new file mode 100644 index 00000000..6ae7c94f --- /dev/null +++ b/lib/widgets/pending_auth_dialog.dart @@ -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 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 _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), + ); + } +} diff --git a/lib/widgets/search_input_field.dart b/lib/widgets/search_input_field.dart new file mode 100644 index 00000000..6090f888 --- /dev/null +++ b/lib/widgets/search_input_field.dart @@ -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 createState() => _SearchInputFieldState(); +} + +class _SearchInputFieldState extends State { + 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), + ), + ], + ), + ); + } +} diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 52f17969..a0c8a305 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -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 = []; - 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 = []; - await executor.collectItemsForList( + await collectListLeaves( client, [_track('played-track', played: true), items[1]], unwatchedOnly: true,