From 4307c49cd2d1f38512fea22952a77337179acdb8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:25:30 +0200 Subject: [PATCH 01/12] refactor: remove unreachable code paths and unused members Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change. --- lib/connection/connection_auth_service.dart | 20 - lib/database/app_database.dart | 2 + lib/database/download_operations.dart | 3 + lib/main.dart | 12 +- lib/media/live_tv_support.dart | 68 ---- lib/media/media_kind.dart | 2 - lib/media/media_server_client.dart | 3 - lib/media/media_source_info.dart | 1 - lib/models/download_models.dart | 2 - lib/models/plex/plex_home.dart | 14 - lib/models/plex/plex_video_playback_data.dart | 2 - lib/models/seerr/seerr_details.dart | 99 +---- lib/models/seerr/seerr_details.g.dart | 55 --- lib/models/user_switch_response.dart | 40 -- lib/navigation/navigation_tabs.dart | 6 - lib/profiles/active_profile_binder.dart | 2 - .../base_media_list_detail_screen.dart | 36 -- .../playlist/playlist_detail_screen.dart | 15 +- lib/services/api_cache.dart | 21 -- .../companion_remote/remote_auth_service.dart | 4 - lib/services/download_artwork_service.dart | 7 - lib/services/episode_navigation_service.dart | 1 - lib/services/jellyfin_auth_service.dart | 16 +- .../jellyfin_client/parts/browse.dart | 21 -- .../jellyfin_client/parts/metadata_edit.dart | 7 - lib/services/macos_window_service.dart | 4 - lib/services/multi_server_manager.dart | 59 +-- .../music/music_playback_service.dart | 8 +- lib/services/music/music_source_resolver.dart | 7 - lib/services/play_queue_launcher.dart | 83 ++--- lib/services/playback_context.dart | 1 - lib/services/plex_api_cache.dart | 2 + lib/services/plex_client.dart | 23 -- lib/services/plex_client/parts/live_tv.dart | 351 +----------------- lib/services/seerr/seerr_client.dart | 4 - lib/services/seerr/seerr_constants.dart | 2 - lib/services/sync_rule_executor.dart | 2 - lib/services/trakt/trakt_constants.dart | 6 - lib/utils/app_logger.dart | 4 - lib/utils/content_utils.dart | 1 - .../continuation_pagination_coordinator.dart | 7 - lib/utils/hierarchical_event_mixin.dart | 4 - lib/utils/layout_constants.dart | 1 - lib/utils/media_image_helper.dart | 27 -- lib/utils/platform_detector.dart | 2 - .../providers/watch_together_provider.dart | 5 - .../services/guest_playback_reconciler.dart | 1 - .../services/host_playback_coordinator.dart | 1 - lib/widgets/hub_section.dart | 3 - .../helpers/track_selection_helper.dart | 29 -- .../player_chrome_controller.dart | 1 - test/models/livetv_flexible_parsing_test.dart | 45 --- test/services/plex_live_tv_support_test.dart | 35 +- 53 files changed, 59 insertions(+), 1118 deletions(-) delete mode 100644 lib/connection/connection_auth_service.dart diff --git a/lib/connection/connection_auth_service.dart b/lib/connection/connection_auth_service.dart deleted file mode 100644 index 304b0a39..00000000 --- a/lib/connection/connection_auth_service.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'connection.dart'; - -/// Backend-neutral auth service interface. Each backend's implementation -/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its -/// own UX (PIN flow vs. password) but produces the same opaque -/// [Connection] record at the end. -abstract class ConnectionAuthService { - /// Best-effort check that an existing token still works. Returns false on - /// 401/403; throws on transport failures the caller should retry. - Future validate(Connection connection); - - /// Refresh whatever side-channel state belongs to a connection — for Plex - /// that's the discovered server list and Home users; for Jellyfin it's a - /// no-op once auth has succeeded. Returns the updated connection. - Future refresh(Connection connection); - - /// Revoke the token server-side and forget local credentials. The caller - /// is responsible for removing the row from [ConnectionRegistry]. - Future signOut(Connection connection); -} diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index cae5d3e1..6c54a441 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -950,6 +950,7 @@ class AppDatabase extends _$AppDatabase { } /// Get pending watch actions for a specific server + @visibleForTesting Future> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) { return (select(offlineWatchProgress) ..where( @@ -979,6 +980,7 @@ class AppDatabase extends _$AppDatabase { } /// Get the latest action for a specific item + @visibleForTesting Future getLatestWatchAction( String globalKey, { String? profileId, diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index ea0def15..eb22c743 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import '../media/ids.dart'; import 'app_database.dart'; @@ -138,6 +139,7 @@ extension DownloadDatabaseOperations on AppDatabase { return (await _validDownloadOwnerRows(globalKey)).length; } + @visibleForTesting Future hasDownloadOwner(String globalKey, {String? excludingProfileId}) async { final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId); return rows.isNotEmpty; @@ -573,6 +575,7 @@ extension DownloadDatabaseOperations on AppDatabase { return (await query.map((row) => row.read(count) ?? 0).getSingle()); } + @visibleForTesting Future> getReferencedDownloadSafRoots() async { final rows = await (selectOnly(downloadedMedia) diff --git a/lib/main.dart b/lib/main.dart index 9891c797..1237a6ed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1043,7 +1043,7 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, update: (_, multiServerProvider, previous) { - final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider); + final provider = previous!; provider.updateMultiServerProvider(multiServerProvider); provider.initialize(); // Idempotent - safe to call again return provider; @@ -1081,7 +1081,7 @@ class _MainAppState extends State with WidgetsBindingObserver { ChangeNotifierProxyProvider( create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase), update: (context, activeProfile, previous) { - final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase); + final provider = previous!; provider.setActiveProfileId(activeProfile.activeId); return provider; }, @@ -1134,7 +1134,7 @@ class _MainAppState extends State with WidgetsBindingObserver { return _offlineWatchSyncService; }, update: (_, activeProfile, previous) { - final provider = previous ?? _offlineWatchSyncService; + final provider = previous!; provider.setActiveProfileId( activeProfile.activeId, availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null, @@ -1147,14 +1147,12 @@ class _MainAppState extends State with WidgetsBindingObserver { syncService: context.read(), downloadProvider: context.read(), ), - update: (_, syncService, downloadProvider, previous) { - return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); - }, + update: (_, syncService, downloadProvider, previous) => previous!, ), ChangeNotifierProxyProvider2( create: (context) => UserProfileProvider(storageService: context.read()), update: (context, activeProfile, connections, previous) { - final provider = previous ?? UserProfileProvider(storageService: context.read()); + final provider = previous!; provider.attach( connections: connections, activeProfile: activeProfile, diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 050ef239..392bfe81 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -1,13 +1,8 @@ import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; -import '../models/livetv_lineup.dart'; import '../models/livetv_program.dart'; -import '../models/livetv_server_status.dart'; -import '../models/livetv_session.dart'; import '../models/media_grab_operation.dart'; -import '../models/media_grabber_device.dart'; -import '../models/media_provider_info.dart'; import '../models/media_subscription.dart'; class LiveTvActivityResult { @@ -202,67 +197,13 @@ abstract class LiveTvSupport { /// recording APIs. abstract class LiveTvDvrSupport { Future> fetchDvrs(); - Future fetchLiveTvServerStatus(); - Future fetchDvr(String dvrId); - Future> createDvr({ - required List devices, - required List lineups, - String? language, - String? country, - String? postalCode, - }); - Future deleteDvr(String dvrId); - Future updateDvrPrefs(String dvrId, Map prefs); - Future attachDeviceToDvr(String dvrId, String deviceId); - Future detachDeviceFromDvr(String dvrId, String deviceId); - Future addLineupToDvr(String dvrId, String lineupUri); - Future removeLineupFromDvr(String dvrId, String lineupUri); Future> reloadGuide(String dvrId); - Future cancelGuideReload(String dvrId); - - Future> fetchGrabbers({String? protocol}); - Future> fetchGrabberDevices(); - Future>> discoverGrabberDevices(); - Future fetchGrabberDevice(String deviceId); - Future addGrabberDevice(String uri, {String? grabberId}); - Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}); - Future deleteGrabberDevice(String deviceId); - Future> fetchGrabberDeviceChannels(String deviceId); - Future> scanGrabberDevice( - String deviceId, { - String? source, - Map prefs = const {}, - String? network, - String? country, - }); - Future cancelGrabberDeviceScan(String deviceId); - Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request); - Future updateGrabberDevicePrefs(String deviceId, Map prefs); - String buildGrabberDeviceThumbUrl(String deviceId, int version); - - Future> fetchEpgCountries(); - Future> fetchEpgLanguages(); - Future> fetchEpgRegions(String country, String epgId); - Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}); - Future> fetchEpgChannelsForLineup(String lineupUri); - Future> fetchEpgChannelsForLineups(List lineupUris); - Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}); - Future?>> findBestLineup({ - required String deviceUri, - required String lineupGroupUri, - }); Future> getSubscriptionTemplate(String guid); Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}); - Future fetchRecordingRule( - String subscriptionId, { - bool includeGrabs = true, - bool includeStorage = true, - }); Future createRecordingRule(MediaSubscriptionCreateRequest request); Future updateRecordingRule(String subscriptionId, Map prefs); Future deleteRecordingRule(String subscriptionId); - Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}); Future processRecordingRules(); Future> fetchScheduledRecordings(); Future cancelGrab(String operationId); @@ -271,13 +212,4 @@ abstract class LiveTvDvrSupport { required List ratingKeys, bool includeStorage = true, }); - - Future> fetchMediaProviders(); - Future registerMediaProvider(String url); - Future refreshMediaProviders(); - Future unregisterMediaProvider(String providerId); - Future> fetchLiveTvSessionsDetailed(); - Future fetchLiveTvSession(String sessionId); - Uri buildNotificationWebSocketUri({List? filters}); - Uri buildNotificationEventSourceUri({List? filters}); } diff --git a/lib/media/media_kind.dart b/lib/media/media_kind.dart index d1d64690..55cb53e0 100644 --- a/lib/media/media_kind.dart +++ b/lib/media/media_kind.dart @@ -26,8 +26,6 @@ enum MediaKind { bool get isVideo => this == movie || this == episode || this == clip; - bool get isShowRelated => this == show || this == season || this == episode; - bool get isMusic => this == artist || this == album || this == track; bool get isPlayable => isVideo || this == track; diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index ad323d04..161978b1 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -266,9 +266,6 @@ abstract class MediaServerClient { /// Free-text search across the user's libraries. Future> searchItems(String query, {int limit = 100}); - /// Recently-added items across all libraries. - Future> fetchRecentlyAdded({int limit = 50}); - /// Items the user has started but not finished. Plex calls this "On Deck" /// internally; the neutral name matches the Continue Watching UI surface. Future> fetchContinueWatching({int? count = 20}); diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 1738f35f..79f3f88a 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -275,7 +275,6 @@ class MediaMarker { Duration get startTime => Duration(milliseconds: startTimeOffset); Duration get endTime => Duration(milliseconds: endTimeOffset); - bool get isIntro => type == 'intro'; bool get isCredits => type == 'credits'; bool containsPosition(Duration position) { diff --git a/lib/models/download_models.dart b/lib/models/download_models.dart index 50d8ce6c..ee591442 100644 --- a/lib/models/download_models.dart +++ b/lib/models/download_models.dart @@ -34,8 +34,6 @@ sealed class DownloadProgress with _$DownloadProgress { double get progressPercent => progress / 100.0; String get speedFormatted => ByteFormatter.formatSpeed(speed); - String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes); - String get totalFormatted => ByteFormatter.formatBytes(totalBytes); bool get hasArtworkPaths => thumbPath != null; } diff --git a/lib/models/plex/plex_home.dart b/lib/models/plex/plex_home.dart index 69924b7b..21f54241 100644 --- a/lib/models/plex/plex_home.dart +++ b/lib/models/plex/plex_home.dart @@ -37,18 +37,4 @@ class PlexHome { Map toJson() => _$PlexHomeToJson(this); PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull; - - List get managedUsers => users.where((user) => !user.admin).toList(); - - List get restrictedUsers => users.where((user) => user.restricted).toList(); - - PlexHomeUser? getUserByUUID(String uuid) { - try { - return users.firstWhere((user) => user.uuid == uuid); - } catch (e) { - return null; - } - } - - bool get hasMultipleUsers => users.length > 1; } diff --git a/lib/models/plex/plex_video_playback_data.dart b/lib/models/plex/plex_video_playback_data.dart index 23febc2d..587fb1f3 100644 --- a/lib/models/plex/plex_video_playback_data.dart +++ b/lib/models/plex/plex_video_playback_data.dart @@ -26,6 +26,4 @@ class PlexVideoPlaybackData { }); bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; - - bool get hasMediaInfo => mediaInfo != null; } diff --git a/lib/models/seerr/seerr_details.dart b/lib/models/seerr/seerr_details.dart index 19a0c504..f6ff6f26 100644 --- a/lib/models/seerr/seerr_details.dart +++ b/lib/models/seerr/seerr_details.dart @@ -5,45 +5,13 @@ import 'seerr_media.dart'; part 'seerr_details.g.dart'; /// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog -/// surfaces need (credits, external ids, availability, air status). +/// surfaces need (credits, availability). @JsonSerializable(createToJson: false) class SeerrMovieDetails { - final int id; - final String? title; - final String? overview; - final String? posterPath; - final String? backdropPath; - final String? releaseDate; - - /// Minutes. - final int? runtime; - - /// `Released` / `In Production` / `Post Production` / `Planned` / - /// `Canceled` / `Rumored`. - final String? status; - final double? voteAverage; - final int? voteCount; - final List? genres; final SeerrCredits? credits; - final SeerrExternalIds? externalIds; final SeerrMediaInfo? mediaInfo; - const SeerrMovieDetails({ - required this.id, - this.title, - this.overview, - this.posterPath, - this.backdropPath, - this.releaseDate, - this.runtime, - this.status, - this.voteAverage, - this.voteCount, - this.genres, - this.credits, - this.externalIds, - this.mediaInfo, - }); + const SeerrMovieDetails({this.credits, this.mediaInfo}); factory SeerrMovieDetails.fromJson(Map json) => _$SeerrMovieDetailsFromJson(json); } @@ -51,66 +19,15 @@ class SeerrMovieDetails { /// Full TV detail from `GET /tv/{tmdbId}`. @JsonSerializable(createToJson: false) class SeerrTvDetails { - final int id; - final String? name; - final String? overview; - final String? posterPath; - final String? backdropPath; - final String? firstAirDate; - final List? episodeRunTime; - - /// `Returning Series` / `Ended` / `Canceled` / `In Production` / - /// `Planned` / `Pilot`. - final String? status; - final double? voteAverage; - final int? voteCount; - final List? genres; - final List? networks; - final int? numberOfEpisodes; - final int? numberOfSeasons; final List? seasons; final SeerrCredits? credits; - final SeerrExternalIds? externalIds; final SeerrMediaInfo? mediaInfo; - const SeerrTvDetails({ - required this.id, - this.name, - this.overview, - this.posterPath, - this.backdropPath, - this.firstAirDate, - this.episodeRunTime, - this.status, - this.voteAverage, - this.voteCount, - this.genres, - this.networks, - this.numberOfEpisodes, - this.numberOfSeasons, - this.seasons, - this.credits, - this.externalIds, - this.mediaInfo, - }); + const SeerrTvDetails({this.seasons, this.credits, this.mediaInfo}); factory SeerrTvDetails.fromJson(Map json) => _$SeerrTvDetailsFromJson(json); } -@JsonSerializable(createToJson: false) -class SeerrGenre { - final String? name; - const SeerrGenre({this.name}); - factory SeerrGenre.fromJson(Map json) => _$SeerrGenreFromJson(json); -} - -@JsonSerializable(createToJson: false) -class SeerrNetwork { - final String? name; - const SeerrNetwork({this.name}); - factory SeerrNetwork.fromJson(Map json) => _$SeerrNetworkFromJson(json); -} - /// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. @JsonSerializable(createToJson: false) class SeerrSeason { @@ -141,13 +58,3 @@ class SeerrCastMember { factory SeerrCastMember.fromJson(Map json) => _$SeerrCastMemberFromJson(json); } - -@JsonSerializable(createToJson: false) -class SeerrExternalIds { - final String? imdbId; - final int? tvdbId; - - const SeerrExternalIds({this.imdbId, this.tvdbId}); - - factory SeerrExternalIds.fromJson(Map json) => _$SeerrExternalIdsFromJson(json); -} diff --git a/lib/models/seerr/seerr_details.g.dart b/lib/models/seerr/seerr_details.g.dart index 133bb02b..d0f9afe4 100644 --- a/lib/models/seerr/seerr_details.g.dart +++ b/lib/models/seerr/seerr_details.g.dart @@ -8,27 +8,9 @@ part of 'seerr_details.dart'; SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => SeerrMovieDetails( - id: (json['id'] as num).toInt(), - title: json['title'] as String?, - overview: json['overview'] as String?, - posterPath: json['posterPath'] as String?, - backdropPath: json['backdropPath'] as String?, - releaseDate: json['releaseDate'] as String?, - runtime: (json['runtime'] as num?)?.toInt(), - status: json['status'] as String?, - voteAverage: (json['voteAverage'] as num?)?.toDouble(), - voteCount: (json['voteCount'] as num?)?.toInt(), - genres: (json['genres'] as List?) - ?.map((e) => SeerrGenre.fromJson(e as Map)) - .toList(), credits: json['credits'] == null ? null : SeerrCredits.fromJson(json['credits'] as Map), - externalIds: json['externalIds'] == null - ? null - : SeerrExternalIds.fromJson( - json['externalIds'] as Map, - ), mediaInfo: json['mediaInfo'] == null ? null : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), @@ -36,48 +18,17 @@ SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => SeerrTvDetails _$SeerrTvDetailsFromJson(Map json) => SeerrTvDetails( - id: (json['id'] as num).toInt(), - name: json['name'] as String?, - overview: json['overview'] as String?, - posterPath: json['posterPath'] as String?, - backdropPath: json['backdropPath'] as String?, - firstAirDate: json['firstAirDate'] as String?, - episodeRunTime: (json['episodeRunTime'] as List?) - ?.map((e) => (e as num).toInt()) - .toList(), - status: json['status'] as String?, - voteAverage: (json['voteAverage'] as num?)?.toDouble(), - voteCount: (json['voteCount'] as num?)?.toInt(), - genres: (json['genres'] as List?) - ?.map((e) => SeerrGenre.fromJson(e as Map)) - .toList(), - networks: (json['networks'] as List?) - ?.map((e) => SeerrNetwork.fromJson(e as Map)) - .toList(), - numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(), - numberOfSeasons: (json['numberOfSeasons'] as num?)?.toInt(), seasons: (json['seasons'] as List?) ?.map((e) => SeerrSeason.fromJson(e as Map)) .toList(), credits: json['credits'] == null ? null : SeerrCredits.fromJson(json['credits'] as Map), - externalIds: json['externalIds'] == null - ? null - : SeerrExternalIds.fromJson( - json['externalIds'] as Map, - ), mediaInfo: json['mediaInfo'] == null ? null : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), ); -SeerrGenre _$SeerrGenreFromJson(Map json) => - SeerrGenre(name: json['name'] as String?); - -SeerrNetwork _$SeerrNetworkFromJson(Map json) => - SeerrNetwork(name: json['name'] as String?); - SeerrSeason _$SeerrSeasonFromJson(Map json) => SeerrSeason( seasonNumber: (json['seasonNumber'] as num).toInt(), name: json['name'] as String?, @@ -97,9 +48,3 @@ SeerrCastMember _$SeerrCastMemberFromJson(Map json) => character: json['character'] as String?, profilePath: json['profilePath'] as String?, ); - -SeerrExternalIds _$SeerrExternalIdsFromJson(Map json) => - SeerrExternalIds( - imdbId: json['imdbId'] as String?, - tvdbId: (json['tvdbId'] as num?)?.toInt(), - ); diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart index 25f8ad83..03149da3 100644 --- a/lib/models/user_switch_response.dart +++ b/lib/models/user_switch_response.dart @@ -117,44 +117,4 @@ class UserSwitchResponse { attributionPartner: optString('attributionPartner'), ); } - - Map toJson() { - return { - 'id': id, - 'uuid': uuid, - 'username': username, - 'title': title, - 'email': email, - 'friendlyName': friendlyName, - 'locale': locale, - 'confirmed': confirmed, - 'joinedAt': joinedAt, - 'emailOnlyAuth': emailOnlyAuth, - 'hasPassword': hasPassword, - 'protected': protected, - 'thumb': thumb, - 'authToken': authToken, - 'mailingListActive': mailingListActive, - 'scrobbleTypes': scrobbleTypes, - 'country': country, - 'restricted': restricted, - 'anonymous': anonymous, - 'home': home, - 'guest': guest, - 'homeSize': homeSize, - 'homeAdmin': homeAdmin, - 'maxHomeSize': maxHomeSize, - 'profile': profile.toJson()['profile'], - 'twoFactorEnabled': twoFactorEnabled, - 'backupCodesCreated': backupCodesCreated, - 'attributionPartner': attributionPartner, - }; - } - - String get displayName => friendlyName ?? title; - - bool get isAdminUser => homeAdmin; - bool get isRestrictedUser => restricted; - bool get isGuestUser => guest; - bool get requiresPassword => hasPassword; } diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index e4a70954..e1653b17 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -21,12 +21,6 @@ class NavigationTab { return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel()); } - /// Get the index for a tab ID in the visible tabs list - static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false, bool hasExplore = false}) { - final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore); - return tabs.indexWhere((tab) => tab.id == id); - } - /// Get tabs filtered by offline mode and feature availability static List getVisibleTabs({ required bool isOffline, diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 32fda426..43c1edd8 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -113,8 +113,6 @@ class ActiveProfileBinder { final Set _plexHomePreVerified = {}; final Set _userInitiatedActivations = {}; - bool get isSwitching => _isSwitching; - @visibleForTesting String? get debugLastBoundProfileId => _lastBoundProfileId; diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 63d7a30b..edae8c09 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import '../media/ids.dart'; -import 'package:plezy/widgets/app_icon.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../media/media_item.dart'; import '../media/media_playlist.dart'; @@ -133,40 +131,6 @@ abstract class BaseMediaListDetailScreen extends State return []; } - - /// Build standard app bar actions (play, shuffle, delete) - /// Subclasses can override to customize actions - List buildAppBarActions({ - VoidCallback? onDelete, - String? deleteTooltip, - Color? deleteColor, - bool showDelete = true, - }) { - return [ - // Play button - if (items.isNotEmpty) - IconButton( - icon: const AppIcon(Symbols.play_arrow_rounded, fill: 1), - tooltip: t.common.play, - onPressed: playItems, - ), - // Shuffle button - if (items.isNotEmpty) - IconButton( - icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), - tooltip: t.common.shuffle, - onPressed: shufflePlayItems, - ), - // Delete button - if (showDelete && onDelete != null) - IconButton( - icon: const AppIcon(Symbols.delete_rounded, fill: 1), - tooltip: deleteTooltip ?? t.common.delete, - onPressed: onDelete, - color: deleteColor ?? Colors.red, - ), - ]; - } } /// Mixin that provides standard loadItems implementation for media lists diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 3f944684..612b86b5 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -48,10 +48,7 @@ class PlaylistDetailScreen extends StatefulWidget { } class _PlaylistDetailScreenState extends BaseMediaListDetailScreen - with - StandardItemLoader, - GridFocusNodeMixin, - FocusableDetailScreenMixin { + with GridFocusNodeMixin, FocusableDetailScreenMixin { static const int _pageSize = playlistItemsPageSize; @override @@ -231,11 +228,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen> fetchItems() async { - return fetchAllPlaylistItems(mediaClient, widget.playlist.id); - } - @override Future loadItems() async { if (mounted) { @@ -323,11 +315,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen t.pinned.equals(false))).go(); } - /// Pin every row whose `cacheKey` matches the SQL `LIKE` [pattern]. Used by - /// backend subclasses that pin by item-shape rather than a single endpoint - /// (e.g. Jellyfin's per-user item rows where the user segment is a - /// wildcard). - Future pinByKeyPattern(String pattern) async { - await (_db.update( - _db.apiCache, - )..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(true))); - } - - Future unpinByKeyPattern(String pattern) async { - await (_db.update( - _db.apiCache, - )..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(false))); - } - - Future hasPinnedMatching(String pattern) async { - final rows = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.like(pattern) & t.pinned.equals(true))).get(); - return rows.isNotEmpty; - } - /// Pull pinned rows for [serverId] and extract the first capture group of /// [keyPattern] from each `cacheKey`. Returns the unique set of captured /// ids — backend subclasses use this to enumerate their pinned items diff --git a/lib/services/companion_remote/remote_auth_service.dart b/lib/services/companion_remote/remote_auth_service.dart index 507f60a2..f4bf1d6d 100644 --- a/lib/services/companion_remote/remote_auth_service.dart +++ b/lib/services/companion_remote/remote_auth_service.dart @@ -388,10 +388,6 @@ class RemoteAuthService { _cachedSecret = null; _cachedSecretKey = null; } - - // Static direction constants for external use - static int get directionHost => _directionHost; - static int get directionClient => _directionClient; } /// Helper for building byte arrays. diff --git a/lib/services/download_artwork_service.dart b/lib/services/download_artwork_service.dart index b807779c..103991a5 100644 --- a/lib/services/download_artwork_service.dart +++ b/lib/services/download_artwork_service.dart @@ -44,13 +44,6 @@ class DownloadArtworkService { return isUsableArtworkFile(file); } - Future hasMissingArtwork(ServerId serverId, Iterable specs) async { - for (final spec in specs) { - if (!await existsUsable(serverId, spec.localKey)) return true; - } - return false; - } - Future ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { final serverId = metadata.serverId; if (serverId == null) return false; diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index f455c8d1..322a6e06 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -36,7 +36,6 @@ class AdjacentEpisodes { bool get hasNext => next != null; bool get hasPrevious => previous != null; bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary; - bool get nextLoadFailed => nextStatus == QueueNavigationStatus.failed; } enum _EpisodeQueueAvailability { active, unavailable, failed } diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index 5dac5ee2..03387049 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import '../connection/connection.dart'; -import '../connection/connection_auth_service.dart'; import '../exceptions/media_server_exceptions.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart'; @@ -45,9 +44,9 @@ class _JellyfinAuthenticationResponse { /// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges /// credentials for a long-lived access token and returns a built /// [JellyfinConnection] ready to insert into [ConnectionRegistry]. -/// 3. (later) [validate] / [refresh] / [signOut] for the [ConnectionAuthService] -/// contract. -class JellyfinConnectionAuthService implements ConnectionAuthService { +/// 3. (later) [validate] / [refresh] / [signOut] to keep the stored +/// connection current. +class JellyfinConnectionAuthService { JellyfinConnectionAuthService({ required this.clientName, required this.clientVersion, @@ -320,7 +319,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { } } - @override + /// Best-effort check that an existing token still works. Returns false on + /// 401/403; throws on transport failures the caller should retry. Future validate(Connection connection) async { if (connection is! JellyfinConnection) return false; final client = _authenticatedClient(connection); @@ -335,7 +335,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { } } - @override + /// Re-check the stored token and return the connection with its status + /// updated accordingly. Future refresh(Connection connection) async { if (connection is! JellyfinConnection) return connection; final ok = await validate(connection); @@ -345,7 +346,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { return connection.copyWith(status: ConnectionStatus.online, lastAuthenticatedAt: DateTime.now()); } - @override + /// Revoke the token server-side and forget local credentials. The caller + /// is responsible for removing the row from [ConnectionRegistry]. Future signOut(Connection connection) async { if (connection is! JellyfinConnection) return; final client = _authenticatedClient(connection); diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index ecbda98a..f53ddc61 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1189,27 +1189,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); } - @override - Future> fetchRecentlyAdded({int limit = 50}) async { - // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. - final response = await _http.get( - '/Users/${_segment(connection.userId)}/Items/Latest', - queryParameters: { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - final data = response.data; - // Latest returns a bare array, not an Items wrapper. - if (data is List) { - return _mapItems(data.whereType>()); - } - return _mapItems(_itemsArray(data)); - } - @override Future> fetchContinueWatching({int? count = 20}) async { final results = await Future.wait([ diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart index 5afd2356..926cf395 100644 --- a/lib/services/jellyfin_client/parts/metadata_edit.dart +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -43,13 +43,6 @@ mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { return data is Map ? data : const {}; } - Future>> getItemImageInfos(String itemId) async { - final response = await _http.get('/Items/${_segment(itemId)}/Images'); - throwIfHttpError(response); - final data = response.data; - return data is List ? data.whereType>().toList() : const >[]; - } - Future downloadRemoteImage(String itemId, {required String imageType, required String imageUrl}) async { final response = await _http.post( '/Items/${_segment(itemId)}/RemoteImages/Download', diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart index 701525eb..9f7f413a 100644 --- a/lib/services/macos_window_service.dart +++ b/lib/services/macos_window_service.dart @@ -110,10 +110,6 @@ class MacOSWindowService { } } - static void removeWindowDelegate(MacOSWindowDelegate delegate) { - _delegates.remove(delegate); - } - static Future setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible}); static Future syncWindowChrome() => _invoke('syncWindowChrome'); diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 5714618d..38b065f5 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -97,10 +97,10 @@ class MultiServerManager { /// Map of serverId to active optimization futures final Map> _activeOptimizations = {}; - /// Per-server clientIdentifier. Plex servers added via [addPlexAccount] - /// register their owning account's clientIdentifier here so reconnects + - /// endpoint optimization use the right identity (each account has its own - /// device row on plex.tv). + /// Per-server clientIdentifier. Plex servers added via + /// [refreshTokensForProfile] register their owning account's + /// clientIdentifier here so reconnects + endpoint optimization use the + /// right identity (each account has its own device row on plex.tv). final Map _clientIdByServer = {}; final Map _plexScopeByServer = {}; @@ -499,57 +499,6 @@ class MultiServerManager { } } - /// Connect every server attached to a Plex account in parallel. Each - /// account has its own `clientIdentifier` (registered as a separate - /// device on plex.tv), and we keep that mapping per-server in - /// [_clientIdByServer] so subsequent reconnects + endpoint optimization - /// race connections from the right identity. - Future addPlexAccount( - PlexAccountConnection connection, { - required String profileId, - Duration timeout = MediaServerTimeouts.perServerConnect, - Function(ServerId serverId, bool success)? onServerStatus, - }) async { - if (connection.servers.isEmpty) return 0; - appLogger.i( - 'Connecting Plex account ${connection.accountLabel} ' - '(${connection.servers.length} server${connection.servers.length == 1 ? '' : 's'})', - ); - - int connected = 0; - final futures = connection.servers.map((server) async { - final serverId = server.clientIdentifier; - final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId); - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; - try { - final client = await _createClientForServer( - server: server, - clientIdentifier: connection.clientIdentifier, - profileScopeId: profileScopeId, - ).namedTimeout(timeout, operation: 'connect to ${server.name}'); - final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); - _clients[serverId] = client; - _serverStatus[serverId] = true; - onServerStatus?.call(ServerId(serverId), true); - connected++; - } catch (e, stackTrace) { - appLogger.e('Failed to connect ${server.name}', error: e, stackTrace: stackTrace); - _serverStatus[serverId] = false; - onServerStatus?.call(ServerId(serverId), false); - } - }); - - await Future.wait(futures); - _statusController.add(Map.from(_serverStatus)); - if (connected > 0 && _connectivitySubscription == null) { - _startNetworkMonitoring(); - } - return connected; - } - /// Apply a freshly-fetched [PlexAccountConnection] to the manager, /// rotating per-server access tokens in place when possible. /// diff --git a/lib/services/music/music_playback_service.dart b/lib/services/music/music_playback_service.dart index 6b28526b..02f45b1f 100644 --- a/lib/services/music/music_playback_service.dart +++ b/lib/services/music/music_playback_service.dart @@ -37,9 +37,6 @@ class MusicPlayContext { /// profile switch tears the session down. [notifyListeners] fires only on /// discrete changes (track, status, queue shape, modes) — progress bars /// subscribe to [positionStream] instead. -/// -/// [StubMusicPlaybackService] is registered until the playback engine lands; -/// UI gates transport affordances on [isAvailable]. abstract class MusicPlaybackService extends ChangeNotifier { /// False on the stub — playback affordances should render disabled or /// fall back to a "not supported yet" notice. @@ -155,9 +152,8 @@ abstract class MusicPlaybackService extends ChangeNotifier { Future fetchLyrics(MediaItem track); } -/// No-op placeholder bound while the playback engine is not wired yet (or -/// on platforms where it failed to initialize). Keeps every UI consumer -/// null-safe without per-call-site feature checks. +/// No-op base for test doubles, which override only the members under test. +/// Production always binds `MusicPlaybackServiceImpl`. class StubMusicPlaybackService extends MusicPlaybackService { final ValueNotifier _volumeNotifier = ValueNotifier(100); int _playIntentGeneration = 0; diff --git a/lib/services/music/music_source_resolver.dart b/lib/services/music/music_source_resolver.dart index 4481321e..764a1b48 100644 --- a/lib/services/music/music_source_resolver.dart +++ b/lib/services/music/music_source_resolver.dart @@ -24,9 +24,6 @@ class MusicSource { /// `DirectPlay` / `Transcode` for progress reports. final String? playMethod; - final int selectedMediaIndex; - final String? selectedMediaSourceId; - /// True when [url] points at a downloaded/local copy. final bool isOffline; @@ -41,8 +38,6 @@ class MusicSource { this.headers, this.playSessionId, this.playMethod, - this.selectedMediaIndex = 0, - this.selectedMediaSourceId, this.isOffline = false, this.mediaInfo, this.reportingClient, @@ -93,8 +88,6 @@ class ServerMusicSourceResolver implements MusicSourceResolver { headers: context.streamHeaders, playSessionId: result.playSessionId, playMethod: result.playMethod ?? (result.isTranscoding ? 'Transcode' : 'DirectPlay'), - selectedMediaIndex: result.selectedMediaIndex, - selectedMediaSourceId: result.selectedMediaSourceId, isOffline: result.isOffline, mediaInfo: result.mediaInfo, reportingClient: context.reportingClient, diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 637391fd..21fe222c 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -28,9 +28,8 @@ export 'media_list_playback_launcher.dart' /// 4. Handling errors with appropriate feedback /// /// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for -/// the backend-neutral entry point. Plex-only flows such as -/// [launchFromPlaylistItem] live directly on this class because they have no -/// Jellyfin equivalent. +/// the backend-neutral entry point. Flows outside that abstraction, such as +/// [launchFromFolder], live directly on this class. class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final BuildContext context; final PlexClient client; @@ -153,17 +152,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - // If the queue is empty, try fetching it again with getPlayQueue - if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { - final fetchedQueue = await client.getPlayQueue( - playQueue.playQueueID, - librarySectionID: sourceLibraryId, - librarySectionTitle: sourceLibraryTitle, - ); - if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { - playQueue = fetchedQueue; - } - } + playQueue = await _refetchIfEmpty(playQueue, libraryId: sourceLibraryId, libraryTitle: sourceLibraryTitle); // Close loading dialog before navigating to the player await dismissLoading(); @@ -181,40 +170,6 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - /// Launch playback from a playlist starting at a specific item. - Future launchFromPlaylistItem({ - required MediaPlaylist playlist, - required MediaItem selectedItem, - bool showLoadingIndicator = true, - }) async { - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: t.common.play, - execute: (dismissLoading) async { - // Plex's createPlayQueue takes the metadata `key` (`/library/metadata/{id}`), - // not the bare ratingKey. Construct it from the MediaItem id. - final selectedKey = '/library/metadata/${selectedItem.id}'; - final playQueue = await client.createPlayQueue( - playlistID: int.parse(playlist.id), - type: 'video', - key: selectedKey, - ); - - // Close loading dialog before navigating to the player - await dismissLoading(); - - return _launchFromQueue( - playQueue: playQueue, - ratingKey: playlist.id, - serverId: serverIdOrNull(serverId), - serverName: serverName, - selectedItem: _resolveSelectedMediaItem(playQueue), - ); - }, - ); - } - /// Launch shuffled playback for a show or season. @override Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async { @@ -287,16 +242,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { librarySectionTitle: libraryTitle, ); - if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { - final fetchedQueue = await client.getPlayQueue( - playQueue.playQueueID, - librarySectionID: libraryId, - librarySectionTitle: libraryTitle, - ); - if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { - playQueue = fetchedQueue; - } - } + playQueue = await _refetchIfEmpty(playQueue, libraryId: libraryId, libraryTitle: libraryTitle); await dismissLoading(); @@ -312,6 +258,27 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } + /// Creation sometimes returns a queue without its items; re-read it by ID + /// and keep the refetched copy only when it actually carries items. + Future _refetchIfEmpty( + PlayQueueResponse? playQueue, { + String? libraryId, + String? libraryTitle, + }) async { + if (playQueue == null || (playQueue.items != null && playQueue.items!.isNotEmpty)) { + return playQueue; + } + final fetchedQueue = await client.getPlayQueue( + playQueue.playQueueID, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ); + if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { + return fetchedQueue; + } + return playQueue; + } + /// Core method to launch playback from a play queue. Future _launchFromQueue({ required PlayQueueResponse? playQueue, diff --git a/lib/services/playback_context.dart b/lib/services/playback_context.dart index 37fce46e..63666d23 100644 --- a/lib/services/playback_context.dart +++ b/lib/services/playback_context.dart @@ -27,5 +27,4 @@ class PlaybackContext { bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile; bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback; - bool get shouldQueueOnly => reportingMode == PlaybackReportingMode.offlineQueue; } diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index d9dee915..05f4754c 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -1,6 +1,7 @@ import '../media/ids.dart'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; import '../database/plex_metadata_recovery.dart'; @@ -73,6 +74,7 @@ class PlexApiCache extends ApiCache { // Rating keys can be alphanumeric, not just numeric. static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$'); + @visibleForTesting Future> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern); /// Copy one pinned item between cache namespaces. diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index b6e2adc7..207e2319 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -34,13 +34,8 @@ import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; import '../models/livetv_hub_result.dart'; -import '../models/livetv_lineup.dart'; import '../models/livetv_program.dart'; -import '../models/livetv_server_status.dart'; -import '../models/livetv_session.dart'; import '../models/media_grab_operation.dart'; -import '../models/media_grabber_device.dart'; -import '../models/media_provider_info.dart'; import '../models/media_subscription.dart'; import '../models/plex/plex_activity.dart'; import '../models/plex/plex_config.dart'; @@ -1420,18 +1415,6 @@ class PlexClient return results; } - /// Get recently added media (filtered to video content only) - Future> _getRecentlyAdded({int limit = 50}) async { - final response = await _getWithFailover( - '/library/recentlyAdded', - queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, - ); - final allItems = _extractMetadataList(response); - - // Filter out music content (artists, albums, tracks) - return allItems.where((item) => !ContentTypes.musicTypes.contains(item.type?.toLowerCase())).toList(); - } - /// Get continue watching items via the hubs system. /// Prefer the provider's dedicated Continue Watching feature key when /// advertised; fall back to Plex Web's legacy hubs query. Both respect the @@ -3463,12 +3446,6 @@ class PlexClient return results.map((m) => PlexMappers.mediaItem(m)).toList(); } - @override - Future> fetchRecentlyAdded({int limit = 50}) async { - final items = await _getRecentlyAdded(limit: limit); - return items.map((m) => PlexMappers.mediaItem(m)).toList(); - } - @override Future> fetchContinueWatching({int? count = 20}) async { final items = await _getContinueWatching(count: count); diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 3a183601..d3aa59d2 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -20,6 +20,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport Map? queryParameters, // ignore: unused_element_parameter Map? headers, + // ignore: unused_element_parameter Duration? timeout, // ignore: unused_element_parameter AbortController? abort, @@ -197,73 +198,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return dvrs.isNotEmpty; } - @override - Future fetchLiveTvServerStatus() async { - final response = await _getWithFailover('/'); - final container = _getMediaContainer(response); - return LiveTvServerStatus.fromJson(container ?? const {}); - } - - @override - Future fetchDvr(String dvrId) async { - final response = await _getWithFailover('/livetv/dvrs/$dvrId'); - final container = _getMediaContainer(response); - final rootMappings = container?['ChannelMapping']; - return _extractFirst(response, const ['Dvr'], (json) { - final map = Map.from(json)..putIfAbsent('ChannelMapping', () => rootMappings); - return LiveTvDvr.fromJson(map); - }); - } - - @override - Future> createDvr({ - required List devices, - required List lineups, - String? language, - String? country, - String? postalCode, - }) async { - final response = await _http.post( - '/livetv/dvrs', - queryParameters: { - 'device': devices, - 'lineup': lineups, - ...?(language == null ? null : {'language': language}), - ...?(country == null ? null : {'country': country}), - ...?(postalCode == null ? null : {'postalCode': postalCode}), - }, - timeout: MediaServerTimeouts.receive, - ); - _throwIfFailed(response); - return LiveTvActivityResult( - value: _extractFirst(response, const ['Dvr'], LiveTvDvr.fromJson), - activityUuid: _activityUuid(response), - ); - } - - @override - Future deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId')); - - @override - Future updateDvrPrefs(String dvrId, Map prefs) => - _expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs)); - - @override - Future attachDeviceToDvr(String dvrId, String deviceId) => - _expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId')); - - @override - Future detachDeviceFromDvr(String dvrId, String deviceId) => - _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId')); - - @override - Future addLineupToDvr(String dvrId, String lineupUri) => - _expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); - - @override - Future removeLineupFromDvr(String dvrId, String lineupUri) => - _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); - @override Future> reloadGuide(String dvrId) async { final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive); @@ -271,200 +205,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response)); } - @override - Future cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide')); - - @override - Future> fetchGrabbers({String? protocol}) async { - final response = await _getWithFailover( - '/media/grabbers', - queryParameters: { - ...?(protocol == null ? null : {'protocol': protocol}), - }, - ); - return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson); - } - - @override - Future> fetchGrabberDevices() async { - final response = await _getWithFailover('/media/grabbers/devices'); - return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future>> discoverGrabberDevices() async { - final response = await _http.post('/media/grabbers/devices/discover', timeout: MediaServerTimeouts.receive); - _throwIfFailed(response); - return LiveTvActivityResult( - value: _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson), - activityUuid: _activityUuid(response), - ); - } - - @override - Future fetchGrabberDevice(String deviceId) async { - final response = await _getWithFailover('/media/grabbers/devices/$deviceId'); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future addGrabberDevice(String uri, {String? grabberId}) async { - final path = grabberId == null ? '/media/grabbers/devices' : '/media/grabbers/$grabberId/devices'; - final response = await _http.post(path, queryParameters: {'uri': uri}); - _throwIfFailed(response); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk( - () => _http.put( - '/media/grabbers/devices/$deviceId', - queryParameters: { - ...?(enabled == null ? null : {'enabled': enabled ? 1 : 0}), - ...?(title == null ? null : {'title': title}), - }, - ), - ); - - @override - Future deleteGrabberDevice(String deviceId) => - _expectOk(() => _http.delete('/media/grabbers/devices/$deviceId')); - - @override - Future> fetchGrabberDeviceChannels(String deviceId) async { - final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels'); - return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson); - } - - @override - Future> scanGrabberDevice( - String deviceId, { - String? source, - Map prefs = const {}, - String? network, - String? country, - }) async { - final response = await _http.post( - '/media/grabbers/devices/$deviceId/scan', - queryParameters: { - ...?(source == null ? null : {'source': source}), - for (final entry in prefs.entries) 'prefs[${entry.key}]': entry.value, - ...?(network == null ? null : {'network': network}), - ...?(country == null ? null : {'country': country}), - }, - timeout: MediaServerTimeouts.receive, - ); - _throwIfFailed(response); - return LiveTvActivityResult( - value: _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson), - activityUuid: _activityUuid(response), - ); - } - - @override - Future cancelGrabberDeviceScan(String deviceId) async { - final response = await _http.delete('/media/grabbers/devices/$deviceId/scan'); - _throwIfFailed(response); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future saveGrabberDeviceChannelMap( - String deviceId, - MediaGrabberChannelMapRequest request, - ) async { - final response = await _http.put( - '/media/grabbers/devices/$deviceId/channelmap', - queryParameters: { - if (request.channelsEnabled.isNotEmpty) 'channelsEnabled': request.channelsEnabled.join(','), - for (final entry in request.channelMapping.entries) 'channelMapping[${entry.key}]': entry.value, - for (final entry in request.channelMappingByKey.entries) 'channelMappingByKey[${entry.key}]': entry.value, - }, - ); - _throwIfFailed(response); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future updateGrabberDevicePrefs(String deviceId, Map prefs) => - _expectOk(() => _http.put('/media/grabbers/devices/$deviceId/prefs', queryParameters: prefs)); - - @override - String buildGrabberDeviceThumbUrl(String deviceId, int version) => - '${config.baseUrl}/media/grabbers/devices/$deviceId/thumb/$version'.withPlexToken(config.token); - - @override - Future> fetchEpgCountries() async { - final response = await _getWithFailover('/livetv/epg/countries'); - return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson); - } - - @override - Future> fetchEpgLanguages() async { - final response = await _getWithFailover('/livetv/epg/languages'); - return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson); - } - - @override - Future> fetchEpgRegions(String country, String epgId) async { - final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions'); - return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson); - } - - @override - Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) async { - final path = region == null - ? '/livetv/epg/countries/$country/$epgId/lineups' - : '/livetv/epg/countries/$country/$epgId/regions/$region/lineups'; - final response = await _getWithFailover( - path, - queryParameters: { - ...?(postalCode == null ? null : {'postalCode': postalCode}), - }, - ); - final container = _getMediaContainer(response); - return LiveTvLineupResult( - lineupGroupUuid: container?['uuid'] as String?, - lineups: _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson), - ); - } - - @override - Future> fetchEpgChannelsForLineup(String lineupUri) async { - final response = await _getWithFailover('/livetv/epg/channels', queryParameters: {'lineup': lineupUri}); - return _extractContainerList(response, const [ - 'Channel', - ], (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName)); - } - - @override - Future> fetchEpgChannelsForLineups(List lineupUris) async { - final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris}); - return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson); - } - - @override - Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}) async { - final response = await _getWithFailover( - '/livetv/epg/channelmap', - queryParameters: {'device': deviceUri, 'lineup': lineupUri}, - ); - return _extractContainerList(response, const ['ChannelMapping'], ChannelMapping.fromJson); - } - - @override - Future?>> findBestLineup({ - required String deviceUri, - required String lineupGroupUri, - }) async { - final response = await _getWithFailover( - '/livetv/epg/lineup', - queryParameters: {'device': deviceUri, 'lineupGroup': lineupGroupUri}, - timeout: MediaServerTimeouts.receive, - ); - return LiveTvActivityResult(value: _getMediaContainer(response), activityUuid: _activityUuid(response)); - } - /// Get EPG channels using provider lineup endpoints (matches official Plex web client) Future> getEpgChannels({String? lineup}) async { List parseChannels(MediaServerResponse response) { @@ -744,19 +484,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } - @override - Future fetchRecordingRule( - String subscriptionId, { - bool includeGrabs = true, - bool includeStorage = true, - }) async { - final response = await _getWithFailover( - '/media/subscriptions/$subscriptionId', - queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0}, - ); - return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); - } - @override Future createRecordingRule(MediaSubscriptionCreateRequest request) async { final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request))); @@ -778,18 +505,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport Future deleteRecordingRule(String subscriptionId) => _expectOk(() => _http.delete('/media/subscriptions/$subscriptionId')); - @override - Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) async { - final response = await _http.put( - '/media/subscriptions/$subscriptionId/move', - queryParameters: { - ...?(afterSubscriptionId == null ? null : {'after': afterSubscriptionId}), - }, - ); - _throwIfFailed(response); - return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); - } - @override Future processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process')); @@ -824,23 +539,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } - @override - Future> fetchMediaProviders() async { - final response = await _getWithFailover('/media/providers'); - return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson); - } - - @override - Future registerMediaProvider(String url) => - _expectOk(() => _http.post('/media/providers', queryParameters: {'url': url})); - - @override - Future refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh')); - - @override - Future unregisterMediaProvider(String providerId) => - _expectOk(() => _http.delete('/media/providers/$providerId')); - /// Tune to a live TV channel. /// /// POSTs to the tune endpoint and extracts metadata, session info, and @@ -1105,53 +803,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return '${config.baseUrl}$streamPath'.withPlexToken(config.token); } - @override - Future> fetchLiveTvSessionsDetailed() async { - final response = await _getWithFailover('/livetv/sessions'); - return _extractContainerList(response, const [ - 'LiveTVSession', - 'LiveTvSession', - 'Session', - 'Metadata', - ], LiveTvSession.fromJson); - } - - @override - Future fetchLiveTvSession(String sessionId) async { - final response = await _getWithFailover('/livetv/sessions/$sessionId'); - return _extractFirst(response, const [ - 'LiveTVSession', - 'LiveTvSession', - 'Session', - 'Metadata', - ], LiveTvSession.fromJson); - } - - @override - Uri buildNotificationWebSocketUri({List? filters}) { - final base = Uri.parse(config.baseUrl); - return base.replace( - scheme: base.scheme == 'https' ? 'wss' : 'ws', - path: '/:/websocket/notifications', - queryParameters: { - if (config.token != null) 'X-Plex-Token': config.token!, - if (filters != null) 'filters': filters.join(','), - }, - ); - } - - @override - Uri buildNotificationEventSourceUri({List? filters}) { - final base = Uri.parse(config.baseUrl); - return base.replace( - path: '/:/eventsource/notifications', - queryParameters: { - if (config.token != null) 'X-Plex-Token': config.token!, - if (filters != null) 'filters': filters.join(','), - }, - ); - } - /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` @override Future buildFavoriteChannelSource({String? lineup}) async { diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index bf5cd27e..144475c5 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -145,10 +145,6 @@ class SeerrClient { return SeerrRequest.fromJson(data as Map); } - Future deleteRequest(int requestId) async { - await _request('DELETE', '/request/$requestId'); - } - // ---------- Sonarr / Radarr options (request sheet advanced pickers) ---------- Future> getRadarrServices() => _serviceList('/service/radarr'); diff --git a/lib/services/seerr/seerr_constants.dart b/lib/services/seerr/seerr_constants.dart index 830a2b5c..c7bd7be1 100644 --- a/lib/services/seerr/seerr_constants.dart +++ b/lib/services/seerr/seerr_constants.dart @@ -23,9 +23,7 @@ abstract final class SeerrMediaServerType { /// app checks are named; the full mask is stored on the session untouched. abstract final class SeerrPermission { static const int admin = 2; - static const int manageRequests = 16; static const int request = 32; - static const int autoApprove = 128; static const int request4k = 1024; static const int request4kMovie = 2048; static const int request4kTv = 4096; diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 1551297b..0362abd3 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -50,8 +50,6 @@ class SyncRuleExecutor { SyncRuleExecutor({required this._database}); - bool get isExecuting => _isExecuting; - /// Execute every enabled sync rule. /// /// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only diff --git a/lib/services/trakt/trakt_constants.dart b/lib/services/trakt/trakt_constants.dart index c758c62c..6afc0304 100644 --- a/lib/services/trakt/trakt_constants.dart +++ b/lib/services/trakt/trakt_constants.dart @@ -59,12 +59,6 @@ enum TraktMediaKind { movie, episode; - static TraktMediaKind? tryFromMediaKindId(String type) => switch (type) { - 'movie' => movie, - 'episode' => episode, - _ => null, - }; - static TraktMediaKind fromName(String name) => values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name')); } diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 2a82fee9..c4ee9877 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -51,10 +51,6 @@ class MemoryLogOutput extends LogOutput { _currentSize = 0; } - static int getCurrentSize() => _currentSize; - - static double getCurrentSizeMB() => _currentSize / (1024 * 1024); - @override void output(OutputEvent event) { // Only print to console — storage is done in MemoryAwareLogPrinter.log() diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index ff1b7d3a..4f8e7f66 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -17,7 +17,6 @@ class ContentTypes { static const Set musicTypes = {artist, album, track}; static const Set videoTypes = {movie, show, season, episode}; - static const Set playableTypes = {movie, episode, clip, track}; } class ContentTypeHelper { diff --git a/lib/utils/continuation_pagination_coordinator.dart b/lib/utils/continuation_pagination_coordinator.dart index b98ddae6..a56f22cc 100644 --- a/lib/utils/continuation_pagination_coordinator.dart +++ b/lib/utils/continuation_pagination_coordinator.dart @@ -37,14 +37,12 @@ class ContinuationPaginationCoordinator { bool _disposed = false; bool _isLoading = false; Object? _error; - StackTrace? _errorStackTrace; int? get nextStartIndex => _nextStartIndex; int? get totalCount => _totalCount; bool get hasMore => _nextStartIndex != null; bool get isLoading => _isLoading; Object? get error => _error; - StackTrace? get errorStackTrace => _errorStackTrace; /// Invalidates all prior work, runs [request], and reports whether its result /// still belongs to the current generation. @@ -65,7 +63,6 @@ class ContinuationPaginationCoordinator { _totalCount = totalCount; _nextStartIndex = startIndex < totalCount ? startIndex : null; _error = null; - _errorStackTrace = null; onStateChanged?.call(); } @@ -99,7 +96,6 @@ class ContinuationPaginationCoordinator { _inFlightGeneration = null; _isLoading = false; _error = null; - _errorStackTrace = null; } int _beginGeneration() { @@ -110,7 +106,6 @@ class ContinuationPaginationCoordinator { _inFlightGeneration = null; _isLoading = false; _error = null; - _errorStackTrace = null; if (!_disposed) onStateChanged?.call(); return _generation; } @@ -120,7 +115,6 @@ class ContinuationPaginationCoordinator { Future _loadRemaining(int generation) async { _isLoading = true; _error = null; - _errorStackTrace = null; onStateChanged?.call(); try { @@ -148,7 +142,6 @@ class ContinuationPaginationCoordinator { } catch (exception, stackTrace) { if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; _error = exception; - _errorStackTrace = stackTrace; onError?.call(exception, stackTrace); return ContinuationLoadStatus.failed; } finally { diff --git a/lib/utils/hierarchical_event_mixin.dart b/lib/utils/hierarchical_event_mixin.dart index 3d3a47f9..d2bc0b85 100644 --- a/lib/utils/hierarchical_event_mixin.dart +++ b/lib/utils/hierarchical_event_mixin.dart @@ -23,10 +23,6 @@ mixin HierarchicalEventMixin { /// Check if this event affects a specific item by id. bool affectsItem(String itemId) => this.itemId == itemId || parentChain.contains(itemId); - /// Check if this event affects a specific globalKey. - bool affectsGlobalKey(String globalKey) => - this.globalKey == globalKey || parentChain.any((pk) => buildGlobalKey(serverId, pk) == globalKey); - /// Check if this event affects any item in a collection. bool affectsAnyOf(Iterable itemIds) { if (itemIds.contains(itemId)) return true; diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index ebfc3ba3..002a101b 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -31,7 +31,6 @@ class ScreenBreakpoints { /// Animation and notification durations. class AppDurations { - static const Duration animFast = Duration(milliseconds: 200); static const Duration animMedium = Duration(milliseconds: 300); static const Duration animSlow = Duration(milliseconds: 500); static const Duration snackBarDefault = Duration(seconds: 3); diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index b430d4da..d7d30b8e 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -334,31 +334,4 @@ class MediaImageHelper { return true; } - - /// Optimized URL for clear-logo overlays ([ImageType.logo]). - static String logoUrl({ - required MediaServerClient? client, - required String? thumbPath, - required BuildContext context, - required double containerWidth, - required double containerHeight, - }) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo); - - static String _typedUrl( - MediaServerClient? client, - String? thumbPath, - BuildContext context, - double containerWidth, - double containerHeight, - ImageType type, - ) { - return getOptimizedImageUrl( - client: client, - thumbPath: thumbPath, - maxWidth: containerWidth, - maxHeight: containerHeight, - devicePixelRatio: effectiveDevicePixelRatio(context), - imageType: type, - ); - } } diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 663dbff8..17320db9 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -112,8 +112,6 @@ class TvDetectionService { bool get isTV => _isTV; - List get tvDetectionReasons => _effectiveDetectionReasons; - List get _effectiveDetectionReasons { final reasons = [..._detectionReasons]; if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv'); diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 644d898e..79df6f7c 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -155,11 +155,6 @@ class WatchTogetherProvider with ChangeNotifier { bool get hasCurrentPlayback => currentMediaRatingKey != null && currentMediaServerId != null && currentMediaTitle != null; - /// Set the display name for this user - void setDisplayName(String name) { - _displayName = name; - } - String? _buildPlaybackKey(String? ratingKey, ServerId? serverId) { if (ratingKey == null || serverId == null) return null; return '$serverId:$ratingKey'; diff --git a/lib/watch_together/services/guest_playback_reconciler.dart b/lib/watch_together/services/guest_playback_reconciler.dart index da907683..189f07be 100644 --- a/lib/watch_together/services/guest_playback_reconciler.dart +++ b/lib/watch_together/services/guest_playback_reconciler.dart @@ -120,7 +120,6 @@ class GuestPlaybackReconciler { Timer? _statusRefreshTimer; PlaybackState? get latestState => _latestState; - bool get isCorrecting => _correcting; // --------------------------------------------------------------------- // Public inputs diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart index e9c905d2..18bfe170 100644 --- a/lib/watch_together/services/host_playback_coordinator.dart +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -117,7 +117,6 @@ class HostPlaybackCoordinator { bool _disposed = false; PlaybackPhase get phase => _phase; - Set get incompatiblePeers => Set.unmodifiable(_incompatiblePeers); // --------------------------------------------------------------------- // Public inputs diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 1475720c..2acf8646 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -230,9 +230,6 @@ class HubSectionState extends State with MountedSetStateMixin, Skele }); } - /// Check if this hub currently has focus - bool get hasFocusedItem => _hubFocusNode.hasFocus; - /// Get the number of items in this hub int get itemCount => _totalItemCount; diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index e541cedf..afe63949 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -2,40 +2,11 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; -import '../../../mpv/mpv.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/track_label_builder.dart'; import '../../../widgets/focusable_list_tile.dart'; class TrackSelectionHelper { - /// Get the appropriate empty message based on track type - static String getEmptyMessage() { - if (T == SubtitleTrack) { - return t.videoControls.noSubtitlesAvailable; - } else if (T == AudioTrack) { - return t.videoControls.noAudioTracksAvailable; - } - return t.videoControls.noTracksAvailable; - } - - static Widget buildEmptyState() { - return Center(child: Text(getEmptyMessage())); - } - - /// Check if "Off" is selected for a track - static bool isOffSelected(T? selectedTrack, bool Function(T track)? isOffTrack) { - return selectedTrack == null || (isOffTrack?.call(selectedTrack) ?? false); - } - - static String getTrackId(T track) { - if (track is AudioTrack) { - return track.id; - } else if (track is SubtitleTrack) { - return track.id; - } - return ''; - } - static Widget buildOffTile({ required BuildContext context, required bool isSelected, diff --git a/lib/widgets/video_controls/player_chrome_controller.dart b/lib/widgets/video_controls/player_chrome_controller.dart index 6e400647..1e6529e2 100644 --- a/lib/widgets/video_controls/player_chrome_controller.dart +++ b/lib/widgets/video_controls/player_chrome_controller.dart @@ -34,7 +34,6 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable _controlsPresented; bool get contentStripVisible => _contentStripVisible; - bool get hasVisibleHold => _holds.isNotEmpty; bool isHeld(PlayerChromeHold hold) => _holds.contains(hold); PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget; diff --git a/test/models/livetv_flexible_parsing_test.dart b/test/models/livetv_flexible_parsing_test.dart index 39e21c6f..6412ab24 100644 --- a/test/models/livetv_flexible_parsing_test.dart +++ b/test/models/livetv_flexible_parsing_test.dart @@ -1,8 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/models/livetv_dvr.dart'; -import 'package:plezy/models/livetv_lineup.dart'; -import 'package:plezy/models/media_grabber_device.dart'; -import 'package:plezy/models/media_provider_info.dart'; import 'package:plezy/models/media_subscription.dart'; void main() { @@ -28,48 +25,6 @@ void main() { {'uuid': 'device-1'}, ]); - final grabber = MediaGrabberDevice.fromJson({ - 'key': 'device-1', - 'uuid': 'device-1', - 'ChannelMapping': [ - {'channelKey': 7}, - {'channelKey': 'channel-1'}, - ], - 'Setting': [ - {'id': 7}, - {'id': 'setting-1'}, - ], - }); - expect(grabber.channelMappings.map((entry) => entry.channelKey), ['channel-1']); - expect(grabber.settings.map((entry) => entry.id), ['setting-1']); - - final lineup = LiveTvLineup.fromJson({ - 'uuid': 'lineup-1', - 'Channel': [ - {'callSign': 7}, - {'key': 'channel-1', 'callSign': 'ONE'}, - ], - }); - expect(lineup.channels.map((entry) => entry.callSign), ['ONE']); - - final provider = MediaProviderInfo.fromJson({ - 'identifier': 'provider-1', - 'Feature': [ - {'type': 7}, - { - 'type': 'livetv', - 'Directory': [ - 'invalid', - {'key': 'guide'}, - ], - }, - ], - }); - expect(provider.features.map((entry) => entry.type), ['livetv']); - expect(provider.features.single.directories, [ - {'key': 'guide'}, - ]); - final template = SubscriptionTemplate.fromJson({ 'MediaSubscription': [ {'title': 7}, diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart index 370d3078..cea40263 100644 --- a/test/services/plex_live_tv_support_test.dart +++ b/test/services/plex_live_tv_support_test.dart @@ -51,8 +51,8 @@ void main() { ); } - http.Response jsonResponse(Map body, {Map? headers}) { - return http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json', ...?headers}); + http.Response jsonResponse(Map body) { + return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); } test('favorite source follows requested lineup provider', () async { @@ -155,37 +155,6 @@ void main() { expect(dvrs.single.channelMappings.single.enabled, isTrue); }); - test('createDvr sends repeated device and lineup query params and exposes activity id', () async { - late http.Request captured; - final client = makeClient((request) async { - captured = request; - return jsonResponse( - { - 'MediaContainer': { - 'Dvr': [ - {'key': '42', 'uuid': 'dvr-42'}, - ], - }, - }, - headers: {'x-plex-activity': 'activity-1'}, - ); - }); - addTearDown(client.close); - - final result = await client.liveTvDvr!.createDvr( - devices: const ['dev-a', 'dev-b'], - lineups: const ['lineup-a', 'lineup-b'], - language: 'eng', - ); - - expect(captured.url.path, '/livetv/dvrs'); - expect(captured.url.queryParametersAll['device'], ['dev-a', 'dev-b']); - expect(captured.url.queryParametersAll['lineup'], ['lineup-a', 'lineup-b']); - expect(captured.url.queryParameters['language'], 'eng'); - expect(result.activityUuid, 'activity-1'); - expect(result.value?.key, '42'); - }); - test('subscription template parses settings and URL-encoded enum labels', () async { final client = makeClient((request) async { expect(request.url.path, '/media/subscriptions/template'); From 04d8070fd48a3a33e9a9d694f3aa10852aebe42f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:36:42 +0200 Subject: [PATCH 02/12] refactor: pin the look-alike code paths that must not be merged Several pairs of near-identical code paths differ in one load-bearing line. Each site now carries a comment naming the invariant that forces it apart, backed by a characterization test so a future deduplication fails loudly instead of silently changing behaviour. Pinned: focusable wrapper vs. chip D-pad activation policy, profile connection cleanup's raw-id vs. ServerId-typed server projections, live TV tab loaders, video player display matching and playback service wiring, track selection container ordering, tracker HTTP client status ladder, and the MediaServerHttpClient shutdown/cancellation contract versus ManagedHttpClient's closing guard. New tests: test/focus/dpad_activation_policy_test.dart test/services/track_selection_container_ordinal_test.dart test/services/trackers/tracker_status_ladder_test.dart test/utils/media_server_http_client_shutdown_test.dart --- lib/focus/focusable_chip_mixin.dart | 6 + lib/focus/focusable_wrapper.dart | 5 + lib/profiles/active_profile_binder.dart | 5 + lib/profiles/profile_connection_cleanup.dart | 7 ++ lib/screens/livetv/tabs/guide_tab.dart | 3 + lib/screens/livetv/tabs/recordings_tab.dart | 3 + lib/screens/livetv/tabs/whats_on_tab.dart | 3 + .../profile/profile_detail_screen.dart | 8 +- .../video_player/parts/display_matching.dart | 4 +- .../video_player/parts/playback_services.dart | 14 +++ lib/screens/video_player_screen.dart | 11 ++ lib/services/track_selection_service.dart | 8 ++ .../trackers/anilist/anilist_client.dart | 2 + lib/services/trackers/mal/mal_client.dart | 2 + lib/services/trackers/simkl/simkl_client.dart | 2 + .../trackers/tracker_http_client.dart | 8 ++ lib/services/trakt/trakt_client.dart | 2 + lib/utils/abortable_http_request.dart | 3 + lib/utils/managed_http_client.dart | 3 + lib/utils/media_server_http_client.dart | 9 ++ test/focus/dpad_activation_policy_test.dart | 88 +++++++++++++++ ...rack_selection_container_ordinal_test.dart | 36 ++++++ .../trackers/tracker_status_ladder_test.dart | 106 ++++++++++++++++++ ...edia_server_http_client_shutdown_test.dart | 83 ++++++++++++++ 24 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 test/focus/dpad_activation_policy_test.dart create mode 100644 test/services/track_selection_container_ordinal_test.dart create mode 100644 test/services/trackers/tracker_status_ladder_test.dart create mode 100644 test/utils/media_server_http_client_shutdown_test.dart diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index e2fe987d..9757acec 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -102,6 +102,12 @@ mixin FocusableChipStateMixin on State { /// /// Returns [KeyEventResult.handled] if the event was consumed, /// [KeyEventResult.ignored] otherwise. + /// + /// Runs the same activation sequence as `_FocusableWrapperState._handleKeyEvent` + /// but is deliberately kept separate: a chip leaves the context-menu key + /// unconsumed when [ChipKeyCallbacks.onLongPress] is null and traps RIGHT/DOWN + /// so focus cannot escape the strip, where a wrapper does the opposite on both + /// counts. KeyEventResult handleChipKeyEvent(FocusNode _, KeyEvent event, ChipKeyCallbacks callbacks) { final key = event.logicalKey; diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 88050c25..fe2b30e8 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -410,6 +410,11 @@ class _FocusableWrapperState extends State with SingleTickerPr }); } + // Runs the same activation sequence as FocusableChipStateMixin.handleChipKeyEvent + // but is deliberately kept separate: a wrapper always consumes the context-menu + // key (even with no onLongPress, so a card never leaks it upward) and passes + // every unmapped arrow through to framework traversal, where a chip does the + // opposite on both counts. KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; final diagnosticsEnabled = TextInputDiagnostics.enabled; diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 43c1edd8..07083332 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -372,6 +372,11 @@ class ActiveProfileBinder { return success; } + /// Server ids the profile should reach once bound: its join rows plus the + /// implicit Plex Home parent, which normally has no row. Not shared with + /// `_serverIdsForProfile` (profile_connection_cleanup.dart) — that one is + /// join-rows-only and [ServerId]-typed, while this set keeps growing with + /// bind results and is compared against the manager's raw string ids. Set _expectedServerIdsForProfile( Profile profile, { required List joinRows, diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart index 53fa0875..b754b598 100644 --- a/lib/profiles/profile_connection_cleanup.dart +++ b/lib/profiles/profile_connection_cleanup.dart @@ -283,6 +283,10 @@ Future _clearProfileServerPrefsNoLongerReferenced({ } } +/// Server ids reachable through this profile's join rows. Narrower than +/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home +/// parent is not counted here, so folding the two together would change which +/// per-profile prefs survive an unlink. Future> _serverIdsForProfile( String profileId, { required ProfileConnectionRegistry profileConnections, @@ -316,6 +320,9 @@ Future _isServerReferenced( return false; } +// [ServerId]-typed for the preference APIs, which drops ids that fail to +// parse; the twin in profile_detail_screen.dart stays raw so it can be +// differenced against download keys. Set _serverIdsForConnection(Connection connection) { return switch (connection) { PlexAccountConnection(:final servers) => { diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 66045c14..a26fab6c 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -192,6 +192,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi }); } + // Not the gated data-refresh timer the other tabs run: pause/resume drive the + // per-minute UI ticker, and pause has to stamp _hiddenSince on both a section + // hide and an app background so _catchUpIfStale can measure the absence. void pauseRefresh() { _hiddenSince ??= DateTime.now(); _timeIndicatorTimer?.cancel(); diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 998515f9..a68e0746 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -125,6 +125,9 @@ class RecordingsTabState extends State with WidgetsBindingObserve } } + // Same three gates as WhatsOnTab (tab selected, subtree visible, app + // foregrounded), but resume also reloads: a recording scheduled from the + // guide has to show up on arrival, not on the next 30s tick. void pauseRefresh() { _refreshRequested = false; _syncRefreshTimer(); diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 70c400b9..7d46f7c2 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -80,6 +80,9 @@ class WhatsOnTabState extends State } } + // Refreshes only while all three gates hold: tab selected, subtree visible, + // app foregrounded. Resume just re-arms the tick — unlike RecordingsTab there + // is no immediate reload, since nothing done on the other tabs changes hubs. void pauseRefresh() { _refreshRequested = false; _syncRefreshTimer(); diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 39c04303..bf652cef 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -228,7 +228,10 @@ class _ProfileDetailScreenState extends State with Controll /// Server ids the profile keeps after removing [excludingConnectionId]: /// its other join rows plus, for Plex Home profiles, the implicit parent - /// account. + /// account. Raw ids, matching the download keys this is differenced + /// against; `_serverIdsForProfile` in profile_connection_cleanup.dart is + /// ServerId-typed and ignores the parent, so the two are not the same + /// projection. Future> _retainedServerIds({ required String excludingConnectionId, required ProfileConnectionRegistry profileConnections, @@ -260,6 +263,9 @@ class _ProfileDetailScreenState extends State with Controll unawaited(context.read().rebindIfActive(_profile.id)); } + // Raw machine ids rather than the ServerId-typed twin in + // profile_connection_cleanup.dart: these are differenced against retained + // ids and matched to download global keys, which carry the unparsed id. Set _serverIdsForConnection(Connection conn) { return switch (conn) { PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(), diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index 35d45795..5e2255fd 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -143,7 +143,9 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { } } - /// Restore Windows display mode to original state. + /// Restore Windows display mode to original state. Fullscreen-exit only: + /// `dispose()` runs its own fire-and-forget variant because it cannot await + /// the HDR settle below. Future _restoreWindowsDisplayMode() async { if (_displayModeService == null || !_displayModeService!.anyChangeApplied) return; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 4b327042..7ce9b50d 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -61,6 +61,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { required SettingsService settingsService, required bool useExoPlayer, }) async { + // Re-wire scope: exactly the nine subscriptions re-created below. The + // media-controls listeners belong to _setupMediaControls and the + // sleep-timer/Apple TV ones to initState; both outlive a re-wire. await Future.wait([ if (_playingSubscription != null) _playingSubscription!.cancel(), if (_completedSubscription != null) _completedSubscription!.cancel(), @@ -190,10 +193,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { }); } + /// Roll the screen back to a re-runnable state after a failed player + /// attempt. The player is gone but the screen stays mounted and + /// [_retryPlayerInitialization] may run again, so every collaborator is + /// released *and* nulled so it can be built once more. Kept separate from + /// `dispose()`, which instead destroys the notifiers, focus nodes and + /// player, and cannot await any of this. Future _tearDownFailedPlayerAttempt(Player attemptPlayer) async { final activePlayer = player; if (activePlayer != null && !identical(activePlayer, attemptPlayer)) return; + // Rollback scope: the nine player streams plus the five media-controls + // ones. _sleepTimerSubscription and _appleTvPlayPauseSubscription are + // initState-owned and never re-created — cancelling them here would kill + // the sleep-timer prompt and the Apple TV remote for the rest of the + // screen's life. final cancellationFutures = >[ if (_playingSubscription != null) _playingSubscription!.cancel(), if (_completedSubscription != null) _completedSubscription!.cancel(), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 8ad82a5d..3910c919 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1509,6 +1509,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin _chromeController.dispose(); _toastController.dispose(); + // The release sequence below mirrors _tearDownFailedPlayerAttempt but is + // deliberately separate: dispose() cannot await, and it destroys the + // notifiers, focus nodes and player that the rollback path keeps alive + // for a retry on a still-mounted screen. + // // Stop progress tracking and send final state. Normal back navigation // awaits this before popping; dispose keeps a fallback for externally // removed routes where dispose() cannot await. @@ -1531,6 +1536,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin SleepTimerService().markNeedsRestart(); } + // Teardown scope: every subscription the screen ever owns, including the + // initState-owned sleep-timer and Apple TV ones that the rollback path + // must leave alive. _playingSubscription?.cancel(); _completedSubscription?.cancel(); _errorSubscription?.cancel(); @@ -1577,6 +1585,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin FullscreenStateManager().removeListener(_onFullscreenChanged); _fullscreenListenerAttached = false; } + // Not _restoreWindowsDisplayMode(): that helper waits 200ms after clearing + // the HDR hint before restoring, which dispose() cannot do. Fire the hint + // clear at the still-live player and restore immediately. if (!isReplacingWithVideo && Platform.isWindows && _displayModeService != null && diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 4086fef3..a8dadc2b 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -127,6 +127,8 @@ SubtitleTrack? findMpvTrackForPlexSubtitle( // A container track has no stable native ID. Its source-container ordinal // is authoritative; a metadata-identical earlier track is not a match. + // Narrower than the guard in [findPlexTrackForMpvSubtitle]: a Plex stream + // carrying no container ordinal still falls back to metadata scoring. if (mpvTrack.isContainer && plexOrdinal >= 0 && !ordinalMatches) continue; final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); @@ -191,6 +193,8 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle( final ordinalMatches = containerPlexTracks != null && mpvOrdinal >= 0 && containerPlexTracks.indexOf(plexTrack) == mpvOrdinal; + // The probe fixes isContainer here, so once a container ordinal list exists + // a container track matches at its own ordinal or not at all. if (mpvTrack.isContainer && containerPlexTracks != null && !ordinalMatches) continue; final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); @@ -217,6 +221,8 @@ AudioTrack? findMpvTrackForPlexAudio( AudioTrack? bestMatch; int bestScore = 0; + // Ordinal identity is cross-side: the probe's index in the Plex list against + // the candidate's index in the MPV list. final plexOrdinal = allPlexTracks?.indexOf(plexTrack) ?? -1; for (final mpvTrack in mpvTracks) { @@ -244,6 +250,8 @@ MediaAudioTrack? findPlexTrackForMpvAudio( MediaAudioTrack? bestMatch; int bestScore = 0; + // Same cross-side ordinal rule as [findMpvTrackForPlexAudio] with the two + // lists swapped; the score arguments stay MPV-first either way. final mpvOrdinal = allMpvTracks?.indexOf(mpvTrack) ?? -1; for (final plexTrack in plexTracks) { diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index 91247283..214dcccb 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -408,6 +408,8 @@ class AnilistClient implements DisposableTrackerClient { final res = await send(); + // Rate limits are typed here and in Trakt only; MAL and Simkl surface a 429 + // as a plain TrackerApiException. if (res.statusCode == 429) { throw TrackerRateLimitException( service: TrackerService.anilist, diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 9cd09e52..595f3024 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -193,6 +193,8 @@ class MalClient implements DisposableTrackerClient { try { await _refresh(); } catch (_) { + // Reported as an API 401, not as the TrackerAuthException Trakt + // propagates from the same path. throw const TrackerApiException(service: TrackerService.mal, statusCode: 401); } res = await _send(method, path, body: body, formBody: formBody); diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index dace4a59..99e2cfe7 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -154,6 +154,8 @@ class SimklClient implements DisposableTrackerClient { allowedMethods: const {'GET', 'POST'}, ); + // Only the authenticated host may invalidate: the data host is called + // without a token, so its 401s say nothing about the session. if (mainApiHost && response.statusCode == 401) { onSessionInvalidated(); throw const TrackerAuthException( diff --git a/lib/services/trackers/tracker_http_client.dart b/lib/services/trackers/tracker_http_client.dart index 8a96c167..3c8b541b 100644 --- a/lib/services/trackers/tracker_http_client.dart +++ b/lib/services/trackers/tracker_http_client.dart @@ -9,6 +9,14 @@ import '../../utils/platform_http_client_stub.dart' as platform; import 'tracker_constants.dart'; +/// Transport shared by the tracker clients: builds, times and logs a request, +/// then hands back the raw response. +/// +/// Status handling stays with each client because the rules genuinely differ: +/// MAL and Simkl accept any 2xx, Trakt a per-call set (200/201/204, plus 409 +/// for scrobble), AniList only 200 (GraphQL errors ride a 200 body); and a 401 +/// means refresh-and-retry for Trakt/MAL but a terminal session for AniList +/// and Simkl. class TrackerHttpClient { static const Set allMethods = {'GET', 'POST', 'PATCH', 'PUT', 'DELETE'}; diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index f686c1e9..4facd12f 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -307,6 +307,8 @@ class TraktClient implements DisposableTrackerClient { var res = await _send(method, path, body: body); if (res.statusCode == 401) { + // A failed refresh propagates its TrackerAuthException; MAL's equivalent + // path flattens the same failure into TrackerApiException(401). await refresh(); res = await _send(method, path, body: body); } diff --git a/lib/utils/abortable_http_request.dart b/lib/utils/abortable_http_request.dart index fce70294..2dedb64e 100644 --- a/lib/utils/abortable_http_request.dart +++ b/lib/utils/abortable_http_request.dart @@ -14,6 +14,9 @@ Future sendAbortableHttpRequest( Future? abortTrigger, String? operation, }) { + // Deliberately not `AbortController`: that type lives with the media-server + // client and throws `MediaServerHttpException`, which the tracker/Seerr + // callers of this helper must stay independent of. final abort = Completer(); void abortRequest() { if (!abort.isCompleted) abort.complete(); diff --git a/lib/utils/managed_http_client.dart b/lib/utils/managed_http_client.dart index bbdf8770..31644e1b 100644 --- a/lib/utils/managed_http_client.dart +++ b/lib/utils/managed_http_client.dart @@ -240,6 +240,9 @@ class _ManagedStreamedResponseWithUrl extends http.StreamedResponse implements h final Uri url; } +/// Deliberately not `AbortController`: this layer stays a plain [http.Client] +/// with no media-server dependency, and it needs two independent latches +/// (aborted vs. drained) plus the response canceller. class _TrackedRequest { _TrackedRequest(this.url); diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index c0a2f732..52e61524 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -78,7 +78,16 @@ class AbortController { /// timeouts, logging, and optional endpoint failover. class MediaServerHttpClient { final http.Client _client; + + /// Requests owned by this client, aborted at the transport on shutdown so an + /// in-flight body raises [http.RequestAbortedException] instead of truncating. final Set _activeAborts = {}; + + /// Not delegated to [ManagedHttpClient]'s own closing guard: that reports + /// shutdown as an [http.ClientException], which maps to + /// [MediaServerHttpErrorType.connectionError] and so reads as transient. + /// Failover, pagination and download retry all branch on + /// [MediaServerHttpException.isCancellation]. bool _closing = false; MediaServerHttpClient({ diff --git a/test/focus/dpad_activation_policy_test.dart b/test/focus/dpad_activation_policy_test.dart new file mode 100644 index 00000000..3b315c6d --- /dev/null +++ b/test/focus/dpad_activation_policy_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; +import 'package:plezy/widgets/focusable_tab_chip.dart'; + +void main() { + // FocusableWrapper and FocusableChipStateMixin run the same d-pad activation + // sequence under opposite consume policies. These pin the two differences that + // keep the handlers separate. + group('d-pad activation policies', () { + Future> escapedKeysFor( + WidgetTester tester, + FocusNode node, + Widget child, + LogicalKeyboardKey key, + ) async { + final escaped = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent) escaped.add(event.logicalKey); + return KeyEventResult.handled; + }, + child: child, + ), + ), + ), + ); + node.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(key); + await tester.pump(); + return escaped; + } + + testWidgets('wrapper consumes the context menu key with no onLongPress', (tester) async { + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + + final escaped = await escapedKeysFor( + tester, + node, + FocusableWrapper(focusNode: node, onSelect: () {}, child: const SizedBox(width: 10, height: 10)), + LogicalKeyboardKey.contextMenu, + ); + + expect(escaped, isEmpty); + }); + + testWidgets('chip leaves the context menu key to its ancestors with no onLongPress', (tester) async { + final node = FocusNode(debugLabel: 'chip'); + addTearDown(node.dispose); + + final escaped = await escapedKeysFor( + tester, + node, + FocusableTabChip(label: 'Tab', isSelected: true, focusNode: node, onSelect: () {}), + LogicalKeyboardKey.contextMenu, + ); + + expect(escaped, [LogicalKeyboardKey.contextMenu]); + }); + + testWidgets('wrapper passes unmapped RIGHT/DOWN through to the framework', (tester) async { + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + Widget card() => FocusableWrapper(focusNode: node, onSelect: () {}, child: const SizedBox(width: 10, height: 10)); + + expect(await escapedKeysFor(tester, node, card(), LogicalKeyboardKey.arrowRight), [ + LogicalKeyboardKey.arrowRight, + ]); + expect(await escapedKeysFor(tester, node, card(), LogicalKeyboardKey.arrowDown), [LogicalKeyboardKey.arrowDown]); + }); + + testWidgets('chip traps unmapped RIGHT/DOWN so focus cannot escape the strip', (tester) async { + final node = FocusNode(debugLabel: 'chip'); + addTearDown(node.dispose); + Widget chip() => FocusableTabChip(label: 'Tab', isSelected: true, focusNode: node, onSelect: () {}); + + expect(await escapedKeysFor(tester, node, chip(), LogicalKeyboardKey.arrowRight), isEmpty); + expect(await escapedKeysFor(tester, node, chip(), LogicalKeyboardKey.arrowDown), isEmpty); + }); + }); +} diff --git a/test/services/track_selection_container_ordinal_test.dart b/test/services/track_selection_container_ordinal_test.dart new file mode 100644 index 00000000..b639db2b --- /dev/null +++ b/test/services/track_selection_container_ordinal_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/services/track_selection_service.dart'; + +// The container-ordinal guards in `findMpvTrackForPlexSubtitle` and +// `findPlexTrackForMpvSubtitle` look like mirrors but are not: when the probe +// has no ordinal in the container list, the Plex->MPV direction still scores by +// metadata while the MPV->Plex direction refuses to match at all. These tests +// pin that difference so the two guards are not "symmetrised". + +MediaSubtitleTrack _plexSub(int id, {int? index, String? languageCode}) => + MediaSubtitleTrack(id: id, index: index, languageCode: languageCode, selected: false, forced: false); + +SubtitleTrack _containerSub(String id, {String? lang}) => + SubtitleTrack(id: id, language: lang, isExternal: true, isContainer: true); + +void main() { + group('container-ordinal guard asymmetry', () { + test('Plex->MPV keeps metadata scoring when the Plex stream has no container ordinal', () { + final probe = _plexSub(40, index: 0, languageCode: 'eng'); + final otherPlexTracks = [_plexSub(41, index: 1, languageCode: 'eng')]; + final nativeTracks = [_containerSub('2_0', lang: 'eng')]; + + expect(findMpvTrackForPlexSubtitle(probe, nativeTracks, allPlexTracks: otherPlexTracks), nativeTracks.first); + }); + + test('MPV->Plex refuses to match when the container track has no ordinal', () { + final probe = _containerSub('2_0', lang: 'eng'); + final otherNativeTracks = [_containerSub('2_1', lang: 'eng')]; + final plexTracks = [_plexSub(40, index: 0, languageCode: 'eng')]; + + expect(findPlexTrackForMpvSubtitle(probe, plexTracks, allMpvTracks: otherNativeTracks), isNull); + }); + }); +} diff --git a/test/services/trackers/tracker_status_ladder_test.dart b/test/services/trackers/tracker_status_ladder_test.dart new file mode 100644 index 00000000..28b81a9c --- /dev/null +++ b/test/services/trackers/tracker_status_ladder_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/models/trakt/trakt_ids.dart'; +import 'package:plezy/models/trakt/trakt_scrobble_request.dart'; +import 'package:plezy/services/trackers/simkl/simkl_client.dart'; +import 'package:plezy/services/trackers/simkl/simkl_constants.dart'; +import 'package:plezy/services/trackers/tracker_exceptions.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trakt/trakt_client.dart'; + +TrackerSession _session({String refreshToken = 'refresh-old'}) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'access-old', + refreshToken: refreshToken, + expiresAt: now + 86400, + createdAt: now, + username: 'alice', + ); +} + +const _scrobble = TraktScrobbleRequest.movie(ids: TraktIds(trakt: 1)); + +void main() { + group('Trakt status ladder', () { + test('accepts 409 on scrobble but not on other requests', () async { + final client = TraktClient( + _session(), + onSessionInvalidated: () => fail('409 should not invalidate the session'), + httpClient: MockClient((_) async => http.Response('conflict', 409)), + ); + addTearDown(client.dispose); + + await client.scrobbleStart(_scrobble); + + await expectLater( + client.getUserSettings(), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 409)), + ); + }); + + test('propagates the refresh TrackerAuthException after a 401', () async { + var invalidated = 0; + final client = TraktClient( + _session(refreshToken: 'refresh-ladder'), + onSessionInvalidated: () => invalidated++, + httpClient: MockClient((request) async { + if (request.url.path == '/oauth/token') { + return http.Response(json.encode({'error': 'invalid_grant'}), 400); + } + return http.Response('unauthorized', 401); + }), + ); + addTearDown(client.dispose); + + await expectLater( + client.getUserSettings(), + throwsA(isA().having((e) => e.isPermanent, 'isPermanent', isTrue)), + ); + expect(invalidated, 1); + }); + }); + + group('Simkl status ladder', () { + test('only the authenticated host invalidates on 401', () async { + var invalidated = 0; + final client = SimklClient( + _session(), + onSessionInvalidated: () => invalidated++, + httpClient: MockClient((_) async => http.Response('unauthorized', 401)), + ); + addTearDown(client.dispose); + + await expectLater(client.getTrending(SimklCatalogType.tv), throwsA(isA())); + expect(invalidated, 0); + + await expectLater( + client.getUserSettings(), + throwsA(isA().having((e) => e.isPermanent, 'isPermanent', isTrue)), + ); + expect(invalidated, 1); + }); + + test('surfaces 429 as a plain API failure', () async { + final client = SimklClient( + _session(), + onSessionInvalidated: () => fail('429 should not invalidate the session'), + httpClient: MockClient((_) async => http.Response('slow down', 429, headers: {'retry-after': '23'})), + ); + addTearDown(client.dispose); + + await expectLater( + client.getUserSettings(), + throwsA( + allOf( + isA().having((e) => e.statusCode, 'statusCode', 429), + isNot(isA()), + ), + ), + ); + }); + }); +} diff --git a/test/utils/media_server_http_client_shutdown_test.dart b/test/utils/media_server_http_client_shutdown_test.dart new file mode 100644 index 00000000..77387f04 --- /dev/null +++ b/test/utils/media_server_http_client_shutdown_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/utils/managed_http_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +void main() { + group('MediaServerHttpClient shutdown', () { + test('rejects new requests as a cancellation, not a transient failure', () async { + final client = MediaServerHttpClient( + client: ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'), + baseUrl: 'https://example.test/', + ); + + client.close(); + + await expectLater( + client.get('library/sections'), + throwsA( + isA() + .having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled) + .having((e) => e.isTransient, 'isTransient', isFalse), + ), + ); + }); + + test('the layer beneath reports the same shutdown as a transient connection error', () async { + final managed = ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'); + await managed.closeGracefully(drainTimeout: Duration.zero); + + await expectLater( + managed.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))), + throwsA( + isA() + .having( + (e) => MediaServerHttpException.from(e).type, + 'mapped type', + MediaServerHttpErrorType.connectionError, + ) + .having((e) => MediaServerHttpException.from(e).isTransient, 'mapped isTransient', isTrue), + ), + ); + }); + + test('aborts requests already in flight at the transport', () async { + final transport = _AbortAwareClient(); + final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/'); + + final pending = client.get('library/sections'); + await Future.delayed(Duration.zero); + + client.close(); + + await expectLater(transport.abortTrigger, completes); + await expectLater( + pending, + throwsA(isA().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)), + ); + }); + }); +} + +class _AbortAwareClient extends http.BaseClient { + final _response = Completer(); + late final Future abortTrigger; + + @override + Future send(http.BaseRequest request) { + final trigger = (request as http.Abortable).abortTrigger!; + abortTrigger = trigger; + unawaited( + trigger.whenComplete(() { + if (!_response.isCompleted) _response.completeError(http.RequestAbortedException(request.url)); + }), + ); + return _response.future; + } + + @override + void close() {} +} From 61344f7862e34d12103e591ed3bf0fa641f9b5b6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:00:07 +0200 Subject: [PATCH 03/12] test: extract shared fixtures and scaffolds Collapse duplicated setup across the suite into six shared helpers under test/test_helpers/ and rewrite the 28 suites that were open-coding it: http_fixtures.dart jsonResponse() for http.Response JSON stubs library_tab_scaffold.dart pumps library tabs under their required ancestors multi_server_fixtures.dart MultiServerProvider wiring for widget tests playback_report_fakes.dart PlaybackReportCall + fake report sinks profile_stack.dart production-shaped profile dependency graph theme.dart testMonoTokens for fast-settling widget tests Net -1245 lines with no change in coverage or assertions. --- test/profiles/active_profile_binder_test.dart | 22 +- .../companion_remote_provider_test.dart | 318 +-- .../providers/user_profile_provider_test.dart | 197 +- .../libraries/library_browse_music_test.dart | 60 +- .../libraries/library_browse_tab_test.dart | 72 +- .../library_collections_tab_test.dart | 47 +- .../libraries/library_playlists_tab_test.dart | 49 +- test/screens/metadata_edit_screen_test.dart | 8 +- test/screens/search_screen_test.dart | 4 +- .../catalog/plex_catalog_source_test.dart | 35 +- .../catalog/seerr_catalog_source_test.dart | 25 +- .../external_player_service_test.dart | 43 +- test/services/jellyfin_client_urls_test.dart | 2192 +++++++---------- test/services/live_session_tracker_test.dart | 58 +- .../music/music_playback_service_test.dart | 52 +- .../offline_watch_sync_service_test.dart | 51 +- .../playback_progress_tracker_test.dart | 111 +- .../playback_report_session_test.dart | 72 +- test/test_helpers/http_fixtures.dart | 7 + test/test_helpers/library_tab_scaffold.dart | 62 + test/test_helpers/multi_server_fixtures.dart | 30 + test/test_helpers/playback_report_fakes.dart | 120 + test/test_helpers/profile_stack.dart | 74 + test/test_helpers/theme.dart | 46 + test/utils/provider_extensions_test.dart | 32 +- test/widgets/chapter_sheet_test.dart | 22 +- test/widgets/media_context_menu_test.dart | 101 +- test/widgets/music/mini_player_test.dart | 27 +- test/widgets/player_queue_spoilers_test.dart | 23 +- .../server_activities_button_test.dart | 19 +- test/widgets/side_navigation_rail_test.dart | 37 +- test/widgets/track_sheet_test.dart | 24 +- test/widgets/video_controls_test.dart | 39 +- test/widgets/video_settings_sheet_test.dart | 25 +- 34 files changed, 1599 insertions(+), 2505 deletions(-) create mode 100644 test/test_helpers/http_fixtures.dart create mode 100644 test/test_helpers/library_tab_scaffold.dart create mode 100644 test/test_helpers/multi_server_fixtures.dart create mode 100644 test/test_helpers/playback_report_fakes.dart create mode 100644 test/test_helpers/profile_stack.dart create mode 100644 test/test_helpers/theme.dart diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 89261df7..aec5ebc6 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -18,13 +18,13 @@ import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; /// Poll [condition] until it holds, failing after [timeout]. Used to observe @@ -74,7 +74,7 @@ void main() { storage: storage, ); manager = MultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); shouldDeferInitialBind = false; binder = ActiveProfileBinder( activeProfile: activeProfile, @@ -193,7 +193,7 @@ void main() { final failingManager = _FailingPlexMultiServerManager(); manager = failingManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -247,7 +247,7 @@ void main() { final mixedManager = _BlockingMixedMultiServerManager(); manager = mixedManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -349,7 +349,7 @@ void main() { final capturingManager = _CapturingMultiServerManager(); manager = capturingManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -591,7 +591,7 @@ void main() { final recoveringManager = _RecordingPlexManager(); manager = recoveringManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -644,7 +644,7 @@ void main() { multiServerProvider.dispose(); manager = testManager ?? _CapturingMultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -809,7 +809,7 @@ void main() { final gated = _GatedJellyfinManager(); manager = gated; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -849,7 +849,7 @@ void main() { final gated = _GatedJellyfinManager(); manager = gated; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -890,7 +890,7 @@ void main() { final failing = _CountingFailingJellyfinManager(); manager = failing; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -929,7 +929,7 @@ void main() { var pinPrompts = 0; manager = _CapturingMultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, diff --git a/test/providers/companion_remote_provider_test.dart b/test/providers/companion_remote_provider_test.dart index 44db7c3a..72b63cf8 100644 --- a/test/providers/companion_remote_provider_test.dart +++ b/test/providers/companion_remote_provider_test.dart @@ -1,29 +1,22 @@ import 'dart:async'; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/models/plex/plex_home.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/companion_remote/remote_command.dart'; import 'package:plezy/models/companion_remote/remote_session.dart'; -import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart'; import 'package:plezy/services/companion_remote/lan_discovery_service.dart'; import 'package:plezy/services/companion_remote/remote_auth_context.dart'; import 'package:plezy/services/companion_remote/remote_auth_service.dart'; -import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -473,69 +466,48 @@ void main() { }); test('ensureCryptoReady rebuilds when the active profile/account changes', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profileA = _localProfile('profile-a'); final profileB = _localProfile('profile-b'); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profiles.upsert(profileA); - await profiles.upsert(profileB); - await profileConnections.upsert( + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profiles.upsert(profileA); + await stack.profiles.upsert(profileB); + await stack.profileConnections.upsert( ProfileConnection(profileId: profileA.id, connectionId: accountA.id, userIdentifier: 'admin-a'), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profileB.id, connectionId: accountB.id, userIdentifier: 'admin-b'), makeDefault: true, ); - await storage.setActiveProfileId(profileA.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profileA.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final okA = await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: accountA, ); expect(okA, isTrue); expect(provider.debugCryptoConnectionId, accountA.id); expect(provider.debugCryptoProfileId, profileA.id); - await active.activate(profileB); + await stack.active.activate(profileB); final ok = await provider.ensureCryptoReady( _home('admin-b'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: accountB, ); @@ -545,50 +517,29 @@ void main() { }); test('ensureCryptoReady uses the active local profile Plex row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profile = _localProfile('profile-local'); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: accountB.id, userIdentifier: 'child-b', isDefault: true), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _homeWithUsers('admin-b', ['child-b']), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isTrue); @@ -598,48 +549,27 @@ void main() { }); test('ensureCryptoReady uses the active local profile Jellyfin row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-jf'); - await connections.upsert(jellyfin); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(jellyfin); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( null, - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isTrue); @@ -649,54 +579,33 @@ void main() { }); test('ensureCryptoReady includes every active local profile remote identity', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final account = _plexAccount('plex-a', 'client-a'); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-mixed'); final home = _homeWithUsers('admin-a', ['child-a']); - await connections.upsert(account); - await connections.upsert(jellyfin); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.connections.upsert(jellyfin); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'child-a'), ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( home, - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, plexHomeForConnection: (_) async => home, ); @@ -706,41 +615,20 @@ void main() { }); test('ensureCryptoReady does not fall back to an account without an active profile', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); - await connections.upsert(_plexAccount('plex-a', 'client-a')); - await profiles.upsert(_localProfile('profile-a')); - await active.initialize(); + await stack.connections.upsert(_plexAccount('plex-a', 'client-a')); + await stack.profiles.upsert(_localProfile('profile-a')); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isFalse); @@ -748,48 +636,27 @@ void main() { }); test('resetForLogout clears crypto context', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final account = _plexAccount('plex-a', 'client-a'); final profile = _localProfile('profile-a'); - await connections.upsert(account); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'admin-a'), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: account, ); expect(provider.isCryptoReady, isTrue); @@ -1061,46 +928,28 @@ class _FakeLanDiscoveryService extends LanDiscoveryService { } class _RemoteHarness { - _RemoteHarness({required this.provider, required this.database, required this.activeProfile, required this.plexHome}); + _RemoteHarness({required this.provider, required this.stack}); final CompanionRemoteProvider provider; - final AppDatabase database; - final ActiveProfileProvider activeProfile; - final PlexHomeService plexHome; + final ProfileStack stack; bool _closed = false; static Future<_RemoteHarness> create( CompanionRemotePeerServiceFactory peerServiceFactory, { LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new, }) async { - final database = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(database); - final profileConnections = ProfileConnectionRegistry(database); - final profiles = ProfileRegistry(database); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfile = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final account = _plexAccount('remote-account', 'remote-client'); final profile = _localProfile('remote-profile'); - await connections.upsert(account); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'remote-admin'), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await activeProfile.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider.forTesting( peerServiceFactory: peerServiceFactory, @@ -1108,25 +957,22 @@ class _RemoteHarness { ); final ready = await provider.ensureCryptoReady( _home('remote-admin'), - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: account, ); if (!ready) { throw StateError('Remote test harness failed to initialize crypto'); } - return _RemoteHarness(provider: provider, database: database, activeProfile: activeProfile, plexHome: plexHome); + return _RemoteHarness(provider: provider, stack: stack); } Future close() async { if (_closed) return; _closed = true; if (!provider.isDisposed) provider.dispose(); - await activeProfile.resetForTesting(); - activeProfile.dispose(); - await plexHome.dispose(); - await database.close(); + await stack.dispose(); } } diff --git a/test/providers/user_profile_provider_test.dart b/test/providers/user_profile_provider_test.dart index a3da4a3c..6bea338a 100644 --- a/test/providers/user_profile_provider_test.dart +++ b/test/providers/user_profile_provider_test.dart @@ -1,26 +1,19 @@ import 'dart:convert'; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; -import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/user_profile_provider.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; -import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { setUp(resetSharedPreferencesForTest); @@ -57,30 +50,11 @@ void main() { }); test('settings connection follows the profile default row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); @@ -102,10 +76,10 @@ void main() { deviceId: 'device-a', createdAt: DateTime(2026, 1, 1), ); - await profiles.upsert(profile); - await connections.upsert(plex); - await connections.upsert(jellyfin); - await profileConnections.upsert( + await stack.profiles.upsert(profile); + await stack.connections.upsert(plex); + await stack.connections.upsert(jellyfin); + await stack.profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: plex.id, @@ -115,53 +89,36 @@ void main() { ), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final p = UserProfileProvider() ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); - await profileConnections.setDefault(profile.id, jellyfin.id); + await stack.profileConnections.setDefault(profile.id, jellyfin.id); expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); }); test('watches Plex Home profile connection rows', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, + final stack = await ProfileStack.create( + homeUsers: [_homeUser(uuid: 'home-user-a', title: 'Home User')], ); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final account = PlexAccountConnection( @@ -171,21 +128,23 @@ void main() { accountLabel: 'Plex A', createdAt: DateTime(2026, 1, 1), ); - await connections.upsert(account); - await plexHome.refresh(account); - await storage.setActiveProfileId(plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a')); - await active.initialize(); + await stack.connections.upsert(account); + await stack.plexHome.refresh(account); + await stack.storage.setActiveProfileId( + plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'), + ); + await stack.active.initialize(); final p = UserProfileProvider() ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); - expect(p.debugWatchedProfileConnectionProfileId, active.activeId); + expect(p.debugWatchedProfileConnectionProfileId, stack.active.activeId); }); test('Plex Home profile without a switched token makes no user request', () async { @@ -231,30 +190,11 @@ void main() { }); test('Plex token fallback uses the selected local profile account', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); @@ -272,10 +212,10 @@ void main() { accountLabel: 'Plex B', createdAt: DateTime(2026, 1, 1), ); - await profiles.upsert(profile); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profileConnections.upsert( + await stack.profiles.upsert(profile); + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: accountB.id, @@ -284,8 +224,8 @@ void main() { ), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final requests = []; final auth = _recordingAuth(requests, audioLanguage: 'fra'); @@ -293,9 +233,9 @@ void main() { final p = UserProfileProvider(authService: auth) ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); @@ -356,39 +296,16 @@ PlexAuthService _recordingAuth(List requests, {required String aud } class _HomeProfileFixture { - _HomeProfileFixture({ - required this.db, - required this.active, - required this.plexHome, - required this.auth, - required this.provider, - required this.requests, - }); + _HomeProfileFixture({required this.stack, required this.auth, required this.provider, required this.requests}); - final AppDatabase db; - final ActiveProfileProvider active; - final PlexHomeService plexHome; + final ProfileStack stack; final PlexAuthService auth; final UserProfileProvider provider; final List requests; static Future<_HomeProfileFixture> create({String? switchedToken}) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, + final stack = await ProfileStack.create( + homeUsers: [_homeUser(uuid: 'home-user-a', title: 'Home User')], ); final account = PlexAccountConnection( id: 'plex-parent', @@ -397,12 +314,12 @@ class _HomeProfileFixture { accountLabel: 'Plex Parent', createdAt: DateTime(2026, 1, 1), ); - await connections.upsert(account); - await plexHome.refresh(account); + await stack.connections.upsert(account); + await stack.plexHome.refresh(account); final activeId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'); if (switchedToken != null) { - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection( profileId: activeId, connectionId: account.id, @@ -413,29 +330,23 @@ class _HomeProfileFixture { makeDefault: true, ); } - await storage.setActiveProfileId(activeId); - await active.initialize(); + await stack.storage.setActiveProfileId(activeId); + await stack.active.initialize(); final requests = []; final auth = _recordingAuth(requests, audioLanguage: 'jpn'); final provider = UserProfileProvider(authService: auth) - ..attach(connections: connections, activeProfile: active, profileConnections: profileConnections); - return _HomeProfileFixture( - db: db, - active: active, - plexHome: plexHome, - auth: auth, - provider: provider, - requests: requests, - ); + ..attach( + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, + ); + return _HomeProfileFixture(stack: stack, auth: auth, provider: provider, requests: requests); } Future dispose() async { provider.dispose(); auth.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); } } diff --git a/test/screens/libraries/library_browse_music_test.dart b/test/screens/libraries/library_browse_music_test.dart index 9a4f61b6..8ce74156 100644 --- a/test/screens/libraries/library_browse_music_test.dart +++ b/test/screens/libraries/library_browse_music_test.dart @@ -6,12 +6,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/focus/focusable_button.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; @@ -20,13 +18,12 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_filter_chip.dart'; import 'package:plezy/widgets/media_card.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/prefs.dart'; final _musicLibrary = MediaLibrary( @@ -88,7 +85,7 @@ void main() { expect(retry.focusNode!.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(harness.browseRequestCount, 2); expect(find.byType(ErrorStateWidget), findsNothing); @@ -115,52 +112,17 @@ void main() { } Future _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness, {MediaLibrary? library}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryBrowseTab( - library: library ?? _musicLibrary, - canGroupByFolders: true, - suppressAutoFocus: true, - onBack: () {}, - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: LibraryBrowseTab( + library: library ?? _musicLibrary, + canGroupByFolders: true, + suppressAutoFocus: true, + onBack: () {}, ), ); - await _pumpRequestFrames(tester); -} - -Future _pumpRequestFrames(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 500)); + await pumpRequestFrames(tester); } class _MusicBrowseHarness { diff --git a/test/screens/libraries/library_browse_tab_test.dart b/test/screens/libraries/library_browse_tab_test.dart index 6b3c2586..909a1dd2 100644 --- a/test/screens/libraries/library_browse_tab_test.dart +++ b/test/screens/libraries/library_browse_tab_test.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/library_filter_result.dart'; import 'package:plezy/media/library_query.dart'; @@ -15,7 +14,6 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_sort.dart'; import 'package:plezy/media/server_capabilities.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; @@ -23,12 +21,11 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_filter_chip.dart'; -import 'package:provider/provider.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/media_items.dart'; import '../../test_helpers/prefs.dart'; @@ -52,12 +49,12 @@ void main() { expect(harness.clientA.pageRequestCount, 0); harness.selectedLibrary.value = harness.libraryB; - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library B'), findsOneWidget); expect(harness.loadedLibraries, [harness.libraryB.globalKey]); sortA.complete(const []); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library A'), findsNothing); expect(find.text('Library B'), findsOneWidget); @@ -79,7 +76,7 @@ void main() { expect(harness.loadedLibraries, isEmpty); harness.selectedLibrary.value = harness.libraryB; - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library A'), findsNothing); expect(find.text('Library B'), findsOneWidget); @@ -125,7 +122,7 @@ void main() { await _pumpUntil(tester, () => clientA.pageRequestCount == 2); emptyPage.complete(const LibraryPage(items: [], totalCount: 0)); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.byType(ErrorStateWidget), findsNothing); expect(find.byType(EmptyStateWidget), findsOneWidget); @@ -135,56 +132,21 @@ void main() { } Future _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: ValueListenableBuilder( - valueListenable: harness.selectedLibrary, - builder: (context, library, _) => LibraryBrowseTab( - library: library, - canGroupByFolders: true, - isActive: true, - onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), - onBack: () => harness.chromeFocusRequests++, - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: ValueListenableBuilder( + valueListenable: harness.selectedLibrary, + builder: (context, library, _) => LibraryBrowseTab( + library: library, + canGroupByFolders: true, + isActive: true, + onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), + onBack: () => harness.chromeFocusRequests++, ), ), ); - if (settle) await _pumpRequestFrames(tester); -} - -Future _pumpRequestFrames(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 500)); + if (settle) await pumpRequestFrames(tester); } Future _pumpUntil(WidgetTester tester, bool Function() condition) async { diff --git a/test/screens/libraries/library_collections_tab_test.dart b/test/screens/libraries/library_collections_tab_test.dart index 09eaf6a9..72f37f46 100644 --- a/test/screens/libraries/library_collections_tab_test.dart +++ b/test/screens/libraries/library_collections_tab_test.dart @@ -6,7 +6,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -14,23 +13,21 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/plex/plex_config.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_collections_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('collection-server'); @@ -93,39 +90,11 @@ void main() { } Future _pumpTab(WidgetTester tester, {required _CollectionHarness harness, required MediaLibrary library}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), + size: const Size(800, 600), ); await tester.pumpAndSettle(); } @@ -137,7 +106,7 @@ class _CollectionHarness { _CollectionHarness._({required this.database, required MediaServerClient client}) { manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } factory _CollectionHarness.plex() { diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 8ba1aa5d..6b15ef4c 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -7,7 +7,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -15,7 +14,6 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/models/plex/plex_config.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_playlists_tab.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -23,14 +21,13 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('playlist-server'); @@ -187,43 +184,15 @@ Future _pumpTab( required VoidCallback onBack, required VoidCallback onSidebar, }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: onSidebar, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: ValueListenableBuilder( - valueListenable: harness.rebuild, - builder: (context, _, _) => - LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: ValueListenableBuilder( + valueListenable: harness.rebuild, + builder: (context, _, _) => LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), ), + size: const Size(800, 600), + focusSidebar: onSidebar, ); await tester.pumpAndSettle(); } diff --git a/test/screens/metadata_edit_screen_test.dart b/test/screens/metadata_edit_screen_test.dart index 074c4ca6..3f12cb3e 100644 --- a/test/screens/metadata_edit_screen_test.dart +++ b/test/screens/metadata_edit_screen_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:convert'; import 'dart:typed_data'; import 'package:drift/native.dart'; @@ -31,6 +30,7 @@ import 'package:plezy/widgets/loading_indicator_box.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/media_items.dart'; void main() { @@ -448,7 +448,7 @@ class _PlexMetadataRequests { path.startsWith('/library/metadata/') && request.url.queryParameters['includePreferences'] == '1') { final id = path.split('/').last; - return _jsonResponse({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [ { @@ -644,10 +644,6 @@ class _FakeFilePicker implements FilePickerDelegate { } } -http.Response _jsonResponse(Object body) { - return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); -} - http.Response _ok() => _response(200); http.Response _response(int statusCode) { diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 5fe0ebd0..a265fba9 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/search_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -28,6 +27,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -321,7 +321,7 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc for (final additionalClient in additionalClients) { manager.debugRegisterClientForTesting(additionalClient); } - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); final key = GlobalKey>(); diff --git a/test/services/catalog/plex_catalog_source_test.dart b/test/services/catalog/plex_catalog_source_test.dart index c276e9a1..eade4e42 100644 --- a/test/services/catalog/plex_catalog_source_test.dart +++ b/test/services/catalog/plex_catalog_source_test.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -12,10 +10,9 @@ import 'package:plezy/services/catalog/plex_catalog_source.dart'; import 'package:plezy/services/plex_discover_client.dart'; import 'package:plezy/utils/external_ids.dart'; -const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); +import '../../test_helpers/http_fixtures.dart'; -http.Response _json(Object body, [int status = 200]) => - http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); +const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); Map _metadata({ String ratingKey = 'plex-movie-1', @@ -51,7 +48,7 @@ void main() { _session, httpClient: MockClient((request) async { captured = request; - return _json({ + return jsonResponse({ 'MediaContainer': { 'offset': 25, 'size': 1, @@ -94,7 +91,7 @@ void main() { httpClient: MockClient((request) async { requests.add(request); if (request.url.path == '/hubs/sections/watchlist') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'Hub': [ { @@ -122,7 +119,7 @@ void main() { }); } if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'offset': 2, 'totalSize': 3, @@ -130,7 +127,7 @@ void main() { }, }); } - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); @@ -162,7 +159,7 @@ void main() { _session, httpClient: MockClient((request) async { requests.add(request); - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); @@ -181,7 +178,7 @@ void main() { _session, httpClient: MockClient((request) async { captured = request; - return _json({ + return jsonResponse({ 'MediaContainer': { 'SearchResults': [ { @@ -221,7 +218,7 @@ void main() { httpClient: MockClient((request) async { requests.add(request); if (request.url.path == '/library/sections/watchlist/all') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'totalSize': watchlisted ? 1 : 0, 'Metadata': watchlisted ? [_metadata()] : [], @@ -232,7 +229,7 @@ void main() { expect(request.url.path, '/actions/removeFromWatchlist'); expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); watchlisted = false; - return _json(const {}); + return jsonResponse(const {}); }), ), ); @@ -256,7 +253,7 @@ void main() { requests.add(request); if (request.url.path == '/library/metadata/matches') { expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [_metadata()], }, @@ -265,7 +262,7 @@ void main() { expect(request.method, 'PUT'); expect(request.url.path, '/actions/addToWatchlist'); expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); - return _json(const {}); + return jsonResponse(const {}); }), ), ); @@ -283,13 +280,13 @@ void main() { switch (request.url.path) { case '/library/metadata/matches': expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [_metadata(type: 'show')], }, }); case '/library/metadata/plex-movie-1': - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [ { @@ -302,7 +299,7 @@ void main() { }, }); case '/library/metadata/plex-movie-1/related': - return _json({ + return jsonResponse({ 'MediaContainer': { 'Hub': [ { @@ -312,7 +309,7 @@ void main() { }, }); } - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); diff --git a/test/services/catalog/seerr_catalog_source_test.dart b/test/services/catalog/seerr_catalog_source_test.dart index 30ae278a..6945e650 100644 --- a/test/services/catalog/seerr_catalog_source_test.dart +++ b/test/services/catalog/seerr_catalog_source_test.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; @@ -11,6 +8,8 @@ import 'package:plezy/services/catalog/seerr_catalog_source.dart'; import 'package:plezy/services/seerr/seerr_client.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/http_fixtures.dart'; + SeerrCatalogSource _source(MockClient mock) { final client = SeerrClient( const SeerrSession( @@ -36,15 +35,13 @@ SeerrCatalogSource _source(MockClient mock) { return source; } -http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); - void main() { group('SeerrCatalogSource', () { test('trending row keeps movies and shows, drops people, maps TMDB images', () async { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/discover/trending'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 3, 'results': [ @@ -89,7 +86,7 @@ void main() { final source = _source( MockClient((request) async { paths.add('${request.url.path}?${request.url.query}'); - return _json({ + return jsonResponse({ 'page': 2, 'totalPages': 2, 'results': [ @@ -106,13 +103,13 @@ void main() { }); test('rows Seerr does not serve throw', () { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(() => source.fetchRow(CatalogRowId.watchlist), throwsArgumentError); expect(() => source.fetchRow(CatalogRowId.suggestedAnime), throwsArgumentError); }); test('resolveItemIds needs a tmdb id', () async { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); final resolved = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 603, imdb: 'tt0133093')); expect(resolved?.tmdb, 603); expect(resolved?.imdb, 'tt0133093'); @@ -123,7 +120,7 @@ void main() { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/tv/1396'); - return _json({ + return jsonResponse({ 'id': 1396, 'name': 'Breaking Bad', 'credits': { @@ -156,7 +153,7 @@ void main() { MockClient((request) async { expect(request.url.path, '/api/v1/search'); expect(request.url.queryParameters['query'], 'the matrix'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 1, 'results': [ @@ -175,7 +172,7 @@ void main() { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/movie/603/recommendations'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 1, 'results': [ @@ -197,13 +194,13 @@ void main() { test('canRequest honors the per-kind permission split', () { // permissions: 2 = ADMIN in the fixture session → everything allowed. - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(source.canRequest(MediaKind.movie), isTrue); expect(source.canRequest(MediaKind.show), isTrue); }); test('has no watchlist: membership unknown, mutations unsupported', () async { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(source.supportsWatchlist, isFalse); expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull); expect(() => source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsUnsupportedError); diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index f0149417..8d479b83 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -6,7 +6,6 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/models/external_player_models.dart'; import 'package:plezy/services/external_player_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -15,8 +14,9 @@ import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/playback_report_fakes.dart'; -class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { +class _RecordingClient with PlaybackReportRecorder implements MediaServerClient, ScopedMediaServerClient { _RecordingClient({this.backend = MediaBackend.plex, String? scopedServerId}) : scopedServerId = scopedServerId ?? @@ -48,33 +48,18 @@ class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { bool get marksWatchedOnPlaybackStopped => backend == MediaBackend.jellyfin; @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - started.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - if (failStart) throw StateError('start failed'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - stopped.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - if (failStop) throw StateError('stop failed'); + Future onPlaybackReport(PlaybackReportCall call) async { + final entry = (positionMs: call.position.inMilliseconds, durationMs: call.duration?.inMilliseconds); + switch (call.kind) { + case PlaybackReportKind.started: + started.add(entry); + if (failStart) throw StateError('start failed'); + case PlaybackReportKind.progress: + throw UnimplementedError(); + case PlaybackReportKind.stopped: + stopped.add(entry); + if (failStop) throw StateError('stop failed'); + } } @override diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 3713b6e0..3ac9e576 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -19,6 +19,7 @@ import 'package:plezy/utils/device_identity.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/media_items.dart'; @@ -50,13 +51,7 @@ JellyfinClient _clientWithPlaybackInfo( connection: _conn(), httpClient: MockClient((request) { if (request.url.path == '/Users/user-1/Items/item-1') { - return Future.value( - http.Response( - jsonEncode({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources}), - 200, - headers: {'content-type': 'application/json'}, - ), - ); + return Future.value(jsonResponse({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources})); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return playbackInfo(request); @@ -66,6 +61,23 @@ JellyfinClient _clientWithPlaybackInfo( ); } +/// Serves [routes] as JSON keyed by request path and records the last URL seen +/// for each path; every other path answers 404. +({JellyfinClient client, Map requests}) _routedClient(Map routes) { + final requests = {}; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + final body = routes[req.url.path]; + if (body == null) return http.Response('not found', 404); + requests[req.url.path] = req.url; + return jsonResponse(body); + }), + ); + addTearDown(client.close); + return (client: client, requests: requests); +} + /// URL-builder smoke tests. We can't unit-test a network round-trip without /// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: /// query parameters must include the right keys and the auth token. These @@ -212,38 +224,30 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/$encodedItemId/LocalTrailers') { - return http.Response( - jsonEncode([ - { - 'Id': 'trailer-1', - 'Name': 'Trailer', - 'Type': 'Trailer', - 'ExtraType': 'Trailer', - 'RunTimeTicks': 900000000, - 'ImageTags': {'Primary': 'trailer-tag'}, - }, - {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse([ + { + 'Id': 'trailer-1', + 'Name': 'Trailer', + 'Type': 'Trailer', + 'ExtraType': 'Trailer', + 'RunTimeTicks': 900000000, + 'ImageTags': {'Primary': 'trailer-tag'}, + }, + {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, + ]); } if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') { - return http.Response( - jsonEncode([ - {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, - { - 'Id': 'featurette-1', - 'Name': 'Making Of', - 'Type': 'Video', - 'ExtraType': 'Featurette', - 'RunTimeTicks': 1800000000, - 'BackdropImageTags': ['featurette-backdrop'], - }, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse([ + {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, + { + 'Id': 'featurette-1', + 'Name': 'Making Of', + 'Type': 'Video', + 'ExtraType': 'Featurette', + 'RunTimeTicks': 1800000000, + 'BackdropImageTags': ['featurette-backdrop'], + }, + ]); } return http.Response('unexpected ${request.url}', 500); }), @@ -405,57 +409,49 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'MediaStreams': []}, - { - 'Id': 'src-2', - 'MediaStreams': [ - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayLanguage': 'English', - 'DisplayTitle': 'English - SRT', - 'IsExternal': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', - }, - { - 'Index': 4, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'fra', - 'DisplayLanguage': 'French', - 'DisplayTitle': 'French - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + { + 'Id': 'src-2', + 'MediaStreams': [ + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', + }, + { + 'Index': 4, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'fra', + 'DisplayLanguage': 'French', + 'DisplayTitle': 'French - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -503,14 +499,7 @@ void main() { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), - ( - 'malformed success', - (_) async => http.Response( - jsonEncode({'MediaSources': 'invalid'}), - 200, - headers: {'content-type': 'application/json'}, - ), - ), + ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { @@ -530,15 +519,11 @@ void main() { test('resolveDownload keeps the static stream when subtitle metadata is malformed', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), ); addTearDown(scoped.close); @@ -569,14 +554,7 @@ void main() { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), - ( - 'malformed success', - (_) async => http.Response( - jsonEncode({'MediaSources': 'invalid'}), - 200, - headers: {'content-type': 'application/json'}, - ), - ), + ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { @@ -635,19 +613,15 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, - {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); } return http.Response('{}', 404); }), @@ -672,46 +646,38 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUris.add(request.url); playbackInfoBodies.add(request.body); - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Id': 'src-1', - 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, - { - 'Index': 2, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -762,33 +728,25 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }); } return http.Response('{}', 404); }), @@ -826,57 +784,49 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'DefaultAudioStreamIndex': 1, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - 'TranscodingUrl': - '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'IsExternal': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 1, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -931,77 +881,69 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, - {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, - {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, + {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'DefaultSubtitleStreamIndex': 4, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - }, - { - 'Index': 4, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'fra', - 'DisplayTitle': 'French - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', - }, - { - 'Index': 5, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English Forced - SRT', - 'IsForced': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 4, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + { + 'Index': 4, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'fra', + 'DisplayTitle': 'French - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', + }, + { + 'Index': 5, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English Forced - SRT', + 'IsForced': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -1075,42 +1017,34 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Id': 'src-1', - 'TranscodingUrl': - '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -1152,39 +1086,31 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, - {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1226,46 +1152,38 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, - ], - }, - { - 'Id': 'src-2', - 'Container': 'mp4', - 'DefaultAudioStreamIndex': 8, - 'MediaStreams': [ - {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, + ], + }, + { + 'Id': 'src-2', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 8, + 'MediaStreams': [ + {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-2'}, + ], + }); } return http.Response('{}', 404); }), @@ -1303,44 +1221,36 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, - ], - }, - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, + ], + }, + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1080'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1080'}, + ], + }); } return http.Response('{}', 404); }), @@ -1376,44 +1286,36 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'item-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'item-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'item-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'item-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1448,47 +1350,39 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'wrong-session', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'wrong-session', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', + }, + ], + }); } return http.Response('{}', 404); }), @@ -1516,10 +1410,7 @@ void main() { ); test('empty successful negotiation falls back to the static VOD stream', () async { - final scoped = _clientWithPlaybackInfo( - (_) async => - http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}), - ); + final scoped = _clientWithPlaybackInfo((_) async => jsonResponse({'MediaSources': []})); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( @@ -1540,15 +1431,11 @@ void main() { test('applicable source without negotiated URL falls back to static direct play', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), ); addTearDown(scoped.close); @@ -1627,7 +1514,7 @@ void main() { for (final testCase in cases) { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response(jsonEncode(testCase.response), 200, headers: {'content-type': 'application/json'}), + (_) async => jsonResponse(testCase.response), itemSources: testCase.itemSources, ); addTearDown(scoped.close); @@ -1640,15 +1527,11 @@ void main() { test('matching download source with empty streams is a complete empty-sidecar plan', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + ], + }), ); addTearDown(scoped.close); @@ -1683,7 +1566,7 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); @@ -1701,7 +1584,7 @@ void main() { httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.body; - return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); @@ -1746,7 +1629,7 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { captured.add(request.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }), ); addTearDown(scoped.close); @@ -1805,29 +1688,21 @@ void main() { connection: _conn(accessToken: 'tok+with spaces/?&'), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1856,35 +1731,27 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1919,21 +1786,17 @@ void main() { requests.add(request.url); capturedBody = request.body; if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'live-session-1', - 'MediaSources': [ - { - 'Id': 'source-1', - 'Container': 'ts', - 'LiveStreamId': 'open-stream-1', - 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'live-session-1', + 'MediaSources': [ + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'open-stream-1', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', + }, + ], + }); } return http.Response('{}', 404); }), @@ -1970,19 +1833,15 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Container': 'ts', - 'TranscodingUrl': - '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Container': 'ts', + 'TranscodingUrl': + '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', + }, + ], + }); } return http.Response('{}', 404); }), @@ -2003,15 +1862,11 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, + ], + }); } return http.Response('{}', 404); }), @@ -2077,33 +1932,25 @@ void main() { connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: MockClient((request) async { if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }); } return http.Response('{}', 404); }), @@ -2140,48 +1987,40 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-trickplay') { - return http.Response( - jsonEncode({ - 'Id': 'item-trickplay', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, - {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, - ], - 'Trickplay': { - 'src-a': { - '160': { - 'Width': 160, - 'Height': 90, - 'TileWidth': 4, - 'TileHeight': 4, - 'ThumbnailCount': 16, - 'Interval': 10000, - }, + return jsonResponse({ + 'Id': 'item-trickplay', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, + {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, + ], + 'Trickplay': { + 'src-a': { + '160': { + 'Width': 160, + 'Height': 90, + 'TileWidth': 4, + 'TileHeight': 4, + 'ThumbnailCount': 16, + 'Interval': 10000, }, }, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + }, + }); } if (request.url.path == '/Items/item-trickplay/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-b', - 'MediaSources': [ - { - 'Id': 'src-b', - 'Container': 'mp4', - 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', - 'MediaStreams': [], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-b', + 'MediaSources': [ + { + 'Id': 'src-b', + 'Container': 'mp4', + 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', + 'MediaStreams': [], + }, + ], + }); } return http.Response('{}', 404); }), @@ -2269,21 +2108,17 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'movie-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], - }, - ], - 'TotalRecordCount': 123, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'movie-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], + }, + ], + 'TotalRecordCount': 123, + }); }), ); addTearDown(scoped.close); @@ -2318,11 +2153,7 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); @@ -2354,16 +2185,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Genres': ['Drama', 'Action'], - 'OfficialRatings': ['PG-13'], - 'Tags': ['Holiday'], - 'Years': [2024, 1999], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Genres': ['Drama', 'Action'], + 'OfficialRatings': ['PG-13'], + 'Tags': ['Holiday'], + 'Years': [2024, 1999], + }); }), ); addTearDown(scoped.close); @@ -2402,15 +2229,11 @@ void main() { httpClient: MockClient((req) async { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final limit = int.parse(req.url.queryParameters['Limit'] ?? '25'); - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }); }), ); addTearDown(scoped.close); @@ -2430,11 +2253,7 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); @@ -2468,11 +2287,7 @@ void main() { captured.add(req.url); final foldersOnly = req.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder'; final items = allChildren.where((c) => (c['Type'] == 'Folder') == foldersOnly).toList(); - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': items.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); }), ); addTearDown(scoped.close); @@ -2519,27 +2334,19 @@ void main() { httpClient: MockClient((req) async { if (req.url.queryParameters.containsKey('IncludeItemTypes')) { // Folders query — this directory has none. - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); } mediaStarts.add(req.url.queryParameters['StartIndex']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < end; i++) - {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, - ], - 'TotalRecordCount': total, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < end; i++) + {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, + ], + 'TotalRecordCount': total, + }); }), ); addTearDown(scoped.close); @@ -2572,16 +2379,12 @@ void main() { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, - ], - 'TotalRecordCount': total, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, + ], + 'TotalRecordCount': total, + }); }), ); addTearDown(scoped.close); @@ -2623,11 +2426,7 @@ void main() { 'UserData': {'PlayCount': 0}, }, ]; - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': total}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': total}); }), ); addTearDown(pagedClient.close); @@ -2646,16 +2445,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, - ], - 'TotalRecordCount': 1, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, + ], + 'TotalRecordCount': 1, + }); }), ); addTearDown(scoped.close); @@ -2682,15 +2477,11 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/show-1') { - return http.Response( - jsonEncode({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}); } if (req.url.path == '/Shows/NextUp') { capturedNextUp = req.url; - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } return http.Response('not found', 404); }), @@ -2715,23 +2506,15 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}); } if (req.url.path == '/MediaSegments/item-1') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, - {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, + {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, + ], + }); } return http.Response('not found', 404); }), @@ -2751,21 +2534,17 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Episode', - 'Name': 'Episode', - 'RunTimeTicks': 1200000000, - 'Chapters': [ - {'Name': 'OP', 'StartPositionTicks': 100000000}, - {'Name': 'Episode', 'StartPositionTicks': 450000000}, - {'Name': 'ED', 'StartPositionTicks': 900000000}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'RunTimeTicks': 1200000000, + 'Chapters': [ + {'Name': 'OP', 'StartPositionTicks': 100000000}, + {'Name': 'Episode', 'StartPositionTicks': 450000000}, + {'Name': 'ED', 'StartPositionTicks': 900000000}, + ], + }); } if (req.url.path == '/MediaSegments/item-1') { return http.Response('not found', 404); @@ -2789,28 +2568,20 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, - {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }); } return http.Response('not found', 404); }), @@ -2845,47 +2616,35 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'resume-old', - 'Type': 'Movie', - 'Name': 'Old Movie', - 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'resume-old', + 'Type': 'Movie', + 'Name': 'Old Movie', + 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, + }, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'ep-played', - 'Type': 'Episode', - 'SeriesId': 'show-recent', - 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }); } return http.Response('not found', 404); }), @@ -2915,53 +2674,41 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'resume-old-1', - 'Type': 'Movie', - 'Name': 'Old Movie 1', - 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, - }, - { - 'Id': 'resume-old-2', - 'Type': 'Movie', - 'Name': 'Old Movie 2', - 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'resume-old-1', + 'Type': 'Movie', + 'Name': 'Old Movie 1', + 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, + }, + { + 'Id': 'resume-old-2', + 'Type': 'Movie', + 'Name': 'Old Movie 2', + 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, + }, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'ep-played', - 'Type': 'Episode', - 'SeriesId': 'show-recent', - 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }); } return http.Response('not found', 404); }), @@ -2980,15 +2727,11 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { return http.Response('server error', 500); @@ -3010,28 +2753,20 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, - {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }); } return http.Response('not found', 404); }), @@ -3055,7 +2790,7 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } @@ -3066,15 +2801,11 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -3124,7 +2855,7 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } @@ -3336,16 +3067,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 30, - 'Items': [ - {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 30, + 'Items': [ + {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, + ], + }); }), ); addTearDown(client.close); @@ -3368,16 +3095,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 321, - 'Items': [ - {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 321, + 'Items': [ + {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, + ], + }); }), ); addTearDown(client.close); @@ -3436,28 +3159,20 @@ void main() { final mock = MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'TotalRecordCount': 1, - 'Items': [ - {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 1, + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3491,28 +3206,20 @@ void main() { Uri? itemsRequest; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { itemsRequest = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 30, - 'Items': [ - {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 30, + 'Items': [ + {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3534,27 +3241,19 @@ void main() { test('fetchCollectionsPage uses sentinel total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, - {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3571,29 +3270,21 @@ void main() { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = req.url.queryParameters['StartIndex']; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 2, - 'Items': [ - {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 2, + 'Items': [ + {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3611,19 +3302,15 @@ void main() { var itemsRequested = false; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + ], + }); } if (req.url.path == '/Items') { itemsRequested = true; - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } return http.Response('not found', 404); }); @@ -3641,16 +3328,12 @@ void main() { final mock = MockClient((req) async { if (req.url.path == '/Items') { itemsRequest = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 25, - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 25, + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, + ], + }); } return http.Response('not found', 404); }); @@ -3707,176 +3390,119 @@ void main() { group('JellyfinClient paged media lists', () { test('fetchPersonMediaPage uses requested page bounds', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchPersonMediaPage('person-1', start: 20, size: 10); + final page = await routed.client.fetchPersonMediaPage('person-1', start: 20, size: 10); expect(page.items.single.id, 'movie-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['PersonIds'], 'person-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['PersonIds'], 'person-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); }); test('fetchPlayableDescendantsPage uses requested page bounds', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); + final page = await routed.client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'show-1'); - expect(requestUri!.queryParameters['Recursive'], 'true'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'show-1'); + expect(query['Recursive'], 'true'); // Audio rides along so albums/artists/audio playlists expand to tracks. - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Audio'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); + expect(query['IncludeItemTypes'], 'Movie,Episode,Audio'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); }); test('fetchSeasonEpisodesPage uses Jellyfin episode endpoint scoped to season', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Shows/show-1/Episodes') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Shows/show-1/Episodes': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); + final page = await routed.client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['SeasonId'], 'season-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); - expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); - expect(requestUri!.queryParameters['IsMissing'], 'false'); - expect(requestUri!.queryParameters['IsVirtualUnaired'], 'false'); - expect(requestUri!.queryParameters['Fields']!.split(','), contains('MediaSources')); - expect(requestUri!.queryParameters.containsKey('SortBy'), isFalse); - expect(requestUri!.queryParameters.containsKey('SortOrder'), isFalse); + final query = routed.requests['/Shows/show-1/Episodes']!.queryParameters; + expect(query['SeasonId'], 'season-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); + expect(query['EnableTotalRecordCount'], 'true'); + expect(query['IsMissing'], 'false'); + expect(query['IsVirtualUnaired'], 'false'); + expect(query['Fields']!.split(','), contains('MediaSources')); + expect(query.containsKey('SortBy'), isFalse); + expect(query.containsKey('SortOrder'), isFalse); }); test('fetchChildrenPage orders direct episode children by season and episode index', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Shows/season-1/Seasons') { - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); - } - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Shows/season-1/Seasons': {'Items': []}, + '/Items': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchChildrenPage('season-1', start: 20, size: 10); + final page = await routed.client.fetchChildrenPage('season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'season-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); - expect(requestUri!.queryParameters['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); - expect(requestUri!.queryParameters['SortOrder'], 'Ascending,Ascending,Ascending'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'season-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); + expect(query['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); + expect(query['SortOrder'], 'Ascending,Ascending,Ascending'); }); test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, - ], - 'TotalRecordCount': 1, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, + ], + 'TotalRecordCount': 1, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final items = await client.fetchPlayableFolderDescendants('folder-1'); + final items = await routed.client.fetchPlayableFolderDescendants('folder-1'); expect(items.single.kind, MediaKind.clip); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'folder-1'); - expect(requestUri!.queryParameters['Recursive'], 'true'); - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); - expect(requestUri!.queryParameters['IncludeItemTypes'], isNot(contains('Audio'))); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'folder-1'); + expect(query['Recursive'], 'true'); + expect(query['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); + expect(query['IncludeItemTypes'], isNot(contains('Audio'))); }); test('fetchPlayableDescendants cancellation stops before a second page', () async { @@ -3887,14 +3513,10 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), + 'TotalRecordCount': 501, + }); }), ); addTearDown(client.close); @@ -3914,14 +3536,10 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), + 'TotalRecordCount': 501, + }); }), ); addTearDown(client.close); @@ -3941,17 +3559,13 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate( - 200, - (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, - ), - 'TotalRecordCount': 201, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate( + 200, + (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, + ), + 'TotalRecordCount': 201, + }); }), ); addTearDown(client.close); @@ -3967,23 +3581,16 @@ void main() { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Shows/season-1/Seasons') { - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final count = start == 0 ? 500 : 1; - return http.Response( - jsonEncode({ - 'Items': List.generate( - count, - (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}, - ), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(count, (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}), + 'TotalRecordCount': 501, + }); } return http.Response('not found', 404); }); @@ -4012,11 +3619,7 @@ void main() { if (requestedMediaType == null) return true; return (item['MediaType'] as String).toLowerCase() == requestedMediaType; }).toList(); - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': items.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); } return http.Response('not found', 404); }); @@ -4050,14 +3653,10 @@ void main() { final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(filtered, start: start, size: limit), - 'TotalRecordCount': filtered.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4092,14 +3691,10 @@ void main() { final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(filtered, start: start, size: limit), - 'TotalRecordCount': filtered.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4128,11 +3723,10 @@ void main() { requests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({'Items': sliceFakePage(videos, start: start, size: limit), 'TotalRecordCount': videos.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(videos, start: start, size: limit), + 'TotalRecordCount': videos.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4170,16 +3764,12 @@ void main() { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, + ], + 'TotalRecordCount': 40, + }); } return http.Response('not found', 404); }); @@ -4199,13 +3789,9 @@ void main() { test('fetchPlaylistPage uses minimal fallback total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { - return http.Response( - jsonEncode({ - 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), + }); } return http.Response('not found', 404); }); @@ -4222,21 +3808,17 @@ void main() { test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async { final mock = MockClient((req) async { if (req.url.path == '/jellyfin/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'video-1', - 'Name': 'Video Playlist', - 'Type': 'Playlist', - 'MediaType': 'Video', - 'ImageTags': {'Primary': 'tag 1'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'video-1', + 'Name': 'Video Playlist', + 'Type': 'Playlist', + 'MediaType': 'Video', + 'ImageTags': {'Primary': 'tag 1'}, + }, + ], + }); } return http.Response('not found', 404); }); @@ -4260,16 +3842,12 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response( - jsonEncode({ - 'Id': 'folder/item #1?x', - 'Name': 'Movie', - 'Type': 'Movie', - 'ProviderIds': {'Tmdb': '1'}, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'folder/item #1?x', + 'Name': 'Movie', + 'Type': 'Movie', + 'ProviderIds': {'Tmdb': '1'}, + }); }), ); addTearDown(client.close); @@ -4317,17 +3895,13 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/item-1/RemoteImages') { - return http.Response( - jsonEncode({ - 'TotalRecordCount': 1, - 'Providers': ['TheMovieDb'], - 'Images': [ - {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 1, + 'Providers': ['TheMovieDb'], + 'Images': [ + {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, + ], + }); } return http.Response('', 204); }), diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index 8c8a18f5..120538f9 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -1,57 +1,27 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/live_session_tracker.dart'; -class _FakeJellyfinClient implements JellyfinClient { +import '../test_helpers/playback_report_fakes.dart'; + +class _FakeJellyfinClient with PlaybackReportRecorder implements JellyfinClient { final calls = []; final startGate = Completer(); @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - await startGate.future; - calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId:$playMethod'); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - calls.add('stopped:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); + Future onPlaybackReport(PlaybackReportCall call) async { + final identity = '${call.itemId}:${call.playSessionId}:${call.mediaSourceId}:${call.liveStreamId}'; + switch (call.kind) { + case PlaybackReportKind.started: + await startGate.future; + calls.add('started:$identity:${call.playMethod}'); + case PlaybackReportKind.progress: + calls.add('${call.isPaused ? 'paused' : 'playing'}:$identity'); + case PlaybackReportKind.stopped: + calls.add('stopped:$identity'); + } } @override diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 5a798c95..26834950 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -8,7 +8,6 @@ import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/mpv/models.dart'; import 'package:plezy/mpv/player/player.dart'; import 'package:plezy/mpv/player/player_state.dart'; @@ -20,6 +19,7 @@ import 'package:plezy/services/music/music_playback_service_impl.dart'; import 'package:plezy/services/music/music_source_resolver.dart'; import 'package:plezy/services/playback_coordinator.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/playback_report_fakes.dart'; const _trackDuration = Duration(minutes: 3); @@ -351,7 +351,7 @@ class RecordedReport { /// Records the progress-report surface; everything else is unimplemented /// (the engine and tracker never touch it in these tests). -class FakeMediaServerClient extends Fake implements MediaServerClient { +class FakeMediaServerClient extends Fake with PlaybackReportRecorder implements MediaServerClient { final List reports = []; final List markedWatched = []; Completer>? instantMixGate; @@ -381,47 +381,13 @@ class FakeMediaServerClient extends Fake implements MediaServerClient { } @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - reports.add(RecordedReport('started', itemId, position)); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - reports.add(RecordedReport(isPaused ? 'paused' : 'progress', itemId, position)); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - reports.add(RecordedReport('stopped', itemId, position)); + Future onPlaybackReport(PlaybackReportCall call) async { + final state = switch (call.kind) { + PlaybackReportKind.started => 'started', + PlaybackReportKind.progress => call.isPaused ? 'paused' : 'progress', + PlaybackReportKind.stopped => 'stopped', + }; + reports.add(RecordedReport(state, call.itemId, call.position)); } } diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 5e13f227..8c65fad9 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -22,6 +22,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/playback_report_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; @@ -55,7 +56,7 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource bool get hasListeners => super.hasListeners; } -class _RecordingMediaClient implements MediaServerClient { +class _RecordingMediaClient with PlaybackReportRecorder implements MediaServerClient { _RecordingMediaClient({required this.serverId, required this.backend}); @override @@ -85,36 +86,24 @@ class _RecordingMediaClient implements MediaServerClient { testMediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId); @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - started.add((itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - stopped.add(( - itemId: itemId, - positionMs: position.inMilliseconds, - durationMs: duration?.inMilliseconds, - report: report, - )); + Future onPlaybackReport(PlaybackReportCall call) async { + switch (call.kind) { + case PlaybackReportKind.started: + started.add(( + itemId: call.itemId, + positionMs: call.position.inMilliseconds, + durationMs: call.duration?.inMilliseconds, + )); + case PlaybackReportKind.progress: + throw UnimplementedError(); + case PlaybackReportKind.stopped: + stopped.add(( + itemId: call.itemId, + positionMs: call.position.inMilliseconds, + durationMs: call.duration?.inMilliseconds, + report: call.report, + )); + } } @override diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 684dfd4e..565f8f9c 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/playback_report_fakes.dart'; // Periodic behavior is virtualized with fake_async and the tracker's existing // updateInterval seam. Routing, threshold, scrobble, cadence, coalescing, @@ -85,7 +86,7 @@ class _FakePlayer implements Player { /// Recording fake [PlexClient] that captures every progress / scrobble call /// without touching the network. -class _FakePlexClient implements PlexClient { +class _FakePlexClient with PlaybackReportRecorder implements PlexClient { _FakePlexClient({this.thresholdPercent = 90}); /// Watched-threshold percentage to report. Defaults to 90 (matches @@ -149,68 +150,25 @@ class _FakePlexClient implements PlexClient { // The interface report* methods delegate to updateProgress so existing // assertions on `updateProgressCalls` keep working. @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) { - playbackSessionIds.add(playSessionId); + Future onPlaybackReport(PlaybackReportCall call) { + playbackSessionIds.add(call.playSessionId); playbackStreamSelections.add(( - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - )); - return updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) { - playbackSessionIds.add(playSessionId); - playbackStreamSelections.add(( - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, + mediaSourceId: call.mediaSourceId, + audioStreamIndex: call.audioStreamIndex, + subtitleStreamIndex: call.subtitleStreamIndex, )); return updateProgress( - itemId, - time: position.inMilliseconds, - state: isPaused ? 'paused' : 'playing', - duration: duration.inMilliseconds, + call.itemId, + time: call.position.inMilliseconds, + state: switch (call.kind) { + PlaybackReportKind.started => 'playing', + PlaybackReportKind.progress => call.isPaused ? 'paused' : 'playing', + PlaybackReportKind.stopped => 'stopped', + }, + duration: call.duration?.inMilliseconds, ); } - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) { - playbackSessionIds.add(playSessionId); - playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null)); - return updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); - } - // Transport-only, like production: the single watch event for the stop // flow is emitted by markWatchedFromPlaybackStop after this returns. @override @@ -1431,7 +1389,7 @@ void main() { /// A more precise fake than [_FakePlexClient]: lets the test independently /// fail the scrobble (markWatched) without touching the progress signals. -class _ScrobblePreciseClient implements PlexClient { +class _ScrobblePreciseClient with PlaybackReportRecorder implements PlexClient { _ScrobblePreciseClient({this.thresholdPercent = 90, this.failScrobbleFirstTime = false}); final int thresholdPercent; @@ -1472,42 +1430,7 @@ class _ScrobblePreciseClient implements PlexClient { }) async {} @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async {} - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async {} - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async {} + Future onPlaybackReport(PlaybackReportCall call) async {} @override Future markWatched(MediaItem item) async { diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 848fa435..f538a28d 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -2,66 +2,36 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/playback_report_session.dart'; -class _RecordingClient implements MediaServerClient { +import '../test_helpers/playback_report_fakes.dart'; + +class _RecordingClient with PlaybackReportRecorder implements MediaServerClient { final calls = []; Completer? startGate; Completer? stopGate; bool failNextStop = false; @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final gate = startGate; - if (gate != null) await gate.future; - calls.add('started:${position.inMilliseconds}:$mediaSourceId:$audioStreamIndex:$subtitleStreamIndex'); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:${position.inMilliseconds}'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); - final gate = stopGate; - if (gate != null) await gate.future; - if (failNextStop) { - failNextStop = false; - throw StateError('stop failed'); + Future onPlaybackReport(PlaybackReportCall call) async { + final positionMs = call.position.inMilliseconds; + switch (call.kind) { + case PlaybackReportKind.started: + final start = startGate; + if (start != null) await start.future; + calls.add('started:$positionMs:${call.mediaSourceId}:${call.audioStreamIndex}:${call.subtitleStreamIndex}'); + case PlaybackReportKind.progress: + calls.add('${call.isPaused ? 'paused' : 'playing'}:$positionMs'); + case PlaybackReportKind.stopped: + calls.add('stopped-attempt:$positionMs:${call.mediaSourceId}'); + final stop = stopGate; + if (stop != null) await stop.future; + if (failNextStop) { + failNextStop = false; + throw StateError('stop failed'); + } + calls.add('stopped:$positionMs:${call.mediaSourceId}'); } - calls.add('stopped:${position.inMilliseconds}:$mediaSourceId'); } @override diff --git a/test/test_helpers/http_fixtures.dart b/test/test_helpers/http_fixtures.dart new file mode 100644 index 00000000..a9d7d3ce --- /dev/null +++ b/test/test_helpers/http_fixtures.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// JSON-encodes [body] into an [http.Response] carrying a JSON content type. +http.Response jsonResponse(Object body, {int status = 200}) => + http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); diff --git a/test/test_helpers/library_tab_scaffold.dart b/test/test_helpers/library_tab_scaffold.dart new file mode 100644 index 00000000..9f58419a --- /dev/null +++ b/test/test_helpers/library_tab_scaffold.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/navigation/main_screen_scope.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:provider/provider.dart'; + +/// Pumps [tab] under the ancestors every library tab requires: [provider], an +/// [InputModeTracker], a [MainScreenFocusScope] and a [NestedScrollView] whose +/// overlap absorber handle the tabs look up. Sizes the view to [size] and +/// restores it when the test ends. Settling is left to the caller. +Future pumpLibraryTab( + WidgetTester tester, { + required MultiServerProvider provider, + required Widget tab, + Size size = const Size(1280, 720), + VoidCallback? focusSidebar, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: focusSidebar ?? () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: 0, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), + ), + ], + body: tab, + ), + ), + ), + ), + ), + ), + ); +} + +/// Frames a library tab needs to issue its debounced request and apply the +/// response, for tabs whose loading never quiesces enough for `pumpAndSettle`. +Future pumpRequestFrames(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 500)); +} diff --git a/test/test_helpers/multi_server_fixtures.dart b/test/test_helpers/multi_server_fixtures.dart new file mode 100644 index 00000000..bfbf16bf --- /dev/null +++ b/test/test_helpers/multi_server_fixtures.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; + +/// Wires [manager] into the provider widget tests read servers from. The caller +/// owns disposal of the returned provider. +MultiServerProvider testMultiServerProvider(MultiServerManager manager) { + return MultiServerProvider(manager, DataAggregationService(manager)); +} + +/// Registers [clients] on a fresh manager and returns it with its provider, +/// disposing both when the test ends. Clients also named in [offline] are +/// registered as unreachable; [clients] fixes the registration order. +({MultiServerManager manager, MultiServerProvider provider}) testMultiServer({ + List clients = const [], + List offline = const [], +}) { + final manager = MultiServerManager(); + for (final client in clients) { + manager.debugRegisterClientForTesting(client, online: !offline.contains(client)); + } + final provider = testMultiServerProvider(manager); + addTearDown(() { + provider.dispose(); + manager.dispose(); + }); + return (manager: manager, provider: provider); +} diff --git a/test/test_helpers/playback_report_fakes.dart b/test/test_helpers/playback_report_fakes.dart new file mode 100644 index 00000000..5c89d80d --- /dev/null +++ b/test/test_helpers/playback_report_fakes.dart @@ -0,0 +1,120 @@ +import 'package:plezy/media/playback_report_metadata.dart'; + +enum PlaybackReportKind { started, progress, stopped } + +/// One `reportPlayback*` invocation flattened into a single value. +class PlaybackReportCall { + const PlaybackReportCall({ + required this.kind, + required this.itemId, + required this.position, + this.duration, + this.isPaused = false, + this.playSessionId, + this.playMethod, + this.liveStreamId, + this.mediaSourceId, + this.audioStreamIndex, + this.subtitleStreamIndex, + this.report = const PlaybackReportMetadata.live(), + }); + + final PlaybackReportKind kind; + final String itemId; + final Duration position; + final Duration? duration; + final bool isPaused; + final String? playSessionId; + final String? playMethod; + final String? liveStreamId; + final String? mediaSourceId; + final int? audioStreamIndex; + final int? subtitleStreamIndex; + final PlaybackReportMetadata report; +} + +/// Carries the three playback-reporting signatures so fakes implement the +/// surface once in [onPlaybackReport]. Forwarding is synchronous, so +/// [onPlaybackReport] runs with the same timing the overridden method had. +mixin PlaybackReportRecorder { + Future onPlaybackReport(PlaybackReportCall call); + + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.started, + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ), + ); + } + + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.progress, + itemId: itemId, + position: position, + duration: duration, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ), + ); + } + + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? liveStreamId, + String? mediaSourceId, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.stopped, + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + report: report, + ), + ); + } +} diff --git a/test/test_helpers/profile_stack.dart b/test/test_helpers/profile_stack.dart new file mode 100644 index 00000000..b7d7ecca --- /dev/null +++ b/test/test_helpers/profile_stack.dart @@ -0,0 +1,74 @@ +import 'package:drift/native.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/services/storage_service.dart'; + +/// The profile dependency graph wired the way production wires it: database → +/// registries → [PlexHomeService] → [ActiveProfileProvider]. +class ProfileStack { + ProfileStack._({ + required this.db, + required this.connections, + required this.profileConnections, + required this.profiles, + required this.plexHome, + required this.active, + required this._storage, + required this._ownsDatabase, + }); + + final AppDatabase db; + final ConnectionRegistry connections; + final ProfileConnectionRegistry profileConnections; + final ProfileRegistry profiles; + final PlexHomeService plexHome; + final ActiveProfileProvider active; + + final StorageService? _storage; + final bool _ownsDatabase; + + /// Only wired when the stack was created with `withStorage: true`. + StorageService get storage => _storage!; + + /// Pass [db] when the test also needs the database for caches or downloads; + /// the caller then owns closing it. + static Future create({ + AppDatabase? db, + List homeUsers = const [], + bool withStorage = true, + }) async { + final database = db ?? AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final profiles = ProfileRegistry(database); + final storage = withStorage ? await StorageService.getInstance() : null; + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => homeUsers, + ); + return ProfileStack._( + db: database, + connections: connections, + profileConnections: profileConnections, + profiles: profiles, + plexHome: plexHome, + active: ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage), + storage: storage, + ownsDatabase: db == null, + ); + } + + Future dispose() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + if (_ownsDatabase) await db.close(); + } +} diff --git a/test/test_helpers/theme.dart b/test/test_helpers/theme.dart new file mode 100644 index 00000000..170671b3 --- /dev/null +++ b/test/test_helpers/theme.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/theme/mono_tokens.dart'; + +/// Default [MonoTokens] for widget tests: production-shaped metrics with +/// 1ms animations and no ink splashes, so a single `pump` settles the tree. +/// +/// Use as `ThemeData(extensions: const [testMonoTokens])`. +const testMonoTokens = MonoTokens( + radiusSm: 8, + radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 1), + normal: Duration(milliseconds: 1), + slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), + bg: Colors.black, + surface: Colors.black, + outline: Colors.white24, + text: Colors.white, + textMuted: Colors.white70, + splashFactory: NoSplash.splashFactory, +); + +/// [testMonoTokens] with realistic animation durations, for tests that step +/// through intermediate frames instead of settling straight to the end state. +const testMonoTokensAnimated = MonoTokens( + radiusSm: 4, + radiusMd: 8, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 100), + normal: Duration(milliseconds: 200), + slow: Duration(milliseconds: 300), + expressive: Duration(milliseconds: 300), + bg: Colors.black, + surface: Color(0xFF111111), + outline: Color(0xFF333333), + text: Colors.white, + textMuted: Color(0xFFAAAAAA), + splashFactory: NoSplash.splashFactory, +); diff --git a/test/utils/provider_extensions_test.dart b/test/utils/provider_extensions_test.dart index 19b7ac20..afaab4a8 100644 --- a/test/utils/provider_extensions_test.dart +++ b/test/utils/provider_extensions_test.dart @@ -8,13 +8,12 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/provider_extensions.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; const _missingOwnerLibrary = MediaLibrary( id: '1', @@ -59,13 +58,7 @@ void main() { tester, ) async { final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext(tester, testMultiServer(clients: [replacement]).provider); expect(() => context.getPlexClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); expect(() => context.getMediaClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); @@ -75,13 +68,7 @@ void main() { tester, ) async { final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext(tester, testMultiServer(clients: [replacement]).provider); for (final serverId in [null, ' ']) { final library = MediaLibrary( @@ -103,15 +90,10 @@ void main() { testWidgets('library-qualified helpers return their registered owner even when it is marked offline', (tester) async { final owner = testPlexClient(serverId: ServerId('server-a')); final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager() - ..debugRegisterClientForTesting(owner, online: false) - ..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext( + tester, + testMultiServer(clients: [owner, replacement], offline: [owner]).provider, + ); expect(context.getPlexClientForLibrary(_missingOwnerLibrary), same(owner)); expect(context.getMediaClientForLibrary(_missingOwnerLibrary), same(owner)); diff --git a/test/widgets/chapter_sheet_test.dart b/test/widgets/chapter_sheet_test.dart index 76bc0768..9540d902 100644 --- a/test/widgets/chapter_sheet_test.dart +++ b/test/widgets/chapter_sheet_test.dart @@ -4,28 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/sheets/chapter_sheet.dart'; -const _tokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); @@ -64,7 +46,7 @@ Future _pumpSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_tokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: Builder( diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index af1c3db5..63c38449 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -9,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/navigation/profile_navigation_scope.dart'; @@ -26,9 +25,6 @@ import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; @@ -51,6 +47,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -126,28 +123,14 @@ void main() { ]; final client = _AudioPlaylistClient(tracks); final music = _RecordingMusicPlaybackService(); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); music.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -164,7 +147,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: music), ], child: MaterialApp( @@ -244,28 +227,14 @@ void main() { ), ])..blockWithAbort = true; final playback = PlaybackStateProvider(); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { playback.dispose(); - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -281,7 +250,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: playback), ], child: MaterialApp( @@ -323,27 +292,13 @@ void main() { TvDetectionService.debugSetAppleTVOverride(true); addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -360,7 +315,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -615,21 +570,9 @@ Future> _pumpPlexMovieMenu( ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(db: db, withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); await db.close(); @@ -648,7 +591,7 @@ Future> _pumpPlexMovieMenu( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -829,18 +772,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( final client = _RelatedMusicClient(relatedItems); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(db: db, withStorage: false); final music = _RecordingMusicPlaybackService(); final rootNavigatorKey = GlobalKey(); final profileNavigatorKey = GlobalKey(); @@ -849,8 +781,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( addTearDown(() async { downloadProvider.dispose(); downloadManager.dispose(); - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); music.dispose(); multiServerProvider.dispose(); manager.dispose(); @@ -866,7 +797,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: downloadProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: music), ], child: ProfileNavigationScope( diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 2b8e069f..d114bccc 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -1,12 +1,9 @@ import 'dart:async'; -import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/focus/focusable_wrapper.dart'; import 'package:plezy/i18n/strings.g.dart'; @@ -15,9 +12,6 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -33,6 +27,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/profile_stack.dart'; final _track = testMediaItem( id: 'track_1', @@ -303,25 +298,11 @@ void main() { testWidgets('keyboard long-press anchors the context menu to the focused card instead of a stale pointer', ( tester, ) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); final downloadProvider = _FakeDownloadProvider(); addTearDown(() async { - activeProfileProvider.dispose(); + await stack.dispose(); downloadProvider.dispose(); - await plexHome.dispose(); - await db.close(); }); final service = _FakeMusicService(track: _track); final observer = MusicUiRouteObserver(); @@ -330,7 +311,7 @@ void main() { wrap( service: service, observer: observer, - activeProfileProvider: activeProfileProvider, + activeProfileProvider: stack.active, downloadProvider: downloadProvider, ), ); diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart index 21a0ac27..eb45d6c6 100644 --- a/test/widgets/player_queue_spoilers_test.dart +++ b/test/widgets/player_queue_spoilers_test.dart @@ -12,7 +12,6 @@ import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/sheets/queue_sheet.dart'; import 'package:plezy/widgets/video_controls/widgets/content_strip.dart'; import 'package:plezy/widgets/video_controls/widgets/media_selector_thumbnail.dart'; @@ -20,25 +19,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -334,7 +315,7 @@ Widget _queueHarness({required PlaybackStateProvider playback, required Widget c value: playback, child: InputModeTracker( child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)), ), ), diff --git a/test/widgets/server_activities_button_test.dart b/test/widgets/server_activities_button_test.dart index dfe410a1..fff44583 100644 --- a/test/widgets/server_activities_button_test.dart +++ b/test/widgets/server_activities_button_test.dart @@ -9,14 +9,13 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/widgets/server_activities_button.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -31,15 +30,9 @@ void main() { tearDown(() => database.close()); testWidgets('togglePanel opens and closes the server activities overlay', (tester) async { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServer().provider; final buttonKey = GlobalKey(); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); - await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( @@ -178,15 +171,9 @@ class _ActivitiesHarness { Future<_ActivitiesHarness> _pumpActivitiesHarness(WidgetTester tester, _ControlledActivitiesClient transport) async { final serverId = ServerId('plex-server'); final client = testPlexClient(serverId: serverId, serverName: 'Test server', httpClient: transport); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServer(clients: [client]).provider; final buttonKey = GlobalKey(); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); - await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 4fe8602a..69a93be1 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -17,32 +17,13 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; MediaLibrary _library({ required String id, @@ -124,7 +105,7 @@ Future _pumpBasicRail( ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: height == null ? rail : SizedBox(height: height, child: rail), ), @@ -172,7 +153,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -237,7 +218,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -263,7 +244,7 @@ void main() { await _pumpBasicRail(tester, alwaysExpanded: true); final selectedItem = find.byType(NavigationRailItem).first; - expect(_railItemDecoration(tester, selectedItem)?.color, _testTokens.text.withValues(alpha: 0.1)); + expect(_railItemDecoration(tester, selectedItem)?.color, testMonoTokens.text.withValues(alpha: 0.1)); }); testWidgets('D-pad sidebar focus hides selected item background after focus moves', (tester) async { @@ -343,7 +324,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -408,7 +389,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( key: sideNavKey, @@ -485,7 +466,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( key: sideNavKey, @@ -527,7 +508,7 @@ void main() { await tester.pumpWidget( InputModeTracker( child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: Builder( builder: (context) { diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 44a2b0a1..7bead78f 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -8,29 +8,11 @@ import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/playback_subtitle_resolver.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -175,7 +157,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Builder( builder: (context) { @@ -399,7 +381,7 @@ Future _pumpTrackSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: SizedBox( diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 2c428548..227ca24a 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/services/playback_subtitle_resolver.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/video_volume_controller.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; import 'package:plezy/widgets/video_controls/desktop_video_controls.dart'; import 'package:plezy/widgets/video_controls/mobile_video_controls.dart'; @@ -36,25 +35,7 @@ import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart'; import '../test_helpers/watch_together_fakes.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -968,7 +949,7 @@ void main() { ChangeNotifierProvider.value( value: watchTogether, child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 1000, @@ -1023,7 +1004,7 @@ void main() { ChangeNotifierProvider.value( value: watchTogether, child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 500, @@ -1514,7 +1495,7 @@ void main() { ChangeNotifierProvider.value(value: watchTogether), ], child: MaterialApp( - theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [_testTokens]), + theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 1200, @@ -1568,7 +1549,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1604,7 +1585,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1650,7 +1631,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1703,7 +1684,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1755,7 +1736,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1865,7 +1846,7 @@ Future _pumpSkipMarkerButton( }) { return tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: Center( child: SkipMarkerButton( diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index e45e9965..babdd7e1 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -10,29 +10,10 @@ import 'package:plezy/mpv/player/player_streams.dart'; import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 4, - radiusMd: 8, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 100), - normal: Duration(milliseconds: 200), - slow: Duration(milliseconds: 300), - expressive: Duration(milliseconds: 300), - bg: Colors.black, - surface: Color(0xFF111111), - outline: Color(0xFF333333), - text: Colors.white, - textMuted: Color(0xFFAAAAAA), - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { setUpAll(() async { @@ -82,7 +63,7 @@ void main() { LocaleSettings.setLocaleSync(AppLocale.ru); await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: const SubtitleStylingScreen(), ), ); @@ -159,7 +140,7 @@ Future _pumpSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: Scaffold( body: SizedBox( width: 900, From 352b88109b12ae36fd155f1eee71f5ef80cbcdf3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:45:07 +0200 Subject: [PATCH 04/12] refactor: extract shared mixins and helpers, drop dead abstractions Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action. --- .github/actions/setup-flutter-git/action.yml | 33 + .github/workflows/build.yml | 47 +- .github/workflows/ci.yml | 75 +- .github/workflows/e2e.yml | 10 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 17 +- .../plezy/exoplayer/ExoPlayerPlugin.kt | 63 +- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 17 +- .../edde746/plezy/shared/SurfacePlayerCore.kt | 27 + lib/connection/connection_bootstrap.dart | 12 +- lib/database/app_database.dart | 443 ++++-------- lib/database/download_operations.dart | 58 -- lib/database/tables.dart | 2 +- lib/focus/dpad_reorder_mixin.dart | 207 ++++++ lib/focus/focusable_chip_mixin.dart | 36 +- lib/main.dart | 49 +- lib/media/library_query.dart | 31 + lib/media/server_capabilities.dart | 74 +- .../jellyfin_metadata_edit_adapter.dart | 155 +---- lib/metadata_edit/metadata_edit_models.dart | 132 +++- .../plex_metadata_edit_adapter.dart | 127 +--- lib/mixins/deletion_aware.dart | 19 + lib/mixins/paginated_item_loader.dart | 37 - lib/mixins/standard_paginated_view.dart | 74 ++ lib/models/livetv_channel.dart | 5 +- lib/models/media_provider_info.dart | 71 -- lib/models/media_provider_info.g.dart | 38 -- lib/models/mixins/multi_server_fields.dart | 15 - lib/models/shader_preset.dart | 123 ++-- lib/models/trakt/trakt_scrobble_request.dart | 29 +- lib/profiles/plex_home_service.dart | 12 +- lib/profiles/profile_activation.dart | 7 +- lib/profiles/profile_connection_cleanup.dart | 430 +++++------- lib/profiles/profile_selection_policy.dart | 14 + lib/providers/companion_remote_provider.dart | 202 +++--- lib/providers/discover_provider.dart | 66 +- lib/providers/download_metadata_store.dart | 2 +- lib/providers/download_provider.dart | 44 +- lib/providers/multi_server_provider.dart | 48 +- lib/providers/watch_state_store.dart | 47 +- lib/screens/actor_media_screen.dart | 37 +- lib/screens/auth_screen.dart | 4 +- .../base_media_list_detail_screen.dart | 40 +- lib/screens/collection_detail_screen.dart | 87 +-- lib/screens/discover_screen.dart | 139 +--- .../focusable_detail_screen_mixin.dart | 64 +- lib/screens/hub_detail_screen.dart | 37 +- .../libraries/content_state_builder.dart | 50 ++ lib/screens/libraries/folder_tree_view.dart | 50 +- .../libraries/tabs/base_library_tab.dart | 14 + .../libraries/tabs/library_browse_tab.dart | 34 +- .../tabs/library_collections_tab.dart | 34 +- .../libraries/tabs/library_playlists_tab.dart | 34 +- .../tabs/library_recommended_tab.dart | 72 +- lib/screens/livetv/live_tv_screen.dart | 77 +-- .../livetv/reorder_favorites_sheet.dart | 180 +---- lib/screens/main_screen.dart | 229 +++---- lib/screens/media_detail_screen.dart | 42 +- lib/screens/metadata_edit_screen.dart | 25 +- lib/screens/music/album_detail_screen.dart | 40 +- lib/screens/music/artist_detail_screen.dart | 68 +- .../playlist/playlist_detail_screen.dart | 109 +-- .../profile/borrow_connection_screen.dart | 47 +- lib/screens/profile/pin_entry_dialog.dart | 9 + .../profile/profile_detail_screen.dart | 63 +- .../profile/profile_switch_screen.dart | 7 +- lib/screens/profile/profile_teardown.dart | 30 +- .../settings/add_connection_screen.dart | 8 +- lib/screens/settings/add_jellyfin_screen.dart | 16 +- .../settings/add_plex_account_screen.dart | 5 +- .../settings/appearance_settings_screen.dart | 73 +- .../edit_jellyfin_connection_screen.dart | 8 +- .../settings/keyboard_shortcuts_screen.dart | 6 +- .../settings/playback_settings_screen.dart | 20 +- .../settings/services_settings_screen.dart | 104 +-- lib/screens/settings/settings_screen.dart | 212 +++--- lib/screens/settings/settings_utils.dart | 26 + .../settings/subtitle_styling_screen.dart | 12 +- .../tracker_library_filter_screen.dart | 4 +- .../settings/tracker_service_info.dart | 94 +++ .../settings/tracker_settings_screen.dart | 7 +- .../settings/trakt_settings_screen.dart | 2 +- lib/screens/video_player/parts/build.dart | 1 - lib/screens/video_player/parts/pip.dart | 7 +- .../video_player/parts/playback_services.dart | 2 +- lib/screens/video_player/parts/shader.dart | 77 +-- .../widgets/player_prompt_overlays.dart | 283 ++++---- lib/screens/video_player_screen.dart | 44 +- .../companion_remote_host_controller.dart | 51 +- lib/services/device_performance.dart | 57 +- lib/services/download_manager_service.dart | 240 ++++--- lib/services/downloaded_video_source.dart | 77 +++ lib/services/fullscreen_state_manager.dart | 98 ++- lib/services/jellyfin_client.dart | 15 + .../jellyfin_client/parts/browse.dart | 103 ++- .../jellyfin_client/parts/collections.dart | 34 +- .../jellyfin_client/parts/file_info.dart | 2 +- .../parts/images_downloads.dart | 3 +- .../jellyfin_client/parts/live_tv.dart | 5 +- .../jellyfin_client/parts/metadata_edit.dart | 5 +- lib/services/jellyfin_client/parts/music.dart | 6 +- .../jellyfin_client/parts/playback.dart | 149 ++-- .../jellyfin_client/parts/playlists.dart | 51 +- .../jellyfin_client/parts/watch_state.dart | 5 +- lib/services/jellyfin_endpoint_discovery.dart | 10 + .../jellyfin_sequential_launcher.dart | 1 + lib/services/keyboard_shortcuts_service.dart | 177 ++--- lib/services/macos_window_service.dart | 63 +- .../media_control_router.dart | 14 +- .../media_list_playback_launcher.dart | 11 + lib/services/multi_server_manager.dart | 249 +++---- .../music/music_playback_service_impl.dart | 144 ++-- lib/services/play_queue_launcher.dart | 21 +- .../playback_initialization_service.dart | 52 +- .../playback_initialization_types.dart | 33 + lib/services/playlist_items_loader.dart | 49 +- lib/services/plex_auth_service.dart | 21 +- lib/services/plex_client.dart | 272 ++++---- .../plex_client/parts/collections.dart | 32 +- lib/services/plex_client/parts/live_tv.dart | 41 +- .../plex_client/parts/metadata_edit.dart | 28 +- .../plex_client/parts/play_queues.dart | 19 +- lib/services/plex_client/parts/playlists.dart | 48 +- lib/services/seerr/seerr_http_client.dart | 14 +- lib/services/settings_export_service.dart | 118 +--- lib/services/settings_service.dart | 137 ++-- lib/services/shader_asset_loader.dart | 7 +- lib/services/shortcut_action.dart | 139 ++++ lib/services/storage_service.dart | 120 ++-- lib/services/system_shelf_service.dart | 135 ++-- .../trackers/anilist/anilist_tracker.dart | 10 - .../trackers/anime_list_tracker_base.dart | 6 +- lib/services/trackers/mal/mal_tracker.dart | 10 - .../trackers/simkl/simkl_tracker.dart | 4 - lib/services/trackers/tracker.dart | 9 +- .../trackers/tracker_coordinator.dart | 97 +-- lib/services/trackers/tracker_session.dart | 5 +- .../trackers/tracker_session_utils.dart | 6 - lib/services/trakt/trakt_client.dart | 4 +- .../trakt/trakt_scrobble_service.dart | 2 +- lib/services/video_pip_manager.dart | 20 +- lib/services/watch_state_resolver.dart | 14 + lib/theme/mono_tokens.dart | 11 + lib/utils/android_exit_diagnostics.dart | 9 +- lib/utils/async_singleton.dart | 62 ++ lib/utils/device_channel.dart | 5 + lib/utils/download_utils.dart | 44 ++ lib/utils/hub_icons.dart | 66 ++ lib/utils/media_event_keys.dart | 36 + lib/utils/media_server_http_client.dart | 40 +- lib/utils/music_navigation.dart | 45 ++ lib/utils/platform_detector.dart | 51 +- lib/utils/url_utils.dart | 30 + .../screens/watch_together_screen.dart | 140 ++-- .../services/watch_together_peer_service.dart | 22 +- lib/widgets/catalog_source_logo.dart | 37 +- .../companion_remote/discovery_view.dart | 33 +- lib/widgets/download_tree_view.dart | 305 +++------ lib/widgets/focusable_filter_chip.dart | 18 - lib/widgets/focusable_tab_chip.dart | 18 - lib/widgets/library_management_sheet.dart | 300 ++------ lib/widgets/media_context_menu.dart | 47 +- lib/widgets/rating_bottom_sheet.dart | 94 +-- lib/widgets/setting_tile.dart | 205 +++--- lib/widgets/settings_section.dart | 9 +- .../desktop_video_controls.dart | 58 +- .../video_controls/mobile_video_controls.dart | 51 +- .../models/track_controls_state.dart | 2 - .../video_controls/parts/key_events.dart | 88 +-- .../video_controls/parts/track_controls.dart | 1 - .../sheets/sheet_selection_column.dart | 93 +++ .../video_controls/sheets/track_sheet.dart | 509 ++++++-------- .../sheets/version_quality_sheet.dart | 126 ++-- .../sheets/video_settings_sheet.dart | 176 ++--- .../video_controls/widgets/content_strip.dart | 303 ++++----- .../widgets/content_strip_panel.dart | 52 ++ .../widgets/track_chapter_controls.dart | 174 ++--- linux/runner/mpv/mpv_player.cc | 211 ++---- linux/runner/mpv/mpv_player.h | 10 +- linux/runner/mpv/mpv_player_lifecycle_test.cc | 4 +- scripts/check_build_workflow.py | 60 +- scripts/check_update_packages_workflow.py | 10 +- scripts/check_workflow_action_pins.py | 335 +-------- scripts/check_workflow_security.py | 49 +- scripts/ci_checks.sh | 24 +- scripts/ci_guard_checks.sh | 30 + scripts/workflow_yaml.py | 358 ++++++++++ server/artifact_store.go | 434 ++++++++++++ server/main.go | 642 ++---------------- server/main_test.go | 48 +- shared/mpv/mpv_player_common.h | 241 +++++++ shared/mpv/mpv_player_common_test.cpp | 91 +++ test/database/app_database_test.dart | 5 +- test/database/download_operations_test.dart | 111 +-- test/mixins/paginated_item_loader_test.dart | 56 +- .../profile_connection_cleanup_test.dart | 86 +-- test/providers/download_provider_test.dart | 1 + test/providers/watch_state_store_test.dart | 9 +- .../download_manager_service_test.dart | 1 + .../media_control_router_test.dart | 6 +- .../offline_watch_sync_service_test.dart | 1 + test/startup_bootstrap_test.dart | 2 + test/test_helpers/download_fixtures.dart | 70 ++ test/test_helpers/download_fixtures_test.dart | 126 ++++ test/widgets/video_settings_sheet_test.dart | 5 +- .../src/lib/components/DownloadButtons.svelte | 12 +- website/src/lib/components/FAQ.svelte | 50 +- website/src/lib/components/Features.svelte | 53 +- website/src/lib/components/Reviews.svelte | 51 +- website/src/lib/components/Screenshots.svelte | 53 +- .../src/lib/components/SectionHeader.svelte | 60 ++ website/src/lib/content/downloads.ts | 47 +- .../src/lib/content/software_app_offers.ts | 8 +- website/src/routes/+error.svelte | 65 +- website/src/routes/+page.server.ts | 5 +- website/src/routes/layout.css | 81 +++ website/src/routes/scan/+page.svelte | 71 +- windows/runner/mpv/mpv_player.cpp | 170 +---- 217 files changed, 6813 insertions(+), 8773 deletions(-) create mode 100644 .github/actions/setup-flutter-git/action.yml create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt create mode 100644 lib/focus/dpad_reorder_mixin.dart create mode 100644 lib/mixins/standard_paginated_view.dart delete mode 100644 lib/models/media_provider_info.dart delete mode 100644 lib/models/media_provider_info.g.dart delete mode 100644 lib/models/mixins/multi_server_fields.dart create mode 100644 lib/profiles/profile_selection_policy.dart create mode 100644 lib/screens/settings/tracker_service_info.dart create mode 100644 lib/services/downloaded_video_source.dart rename lib/{screens/video_player => services}/media_control_router.dart (80%) create mode 100644 lib/services/shortcut_action.dart create mode 100644 lib/utils/async_singleton.dart create mode 100644 lib/utils/device_channel.dart create mode 100644 lib/utils/hub_icons.dart create mode 100644 lib/utils/media_event_keys.dart create mode 100644 lib/widgets/video_controls/sheets/sheet_selection_column.dart create mode 100644 lib/widgets/video_controls/widgets/content_strip_panel.dart create mode 100644 scripts/ci_guard_checks.sh create mode 100644 scripts/workflow_yaml.py create mode 100644 server/artifact_store.go rename test/{screens/video_player => services}/media_control_router_test.dart (94%) create mode 100644 test/test_helpers/download_fixtures.dart create mode 100644 test/test_helpers/download_fixtures_test.dart create mode 100644 website/src/lib/components/SectionHeader.svelte diff --git a/.github/actions/setup-flutter-git/action.yml b/.github/actions/setup-flutter-git/action.yml new file mode 100644 index 00000000..573d4fca --- /dev/null +++ b/.github/actions/setup-flutter-git/action.yml @@ -0,0 +1,33 @@ +name: Set up Flutter from git +description: >- + Clone the pinned Flutter SDK from its release tag and put it on PATH, for + runners without a published archive. Flutter ships no windows-arm64 SDK, so + subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry + in the stable manifest) and `channel: master` would clone master HEAD, whose + engine is not the patched revision install-patched-engine.ps1 asserts + (4c525dac). This file is the only place that pin lives: the tag is fetched so + the SDK reports its own version, then verified against the immutable commit, + so a moved tag fails the job instead of quietly changing SDKs. + +runs: + using: composite + steps: + - name: Clone Flutter from its immutable commit + shell: pwsh + run: | + $version = "3.44.0" + $expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + $root = "$env:RUNNER_TEMP\flutter" + git init $root + git -C $root remote add origin https://github.com/flutter/flutter.git + git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}" + git -C $root checkout --detach "refs/tags/$version" + $actualCommit = git -C $root rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) { + throw "Flutter $version resolved to $actualCommit, expected $expectedCommit" + } + "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + & "$root\bin\flutter.bat" --version + if ($LASTEXITCODE -ne 0) { + throw "Unable to bootstrap the Flutter SDK" + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6dc8c58..c5220d2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,10 +25,10 @@ on: type: boolean env: - SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} - GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }} - SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github - DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true + # Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release. + FLUTTER_VERSION: "3.44.0" + # Shared by every release build command; SENTRY_DIST stays per-platform. + RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1 LINUX_APT_PACKAGES: > clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev @@ -76,7 +76,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -110,7 +110,7 @@ jobs: EOF - name: Build APKs - run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json + run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -163,7 +163,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -188,7 +188,7 @@ jobs: run: flutter pub get --enforce-lockfile --no-example - name: Build iOS (no codesign) - run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios + run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -231,7 +231,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -256,7 +256,7 @@ jobs: run: flutter pub get --enforce-lockfile --no-example - name: Build macOS - run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos + run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -436,27 +436,14 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - - name: Set up Flutter 3.44.0 (git tag) + - name: Set up Flutter from its pinned commit if: matrix.flutter_setup == 'git' - # Flutter publishes no windows-arm64 SDK archive, so subosito can't - # resolve 3.44.0 for arm64: the stable manifest has no arm64 entry, and - # `channel: master` would git-clone master HEAD (whose engine != our - # patched 3.44.0). Clone the 3.44.0 tag directly to get engine rev - # 4c525dac, which install-patched-engine.ps1 asserts before swapping. - shell: pwsh - run: | - $root = "$env:RUNNER_TEMP\flutter" - git init $root - git -C $root remote add origin https://github.com/flutter/flutter.git - git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - git -C $root checkout --detach FETCH_HEAD - "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - & "$root\bin\flutter.bat" --version + uses: ./.github/actions/setup-flutter-git - name: Cache Pub dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 @@ -477,7 +464,7 @@ jobs: - name: Build Windows ${{ matrix.arch }} shell: pwsh - run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-${{ matrix.arch }} + run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }} - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -510,7 +497,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -623,7 +610,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: ${{ matrix.flutter_channel }} - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -680,7 +667,7 @@ jobs: - name: Build Linux ${{ matrix.arch }} shell: bash - run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-${{ matrix.arch }} + run: flutter build linux --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} --split-debug-info=debug-info/linux-${{ matrix.arch }} env: PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7a75f2..26a2664c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: - main workflow_dispatch: +env: + # Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release. + FLUTTER_VERSION: "3.44.0" + jobs: analyze: name: Code Analysis @@ -26,7 +30,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -53,30 +57,7 @@ jobs: run: python3 scripts/clean_translations.py --check --strict - name: Verify workflow and script guards - run: | - python3 scripts/check_build_workflow.py - python3 scripts/test_check_build_workflow.py - python3 scripts/check_apple_spm_locks.py - python3 scripts/test_check_apple_spm_locks.py - python3 scripts/verify_runtime_inputs.py - python3 scripts/test_verify_runtime_inputs.py - python3 scripts/check_workflow_security.py - python3 scripts/test_check_workflow_security.py - python3 scripts/check_workflow_action_pins.py - python3 scripts/test_check_workflow_action_pins.py - python3 scripts/check_container_image_pins.py - python3 scripts/test_check_container_image_pins.py - python3 scripts/test_fetch_tvos_engine.py - python3 scripts/test_check_codegen.py - python3 scripts/test_generate_relay_protocol.py - python3 scripts/test_format_native.py - python3 scripts/test_run_maestro.py - python3 scripts/test_maestro_flow_contracts.py - python3 scripts/test_maestro_jellyfin_proxy.py - python3 scripts/check_update_packages_workflow.py - python3 scripts/test_pubspec_version.py - python3 scripts/test_clean_translations.py - python3 scripts/test_check_icon_consistency.py + run: bash scripts/ci_guard_checks.sh - name: Verify formatting run: | @@ -130,7 +111,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -187,7 +168,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -281,7 +262,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -355,7 +336,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -478,41 +459,13 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false - - name: Setup Flutter 3.44.0 from its immutable commit + - name: Set up Flutter from its pinned commit if: matrix.flutter_setup == 'git' - shell: pwsh - run: | - $root = "$env:RUNNER_TEMP\flutter" - $expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" - git init $root - git -C $root remote add origin https://github.com/flutter/flutter.git - git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0 - git -C $root checkout --detach refs/tags/3.44.0 - $actualCommit = git -C $root rev-parse HEAD - if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) { - throw "Flutter 3.44.0 resolved to $actualCommit, expected $expectedCommit" - } - "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - & "$root\bin\flutter.bat" --version - if ($LASTEXITCODE -ne 0) { - throw "Unable to bootstrap the Flutter SDK" - } - $versionOutput = & "$root\bin\flutter.bat" --version --machine - if ($LASTEXITCODE -ne 0) { - throw "Unable to resolve the Flutter SDK version" - } - $versionJson = $versionOutput -join "`n" - if ([string]::IsNullOrWhiteSpace($versionJson)) { - throw "Flutter did not report machine-readable version JSON" - } - $version = $versionJson | ConvertFrom-Json - if ($version.frameworkVersion -ne "3.44.0") { - throw "Flutter reported version $($version.frameworkVersion), expected 3.44.0" - } + uses: ./.github/actions/setup-flutter-git - name: Install locked Dart dependencies shell: pwsh @@ -566,7 +519,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 26ca7c52..2e7ded8f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -243,7 +243,7 @@ jobs: uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.pub-cache - key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + key: ${{ steps.pub-cache.outputs.cache-primary-key }} - name: Save Gradle cache if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true' @@ -252,7 +252,7 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} - name: Save Maestro CLI cache if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true' @@ -261,7 +261,7 @@ jobs: path: | ~/.maestro/bin ~/.maestro/lib - key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }} + key: ${{ steps.maestro-cache.outputs.cache-primary-key }} - name: Save Android 15 AVD cache if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true' @@ -270,7 +270,7 @@ jobs: path: | ~/.android/avd/maestro-api35.avd ~/.android/avd/maestro-api35.ini - key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6 + key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }} - name: Save Android 9 AVD cache if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true' @@ -279,7 +279,7 @@ jobs: path: | ~/.android/avd/maestro-api28.avd ~/.android/avd/maestro-api28.ini - key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore + key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }} - name: Upload Maestro diagnostics if: always() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 5543ab1e..03880b97 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -77,6 +77,7 @@ import com.edde746.plezy.shared.FlutterOverlayHelper import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.MediaCodecQuery import com.edde746.plezy.shared.PlayerSurfaceHost +import com.edde746.plezy.shared.SurfacePlayerCore import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong import org.chromium.net.CronetEngine @@ -102,7 +103,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate { internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null @OptIn(UnstableApi::class) -class ExoPlayerCore(private val activity: Activity) : Player.Listener { +class ExoPlayerCore(private val activity: Activity) : Player.Listener, SurfacePlayerCore { companion object { private const val TAG = "ExoPlayerCore" @@ -3400,7 +3401,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { else -> null } - fun setVisible(visible: Boolean) { + override fun setVisible(visible: Boolean) { if (disposing) return currentVisible = visible activity.runOnUiThread { @@ -3493,7 +3494,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - fun onPipModeChanged(isInPipMode: Boolean) { + override fun onPipModeChanged(isInPipMode: Boolean) { if (disposing) return activity.runOnUiThread { if (disposing) return@runOnUiThread @@ -3509,7 +3510,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - fun updateFrame() { + override fun updateFrame() { if (disposing) return activity.runOnUiThread { if (disposing) return@runOnUiThread @@ -3524,15 +3525,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Audio Focus - fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false + override fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false - fun abandonAudioFocus() { + override fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() } // Frame Rate Matching - fun setVideoFrameRate( + override fun setVideoFrameRate( fps: Float, videoDurationMs: Long, extraDelayMs: Long, @@ -3548,7 +3549,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete) } - fun clearVideoFrameRate() { + override fun clearVideoFrameRate() { frameRateManager?.clearVideoFrameRate() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 0fe6cb2c..07d0f408 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -12,6 +12,7 @@ import com.edde746.plezy.shared.MpvContentUriResolver import com.edde746.plezy.shared.PlayerChannelBinding import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.ResolvedMpvUri +import com.edde746.plezy.shared.SurfacePlayerCore import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -45,6 +46,10 @@ class ExoPlayerPlugin : private var activity: Activity? = null private var activityBinding: ActivityPluginBinding? = null + /** Whichever core currently owns the surface; both expose [SurfacePlayerCore] identically. */ + private val activeSurfaceCore: SurfacePlayerCore? + get() = if (usingMpvFallback) mpvCore else playerCore + // Every Dart observeProperty registration, kept so an ExoPlayer→MPV // fallback can re-observe exactly what Dart asked for instead of // maintaining a parallel hard-coded list. @@ -949,20 +954,12 @@ class ExoPlayerPlugin : return } - if (usingMpvFallback) { - mpvCore?.setVisible(visible) - } else { - playerCore?.setVisible(visible) - } + activeSurfaceCore?.setVisible(visible) result.success(null) } private fun handleUpdateFrame(result: MethodChannel.Result) { - if (usingMpvFallback) { - mpvCore?.updateFrame() - } else { - playerCore?.updateFrame() - } + activeSurfaceCore?.updateFrame() result.success(null) } @@ -974,51 +971,31 @@ class ExoPlayerPlugin : val videoHeight = call.argument("videoHeight")?.toInt() ?: 0 Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight") - val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } - if (usingMpvFallback) { - val core = mpvCore - if (core == null) { - result.success(false) - } else { - core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete) - } - } else { - val core = playerCore - if (core == null) { - result.success(false) - } else { - core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete) - } + val core = activeSurfaceCore + if (core == null) { + result.success(false) + return + } + core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched -> + result.success(switched) } } private fun handleClearVideoFrameRate(result: MethodChannel.Result) { Log.d(TAG, "clearVideoFrameRate") - if (usingMpvFallback) { - mpvCore?.clearVideoFrameRate() - } else { - playerCore?.clearVideoFrameRate() - } + activeSurfaceCore?.clearVideoFrameRate() result.success(null) } private fun handleRequestAudioFocus(result: MethodChannel.Result) { Log.d(TAG, "requestAudioFocus") - val granted = if (usingMpvFallback) { - mpvCore?.requestAudioFocus() ?: false - } else { - playerCore?.requestAudioFocus() ?: false - } + val granted = activeSurfaceCore?.requestAudioFocus() ?: false result.success(granted) } private fun handleAbandonAudioFocus(result: MethodChannel.Result) { Log.d(TAG, "abandonAudioFocus") - if (usingMpvFallback) { - mpvCore?.abandonAudioFocus() - } else { - playerCore?.abandonAudioFocus() - } + activeSurfaceCore?.abandonAudioFocus() result.success(null) } @@ -1228,11 +1205,7 @@ class ExoPlayerPlugin : fun onPipModeChanged(isInPipMode: Boolean) { activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.onPipModeChanged(isInPipMode) - } else { - playerCore?.onPipModeChanged(isInPipMode) - } + activeSurfaceCore?.onPipModeChanged(isInPipMode) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index c81a093a..0d6b32fb 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.PlayerSurfaceHost +import com.edde746.plezy.shared.SurfacePlayerCore import dev.jdtech.mpv.* import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -40,7 +41,7 @@ class MpvPlayerCore private constructor( private val audioOnly: Boolean, private val propertyWriterOverride: (suspend (String, String) -> Unit)?, initializedForTesting: Boolean -) : SurfaceHolder.Callback { +) : SurfaceHolder.Callback, SurfacePlayerCore { constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false) internal constructor( @@ -405,7 +406,7 @@ class MpvPlayerCore private constructor( // Audio Focus - fun requestAudioFocus(): Boolean { + override fun requestAudioFocus(): Boolean { val granted = audioFocusManager?.requestAudioFocus() ?: false if (granted && pausedForAudioFocusLoss) { resumeAfterAudioFocusGain("audio focus request granted") @@ -413,7 +414,7 @@ class MpvPlayerCore private constructor( return granted } - fun abandonAudioFocus() { + override fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() } @@ -1042,7 +1043,7 @@ class MpvPlayerCore private constructor( } } - fun setVisible(visible: Boolean) { + override fun setVisible(visible: Boolean) { // Audio-only: no render layer to show or hide — tolerated no-op. if (audioOnly || disposing) return runOnMain { @@ -1067,11 +1068,11 @@ class MpvPlayerCore private constructor( } } - fun onPipModeChanged(isInPipMode: Boolean) { + override fun onPipModeChanged(isInPipMode: Boolean) { // MPV handles aspect ratio internally via its own surface management } - fun updateFrame() { + override fun updateFrame() { // Audio-only: no surface to refresh — tolerated no-op. if (audioOnly || disposing) return runOnMain { @@ -1106,7 +1107,7 @@ class MpvPlayerCore private constructor( // Frame Rate Matching - fun setVideoFrameRate( + override fun setVideoFrameRate( fps: Float, videoDurationMs: Long, extraDelayMs: Long, @@ -1128,7 +1129,7 @@ class MpvPlayerCore private constructor( } } - fun clearVideoFrameRate() { + override fun clearVideoFrameRate() { frameRateManager?.clearVideoFrameRate() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt new file mode 100644 index 00000000..bac39951 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt @@ -0,0 +1,27 @@ +package com.edde746.plezy.shared + +/** + * Surface and display concerns that the ExoPlayer and mpv cores implement + * identically, so a plugin holding either one dispatches without branching + * on which backend is active. + * + * Only backend-independent members belong here: playback control + * (play/seek/track selection) stays off this interface because mpv drives it + * through properties and commands where ExoPlayer uses direct method calls. + */ +interface SurfacePlayerCore { + fun setVisible(visible: Boolean) + fun updateFrame() + fun onPipModeChanged(isInPipMode: Boolean) + fun requestAudioFocus(): Boolean + fun abandonAudioFocus() + fun clearVideoFrameRate() + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + extraDelayMs: Long, + videoWidth: Int, + videoHeight: Int, + onComplete: (switched: Boolean) -> Unit + ) +} diff --git a/lib/connection/connection_bootstrap.dart b/lib/connection/connection_bootstrap.dart index 92c0c377..4d7ed992 100644 --- a/lib/connection/connection_bootstrap.dart +++ b/lib/connection/connection_bootstrap.dart @@ -29,7 +29,7 @@ class ConnectionBootstrap { required this.profileRegistry, Future> Function(String accountToken)? plexHomeUserFetcher, Future> Function(String accountToken)? plexUserInfoFetcher, - }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers, + }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers, _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo; final StorageService storage; @@ -283,16 +283,6 @@ class ConnectionBootstrap { } } -Future> _fetchPlexHomeUsers(String accountToken) async { - final auth = await PlexAuthService.create(); - try { - final home = await auth.getHomeUsers(accountToken); - return home.users; - } finally { - auth.dispose(); - } -} - Future> _fetchPlexUserInfo(String accountToken) async { final auth = await PlexAuthService.create(); try { diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 6c54a441..fc46363f 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -237,197 +237,56 @@ class AppDatabase extends _$AppDatabase { final joinRows = await (select( profileConnections, )..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(); + // Drift's generated serializer is the recovery image's column schema: + // `toJson`/`fromJson` use these camelCase keys, so the read and restore + // sides can never drift apart when a column is added or renamed. return { - 'connections': [ - for (final row in connectionRows) - { - 'id': row.id, - 'kind': row.kind, - 'displayName': row.displayName, - 'configJson': row.configJson, - 'isDefault': row.isDefault, - 'createdAt': row.createdAt, - 'lastAuthenticatedAt': row.lastAuthenticatedAt, - }, - ], - 'profiles': [ - for (final row in profileRows) - { - 'id': row.id, - 'kind': row.kind, - 'displayName': row.displayName, - 'avatarThumbUrl': row.avatarThumbUrl, - 'configJson': row.configJson, - 'sortOrder': row.sortOrder, - 'createdAt': row.createdAt, - 'lastUsedAt': row.lastUsedAt, - }, - ], - 'profileConnections': [ - for (final row in joinRows) - { - 'profileId': row.profileId, - 'connectionId': row.connectionId, - 'userToken': row.userToken, - 'userIdentifier': row.userIdentifier, - 'isDefault': row.isDefault, - 'tokenAcquiredAt': row.tokenAcquiredAt, - 'lastUsedAt': row.lastUsedAt, - }, - ], + 'connections': [for (final row in connectionRows) row.toJson()], + 'profiles': [for (final row in profileRows) row.toJson()], + 'profileConnections': [for (final row in joinRows) row.toJson()], }; } Future> _readPendingRecoveryRows() async { final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); return { - 'offlineWatchProgress': [ - for (final row in rows) - { - 'id': row.id, - 'profileId': row.profileId, - 'serverId': row.serverId, - 'clientScopeId': row.clientScopeId, - 'ratingKey': row.ratingKey, - 'globalKey': row.globalKey, - 'actionType': row.actionType, - 'viewOffset': row.viewOffset, - 'duration': row.duration, - 'shouldMarkWatched': row.shouldMarkWatched, - 'createdAt': row.createdAt, - 'updatedAt': row.updatedAt, - 'syncAttempts': row.syncAttempts, - 'lastError': row.lastError, - }, - ], + 'offlineWatchProgress': [for (final row in rows) row.toJson()], }; } Future _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async { - final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const { - 'id', - 'kind', - 'displayName', - 'configJson', - 'isDefault', - 'createdAt', - 'lastAuthenticatedAt', - }); - final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', const { - 'id', - 'kind', - 'displayName', - 'avatarThumbUrl', - 'configJson', - 'sortOrder', - 'createdAt', - 'lastUsedAt', - }); - final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', const { - 'profileId', - 'connectionId', - 'userToken', - 'userIdentifier', - 'isDefault', - 'tokenAcquiredAt', - 'lastUsedAt', - }); - final pendingRows = _decodeRecoveryRows(snapshot.pending, 'offlineWatchProgress', const { - 'id', - 'profileId', - 'serverId', - 'clientScopeId', - 'ratingKey', - 'globalKey', - 'actionType', - 'viewOffset', - 'duration', - 'shouldMarkWatched', - 'createdAt', - 'updatedAt', - 'syncAttempts', - 'lastError', - }); + final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson); + final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson); + final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson); + final pendingRows = _decodeRecoveryRows( + snapshot.pending, + 'offlineWatchProgress', + OfflineWatchProgressItem.fromJson, + ); // Recovery images from releases before the credential vault may contain // plaintext secrets. Protect them before they cross into Drift; already // protected values remain byte-identical because vault protection is // idempotent. - for (final row in connectionRows) { - final kind = _requiredRecoveryValue(row, 'kind'); - final configJson = _requiredRecoveryValue(row, 'configJson'); - final decoded = jsonDecode(configJson); + for (var index = 0; index < connectionRows.length; index++) { + final row = connectionRows[index]; + final decoded = jsonDecode(row.configJson); if (decoded is! Map) { throw const FormatException('Invalid connection configuration'); } - if (_containsPlaintextConnectionCredential(kind, decoded)) { - row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded)); + if (_containsPlaintextConnectionCredential(row.kind, decoded)) { + connectionRows[index] = row.copyWith( + configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)), + ); } } - for (final row in joinRows) { - final token = _requiredRecoveryValue(row, 'userToken'); - if (token.isNotEmpty && !CredentialVault.isProtected(token)) { - row['userToken'] = await CredentialVault.protect(token); + for (var index = 0; index < joinRows.length; index++) { + final row = joinRows[index]; + if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) { + joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken)); } } - final connectionCompanions = [ - for (final row in connectionRows) - ConnectionsCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - kind: Value(_requiredRecoveryValue(row, 'kind')), - displayName: Value(_requiredRecoveryValue(row, 'displayName')), - configJson: Value(_requiredRecoveryValue(row, 'configJson')), - isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - lastAuthenticatedAt: Value(_nullableRecoveryValue(row, 'lastAuthenticatedAt')), - ), - ]; - final profileCompanions = [ - for (final row in profileRows) - ProfilesCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - kind: Value(_requiredRecoveryValue(row, 'kind')), - displayName: Value(_requiredRecoveryValue(row, 'displayName')), - avatarThumbUrl: Value(_nullableRecoveryValue(row, 'avatarThumbUrl')), - configJson: Value(_requiredRecoveryValue(row, 'configJson')), - sortOrder: Value(_requiredRecoveryValue(row, 'sortOrder')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), - ), - ]; - final joinCompanions = [ - for (final row in joinRows) - ProfileConnectionsCompanion( - profileId: Value(_requiredRecoveryValue(row, 'profileId')), - connectionId: Value(_requiredRecoveryValue(row, 'connectionId')), - userToken: Value(_requiredRecoveryValue(row, 'userToken')), - userIdentifier: Value(_requiredRecoveryValue(row, 'userIdentifier')), - isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), - tokenAcquiredAt: Value(_nullableRecoveryValue(row, 'tokenAcquiredAt')), - lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), - ), - ]; - final pendingCompanions = [ - for (final row in pendingRows) - OfflineWatchProgressCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - profileId: Value(_nullableRecoveryValue(row, 'profileId')), - serverId: Value(_requiredRecoveryValue(row, 'serverId')), - clientScopeId: Value(_nullableRecoveryValue(row, 'clientScopeId')), - ratingKey: Value(_requiredRecoveryValue(row, 'ratingKey')), - globalKey: Value(_requiredRecoveryValue(row, 'globalKey')), - actionType: Value(_requiredRecoveryValue(row, 'actionType')), - viewOffset: Value(_nullableRecoveryValue(row, 'viewOffset')), - duration: Value(_nullableRecoveryValue(row, 'duration')), - shouldMarkWatched: Value(_requiredRecoveryValue(row, 'shouldMarkWatched')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - updatedAt: Value(_requiredRecoveryValue(row, 'updatedAt')), - syncAttempts: Value(_requiredRecoveryValue(row, 'syncAttempts')), - lastError: Value(_nullableRecoveryValue(row, 'lastError')), - ), - ]; - await transaction(() async { // Recovery completion (the durable marker removal) is deliberately // separate from this transaction. Replace the snapshot-owned rows so a @@ -437,54 +296,57 @@ class AppDatabase extends _$AppDatabase { await delete(profiles).go(); await delete(connections).go(); await delete(offlineWatchProgress).go(); - for (final row in connectionCompanions) { - await into(connections).insert(row); + // `toCompanion(false)` writes every column explicitly, including the + // nulls, so a restored row is byte-identical to the captured one rather + // than picking up column defaults. + for (final row in connectionRows) { + await into(connections).insert(row.toCompanion(false)); } - for (final row in profileCompanions) { - await into(profiles).insert(row); + for (final row in profileRows) { + await into(profiles).insert(row.toCompanion(false)); } - for (final row in joinCompanions) { - await into(profileConnections).insert(row); + for (final row in joinRows) { + await into(profileConnections).insert(row.toCompanion(false)); } - for (final row in pendingCompanions) { - await into(offlineWatchProgress).insert(row); + for (final row in pendingRows) { + await into(offlineWatchProgress).insert(row.toCompanion(false)); } }); } - static List> _decodeRecoveryRows( + static List _decodeRecoveryRows( Map group, String key, - Set expectedKeys, + T Function(Map json) fromJson, ) { final value = group[key]; - if (value is! List) throw const FormatException('Invalid tvOS database recovery image'); + if (value is! List) throw _invalidRecoveryImage; return [ - for (final value in value) - if (value is Map && - value.keys.toSet().containsAll(expectedKeys) && - value.length == expectedKeys.length) - value - else - throw const FormatException('Invalid tvOS database recovery image'), + for (final row in value) + if (row is Map) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage, ]; } - static T _requiredRecoveryValue(Map row, String key) { - final value = row[key]; - if (!row.containsKey(key) || value is! T) { - throw const FormatException('Invalid tvOS database recovery image'); + /// Reads one row through drift's generated deserializer and rejects anything + /// that does not round-trip back to the exact same map. Drift already throws + /// on a missing or mistyped required column; the round-trip additionally + /// rejects unknown and missing-but-nullable columns, which the serializer + /// would otherwise accept silently. + static T _decodeRecoveryRow( + Map row, + T Function(Map json) fromJson, + ) { + final T decoded; + try { + decoded = fromJson(row); + } catch (_) { + throw _invalidRecoveryImage; } - return value; + if (!mapEquals(decoded.toJson(), row)) throw _invalidRecoveryImage; + return decoded; } - static T? _nullableRecoveryValue(Map row, String key) { - if (!row.containsKey(key)) throw const FormatException('Invalid tvOS database recovery image'); - final value = row[key]; - if (value == null) return null; - if (value is! T) throw const FormatException('Invalid tvOS database recovery image'); - return value as T; - } + static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image'); @override int get schemaVersion => 19; @@ -649,89 +511,27 @@ class AppDatabase extends _$AppDatabase { } if (from < 17) { appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)'); - await customStatement(''' - WITH download_metadata_ids AS ( - SELECT global_key, server_id, rating_key AS metadata_id - FROM downloaded_media - UNION - SELECT global_key, server_id, parent_rating_key AS metadata_id - FROM downloaded_media - WHERE parent_rating_key IS NOT NULL - AND parent_rating_key != '' - UNION - SELECT global_key, server_id, grandparent_rating_key AS metadata_id - FROM downloaded_media - WHERE grandparent_rating_key IS NOT NULL - AND grandparent_rating_key != '' - ) - INSERT INTO api_cache (cache_key, data, pinned, cached_at) - SELECT DISTINCT - metadata.server_id - || '/~plex-profile/' - || owner.profile_id - || ':' - || substr(source.cache_key, length(metadata.server_id) + 2), - source.data, - source.pinned, - source.cached_at - FROM download_metadata_ids AS metadata - JOIN download_owners AS owner - ON owner.global_key = metadata.global_key - JOIN api_cache AS source - ON source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id - OR source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' - WHERE source.pinned = 1 - ON CONFLICT(cache_key) DO UPDATE SET - data = excluded.data, - pinned = excluded.pinned, - cached_at = excluded.cached_at - '''); + await customStatement( + _rescopePinnedPlexMetadataStatement( + namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'", + ownerJoin: '''JOIN download_owners AS owner + ON owner.global_key = metadata.global_key''', + ), + ); // A direct pre-v14 upgrade has no owners yet: profiles and owner // adoption are bootstrapped only after the database opens. Preserve // those downloads in the neutral Plex transfer namespace so the // first profile can adopt them without inheriting legacy watch data. - await customStatement(''' - WITH download_metadata_ids AS ( - SELECT global_key, server_id, rating_key AS metadata_id - FROM downloaded_media - UNION - SELECT global_key, server_id, parent_rating_key AS metadata_id - FROM downloaded_media - WHERE parent_rating_key IS NOT NULL - AND parent_rating_key != '' - UNION - SELECT global_key, server_id, grandparent_rating_key AS metadata_id - FROM downloaded_media - WHERE grandparent_rating_key IS NOT NULL - AND grandparent_rating_key != '' - ) - INSERT INTO api_cache (cache_key, data, pinned, cached_at) - SELECT DISTINCT - metadata.server_id - || '/~plex-transfer:' - || substr(source.cache_key, length(metadata.server_id) + 2), - source.data, - source.pinned, - source.cached_at - FROM download_metadata_ids AS metadata - JOIN api_cache AS source - ON source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id - OR source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' - WHERE source.pinned = 1 - AND NOT EXISTS ( - SELECT 1 - FROM download_owners AS owner - WHERE owner.global_key = metadata.global_key - ) - ON CONFLICT(cache_key) DO UPDATE SET - data = excluded.data, - pinned = excluded.pinned, - cached_at = excluded.cached_at - '''); + await customStatement( + _rescopePinnedPlexMetadataStatement( + namespaceExpression: "'/~plex-transfer:'", + ownerFilter: '''AND NOT EXISTS ( + SELECT 1 + FROM download_owners AS owner + WHERE owner.global_key = metadata.global_key + )''', + ), + ); final transferRows = await customSelect(''' SELECT cache_key, data @@ -920,10 +720,6 @@ class AppDatabase extends _$AppDatabase { } } - Expression _clientScopePredicate(GeneratedColumn column, String? clientScopeId) { - return clientScopeId == null ? column.isNull() : column.equals(clientScopeId); - } - Expression _nullableTextPredicate(GeneratedColumn column, String? value) { return value == null ? column.isNull() : column.equals(value); } @@ -974,7 +770,7 @@ class AppDatabase extends _$AppDatabase { (t) => matchesKey(t) & (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & - (filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)), + (filterClientScope ? _nullableTextPredicate(t.clientScopeId, clientScopeId) : const Constant(true)), ) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]); } @@ -1083,7 +879,7 @@ class AppDatabase extends _$AppDatabase { (t) => t.globalKey.equals(globalKey) & _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId) & + _nullableTextPredicate(t.clientScopeId, clientScopeId) & t.actionType.equals(OfflineActionType.progress.id), ) ..orderBy([(t) => OrderingTerm.asc(t.id)])) @@ -1145,7 +941,7 @@ class AppDatabase extends _$AppDatabase { (t) => t.globalKey.equals(globalKey) & _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId), + _nullableTextPredicate(t.clientScopeId, clientScopeId), )) .go(); @@ -1287,29 +1083,21 @@ class AppDatabase extends _$AppDatabase { } } - Future updateSyncRuleCount(String globalKey, int episodeCount) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount))); + Future _writeSyncRule(String globalKey, SyncRulesCompanion values) async { + await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values); } - Future updateSyncRuleFilter(String globalKey, String downloadFilter) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter))); - } + Future updateSyncRuleCount(String globalKey, int episodeCount) => + _writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount))); - Future updateSyncRuleEnabled(String globalKey, bool enabled) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled))); - } + Future updateSyncRuleFilter(String globalKey, String downloadFilter) => + _writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter))); - Future updateSyncRuleLastExecuted(String globalKey) async { - await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( - SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)), - ); - } + Future updateSyncRuleEnabled(String globalKey, bool enabled) => + _writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled))); + + Future updateSyncRuleLastExecuted(String globalKey) => + _writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch))); Future deleteSyncRule(String globalKey) async { await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go(); @@ -1331,6 +1119,57 @@ class AppDatabase extends _$AppDatabase { } } +/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into +/// a scoped cache namespace. +/// +/// The owned and the ownerless branch run the same operation over the same +/// `download_metadata_ids` set and differ only in three spots: the expression +/// spliced into the new `cache_key` ([namespaceExpression]), an optional join +/// that exposes the owning profile ([ownerJoin]), and an optional extra +/// predicate that keeps each branch to its own rows ([ownerFilter]). +String _rescopePinnedPlexMetadataStatement({ + required String namespaceExpression, + String ownerJoin = '', + String ownerFilter = '', +}) => + ''' + WITH download_metadata_ids AS ( + SELECT global_key, server_id, rating_key AS metadata_id + FROM downloaded_media + UNION + SELECT global_key, server_id, parent_rating_key AS metadata_id + FROM downloaded_media + WHERE parent_rating_key IS NOT NULL + AND parent_rating_key != '' + UNION + SELECT global_key, server_id, grandparent_rating_key AS metadata_id + FROM downloaded_media + WHERE grandparent_rating_key IS NOT NULL + AND grandparent_rating_key != '' + ) + INSERT INTO api_cache (cache_key, data, pinned, cached_at) + SELECT DISTINCT + metadata.server_id + || $namespaceExpression + || substr(source.cache_key, length(metadata.server_id) + 2), + source.data, + source.pinned, + source.cached_at + FROM download_metadata_ids AS metadata + $ownerJoin + JOIN api_cache AS source + ON source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id + OR source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' + WHERE source.pinned = 1 + $ownerFilter + ON CONFLICT(cache_key) DO UPDATE SET + data = excluded.data, + pinned = excluded.pinned, + cached_at = excluded.cached_at +'''; + Future _resolveProductionDatabaseFile() async { final dbFolder = (Platform.isAndroid || Platform.isIOS) ? await getApplicationDocumentsDirectory() diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index eb22c743..94d47e2e 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -271,64 +271,6 @@ extension DownloadDatabaseOperations on AppDatabase { } } - Future insertDownload({ - required ServerId serverId, - String? clientScopeId, - required String ratingKey, - required String globalKey, - required String type, - String? parentRatingKey, - String? grandparentRatingKey, - required int status, - int mediaIndex = 0, - String? mediaSourceId, - }) async { - await customUpdate( - ''' - INSERT INTO downloaded_media ( - server_id, - client_scope_id, - rating_key, - global_key, - type, - parent_rating_key, - grandparent_rating_key, - status, - media_index, - media_source_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(global_key) DO UPDATE SET - server_id = excluded.server_id, - client_scope_id = excluded.client_scope_id, - rating_key = excluded.rating_key, - type = excluded.type, - parent_rating_key = excluded.parent_rating_key, - grandparent_rating_key = excluded.grandparent_rating_key, - status = excluded.status, - progress = 0, - total_bytes = NULL, - downloaded_bytes = 0, - error_message = NULL, - retry_count = 0, - media_index = excluded.media_index, - media_source_id = excluded.media_source_id - ''', - variables: [ - Variable(serverId), - Variable(clientScopeId), - Variable(ratingKey), - Variable(globalKey), - Variable(type), - Variable(parentRatingKey), - Variable(grandparentRatingKey), - Variable(status), - Variable(mediaIndex), - Variable(mediaSourceId), - ], - updates: {downloadedMedia}, - ); - } - Future addToQueue({ required String mediaGlobalKey, int priority = 0, diff --git a/lib/database/tables.dart b/lib/database/tables.dart index a144f17c..68477fa0 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -197,7 +197,7 @@ class ProfileConnections extends Table { // Profile.virtualPlexHome from PlexHomeService's live cache, never // persisted in `profiles`), so an FK here would reject every join row // they need. Profile deletion instead cleans up join rows explicitly - // (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup) + // (ProfileConnectionCleanup.removeAllProfileConnections) // before calling ProfileRegistry.remove. TextColumn get profileId => text()(); TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)(); diff --git a/lib/focus/dpad_reorder_mixin.dart b/lib/focus/dpad_reorder_mixin.dart new file mode 100644 index 00000000..38e07333 --- /dev/null +++ b/lib/focus/dpad_reorder_mixin.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../widgets/overlay_sheet.dart'; +import 'dpad_navigator.dart'; +import 'key_event_utils.dart'; + +/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows +/// inside a sheet or dialog. +/// +/// The host keeps its list and row widgets; this mixin owns the virtual cursor +/// ([focusedIndex] / [focusedColumn]), the move-mode state and the key handler. +/// Wire it up by passing [handleReorderKeyEvent] to the list's +/// `Focus.onKeyEvent` and by reading [focusedIndex], [focusedColumn] and +/// [movingIndex] when building rows. +/// +/// Navigation mode: UP/DOWN move between rows (resetting to column 0), +/// LEFT/RIGHT move between the row (column 0) and the trailing action columns +/// up to [lastReorderColumn], SELECT on column 0 enters move mode and on any +/// other column calls [onReorderColumnActivated]. +/// +/// Move mode: UP/DOWN swap the moving row with its neighbour, SELECT confirms +/// through [onReorderMoveConfirmed], and BACK restores the order captured when +/// move mode was entered. BACK outside move mode dismisses the hosting sheet. +/// D-pad keys are consumed at the list boundaries so focus cannot escape. +mixin DpadReorderListMixin on State { + /// Row height assumed by [ensureFocusedVisible] (Material `ListTile` with a + /// subtitle) and the list's top padding. + static const double _itemHeight = 72.0; + static const double _listTopPadding = 8.0; + + /// Row the virtual cursor sits on. + int focusedIndex = 0; + + /// Column within [focusedIndex]: 0 is the row itself, 1..[lastReorderColumn] + /// are the trailing action buttons. + int focusedColumn = 0; + + /// Row being moved, or null when not in move mode. + int? movingIndex; + + int? _originalIndex; + List? _originalOrder; + bool _backKeyDownSeen = false; + + /// The list being reordered. Mutated in place while moving and replaced + /// wholesale when a move is cancelled. + List get reorderItems; + set reorderItems(List value); + + /// Right-most focusable column index (0 when the row has no action buttons). + int get lastReorderColumn; + + /// Scrollable holding the rows, or null when the host does not scroll the + /// focused row into view. + ScrollController? get reorderScrollController; + + /// Called when SELECT confirms a move; [reorderItems] already holds the new + /// order. + void onReorderMoveConfirmed(); + + /// Called when SELECT activates a trailing action column (1 or greater). + void onReorderColumnActivated(int column, int index); + + /// Scrolls [focusedIndex] into view, parking it ~25% from the viewport top. + void ensureFocusedVisible() { + final scrollController = reorderScrollController; + if (scrollController == null || !scrollController.hasClients) return; + + final double targetTop = _listTopPadding + (focusedIndex * _itemHeight); + final double targetBottom = targetTop + _itemHeight; + + final double viewportTop = scrollController.offset; + final double viewportHeight = scrollController.position.viewportDimension; + final double viewportBottom = viewportTop + viewportHeight; + + // Already fully visible — skip + if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; + + final double destination = (targetTop - viewportHeight * 0.25).clamp( + 0.0, + scrollController.position.maxScrollExtent, + ); + + scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } + + KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + + // Track back key down/up pairing. If focus was elsewhere during KeyDown + // (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray + // KeyUp that would incorrectly pop the dialog. Consume it instead. + if (key.isBackKey) { + if (event is KeyDownEvent) { + _backKeyDownSeen = true; + } else if (event is KeyUpEvent && !_backKeyDownSeen) { + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + _backKeyDownSeen = false; + } + } + + final backResult = handleBackKeyAction(event, () { + if (movingIndex != null) { + // Cancel move - restore original position + setState(() { + final originalOrder = _originalOrder; + if (originalOrder != null) { + reorderItems = List.from(originalOrder); + } + focusedIndex = _originalIndex ?? 0; + movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + } else { + OverlaySheetController.popAdaptive(context); + } + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (!event.isActionable) return KeyEventResult.ignored; + + final int? moving = movingIndex; + if (moving != null) { + // Move mode - arrows reorder the item + if (key.isUpKey && moving > 0) { + _swapMovingItem(moving, moving - 1); + return KeyEventResult.handled; + } + if (key.isDownKey && moving < reorderItems.length - 1) { + _swapMovingItem(moving, moving + 1); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + // Confirm move - apply the reorder + onReorderMoveConfirmed(); + setState(() { + movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return KeyEventResult.handled; + } + } else { + // Navigation mode + if (key.isUpKey && focusedIndex > 0) { + setState(() { + focusedIndex--; + focusedColumn = 0; // Reset to row when changing rows + }); + ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && focusedIndex < reorderItems.length - 1) { + setState(() { + focusedIndex++; + focusedColumn = 0; // Reset to row when changing rows + }); + ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isLeftKey && focusedColumn > 0) { + setState(() => focusedColumn--); + return KeyEventResult.handled; + } + if (key.isRightKey && focusedColumn < lastReorderColumn) { + setState(() => focusedColumn++); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (focusedColumn == 0) { + // Enter move mode + setState(() { + movingIndex = focusedIndex; + _originalIndex = focusedIndex; + _originalOrder = List.from(reorderItems); + }); + } else { + onReorderColumnActivated(focusedColumn, focusedIndex); + } + return KeyEventResult.handled; + } + } + + // Block d-pad keys at boundaries so focus doesn't escape the dialog + if (key.isDpadDirection) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _swapMovingItem(int from, int to) { + setState(() { + final item = reorderItems.removeAt(from); + reorderItems.insert(to, item); + movingIndex = to; + focusedIndex = to; + }); + ensureFocusedVisible(); + } +} diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index 9757acec..3067b44d 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -32,22 +32,18 @@ class ChipKeyCallbacks { /// This mixin handles: /// - Internal/external FocusNode pattern /// - `_isFocused` state tracking -/// - Listener setup in `initState` -/// - Listener handoff in `didUpdateWidget` -/// - Cleanup in `dispose` +/// - Listener setup, handoff and cleanup across the State lifecycle /// /// To use this mixin: /// 1. Add `with FocusableChipStateMixin` to your State class /// 2. Implement [widgetFocusNode] to return the widget's optional focusNode /// 3. Implement [debugLabel] to return a debug label for the internal node -/// 4. Call [initFocusNode] in your `initState` -/// 5. Call [updateFocusNode] in your `didUpdateWidget` -/// 6. Call [disposeFocusNode] in your `dispose` -/// 7. Use [focusNode] and [isFocused] in your build method +/// 4. Use [focusNode] and [isFocused] in your build method mixin FocusableChipStateMixin on State { final _focusNodeBinding = OwnedFocusNodeBinding(); bool _isFocused = false; final _selectLongPress = DpadSelectLongPressController(); + FocusNode? _boundExternalNode; /// Override to return the widget's optional external focus node. FocusNode? get widgetFocusNode; @@ -61,22 +57,30 @@ mixin FocusableChipStateMixin on State { /// Whether this widget is currently focused. bool get isFocused => _isFocused; - /// Call this in your `initState` to set up the focus listener. - void initFocusNode() { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); + @override + void initState() { + super.initState(); + _bindFocusNode(); } - /// Call this in your `didUpdateWidget` with the old widget's focusNode. - void updateFocusNode(FocusNode? oldFocusNode) { - if (oldFocusNode != widgetFocusNode) { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (_boundExternalNode != widgetFocusNode) { + _bindFocusNode(); } } - /// Call this in your `dispose` to clean up the focus listener. - void disposeFocusNode() { + @override + void dispose() { _focusNodeBinding.dispose(); _selectLongPress.dispose(); + super.dispose(); + } + + void _bindFocusNode() { + _boundExternalNode = widgetFocusNode; + _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); } void _onFocusChange() { diff --git a/lib/main.dart b/lib/main.dart index 1237a6ed..73c2c501 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'profiles/profile.dart'; import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; +import 'profiles/profile_selection_policy.dart'; import 'mixins/mounted_set_state_mixin.dart'; import 'theme/mono_theme.dart'; import 'profiles/plex_home_service.dart'; @@ -1069,8 +1070,7 @@ class _MainAppState extends State with WidgetsBindingObserver { pinPrompt: _rootPinPrompt, shouldDeferInitialBind: (_) async { final settings = await SettingsService.getInstance(); - return settings.read(SettingsService.requireProfileSelectionOnOpen) && - activeProfile.hasMultipleProfiles; + return activeProfile.requiresSelectionOnOpen(settings); }, ); }, @@ -1211,7 +1211,7 @@ class _AppShell extends StatelessWidget { themeMode: themeProvider.materialThemeMode, navigatorKey: rootNavigatorKey, navigatorObservers: [BackKeySuppressorObserver()], - home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome), + home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome), // Siri Remote select + gamepad A report as // LogicalKeyboardKey.{select,gameButtonA} which aren't // in Flutter's default shortcut set — Material-level @@ -1298,32 +1298,6 @@ bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) { return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired; } -class OrientationAwareSetup extends StatefulWidget { - const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome}); - - final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; - - @override - State createState() => _OrientationAwareSetupState(); -} - -class _OrientationAwareSetupState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _setOrientationPreferences(); - } - - void _setOrientationPreferences() { - OrientationHelper.restoreDefaultOrientations(context); - } - - @override - Widget build(BuildContext context) { - return SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome); - } -} - class SetupScreen extends StatefulWidget { const SetupScreen({ super.key, @@ -1354,6 +1328,15 @@ class _SetupScreenState extends State with MountedSetStateMixin { _loadSavedCredentials(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // The app's first screen: undo any orientation lock a previous run's + // full-screen player left behind, and re-apply it whenever the form + // factor signals (Theme.platform / MediaQuery size) change. + OrientationHelper.restoreDefaultOrientations(context); + } + void _setStatus(String message) { setStateIfMounted(() => _statusMessage = message); } @@ -1416,12 +1399,12 @@ class _SetupScreenState extends State with MountedSetStateMixin { profileRegistry: profileRegistry, ); await bootstrap.run(); - final pruned = await pruneUnreferencedJellyfinConnections( + final pruned = await ProfileConnectionCleanup( profileConnections: profileConnections, connections: connRegistry, storage: storage, serverManager: serverManager, - ); + ).pruneUnreferencedJellyfinConnections(); if (pruned > 0) { appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}'); } @@ -1559,9 +1542,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { final settings = await SettingsService.getInstance(); if (!mounted) return; final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; - final requireOnOpen = - settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; - final shouldPrompt = hasNoActive || requireOnOpen; + final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings); var bindingSucceeded = activeProfile.lastBindingSucceeded; if (shouldPrompt) { diff --git a/lib/media/library_query.dart b/lib/media/library_query.dart index 73be12bb..51f2dc14 100644 --- a/lib/media/library_query.dart +++ b/lib/media/library_query.dart @@ -1,6 +1,7 @@ // ignore_for_file: invalid_annotation_target import 'package:freezed_annotation/freezed_annotation.dart'; +import '../utils/media_server_http_client.dart' show AbortController; import 'media_kind.dart'; part 'library_query.freezed.dart'; @@ -84,3 +85,33 @@ int fallbackPageTotal({required int offset, required int itemCount, int? request final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize; return offset + itemCount + (fullPage ? 1 : 0); } + +/// Walk every page of a paginated endpoint and concatenate the results. +/// +/// [fetchPage] receives a zero-based offset and [pageSize] and is called until +/// a page comes back empty, the accumulated count reaches the page's +/// [LibraryPage.totalCount], or — when [stopOnShortPage] is set — a page comes +/// back shorter than [pageSize]. The short-page break is for backends whose +/// total is unreliable; leave it off when the total is authoritative. +/// +/// [abort] is checked before and after every request. Errors propagate. +Future> drainPages( + Future> Function(int start, int size) fetchPage, { + required int pageSize, + AbortController? abort, + bool stopOnShortPage = false, +}) async { + final all = []; + var start = 0; + while (true) { + abort?.throwIfAborted(); + final page = await fetchPage(start, pageSize); + abort?.throwIfAborted(); + if (page.items.isEmpty) break; + all.addAll(page.items); + start += page.items.length; + if (start >= page.totalCount) break; + if (stopOnShortPage && page.items.length < pageSize) break; + } + return all; +} diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 485de4ef..993251a4 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -212,55 +212,35 @@ class ServerCapabilities { audioTranscoding: true, ); - ServerCapabilities copyWith({ - bool? serverSidePlayQueue, - bool? serverSidePlaylists, - bool? liveTv, - bool? liveTvDvr, - bool? subtitleSearch, - bool? videoTranscoding, - bool? serverSideSync, - bool? richHubs, - bool? numericUserRating, - bool? userFavorites, - bool? continueWatchingRemoval, - bool? externalSubtitleSearch, - bool? trackPreferencePersistence, - bool? endpointFailover, - bool? offlineWatchQueue, - bool? discordRpc, - bool? richMetadataEdit, - AlphaBarMode? alphaBar, - bool? scrubThumbnails, - bool? folderGrouping, - bool? lyrics, - bool? instantMix, - bool? audioTranscoding, - }) { + /// Every flag here is fixed per backend *kind* except [videoTranscoding], + /// which Plex probes per server (`PlexClient.capabilities`) — so that is the + /// only override this type needs. Widen the parameter list if another flag + /// ever becomes a runtime probe. + ServerCapabilities copyWith({bool? videoTranscoding}) { return ServerCapabilities( - serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue, - serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists, - liveTv: liveTv ?? this.liveTv, - liveTvDvr: liveTvDvr ?? this.liveTvDvr, - subtitleSearch: subtitleSearch ?? this.subtitleSearch, + serverSidePlayQueue: serverSidePlayQueue, + serverSidePlaylists: serverSidePlaylists, + liveTv: liveTv, + liveTvDvr: liveTvDvr, + subtitleSearch: subtitleSearch, videoTranscoding: videoTranscoding ?? this.videoTranscoding, - serverSideSync: serverSideSync ?? this.serverSideSync, - richHubs: richHubs ?? this.richHubs, - numericUserRating: numericUserRating ?? this.numericUserRating, - userFavorites: userFavorites ?? this.userFavorites, - continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval, - externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch, - trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence, - endpointFailover: endpointFailover ?? this.endpointFailover, - offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue, - discordRpc: discordRpc ?? this.discordRpc, - richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit, - alphaBar: alphaBar ?? this.alphaBar, - scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails, - folderGrouping: folderGrouping ?? this.folderGrouping, - lyrics: lyrics ?? this.lyrics, - instantMix: instantMix ?? this.instantMix, - audioTranscoding: audioTranscoding ?? this.audioTranscoding, + serverSideSync: serverSideSync, + richHubs: richHubs, + numericUserRating: numericUserRating, + userFavorites: userFavorites, + continueWatchingRemoval: continueWatchingRemoval, + externalSubtitleSearch: externalSubtitleSearch, + trackPreferencePersistence: trackPreferencePersistence, + endpointFailover: endpointFailover, + offlineWatchQueue: offlineWatchQueue, + discordRpc: discordRpc, + richMetadataEdit: richMetadataEdit, + alphaBar: alphaBar, + scrubThumbnails: scrubThumbnails, + folderGrouping: folderGrouping, + lyrics: lyrics, + instantMix: instantMix, + audioTranscoding: audioTranscoding, ); } } diff --git a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart index 55e40b0f..ea40cbad 100644 --- a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart +++ b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart @@ -5,7 +5,6 @@ import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../services/jellyfin_client.dart'; import '../utils/jellyfin_time.dart'; -import '../utils/media_image_helper.dart'; import 'metadata_edit_models.dart'; class JellyfinMetadataEditAdapter extends MetadataEditAdapter { @@ -39,7 +38,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { List buildSchema(MetadataEditDraft draft) { final kind = draft.sourceItem.kind; return [ - MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), + MetadataEditSection( + id: 'basic', + title: t.metadataEdit.basicInfo, + fields: metadataBasicFields(kind, studioType: MetadataEditFieldType.stringList), + ), if (_tagFields(kind).isNotEmpty) MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), @@ -53,11 +56,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { final dto = Map.from(raw); dto['ProviderIds'] = _stringMap(dto['ProviderIds']); - dto['Tags'] = _stringList(dto['Tags']); - dto['Genres'] = _stringList(dto['Genres']); + dto['Tags'] = metadataStringList(dto['Tags']); + dto['Genres'] = metadataStringList(dto['Genres']); dto['People'] = _mapList(dto['People']); dto['Studios'] = _mapList(dto['Studios']); - dto['LockedFields'] = _stringList(dto['LockedFields']); + dto['LockedFields'] = metadataStringList(dto['LockedFields']); dto['LockData'] = dto['LockData'] == true; dto.remove('Trickplay'); @@ -71,29 +74,29 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { final value = draft.value('originallyAvailableAt') ?? ''; dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']); } - if (_fieldChanged(draft, 'studio')) { + if (_listFieldChanged(draft, 'studio')) { dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio'])); } if (draft.fieldChanged('tagline')) { final tagline = metadataEmptyToNull(draft.value('tagline')); - final existing = _stringList(dto['Taglines']); + final existing = metadataStringList(dto['Taglines']); dto['Taglines'] = tagline == null ? [] : [tagline, ...existing.skip(1)]; } - if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']); - if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']); - if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']); + if (_listFieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']); + if (_listFieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']); + if (_listFieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']); var peopleChanged = false; var people = _mapList(dto['People']); - if (_fieldChanged(draft, 'director')) { + if (_listFieldChanged(draft, 'director')) { people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director'])); peopleChanged = true; } - if (_fieldChanged(draft, 'writer')) { + if (_listFieldChanged(draft, 'writer')) { people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer'])); peopleChanged = true; } - if (_fieldChanged(draft, 'producer')) { + if (_listFieldChanged(draft, 'producer')) { people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer'])); peopleChanged = true; } @@ -132,11 +135,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { .toList(); } - @override - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { - return applyArtworkFromUrl(draft, field, option.sourceUrl); - } - @override Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { final imageType = field.artwork?.key; @@ -180,12 +178,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { ? metadataFirstString(raw['Taglines']) : item.tagline ?? ''; values['summary'] = raw['Overview'] as String? ?? item.summary ?? ''; - values['genre'] = _stringList(raw['Genres']); + values['genre'] = metadataStringList(raw['Genres']); values['director'] = _peopleByType(raw['People'], 'Director'); values['writer'] = _peopleByType(raw['People'], 'Writer'); values['producer'] = _peopleByType(raw['People'], 'Producer'); - values['country'] = _stringList(raw['ProductionLocations']); - values['label'] = _stringList(raw['Tags']); + values['country'] = metadataStringList(raw['ProductionLocations']); + values['label'] = metadataStringList(raw['Tags']); } void _writeArtworkValues(Map values, MediaItem item) { @@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { values['artwork:Logo'] = item.clearLogoPath; } - List _basicFields(MediaKind kind) { - return [ - MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField( - id: 'originallyAvailableAt', - label: t.metadataEdit.releaseDate, - type: MetadataEditFieldType.date, - ), - if (kind != MediaKind.season) - MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.stringList), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), - MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), - ]; - } - List _tagFields(MediaKind kind) { MetadataEditField tag(String id, String label) => MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); @@ -234,100 +209,20 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { }; } - List _artworkFields(MediaKind kind) { - final fields = [ - // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. - kind == MediaKind.episode - ? _artworkField( - 'Primary', - t.metadataEdit.poster, - t.metadataEdit.selectPoster, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.thumb, - ) - : _artworkField('Primary', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), - ]; - if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { - fields.add( - _artworkField( - 'Backdrop', - t.metadataEdit.background, - t.metadataEdit.selectBackground, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.art, - ), - ); - } - if (kind == MediaKind.movie || kind == MediaKind.show) { - fields.add( - _artworkField( - 'Logo', - t.metadataEdit.logo, - t.metadataEdit.selectLogo, - 80, - 32, - 2, - 2.5, - fit: MetadataArtworkFit.contain, - imageType: ImageType.logo, - ), - ); - } - return fields; - } - - MetadataEditField _artworkField( - String key, - String label, - String title, - double width, - double height, - int columns, - double aspectRatio, { - MetadataArtworkFit fit = MetadataArtworkFit.cover, - ImageType imageType = ImageType.poster, - }) { - return MetadataEditField( - id: 'artwork:$key', - label: label, - type: MetadataEditFieldType.artwork, - saveMode: MetadataEditSaveMode.immediate, - artwork: MetadataArtworkConfig( - key: key, - selectTitle: title, - previewWidth: width, - previewHeight: height, - gridColumns: columns, - gridAspectRatio: aspectRatio, - fit: fit, - imageType: imageType, - ), - ); - } + List _artworkFields(MediaKind kind) => + metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo'); void _setChangedString(Map dto, MetadataEditDraft draft, String fieldId, String dtoKey) { if (!draft.fieldChanged(fieldId)) return; dto[dtoKey] = metadataEmptyToNull(draft.value(fieldId)); } - bool _fieldChanged(MetadataEditDraft draft, String fieldId) { - for (final section in schemaFor(draft)) { - for (final field in section.fields) { - if (field.id == fieldId) return metadataEditFieldChanged(draft, field); - } - } - return draft.fieldChanged(fieldId); - } + /// Every id passed here names a `stringList` field, so the comparison is + /// order-insensitive regardless of which kind's schema is in play. + bool _listFieldChanged(MetadataEditDraft draft, String fieldId) => + !metadataEditStringListEquals(draft.values[fieldId], draft.originalValues[fieldId]); } -List _stringList(Object? value) => metadataStringList(value); - Map _stringMap(Object? value) { if (value is! Map) return {}; return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')); diff --git a/lib/metadata_edit/metadata_edit_models.dart b/lib/metadata_edit/metadata_edit_models.dart index bc817887..9b647bc1 100644 --- a/lib/metadata_edit/metadata_edit_models.dart +++ b/lib/metadata_edit/metadata_edit_models.dart @@ -1,3 +1,4 @@ +import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -147,7 +148,9 @@ abstract class MetadataEditAdapter { Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option); + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { + return applyArtworkFromUrl(draft, field, option.sourceUrl); + } Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url); @@ -160,6 +163,133 @@ abstract class MetadataEditAdapter { } } +/// Basic-info fields shared by every backend; only the studio field type differs. +List metadataBasicFields( + MediaKind kind, { + MetadataEditFieldType studioType = MetadataEditFieldType.text, +}) { + return [ + MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField( + id: 'originallyAvailableAt', + label: t.metadataEdit.releaseDate, + type: MetadataEditFieldType.date, + ), + if (kind != MediaKind.season) + MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: studioType), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), + MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), + ]; +} + +/// Artwork fields shared by every backend; each backend supplies its own artwork +/// key names, which kinds carry a logo, and whether square art exists. +List metadataArtworkFields( + MediaKind kind, { + required String posterKey, + required String backdropKey, + required String logoKey, + String? squareKey, + Set logoKinds = const {MediaKind.movie, MediaKind.show}, +}) { + final fields = [ + // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. + kind == MediaKind.episode + ? metadataArtworkField( + posterKey, + t.metadataEdit.poster, + t.metadataEdit.selectPoster, + 80, + 45, + 2, + 16 / 9, + imageType: ImageType.thumb, + ) + : metadataArtworkField(posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), + ]; + if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { + fields.add( + metadataArtworkField( + backdropKey, + t.metadataEdit.background, + t.metadataEdit.selectBackground, + 80, + 45, + 2, + 16 / 9, + imageType: ImageType.art, + ), + ); + } + if (logoKinds.contains(kind)) { + fields.add( + metadataArtworkField( + logoKey, + t.metadataEdit.logo, + t.metadataEdit.selectLogo, + 80, + 32, + 2, + 2.5, + fit: MetadataArtworkFit.contain, + imageType: ImageType.logo, + ), + ); + if (squareKey != null) { + fields.add( + metadataArtworkField( + squareKey, + t.metadataEdit.squareArt, + t.metadataEdit.selectSquareArt, + 50, + 50, + 3, + 1, + imageType: ImageType.avatar, + ), + ); + } + } + return fields; +} + +MetadataEditField metadataArtworkField( + String key, + String label, + String title, + double width, + double height, + int columns, + double aspectRatio, { + MetadataArtworkFit fit = MetadataArtworkFit.cover, + ImageType imageType = ImageType.poster, +}) { + return MetadataEditField( + id: 'artwork:$key', + label: label, + type: MetadataEditFieldType.artwork, + saveMode: MetadataEditSaveMode.immediate, + artwork: MetadataArtworkConfig( + key: key, + selectTitle: title, + previewWidth: width, + previewHeight: height, + gridColumns: columns, + gridAspectRatio: aspectRatio, + fit: fit, + imageType: imageType, + ), + ); +} + bool metadataEditValueEquals(Object? a, Object? b) { if (identical(a, b)) return true; if (a is List && b is List) { diff --git a/lib/metadata_edit/plex_metadata_edit_adapter.dart b/lib/metadata_edit/plex_metadata_edit_adapter.dart index 5557f33a..47ca56cf 100644 --- a/lib/metadata_edit/plex_metadata_edit_adapter.dart +++ b/lib/metadata_edit/plex_metadata_edit_adapter.dart @@ -7,7 +7,6 @@ import '../media/media_server_client.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/language_codes.dart'; -import '../utils/media_image_helper.dart'; import 'metadata_edit_models.dart'; class PlexMetadataEditAdapter extends MetadataEditAdapter { @@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { List buildSchema(MetadataEditDraft draft) { final kind = draft.sourceItem.kind; return [ - MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), + MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: metadataBasicFields(kind)), if (_tagFields(kind).isNotEmpty) MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), @@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { .toList(); } - @override - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { - return applyArtworkFromUrl(draft, field, option.sourceUrl); - } - @override Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { final element = field.artwork?.key; @@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { } } - List _basicFields(MediaKind kind) { - return [ - MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField( - id: 'originallyAvailableAt', - label: t.metadataEdit.releaseDate, - type: MetadataEditFieldType.date, - ), - if (kind != MediaKind.season) - MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), - MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), - ]; - } - List _tagFields(MediaKind kind) { MetadataEditField tag(String id, String label) => MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); @@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { }; } - List _artworkFields(MediaKind kind) { - final fields = [ - // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. - kind == MediaKind.episode - ? _artworkField( - 'posters', - t.metadataEdit.poster, - t.metadataEdit.selectPoster, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.thumb, - ) - : _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), - ]; - if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { - fields.add( - _artworkField( - 'arts', - t.metadataEdit.background, - t.metadataEdit.selectBackground, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.art, - ), - ); - } - if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.collection) { - fields.add( - _artworkField( - 'clearLogos', - t.metadataEdit.logo, - t.metadataEdit.selectLogo, - 80, - 32, - 2, - 2.5, - fit: MetadataArtworkFit.contain, - imageType: ImageType.logo, - ), - ); - fields.add( - _artworkField( - 'squareArts', - t.metadataEdit.squareArt, - t.metadataEdit.selectSquareArt, - 50, - 50, - 3, - 1, - imageType: ImageType.avatar, - ), - ); - } - return fields; - } - - MetadataEditField _artworkField( - String key, - String label, - String title, - double width, - double height, - int columns, - double aspectRatio, { - MetadataArtworkFit fit = MetadataArtworkFit.cover, - ImageType imageType = ImageType.poster, - }) { - return MetadataEditField( - id: 'artwork:$key', - label: label, - type: MetadataEditFieldType.artwork, - saveMode: MetadataEditSaveMode.immediate, - artwork: MetadataArtworkConfig( - key: key, - selectTitle: title, - previewWidth: width, - previewHeight: height, - gridColumns: columns, - gridAspectRatio: aspectRatio, - fit: fit, - imageType: imageType, - ), - ); - } + List _artworkFields(MediaKind kind) => metadataArtworkFields( + kind, + posterKey: 'posters', + backdropKey: 'arts', + logoKey: 'clearLogos', + squareKey: 'squareArts', + logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection}, + ); List _advancedFields(MediaKind kind) { final fields = []; diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart index 81f820cf..cb15c6b7 100644 --- a/lib/mixins/deletion_aware.dart +++ b/lib/mixins/deletion_aware.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../utils/deletion_notifier.dart'; import 'event_aware.dart'; +import 'watch_state_aware.dart'; /// Mixin for screens that need to react to deletion events. /// @@ -74,3 +75,21 @@ mixin DeletionAware on State { super.dispose(); } } + +/// Points [DeletionAware]'s filters at the [WatchStateAware] ones. +/// +/// The usual case: a screen shows the same rows for both event families, so a +/// deleted show and a watched show affect exactly the same items. Mix this in +/// after both aware mixins instead of re-typing the three getters. A screen +/// that genuinely needs a different scope overrides the getter it cares about +/// (or skips this mixin entirely). +mixin DeletionMirrorsWatchState on WatchStateAware, DeletionAware { + @override + String? get deletionServerId => watchStateServerId; + + @override + Set? get deletionGlobalKeys => watchedGlobalKeys; + + @override + Set? get deletionIds => watchedIds; +} diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart index f13f4272..1d729df6 100644 --- a/lib/mixins/paginated_item_loader.dart +++ b/lib/mixins/paginated_item_loader.dart @@ -104,43 +104,6 @@ mixin PaginatedItemLoader on State { return (page: result, applied: true); } - /// Shared initial-load transaction for paginated consumers. - /// - /// Owns reset, stale-result rejection, mounted checks, and error-state - /// application. Callers supply only their view fields, logging, and - /// post-success behavior. - Future loadInitialPaginatedItems({ - required int pageSize, - required VoidCallback resetViewState, - required void Function(List items) applyLoadedItems, - required void Function(Object error, StackTrace stackTrace) applyError, - void Function(int loadedCount, int totalCount)? onLoaded, - void Function(Object error, StackTrace stackTrace)? onError, - }) async { - setState(() { - resetViewState(); - resetPaginationState(); - }); - - try { - final initialPage = await loadInitialPageWithStatus(pageSize); - if (!initialPage.applied || !mounted) return false; - - setState(() { - applyLoadedItems(loadedItems.values.toList()); - }); - onLoaded?.call(loadedItems.length, totalSize); - return true; - } catch (error, stackTrace) { - onError?.call(error, stackTrace); - if (!mounted) return false; - setState(() { - applyError(error, stackTrace); - }); - return false; - } - } - /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount) /// with [buffer] extra indices on each side. Serialized — only one /// range-fetch runs at a time — and re-checks after each success so a diff --git a/lib/mixins/standard_paginated_view.dart b/lib/mixins/standard_paginated_view.dart new file mode 100644 index 00000000..cbae10a4 --- /dev/null +++ b/lib/mixins/standard_paginated_view.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; + +import '../media/media_item.dart'; +import 'item_updatable.dart'; +import 'paginated_item_loader.dart'; + +/// Standard view-state wiring for screens whose body is a single paginated +/// list. +/// +/// [PaginatedItemLoader] owns the sparse `loadedItems` map; the hosts +/// (`BaseMediaListDetailScreen`, `BaseLibraryTabState`) additionally expose +/// `items` / `isLoading` / `errorMessage` to drive the loading, empty and +/// error chrome. This mixin owns the transitions between the two, so a +/// screen's `loadItems` supplies only the page size, the error text, and an +/// optional post-load hook. +mixin StandardPaginatedView on PaginatedItemLoader { + set items(List value); + set isLoading(bool value); + set errorMessage(String? value); + + /// Initial-load transaction: clears the view state, fetches the first page, + /// then publishes either the loaded items or [errorMessageFor]'s text. + /// + /// Stale results — a newer load started, or the screen was disposed — are + /// dropped without touching state. [errorMessageFor] runs even when + /// unmounted, so screens can log from it; [onLoaded] runs only after a + /// successful publish. + Future loadStandardPaginatedItems({ + required int pageSize, + required String Function(Object error, StackTrace stackTrace) errorMessageFor, + void Function(int loadedCount, int totalCount)? onLoaded, + }) async { + setState(() { + isLoading = true; + errorMessage = null; + items = []; + resetPaginationState(); + }); + + try { + final initialPage = await loadInitialPageWithStatus(pageSize); + if (!initialPage.applied || !mounted) return; + + setState(() { + items = loadedItems.values.toList(); + isLoading = false; + }); + onLoaded?.call(loadedItems.length, totalSize); + } catch (error, stackTrace) { + final message = errorMessageFor(error, stackTrace); + if (!mounted) return; + setState(() { + errorMessage = message; + isLoading = false; + }); + } + } +} + +/// [ItemUpdatable.updateItemInLists] for screens whose visible list is the +/// sparse `loadedItems` map rather than a flat `items` list — searching the +/// map is what keeps an item refreshed at a scrolled-in position, past the +/// first page. +mixin PaginatedItemUpdatable on PaginatedItemLoader, ItemUpdatable { + @override + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { + for (final entry in loadedItems.entries) { + if (entry.value.globalKey == sourceGlobalKey) { + loadedItems[entry.key] = updatedItem; + return; + } + } + } +} diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 3ce2cb36..80cdaa73 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart'; import '../i18n/strings.g.dart'; import '../utils/json_utils.dart'; -import 'mixins/multi_server_fields.dart'; part 'livetv_channel.g.dart'; @@ -49,7 +48,7 @@ List filterLiveTvChannelsForFavorites({ } @JsonSerializable(createToJson: false) -class LiveTvChannel with MultiServerFields { +class LiveTvChannel { @JsonKey(readValue: _readChannelKey) final String key; @JsonKey(readValue: _readChannelIdentifier) @@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields { @JsonKey(fromJson: flexibleBool) final bool? drm; - @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverId; - @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverName; @JsonKey(includeFromJson: false, includeToJson: false) diff --git a/lib/models/media_provider_info.dart b/lib/models/media_provider_info.dart deleted file mode 100644 index 5e68e4db..00000000 --- a/lib/models/media_provider_info.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; - -part 'media_provider_info.g.dart'; - -List _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson); - -List> _parseRawMaps(Object? raw) => flexibleMapList(raw); - -@JsonSerializable(createToJson: false) -class MediaProviderInfo { - @JsonKey(fromJson: flexibleInt) - final int? id; - @JsonKey(fromJson: flexibleInt) - final int? parentID; - @JsonKey(defaultValue: '') - final String identifier; - final String? providerIdentifier; - final String? title; - final String? types; - final String? protocols; - final String? epgSource; - final String? friendlyName; - @JsonKey(name: 'Feature', fromJson: _parseFeatures) - final List features; - - const MediaProviderInfo({ - this.id, - this.parentID, - required this.identifier, - this.providerIdentifier, - this.title, - this.types, - this.protocols, - this.epgSource, - this.friendlyName, - this.features = const [], - }); - - factory MediaProviderInfo.fromJson(Map json) => _$MediaProviderInfoFromJson(json); -} - -@JsonSerializable(createToJson: false) -class MediaProviderFeature { - final String? key; - @JsonKey(defaultValue: '') - final String type; - final String? flavor; - final String? scrobbleKey; - final String? unscrobbleKey; - @JsonKey(name: 'Directory', fromJson: _parseRawMaps) - final List> directories; - @JsonKey(name: 'Action', fromJson: _parseRawMaps) - final List> actions; - @JsonKey(name: 'Pivot', fromJson: _parseRawMaps) - final List> pivots; - - const MediaProviderFeature({ - this.key, - required this.type, - this.flavor, - this.scrobbleKey, - this.unscrobbleKey, - this.directories = const [], - this.actions = const [], - this.pivots = const [], - }); - - factory MediaProviderFeature.fromJson(Map json) => _$MediaProviderFeatureFromJson(json); -} diff --git a/lib/models/media_provider_info.g.dart b/lib/models/media_provider_info.g.dart deleted file mode 100644 index de23170f..00000000 --- a/lib/models/media_provider_info.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'media_provider_info.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MediaProviderInfo _$MediaProviderInfoFromJson(Map json) => - MediaProviderInfo( - id: flexibleInt(json['id']), - parentID: flexibleInt(json['parentID']), - identifier: json['identifier'] as String? ?? '', - providerIdentifier: json['providerIdentifier'] as String?, - title: json['title'] as String?, - types: json['types'] as String?, - protocols: json['protocols'] as String?, - epgSource: json['epgSource'] as String?, - friendlyName: json['friendlyName'] as String?, - features: json['Feature'] == null - ? const [] - : _parseFeatures(json['Feature']), - ); - -MediaProviderFeature _$MediaProviderFeatureFromJson( - Map json, -) => MediaProviderFeature( - key: json['key'] as String?, - type: json['type'] as String? ?? '', - flavor: json['flavor'] as String?, - scrobbleKey: json['scrobbleKey'] as String?, - unscrobbleKey: json['unscrobbleKey'] as String?, - directories: json['Directory'] == null - ? const [] - : _parseRawMaps(json['Directory']), - actions: json['Action'] == null ? const [] : _parseRawMaps(json['Action']), - pivots: json['Pivot'] == null ? const [] : _parseRawMaps(json['Pivot']), -); diff --git a/lib/models/mixins/multi_server_fields.dart b/lib/models/mixins/multi_server_fields.dart deleted file mode 100644 index 1ba3e4a8..00000000 --- a/lib/models/mixins/multi_server_fields.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -/// Mixin that provides multi-server support fields for models. -/// -/// This mixin adds serverId and serverName fields that are excluded from -/// JSON serialization but can be used to track which server an item belongs to. -mixin MultiServerFields { - /// Server machine identifier (not from API) - @JsonKey(includeFromJson: false, includeToJson: false) - String? get serverId; - - /// Server display name (not from API) - @JsonKey(includeFromJson: false, includeToJson: false) - String? get serverName; -} diff --git a/lib/models/shader_preset.dart b/lib/models/shader_preset.dart index e55f9e88..14b01a1b 100644 --- a/lib/models/shader_preset.dart +++ b/lib/models/shader_preset.dart @@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom } /// ArtCNN real-time model sizes. enum ArtCNNModel { /// Lightweight real-time model - c4f16, + c4f16('C4F16'), /// Higher-quality real-time model - c4f32, + c4f32('C4F32'); + + const ArtCNNModel(this.label); + + /// Display label for the model. + final String label; } /// ArtCNN luma doubler variants. enum ArtCNNVariant { /// Neutral luma doubler - neutral, + neutral('Neutral', 'neutral'), /// Denoise and soften - denoise, + denoise('Denoise', 'dn'), /// Denoise and sharpen - denoiseSharpen, + denoiseSharpen('Denoise + Sharpen', 'ds'); + + const ArtCNNVariant(this.label, this.slug); + + /// Display label for the variant. + final String label; + + /// Stable slug used in built-in preset ids and shader asset keys. + final String slug; } /// Quality tiers for Anime4K presets @@ -39,22 +52,27 @@ enum Anime4KQuality { /// Anime4K modes that define shader combinations enum Anime4KMode { /// Mode A: Clamp + Restore - modeA, + modeA('A'), /// Mode B: Clamp + Restore + Upscale + Downscale - modeB, + modeB('B'), /// Mode C: Clamp + Upscale + Downscale - modeC, + modeC('C'), /// Mode A+A: Clamp + Restore + Restore - modeAA, + modeAA('A+A'), /// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale - modeBB, + modeBB('B+B'), /// Mode C+A: Clamp + Upscale + Restore + Downscale - modeCA, + modeCA('C+A'); + + const Anime4KMode(this.label); + + /// Display label for the mode. + final String label; } @freezed @@ -120,93 +138,28 @@ class ShaderPreset { ); /// Create an ArtCNN preset with the specified model and variant - static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) { - final modelName = _getArtCNNModelName(model); - final variantName = _getArtCNNVariantName(variant); - final variantId = _getArtCNNVariantId(variant); - - return ShaderPreset( - id: 'artcnn_${model.name}_$variantId', - name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName', - type: ShaderPresetType.artcnn, - artcnnConfig: ArtCNNConfig(model: model, variant: variant), - ); - } + static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset( + id: 'artcnn_${model.name}_${variant.slug}', + name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}', + type: ShaderPresetType.artcnn, + artcnnConfig: ArtCNNConfig(model: model, variant: variant), + ); /// Create an Anime4K preset with the specified quality and mode static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) { final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ'; - final modeName = _getModeName(mode); return ShaderPreset( id: 'anime4k_${quality.name}_${mode.name}', - name: 'Anime4K $qualityName $modeName', + name: 'Anime4K $qualityName ${mode.label}', type: ShaderPresetType.anime4k, anime4kConfig: Anime4KConfig(quality: quality, mode: mode), ); } - static String _getModeName(Anime4KMode mode) { - switch (mode) { - case Anime4KMode.modeA: - return 'A'; - case Anime4KMode.modeB: - return 'B'; - case Anime4KMode.modeC: - return 'C'; - case Anime4KMode.modeAA: - return 'A+A'; - case Anime4KMode.modeBB: - return 'B+B'; - case Anime4KMode.modeCA: - return 'C+A'; - } - } + String get modeDisplayName => anime4kConfig?.mode.label ?? ''; - static String _getArtCNNModelName(ArtCNNModel model) { - switch (model) { - case ArtCNNModel.c4f16: - return 'C4F16'; - case ArtCNNModel.c4f32: - return 'C4F32'; - } - } - - static String _getArtCNNVariantName(ArtCNNVariant variant) { - switch (variant) { - case ArtCNNVariant.neutral: - return 'Neutral'; - case ArtCNNVariant.denoise: - return 'Denoise'; - case ArtCNNVariant.denoiseSharpen: - return 'Denoise + Sharpen'; - } - } - - static String _getArtCNNVariantId(ArtCNNVariant variant) { - switch (variant) { - case ArtCNNVariant.neutral: - return 'neutral'; - case ArtCNNVariant.denoise: - return 'dn'; - case ArtCNNVariant.denoiseSharpen: - return 'ds'; - } - } - - String get modeDisplayName { - if (anime4kConfig != null) { - return _getModeName(anime4kConfig!.mode); - } - return ''; - } - - String get artcnnModelDisplayName { - if (artcnnConfig != null) { - return _getArtCNNModelName(artcnnConfig!.model); - } - return ''; - } + String get artcnnModelDisplayName => artcnnConfig?.model.label ?? ''; static final List _builtInPresets = List.unmodifiable([ none, diff --git a/lib/models/trakt/trakt_scrobble_request.dart b/lib/models/trakt/trakt_scrobble_request.dart index d15b8d8a..9be26a2d 100644 --- a/lib/models/trakt/trakt_scrobble_request.dart +++ b/lib/models/trakt/trakt_scrobble_request.dart @@ -41,11 +41,12 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest { }, }; - /// Build a `POST /sync/history` body that adds this item to history. + /// Build a `POST /sync/history[/remove]` body for this item. Both endpoints + /// take the same shape; only the removal path ignores [watchedAt]. /// /// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play /// to a specific point in time; defaults to "now" on Trakt's side. - Map toHistoryAddBody({String? watchedAt}) => switch (this) { + Map toHistoryBody({String? watchedAt}) => switch (this) { TraktScrobbleMovieRequest(:final ids) => { 'movies': [ {'watched_at': ?watchedAt, 'ids': ids.toJson()}, @@ -67,28 +68,4 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest { ], }, }; - - /// Build a `POST /sync/history/remove` body that removes this item from history. - Map toHistoryRemoveBody() => switch (this) { - TraktScrobbleMovieRequest(:final ids) => { - 'movies': [ - {'ids': ids.toJson()}, - ], - }, - TraktScrobbleEpisodeRequest(:final showIds, :final season, :final number) => { - 'shows': [ - { - 'ids': showIds.toJson(), - 'seasons': [ - { - 'number': season, - 'episodes': [ - {'number': number}, - ], - }, - ], - }, - ], - }, - }; } diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index 0dc70c42..75a0c619 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -26,7 +26,7 @@ class PlexHomeService { this._storage, Future> Function(String accountToken)? plexHomeUserFetcher, this._refreshInterval = const Duration(hours: 1), - }) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher; + }) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers; final ConnectionRegistry _connections; final ProfileConnectionRegistry _profileConnections; @@ -498,13 +498,3 @@ class PlexHomeService { _started = false; } } - -Future> _defaultHomeUserFetcher(String accountToken) async { - final auth = await PlexAuthService.create(); - try { - final home = await auth.getHomeUsers(accountToken); - return home.users; - } finally { - auth.dispose(); - } -} diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart index 416cdee5..c444c9bb 100644 --- a/lib/profiles/profile_activation.dart +++ b/lib/profiles/profile_activation.dart @@ -234,6 +234,8 @@ Future _preVerifyPlexHomePin(BuildContext context, Profile final connections = context.read(); final pcRegistry = context.read(); final binder = context.read(); + // Built before the await: capturing the prompt needs a live context. + final promptForPin = dialogPinPrompt(context, profile.displayName); final all = await connections.list(); PlexAccountConnection? account; for (final c in all) { @@ -248,10 +250,7 @@ Future _preVerifyPlexHomePin(BuildContext context, Profile account: account, homeUserUuid: homeUuid, requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!context.mounted) return null; - return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); - }, + promptForPin: promptForPin, persistTo: pcRegistry, persistProfileId: profile.id, logLabel: profile.displayName, diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart index b754b598..8328baa9 100644 --- a/lib/profiles/profile_connection_cleanup.dart +++ b/lib/profiles/profile_connection_cleanup.dart @@ -9,65 +9,6 @@ import 'profile_connection_registry.dart'; import 'profile_merge.dart'; import 'profile_registry.dart'; -Future removeProfileConnectionAndCleanup({ - required String profileId, - required Connection connection, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final removedServerIds = _serverIdsForConnection(connection); - await profileConnections.remove(profileId, connection.id); - await _clearProfileServerPrefsNoLongerReferenced( - profileId: profileId, - removedServerIds: removedServerIds, - profileConnections: profileConnections, - connections: connections, - storage: storage, - clearEverywhereWhenUnreferenced: connection is JellyfinConnection, - ); - - if (connection is JellyfinConnection) { - await _removeUnreferencedJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } -} - -Future removeAllProfileConnectionsAndCleanup({ - required String profileId, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final rows = await profileConnections.listForProfile(profileId); - if (rows.isEmpty) return; - - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - for (final row in rows) { - final connection = byId[row.connectionId]; - if (connection == null) { - await profileConnections.remove(profileId, row.connectionId); - continue; - } - await removeProfileConnectionAndCleanup( - profileId: profileId, - connection: connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } -} - /// Profile ids affected by a Plex account removal. Planning is read-only so /// callers can finish failure-prone cleanup before committing join/account /// deletion. @@ -96,228 +37,201 @@ Future planPlexAccountConnectionRemoval({ return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); } -/// Sign out of a Plex account: remove the account [Connection], every join -/// row referencing it, and everything owned by its virtual Plex Home -/// profiles — including borrowed Jellyfin connections left unreferenced, -/// which previously survived as orphans and wedged the session (#1423). -/// -/// Pass a read-only [plannedRemoval] from -/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup -/// must finish before this destructive commit. Omitting it preserves the -/// atomic add/cancel-account cleanup path. -/// -/// All cleanup is explicit and completes before this returns; correctness -/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which -/// runs later and no-ops. -Future removePlexAccountConnectionAndCleanup({ - required PlexAccountConnection account, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, - PlexAccountRemoval? plannedRemoval, -}) async { - final removal = - plannedRemoval ?? - await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); - final removedVirtualProfileIds = removal.removedVirtualProfileIds; - final borrowerProfileIds = removal.borrowerProfileIds; - final rows = await profileConnections.listAll(); - // Remove direct join rows first so per-profile pref cleanup observes each - // row going away; the FK cascade from the connection delete is then a no-op. - for (final row in rows.where((r) => r.connectionId == account.id)) { - await removeProfileConnectionAndCleanup( - profileId: row.profileId, - connection: account, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } - await connections.remove(account.id); - await storage.clearPlexHomeUsersCache(account.id); - - // The account's virtual profiles die with the connection; their borrowed - // connections and per-profile prefs must go too. - for (final profileId in removedVirtualProfileIds) { - await removeAllProfileConnectionsAndCleanup( - profileId: profileId, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - await storage.clearProfileLastUsed(profileId); - await storage.clearUserScopedPreferencesForProfile(profileId); - } - - return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); -} - /// Where the session should land after a profile or connection removal. enum PostRemovalRoute { signedOut, staySignedIn } -/// In-session mirror of the boot guard (`main.dart`: "stored connections -/// exist but no profiles resolved — returning to auth"): prune orphaned -/// Jellyfin connections, then decide whether any selectable profile remains. -/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed -/// accounts are harmless because the connection map is re-read here. -Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ - required ProfileRegistry profileRegistry, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required Map> plexHomeUsers, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - final conns = await connections.list(); - if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); +/// Removal of profile↔connection join rows and everything they leave +/// unreferenced, bound to one set of registries. Every flow resolves the same +/// instances from the provider tree, so callers construct this once and call +/// through it. +class ProfileConnectionCleanup { + ProfileConnectionCleanup({ + required this.profileConnections, + required this.connections, + required this.storage, + this.serverManager, + }); - final merged = mergeLocalWithPlexHome( - locals: await profileRegistry.list(), - plexHomeByConnectionId: plexHomeUsers, - connectionsById: {for (final c in conns) c.id: c}, - storage: storage, - ); - if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); - return (route: PostRemovalRoute.staySignedIn, profiles: merged); -} + final ProfileConnectionRegistry profileConnections; + final ConnectionRegistry connections; + final StorageService storage; + final MultiServerManager? serverManager; -Future pruneUnreferencedJellyfinConnections({ - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final all = await connections.list(); - final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); - var removed = 0; + Future removeProfileConnection({required String profileId, required Connection connection}) async { + final removedServerIds = _serverIdsForConnection(connection); + await profileConnections.remove(profileId, connection.id); + await _clearProfileServerPrefsNoLongerReferenced( + profileId: profileId, + removedServerIds: removedServerIds, + clearEverywhereWhenUnreferenced: connection is JellyfinConnection, + ); - for (final connection in all.whereType()) { - if (referencedConnectionIds.contains(connection.id)) continue; - await _removeJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, + if (connection is JellyfinConnection) { + await _removeUnreferencedJellyfinConnection(connection); + } + } + + Future removeAllProfileConnections(String profileId) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection == null) { + await profileConnections.remove(profileId, row.connectionId); + continue; + } + await removeProfileConnection(profileId: profileId, connection: connection); + } + } + + /// Sign out of a Plex account: remove the account [Connection], every join + /// row referencing it, and everything owned by its virtual Plex Home + /// profiles — including borrowed Jellyfin connections left unreferenced, + /// which previously survived as orphans and wedged the session (#1423). + /// + /// Pass a read-only [plannedRemoval] from + /// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup + /// must finish before this destructive commit. Omitting it preserves the + /// atomic add/cancel-account cleanup path. + /// + /// All cleanup is explicit and completes before this returns; correctness + /// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which + /// runs later and no-ops. + Future removePlexAccountConnection( + PlexAccountConnection account, { + PlexAccountRemoval? plannedRemoval, + }) async { + final removal = + plannedRemoval ?? + await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); + final removedVirtualProfileIds = removal.removedVirtualProfileIds; + final borrowerProfileIds = removal.borrowerProfileIds; + final rows = await profileConnections.listAll(); + // Remove direct join rows first so per-profile pref cleanup observes each + // row going away; the FK cascade from the connection delete is then a no-op. + for (final row in rows.where((r) => r.connectionId == account.id)) { + await removeProfileConnection(profileId: row.profileId, connection: account); + } + await connections.remove(account.id); + await storage.clearPlexHomeUsersCache(account.id); + + // The account's virtual profiles die with the connection; their borrowed + // connections and per-profile prefs must go too. + for (final profileId in removedVirtualProfileIds) { + await removeAllProfileConnections(profileId); + await storage.clearProfileLastUsed(profileId); + await storage.clearUserScopedPreferencesForProfile(profileId); + } + + return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); + } + + /// In-session mirror of the boot guard (`main.dart`: "stored connections + /// exist but no profiles resolved — returning to auth"): prune orphaned + /// Jellyfin connections, then decide whether any selectable profile remains. + /// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed + /// accounts are harmless because the connection map is re-read here. + Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ + required ProfileRegistry profileRegistry, + required Map> plexHomeUsers, + }) async { + await pruneUnreferencedJellyfinConnections(); + final conns = await connections.list(); + if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); + + final merged = mergeLocalWithPlexHome( + locals: await profileRegistry.list(), + plexHomeByConnectionId: plexHomeUsers, + connectionsById: {for (final c in conns) c.id: c}, storage: storage, - serverManager: serverManager, ); - removed++; + if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); + return (route: PostRemovalRoute.staySignedIn, profiles: merged); } - return removed; -} + Future pruneUnreferencedJellyfinConnections() async { + final all = await connections.list(); + final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); + var removed = 0; -Future _removeUnreferencedJellyfinConnection( - JellyfinConnection connection, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; - await _removeJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); -} + for (final connection in all.whereType()) { + if (referencedConnectionIds.contains(connection.id)) continue; + await _removeJellyfinConnection(connection); + removed++; + } -Future _removeJellyfinConnection( - JellyfinConnection connection, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - await connections.remove(connection.id); - serverManager?.removeJellyfinConnection(connection); - final serverId = ServerId.tryParse(connection.serverMachineId); - if (serverId != null && - !await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) { - await storage.clearLibraryPreferencesForServerEverywhere(serverId); + return removed; } -} -Future _clearProfileServerPrefsNoLongerReferenced({ - required String profileId, - required Set removedServerIds, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - required bool clearEverywhereWhenUnreferenced, -}) async { - if (removedServerIds.isEmpty) return; - final remainingProfileServerIds = await _serverIdsForProfile( - profileId, - profileConnections: profileConnections, - connections: connections, - ); - final activeProfileId = storage.getActiveProfileId(); + Future _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async { + if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; + await _removeJellyfinConnection(connection); + } - for (final serverId in removedServerIds) { - if (remainingProfileServerIds.contains(serverId)) continue; - final serverStillReferenced = await _isServerReferenced( - serverId, - profileConnections: profileConnections, - connections: connections, - ); - if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { - await storage.clearLibraryPreferencesForServer( - serverId, - profileId: profileId, - includeLegacy: activeProfileId == profileId, - ); - } else { + Future _removeJellyfinConnection(JellyfinConnection connection) async { + await connections.remove(connection.id); + serverManager?.removeJellyfinConnection(connection); + final serverId = ServerId.tryParse(connection.serverMachineId); + if (serverId != null && !await _isServerReferenced(serverId)) { await storage.clearLibraryPreferencesForServerEverywhere(serverId); } } -} -/// Server ids reachable through this profile's join rows. Narrower than -/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home -/// parent is not counted here, so folding the two together would change which -/// per-profile prefs survive an unlink. -Future> _serverIdsForProfile( - String profileId, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, -}) async { - final rows = await profileConnections.listForProfile(profileId); - if (rows.isEmpty) return const {}; + Future _clearProfileServerPrefsNoLongerReferenced({ + required String profileId, + required Set removedServerIds, + required bool clearEverywhereWhenUnreferenced, + }) async { + if (removedServerIds.isEmpty) return; + final remainingProfileServerIds = await _serverIdsForProfile(profileId); + final activeProfileId = storage.getActiveProfileId(); - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - return { - for (final row in rows) - if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), - }; -} + for (final serverId in removedServerIds) { + if (remainingProfileServerIds.contains(serverId)) continue; + final serverStillReferenced = await _isServerReferenced(serverId); + if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { + await storage.clearLibraryPreferencesForServer( + serverId, + profileId: profileId, + includeLegacy: activeProfileId == profileId, + ); + } else { + await storage.clearLibraryPreferencesForServerEverywhere(serverId); + } + } + } -Future _isServerReferenced( - ServerId serverId, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, -}) async { - final rows = await profileConnections.listAll(); - if (rows.isEmpty) return false; + /// Server ids reachable through this profile's join rows. Narrower than + /// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home + /// parent is not counted here, so folding the two together would change which + /// per-profile prefs survive an unlink. + Future> _serverIdsForProfile(String profileId) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return const {}; - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - for (final row in rows) { - final connection = byId[row.connectionId]; - if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + return { + for (final row in rows) + if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), + }; + } + + Future _isServerReferenced(ServerId serverId) async { + final rows = await profileConnections.listAll(); + if (rows.isEmpty) return false; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + } + return false; } - return false; } // [ServerId]-typed for the preference APIs, which drops ids that fail to diff --git a/lib/profiles/profile_selection_policy.dart b/lib/profiles/profile_selection_policy.dart new file mode 100644 index 00000000..b7126dbc --- /dev/null +++ b/lib/profiles/profile_selection_policy.dart @@ -0,0 +1,14 @@ +import '../services/settings_service.dart'; +import 'active_profile_provider.dart'; + +extension ProfileSelectionPolicy on ActiveProfileProvider { + /// The "ask for a profile every time the app opens" rule: the pref only bites + /// when there is more than one profile to pick from. + /// + /// Stated once because the sites must agree — ActiveProfileBinder defers its + /// cold-start bind exactly when this holds, and SetupScreen/MainScreen pop the + /// picker exactly when it holds. If they drifted, the binder would defer a bind + /// that nothing ever prompts for and the user would land on an unbound screen. + bool requiresSelectionOnOpen(SettingsService settings) => + settings.read(SettingsService.requireProfileSelectionOnOpen) && hasMultipleProfiles; +} diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 652fe112..d7157e3c 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -684,57 +684,32 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}'); - final candidate = _peerServiceFactory(); - _pendingRemotePeer = candidate; - _session = RemoteSession( - role: RemoteSessionRole.remote, - status: RemoteSessionStatus.connecting, - createdAt: DateTime.now(), + String? winner; + final connected = await _runRemoteConnect( + generation: generation, + seedConnectingSession: true, + rethrowOnFailure: true, + join: (peer) async { + winner = await peer.joinSessionRacingWithContexts( + _deviceName, + _platform, + host.addresses, + _authContexts, + authContextId: authContext.id, + expectedHostClientId: host.clientId, + ); + }, + onConnected: (peer) { + _lastHostAddresses = [winner!]; + _lastAuthContextId = peer.selectedAuthContextId ?? authContext.id; + _lastHostClientId = peer.selectedHostClientId ?? host.clientId; + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + }, + failureLog: 'CompanionRemote: Failed to connect to host', + onFailure: _failRemoteConnectSession, ); - _setupPeerServiceListeners(candidate, generation); - safeNotifyListeners(); - - try { - final winner = await candidate.joinSessionRacingWithContexts( - _deviceName, - _platform, - host.addresses, - _authContexts, - authContextId: authContext.id, - expectedHostClientId: host.clientId, - ); - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _peerService = candidate; - _lastHostAddresses = [winner]; - _lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id; - _lastHostClientId = candidate.selectedHostClientId ?? host.clientId; - _session = _session?.copyWith(status: RemoteSessionStatus.connected); - safeNotifyListeners(); + if (connected) { appLogger.d('CompanionRemote: Connected to ${host.name} via $winner'); - } catch (error, stackTrace) { - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _cleanupSubscriptions(); - await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace); - _session = _session?.copyWith( - status: RemoteSessionStatus.error, - errorMessage: _localizedRemoteError( - error, - (details) => t.companionRemote.pairing.failedToConnect(error: details), - ), - ); - safeNotifyListeners(); - rethrow; } } @@ -754,48 +729,84 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin appLogger.d('CompanionRemote: Connecting to manual host $hostAddress'); + await _runRemoteConnect( + generation: generation, + seedConnectingSession: true, + rethrowOnFailure: true, + join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts), + onConnected: (peer) { + _lastAuthContextId = peer.selectedAuthContextId; + _lastHostClientId = peer.selectedHostClientId ?? ''; + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + }, + failureLog: 'CompanionRemote: Failed to connect to manual host', + onFailure: _failRemoteConnectSession, + ); + } + + void _failRemoteConnectSession(Object error) { + _session = _session?.copyWith( + status: RemoteSessionStatus.error, + errorMessage: _localizedRemoteError( + error, + (details) => t.companionRemote.pairing.failedToConnect(error: details), + ), + ); + safeNotifyListeners(); + } + + /// Runs the candidate-peer connect lifecycle shared by the discovered/manual + /// connect paths and by reconnect attempts: create a candidate, wire its + /// listeners, then promote it to [_peerService] or dispose it. The generation + /// guards live here so a candidate that lost ownership while joining is + /// disposed rather than promoted, in exactly one place. Returns true only + /// when the candidate was promoted. + Future _runRemoteConnect({ + required int generation, + required Future Function(CompanionRemotePeerService peer) join, + required void Function(CompanionRemotePeerService peer) onConnected, + required String failureLog, + required void Function(Object error) onFailure, + bool seedConnectingSession = false, + bool rethrowOnFailure = false, + }) async { final candidate = _peerServiceFactory(); _pendingRemotePeer = candidate; - _session = RemoteSession( - role: RemoteSessionRole.remote, - status: RemoteSessionStatus.connecting, - createdAt: DateTime.now(), - ); + if (seedConnectingSession) { + _session = RemoteSession( + role: RemoteSessionRole.remote, + status: RemoteSessionStatus.connecting, + createdAt: DateTime.now(), + ); + } _setupPeerServiceListeners(candidate, generation); - safeNotifyListeners(); + if (seedConnectingSession) safeNotifyListeners(); try { - await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts); + await join(candidate); if (!_ownsPeer(candidate, generation)) { await _disposePeerOnce(candidate); - return; + return false; } _pendingRemotePeer = null; _peerService = candidate; - _lastAuthContextId = candidate.selectedAuthContextId; - _lastHostClientId = candidate.selectedHostClientId ?? ''; - _session = _session?.copyWith(status: RemoteSessionStatus.connected); + onConnected(candidate); safeNotifyListeners(); + return true; } catch (error, stackTrace) { if (!_ownsPeer(candidate, generation)) { await _disposePeerOnce(candidate); - return; + return false; } _pendingRemotePeer = null; _cleanupSubscriptions(); await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace); - _session = _session?.copyWith( - status: RemoteSessionStatus.error, - errorMessage: _localizedRemoteError( - error, - (details) => t.companionRemote.pairing.failedToConnect(error: details), - ), - ); - safeNotifyListeners(); - rethrow; + appLogger.e(failureLog, error: error, stackTrace: stackTrace); + onFailure(error); + if (rethrowOnFailure) rethrow; + return false; } } @@ -981,47 +992,34 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } if (generation != _remoteGeneration || isDisposed) return; - final candidate = _peerServiceFactory(); - _pendingRemotePeer = candidate; - _setupPeerServiceListeners(candidate, generation); final authContextId = _authContextForId(_lastAuthContextId)?.id; final expectedHostClientId = _lastHostClientId ?? ''; - try { - await candidate.joinSessionWithContexts( + final reconnected = await _runRemoteConnect( + generation: generation, + join: (peer) => peer.joinSessionWithContexts( _deviceName, _platform, hostAddresses.first, _authContexts, authContextId: authContextId, expectedHostClientId: expectedHostClientId, - ); - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _peerService = candidate; - _lastAuthContextId = candidate.selectedAuthContextId ?? authContextId; - _lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId; - _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); - _reconnectAttempts = 0; - safeNotifyListeners(); + ), + onConnected: (peer) { + _lastAuthContextId = peer.selectedAuthContextId ?? authContextId; + _lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId; + _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); + _reconnectAttempts = 0; + }, + failureLog: 'CompanionRemote: Reconnect failed', + onFailure: (_) { + if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { + _scheduleReconnect(generation); + } + }, + ); + if (reconnected) { appLogger.d('CompanionRemote: Reconnected successfully'); - } catch (error, stackTrace) { - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _cleanupSubscriptions(); - await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace); - if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { - _scheduleReconnect(generation); - } } } diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index 1905c58d..c9123f66 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart'; import '../utils/app_logger.dart'; import '../utils/coalesced_load_coordinator.dart'; import '../utils/deletion_notifier.dart'; -import '../utils/global_key_utils.dart'; +import '../utils/media_event_keys.dart'; import '../utils/media_hub_ordering.dart'; import '../utils/watch_state_notifier.dart'; import 'hidden_libraries_provider.dart'; @@ -467,28 +467,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Watch on-deck items and their parent shows/seasons (an episode's watch /// flip changes what Continue Watching should show for its series). - Set? get _watchedIds { - final keys = {}; - for (final item in _onDeck) { - keys.add(item.id); - if (item.parentId != null) keys.add(item.parentId!); - if (item.grandparentId != null) keys.add(item.grandparentId!); - } - return keys; - } + Set? get _watchedIds => hierarchicalEventIds(_onDeck); - Set? get _watchedGlobalKeys { - final keys = {}; - for (final item in _onDeck) { - final serverId = item.serverId; - if (serverId == null) return null; - - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - } - return keys; - } + Set? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck); void _onWatchStateChanged(WatchStateEvent event) { if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) { @@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin unawaited(refreshContinueWatching()); } + /// Everything on screen: the Continue Watching row plus every hub row. + Iterable get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items)); + /// Deletions can affect any visible list, so the filter covers on-deck and /// hub items plus their parents (a deleted season/show takes its visible /// episodes with it). - Set? get _deletionIds { - final keys = {}; - void addItem(MediaItem item) { - keys.add(item.id); - if (item.parentId != null) keys.add(item.parentId!); - if (item.grandparentId != null) keys.add(item.grandparentId!); - } + Set? get _deletionIds => hierarchicalEventIds(_visibleItems); - _onDeck.forEach(addItem); - for (final hub in _hubs) { - hub.items.forEach(addItem); - } - return keys; - } - - Set? get _deletionGlobalKeys { - final keys = {}; - bool addItem(MediaItem item) { - final serverId = item.serverId; - if (serverId == null) return false; - - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - return true; - } - - for (final item in _onDeck) { - if (!addItem(item)) return null; - } - for (final hub in _hubs) { - for (final item in hub.items) { - if (!addItem(item)) return null; - } - } - return keys; - } + Set? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems); void _onDeletion(DeletionEvent event) { // On-deck and hubs are server-backed: a download-only deletion leaves the diff --git a/lib/providers/download_metadata_store.dart b/lib/providers/download_metadata_store.dart index 3cef5056..406b84f6 100644 --- a/lib/providers/download_metadata_store.dart +++ b/lib/providers/download_metadata_store.dart @@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier { hydrated.add( HydratedWatchStatePatch( globalKey: scopedKey, - patch: WatchStatePatch.fromSnapshot(snapshot), + patch: snapshot, updatedAt: latest.updatedAt, order: latest.id, ), diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index bf50e991..41a3ab23 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; import '../media/ids.dart'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; @@ -17,6 +16,7 @@ import '../services/download_manager_service.dart'; import '../services/api_cache.dart'; import '../services/download_artwork_service.dart'; import '../services/download_storage_service.dart'; +import '../services/downloaded_video_source.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; import '../services/watch_state_resolver.dart'; @@ -25,7 +25,6 @@ import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; import '../utils/deletion_notifier.dart'; -import '../utils/downloaded_version_match.dart'; import '../media/episode_collection.dart'; import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; @@ -925,46 +924,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('No downloaded item found for globalKey: $globalKey'); return null; } - if (downloadedItem.status != DownloadStatus.completed.index) { - appLogger.w('Download not complete. Status: ${downloadedItem.status}'); - return null; - } - if (!downloadedVersionMatches( + + final source = await resolveDownloadedVideoSource( downloadedItem, requestedMediaIndex: mediaIndex, requestedMediaSourceId: mediaSourceId, - )) { - appLogger.w( - 'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} ' - '(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex ' - '(source ${mediaSourceId?.trim()})', - ); - return null; - } - if (downloadedItem.videoFilePath == null) { - appLogger.w('Video file path is null for globalKey: $globalKey'); - return null; - } - - final storedPath = downloadedItem.videoFilePath!; - final storageService = DownloadStorageService.instance; - - // SAF URIs (content://) are already valid - don't transform them - if (storageService.isSafUri(storedPath)) { - appLogger.d('Found SAF video path: $storedPath'); - return storedPath; - } - - // Convert stored path (may be relative) to absolute path - final absolutePath = await storageService.ensureAbsolutePath(storedPath); - - // Verify file exists - final file = File(absolutePath); - if (!await file.exists()) { - appLogger.w('Offline video file not found: $absolutePath'); - return null; - } - return absolutePath; + ); + return source?.path; } /// Queue a download for a media item. diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 3e09fe52..644d3d0a 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// filter to a one-element set when no filter is currently set. void addToVisibleServerIds(ServerId serverId) { final current = _visibleServerIds; - if (current == null) { - _serverManager.setVisibleServerIds({serverId}); - _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; - safeNotifyListeners(); - _refreshLiveTvAvailabilitySoon(); - return; - } - if (current.contains(serverId)) return; - _serverManager.setVisibleServerIds({...current, serverId}); + if (current != null && current.contains(serverId)) return; + _serverManager.setVisibleServerIds({...?current, serverId}); _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; safeNotifyListeners(); _refreshLiveTvAvailabilitySoon(); } + /// Keep only ids the manager considers visible under the active filter. + List _visible(List ids) => + ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList(); + void _pruneLiveTvServersForVisibility() { - final filter = _visibleServerIds; - if (filter == null) return; - _liveTvServers.removeWhere((s) => !filter.contains(s.serverId)); + if (_visibleServerIds == null) return; + _liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId))); _hasLiveTv = _liveTvServers.isNotEmpty; } @@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Get all online server IDs (visibility-filtered). - List get onlineServerIds { - final all = _serverManager.onlineServerIds; - final filter = _visibleServerIds; - if (filter == null) return all; - return all.where(filter.contains).toList(); - } + List get onlineServerIds => _visible(_serverManager.onlineServerIds); /// Get all server IDs (visibility-filtered). - List get serverIds { - final all = _serverManager.serverIds; - final filter = _visibleServerIds; - if (filter == null) return all; - return all.where(filter.contains).toList(); - } + List get serverIds => _visible(_serverManager.serverIds); /// Server ids the active profile is expected to have, including unreachable /// Plex servers that have no live client yet. @@ -223,11 +209,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Check if a server is online (and visible under the active profile). - bool isServerOnline(ServerId serverId) { - final filter = _visibleServerIds; - if (filter != null && !filter.contains(serverId)) return false; - return _serverManager.isServerOnline(serverId); - } + bool isServerOnline(ServerId serverId) => + _serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId); /// Get number of online servers int get onlineServerCount => onlineServerIds.length; @@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } } - final filter = _visibleServerIds; - final visibleLiveTvServers = filter == null - ? newLiveTvServers - : newLiveTvServers.where((s) => filter.contains(s.serverId)).toList(); + final visibleLiveTvServers = newLiveTvServers + .where((s) => _serverManager.isServerVisible(ServerId(s.serverId))) + .toList(); final hadLiveTv = _hasLiveTv; final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index 385ce3e6..a02c1949 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart'; import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; -@immutable -class WatchStatePatch { - final bool? isWatched; - final bool hasViewOffsetMs; - final int? viewOffsetMs; - - const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); - - factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch( - isWatched: snapshot.isWatched, - hasViewOffsetMs: snapshot.hasViewOffsetMs, - viewOffsetMs: snapshot.viewOffsetMs, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is WatchStatePatch && - other.isWatched == isWatched && - other.hasViewOffsetMs == hasViewOffsetMs && - other.viewOffsetMs == viewOffsetMs; - - @override - int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); -} - @immutable class HydratedWatchStatePatch { final String globalKey; - final WatchStatePatch patch; + final WatchStateSnapshot patch; final int updatedAt; final int order; @@ -53,7 +27,7 @@ class HydratedWatchStatePatch { } class _WatchStatePatchEntry { - final WatchStatePatch patch; + final WatchStateSnapshot patch; final int updatedAt; final int sequence; final bool isSessionEvent; @@ -123,9 +97,9 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return _exactEntryFor(globalKey); } - WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; + WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; - WatchStatePatch? patchForItem(MediaItem item) { + WatchStateSnapshot? patchForItem(MediaItem item) { var best = _entryFor(item.globalKey); if (item.parentChain.isNotEmpty) { final serverId = serverIdOrNull(item.serverId); @@ -147,14 +121,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return [for (final item in items) apply(item)]; } - static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) { - if (patch == null) return item; - return WatchStateSnapshot( - isWatched: patch.isWatched, - hasViewOffsetMs: patch.hasViewOffsetMs, - viewOffsetMs: patch.viewOffsetMs, - ).apply(item); - } + static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item); void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; @@ -216,7 +183,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin ? buildGlobalKey(ServerId(resolvedScope), event.itemId) : event.globalKey; _patches[key] = _WatchStatePatchEntry( - WatchStatePatch.fromSnapshot(snapshot), + snapshot, updatedAt: DateTime.now().millisecondsSinceEpoch, sequence: ++_sequence, isSessionEvent: true, @@ -240,7 +207,7 @@ extension WatchStateResolution on BuildContext { /// ancestor). Use in `build`. MediaItem withFreshWatchState(MediaItem item) { try { - final patch = select((store) => store.patchForItem(item)); + final patch = select((store) => store.patchForItem(item)); return WatchStateStore.applyPatch(item, patch); } on ProviderNotFoundException { return item; diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index e63b9608..9157a8a0 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -7,6 +7,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../mixins/paginated_item_loader.dart'; +import '../mixins/standard_paginated_view.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart'; import '../utils/provider_extensions.dart'; @@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen with GridFocusNodeMixin, FocusableDetailScreenMixin, - PaginatedItemLoader { + PaginatedItemLoader, + PaginatedItemUpdatable, + StandardPaginatedView { static const int _pageSize = 200; @override @@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen } @override - void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { - for (final entry in loadedItems.entries) { - if (entry.value.globalKey == sourceGlobalKey) { - loadedItems[entry.key] = updatedItem; - return; - } - } - } - - @override - Future loadItems() async { - await loadInitialPaginatedItems( + Future loadItems() { + return loadStandardPaginatedItems( pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, _) { - errorMessage = t.messages.errorLoading(error: error.toString()); - isLoading = false; + errorMessageFor: (error, stackTrace) { + appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace); + return t.messages.errorLoading(error: error.toString()); }, onLoaded: (loadedCount, totalCount) { appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}'); autoFocusFirstItemAfterLoad(); }, - onError: (error, stackTrace) { - appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace); - }, ); } diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index bc15eeb2..06d09a52 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; import '../profiles/profile.dart'; import '../profiles/profile_connection_registry.dart'; +import '../profiles/profile_selection_policy.dart'; import '../services/plex_auth_service.dart'; import '../services/settings_service.dart'; import '../services/storage_service.dart'; @@ -196,8 +197,7 @@ class _AuthScreenState extends State { activeProfile: activeProfiles.active, hasProfiles: activeProfiles.profiles.isNotEmpty, accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true, - requireProfileSelectionOnOpen: - settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles, + requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings), ); if (promptHandled) { final selected = await Navigator.of( diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index edae8c09..e3a91392 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../media/media_item.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; +import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../services/media_list_playback_launcher.dart'; @@ -41,19 +42,34 @@ abstract class BaseMediaListDetailScreen extends State /// Optional icon to show when list is empty IconData? get emptyIcon => null; + /// Server the displayed item was tagged with, if any. + String? get _mediaItemServerId => switch (mediaItem) { + MediaItem(:final serverId) => serverId, + MediaPlaylist(:final serverId) => serverId, + _ => null, + }; + + /// Sync-rule global key for the displayed collection/playlist, keyed to the + /// item's own server when it has one and to the resolved client's otherwise. + String get syncRuleKey { + final client = mediaClient; + final id = switch (mediaItem) { + MediaItem(:final id) => id, + MediaPlaylist(:final id) => id, + _ => '', + }; + return context.read().syncRuleKeyForClient( + client, + id, + serverId: ServerId(_mediaItemServerId ?? client.serverId), + ); + } + String? _resolveMediaItemServerId() { - final item = mediaItem; - String? serverId; - if (item is MediaItem) { - serverId = item.serverId; - } else if (item is MediaPlaylist) { - serverId = item.serverId; - } - if (serverId == null) { - final multiServerProvider = Provider.of(context, listen: false); - serverId = multiServerProvider.onlineServerIds.firstOrNull; - } - return serverId; + final serverId = _mediaItemServerId; + if (serverId != null) return serverId; + final multiServerProvider = Provider.of(context, listen: false); + return multiServerProvider.onlineServerIds.firstOrNull; } MediaServerClient _getMediaClientForMediaItem() { diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index e79b48f3..e8be7c13 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; -import '../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../media/library_query.dart'; import '../media/media_item.dart'; import '../mixins/paginated_item_loader.dart'; +import '../mixins/standard_paginated_view.dart'; import '../providers/download_provider.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/error_message_utils.dart'; import '../utils/download_utils.dart'; -import '../utils/platform_detector.dart'; import '../utils/media_server_http_client.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; @@ -35,7 +34,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen, FocusableDetailScreenMixin, - PaginatedItemLoader { + PaginatedItemLoader, + PaginatedItemUpdatable, + StandardPaginatedView { static const int _pageSize = 200; @override @@ -75,49 +76,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future loadItems() { + return loadStandardPaginatedItems( pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, stackTrace) { - errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: t.collections.collection); - isLoading = false; - }, + errorMessageFor: (error, stackTrace) => + localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection), onLoaded: (loadedCount, totalCount) { appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}'); autoFocusFirstItemAfterLoad(); }, - onError: (error, stackTrace) { - loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection); - }, ); } @override List getAppBarActions() { - final ruleKey = _collectionSyncRuleKey(); + final ruleKey = syncRuleKey; // Select the specific bool we care about so unrelated DownloadProvider // ticks (e.g. active download progress) don't rebuild the app bar. final hasRule = context.select((p) => p.hasSyncRule(ruleKey)); @@ -127,19 +100,16 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _manageCollectionSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _collectionSyncRuleKey()); - - Future _removeCollectionSyncRule() => removeSyncRuleAndSnack( - context, - downloadProvider: context.read(), - globalKey: _collectionSyncRuleKey(), - displayTitle: widget.collection.displayTitle, - ); - - String _collectionSyncRuleKey() { - final serverId = widget.collection.serverId ?? mediaClient.serverId; - return context.read().syncRuleKeyForClient( - mediaClient, - widget.collection.id, - serverId: ServerId(serverId), - ); - } - Future _deleteCollection() async { final confirmed = await showDeleteConfirmation( context, diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 39f5aaae..43c6414f 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -51,6 +51,7 @@ import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/formatters.dart'; +import '../utils/hub_icons.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; @@ -180,17 +181,7 @@ class _DiscoverScreenState extends State if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!; final hubs = []; if (_onDeck.isNotEmpty) { - hubs.add( - MediaHub( - id: 'continue_watching', - title: t.discover.continueWatching, - type: 'mixed', - identifier: '_continue_watching_', - size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), - more: _hasMoreContinueWatching, - items: _onDeck, - ), - ); + hubs.add(_continueWatchingHub); } hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty)); _tvBrowseHubsCache = hubs; @@ -198,6 +189,18 @@ class _DiscoverScreenState extends State return hubs; } + /// The synthesized Continue Watching row, rendered ahead of the backend hubs + /// on both the mobile list and the TV rail. + MediaHub get _continueWatchingHub => MediaHub( + id: 'continue_watching', + title: t.discover.continueWatching, + type: 'mixed', + identifier: '_continue_watching_', + size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), + more: _hasMoreContinueWatching, + items: _onDeck, + ); + void _setSpotlightItem(MediaItem item) => _spotlight.select(item); void _scrollToTop() { @@ -615,101 +618,6 @@ class _DiscoverScreenState extends State unawaited(_discover.load()); } - /// Get icon for hub based on its title - IconData _getHubIcon(String title) { - final lowerTitle = title.toLowerCase(); - - // Trending/Popular content - if (lowerTitle.contains('trending')) { - return Symbols.trending_up_rounded; - } - if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) { - return Symbols.whatshot_rounded; - } - - // Seasonal/Time-based - if (lowerTitle.contains('seasonal')) { - return Symbols.calendar_month_rounded; - } - if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) { - return Symbols.new_releases_rounded; - } - if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) { - return Symbols.schedule_rounded; - } - - // Top/Rated content - if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) { - return Symbols.star_rounded; - } - if (lowerTitle.contains('top ')) { - return Symbols.military_tech_rounded; - } - - // Genre-specific - if (lowerTitle.contains('thriller')) { - return Symbols.warning_amber_rounded; - } - if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) { - return Symbols.mood_rounded; - } - if (lowerTitle.contains('action')) { - return Symbols.flash_on_rounded; - } - if (lowerTitle.contains('drama')) { - return Symbols.theater_comedy_rounded; - } - if (lowerTitle.contains('fantasy')) { - return Symbols.auto_fix_high_rounded; - } - if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) { - return Symbols.rocket_launch_rounded; - } - if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) { - return Symbols.nights_stay_rounded; - } - if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) { - return Symbols.favorite_border_rounded; - } - if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) { - return Symbols.explore_rounded; - } - - // Watchlist/Playlists - if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) { - return Symbols.playlist_play_rounded; - } - if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) { - return Symbols.visibility_off_rounded; - } - if (lowerTitle.contains('watched') || lowerTitle.contains('played')) { - return Symbols.visibility_rounded; - } - - // Network/Studio - if (lowerTitle.contains('network') || lowerTitle.contains('more from')) { - return Symbols.tv_rounded; - } - - // Actor/Director - if (lowerTitle.contains('actor') || lowerTitle.contains('director')) { - return Symbols.person_rounded; - } - - // Year-based (80s, 90s, etc.) - if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) { - return Symbols.history_rounded; - } - - // Rediscover/Start Watching - if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) { - return Symbols.play_arrow_rounded; - } - - // Default icon for other hubs - return Symbols.auto_awesome_rounded; - } - /// Whether the loaded hubs span more than one connected server. bool _hubsSpanMultipleServers() { final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet(); @@ -1011,6 +919,7 @@ class _DiscoverScreenState extends State final bottomPadding = MediaQuery.paddingOf(context).bottom; final theme = Theme.of(context); + final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub; return Material( color: theme.scaffoldBackgroundColor, child: Stack( @@ -1034,21 +943,13 @@ class _DiscoverScreenState extends State if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load), if (!_isLoading && _errorMessage == null) ...[ // On Deck / Continue Watching - if (_onDeck.isNotEmpty) + if (continueWatchingHub != null) SliverToBoxAdapter( child: HubSection( key: _continueWatchingHubKey, - hub: MediaHub( - id: 'continue_watching', - title: t.discover.continueWatching, - type: 'mixed', - identifier: '_continue_watching_', - size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), - more: _hasMoreContinueWatching, - items: _onDeck, - ), + hub: continueWatchingHub, focusMemory: _hubFocusMemory, - icon: Symbols.play_circle_rounded, + icon: hubIconFor(continueWatchingHub), onRefresh: _discover.updateItem, onRemoveFromContinueWatching: _discover.refreshContinueWatching, isInContinueWatching: true, @@ -1066,7 +967,7 @@ class _DiscoverScreenState extends State key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null, hub: _hubs[i], focusMemory: _hubFocusMemory, - icon: _getHubIcon(_hubs[i].title), + icon: hubIconFor(_hubs[i]), showServerName: showServerNameOnHubs || hubsSpanMultipleServers, onRefresh: _discover.updateItem, // Hub index is i + 1 if continue watching exists, otherwise i @@ -1152,7 +1053,7 @@ class _DiscoverScreenState extends State hubs: browseHubs, focusMemory: _hubFocusMemory, showServerName: showServerName, - iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), + iconForHub: (hub, _) => hubIconFor(hub), onFocusedItemChanged: _setSpotlightItem, onRefresh: _discover.updateItem, onRemoveFromContinueWatching: _discover.refreshContinueWatching, diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 02c7bc9c..ff784071 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; -import '../media/media_playlist.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../services/settings_service.dart'; import '../utils/platform_detector.dart'; @@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/skeleton_media_card.dart'; -/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a -/// Flutter widget Key. -String _idForItem(Object item) { - if (item is MediaItem) return item.id; - if (item is MediaPlaylist) return item.id; - return identityHashCode(item).toString(); -} - /// Mixin that provides common focus navigation functionality for detail screens. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// @@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// Used by collection, smart playlist, and music artist detail screens. /// [shape] overrides the grid cell silhouette (e.g. [CardShape.square] /// for album grids); null keeps the stock poster geometry. + /// + /// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an + /// item, so the skeleton branch is unreachable. Widget buildFocusableGrid({ - required List items, + required List items, required void Function(MediaItem source) onRefresh, String? collectionId, VoidCallback? onListRefresh, CardShape? shape, }) { - return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], - builder: (context) { - final svc = SettingsService.instance; - final viewMode = svc.read(SettingsService.viewMode); - final libraryDensity = svc.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); - final useFullCardLayout = fullCardLayout && shape != CardShape.square; - - return MediaCardSliverLayout( - viewMode: viewMode, - itemCount: items.length, - density: libraryDensity, - padding: const EdgeInsets.all(8), - fullBleedImage: useFullCardLayout, - shape: shape, - itemBuilder: (context, position) { - final index = position.index; - final item = items[index]; - final focusNode = _focusNodeForIndex(index); - - return FocusableMediaCard( - key: Key(_idForItem(item)), - item: item, - focusNode: focusNode, - semanticValue: _semanticPosition(position), - disableScale: position.disableScale, - onRefresh: onRefresh, - collectionId: collectionId, - onListRefresh: onListRefresh, - fullBleedImage: useFullCardLayout && position.isGrid, - cardShapeOverride: shape, - onNavigateUp: position.isFirstRow ? navigateToAppBar : null, - onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), - ); - }, - ); - }, + return buildSparseFocusableGrid( + totalItems: items.length, + itemAt: (index) => items[index], + onRefresh: onRefresh, + collectionId: collectionId, + onListRefresh: onListRefresh, + shape: shape, ); } - /// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems] + /// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems] /// slots; for each, [itemAt] returns the loaded item or null if not yet /// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so /// the caller can kick off a page fetch containing that index. @@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu onSkeletonVisible?.call(index); return const SkeletonMediaCard(); } - final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item'); + final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( key: Key(item.id), item: item, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 2334ca08..1aaf4242 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/overlay_sheet.dart'; import '../focus/focusable_action_bar.dart'; -import '../focus/focusable_button.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/paginated_item_loader.dart'; @@ -493,34 +492,6 @@ class _HubDetailScreenState extends State Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error; bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading; - Widget _buildContinuationStatusSliver() { - final exception = _pageLoadError; - final error = exception == null ? null : t.messages.errorLoading(error: exception.toString()); - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: error == null - ? const CircularProgressIndicator() - : Column( - mainAxisSize: .min, - children: [ - Text(error, textAlign: TextAlign.center), - const SizedBox(height: 8), - FocusableButton( - focusNode: _continuationRetryFocusNode, - onPressed: _retryHubContinuation, - onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), - onBack: handleBackFromContent, - child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)), - ), - ], - ), - ), - ), - ); - } - @override void refresh() { _loadMoreItems(); @@ -633,7 +604,13 @@ class _HubDetailScreenState extends State }, ), if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null)) - _buildContinuationStatusSliver(), + ContinuationStatusSliver( + error: _pageLoadError, + onRetry: _retryHubContinuation, + retryFocusNode: _continuationRetryFocusNode, + onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), + onBack: handleBackFromContent, + ), ], ), ), diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index d0c7f2d1..b279e896 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import 'state_messages.dart'; @@ -101,6 +102,55 @@ class SliverEmptyState extends StatelessWidget { ); } +/// Footer sliver for continuation (append-to-list) pagination: a spinner while +/// the next page loads, or the error message with a focusable retry button. +class ContinuationStatusSliver extends StatelessWidget { + /// Failure from the last page load; null while the page is still loading. + final Object? error; + final VoidCallback onRetry; + final FocusNode retryFocusNode; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; + + const ContinuationStatusSliver({ + super.key, + required this.error, + required this.onRetry, + required this.retryFocusNode, + this.onNavigateUp, + this.onBack, + }); + + @override + Widget build(BuildContext context) { + final exception = error; + final message = exception == null ? null : t.messages.errorLoading(error: exception.toString()); + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: message == null + ? const CircularProgressIndicator() + : Column( + mainAxisSize: .min, + children: [ + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 8), + FocusableButton( + focusNode: retryFocusNode, + onPressed: onRetry, + onNavigateUp: onNavigateUp, + onBack: onBack, + child: TextButton(onPressed: onRetry, child: Text(t.common.retry)), + ), + ], + ), + ), + ), + ); + } +} + /// A widget that handles loading, error, empty, and content states /// Provides a consistent UI pattern across the app for data-driven screens class ContentStateBuilder extends StatelessWidget { diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index bfde4f39..f08ca328 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -6,6 +6,7 @@ import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../services/jellyfin_sequential_launcher.dart'; +import '../../services/media_list_playback_launcher.dart'; import '../../services/play_queue_launcher.dart'; import '../../utils/app_logger.dart'; import '../../utils/error_message_utils.dart'; @@ -243,42 +244,19 @@ class FolderTreeViewState extends State { } } - Future _handleFolderPlay(MediaItem folder) async { + /// Play (or shuffle) a folder row through the backend's launcher. Built + /// here rather than via [MediaListPlaybackLauncher.forItem] because this + /// tree is pinned to one server: the Plex client must be the one backing + /// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution. + Future _launchFolder(MediaItem folder, {required bool shuffle}) async { + final MediaListPlaybackLauncher launcher; if (folder.backend == MediaBackend.jellyfin) { - final launcher = JellyfinSequentialLauncher(context: context); - await launcher.launchFromFolder(folder: folder, shuffle: false); - return; + launcher = JellyfinSequentialLauncher(context: context); + } else { + final client = context.getPlexClientForServer(ServerId(widget.serverId!)); + launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); } - - final folderKey = folder.backendFolderKey; - if (folderKey == null) return; - final client = context.getPlexClientForServer(ServerId(widget.serverId!)); - final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder( - folderKey: folderKey, - shuffle: false, - libraryId: folder.libraryId, - libraryTitle: folder.libraryTitle, - ); - } - - Future _handleFolderShuffle(MediaItem folder) async { - if (folder.backend == MediaBackend.jellyfin) { - final launcher = JellyfinSequentialLauncher(context: context); - await launcher.launchFromFolder(folder: folder, shuffle: true); - return; - } - - final folderKey = folder.backendFolderKey; - if (folderKey == null) return; - final client = context.getPlexClientForServer(ServerId(widget.serverId!)); - final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder( - folderKey: folderKey, - shuffle: true, - libraryId: folder.libraryId, - libraryTitle: folder.libraryTitle, - ); + await launcher.launchFromFolder(folder: folder, shuffle: shuffle); } /// Expandable rows: directory rows plus Jellyfin media containers whose @@ -400,8 +378,8 @@ class FolderTreeViewState extends State { serverId: widget.serverId, onExpand: isExpandable ? () => _toggleFolder(item) : null, onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null, - onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null, - onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, + onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null, + onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, onNavigateLeft: widget.onNavigateLeft, diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index be58fff8..79e2583c 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -216,6 +216,20 @@ abstract class BaseLibraryTabState> extends State } } + /// Post-load bookkeeping for tabs that replace [loadItems] with their own + /// (paginated) fetch: mark the tab loaded, take focus if it's due, and let + /// the parent know once the frame carrying the items is in. + @protected + void markItemsLoaded() { + _hasLoadedData = true; + tryFocus(); + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onDataLoaded!(); + }); + } + } + /// Whether [focusFirstItem] has a real content target to focus. @protected bool get hasFocusableContent => _items.isNotEmpty; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index b8598d6d..3b44e326 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -55,6 +55,7 @@ import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../mixins/deletion_aware.dart'; import '../../../mixins/paginated_item_loader.dart'; +import '../../../mixins/standard_paginated_view.dart'; import '../../../widgets/card_inflation_budget.dart'; import '../../../widgets/skeleton_media_card.dart'; import '../../../widgets/sliver_child_memo.dart'; @@ -104,13 +105,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState, + PaginatedItemUpdatable, SkeletonUpgradeScheduler { String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey); - @override - String? get deletionServerId => widget.library.serverId; - + // DeletionMirrorsWatchState points the deletion filters at these three: the + // grid shows the same loaded items for both event families. @override String? get watchStateServerId => widget.library.serverId; @@ -130,22 +132,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState? get deletionIds => loadedItems.values.map((e) => e.id).toSet(); - - @override - Set? get deletionGlobalKeys { - if (loadedItems.isEmpty) return {}; - - final keys = {}; - for (final item in loadedItems.values) { - final serverId = serverIdOrNull(item.serverId ?? widget.library.serverId); - if (serverId == null) return null; - keys.add(_toGlobalKey(item.id, serverId: serverId)); - } - return keys; - } - @override void onWatchStateChanged(WatchStateEvent event) { if (event.changeType == WatchStateChangeType.progressUpdate || @@ -213,16 +199,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState totalSize; - @override - void updateItemInLists(String sourceGlobalKey, MediaItem updatedMetadata) { - for (final entry in loadedItems.entries) { - if (entry.value.globalKey == sourceGlobalKey) { - loadedItems[entry.key] = updatedMetadata; - break; - } - } - } - // Browse-specific state (not in base class) List _filters = []; List _sortOptions = []; diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index df59c57a..4dbd3cbc 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -5,6 +5,7 @@ import '../../../media/library_query.dart'; import '../../../media/media_item.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../mixins/paginated_item_loader.dart'; +import '../../../mixins/standard_paginated_view.dart'; import '../../../services/settings_service.dart'; import '../../../utils/error_message_utils.dart'; import '../../../utils/layout_constants.dart'; @@ -43,6 +44,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState, PaginatedItemLoader, + StandardPaginatedView, SkeletonUpgradeScheduler { static const int _pageSize = 36; @@ -78,35 +80,11 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future loadItems() { + return loadStandardPaginatedItems( pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, stackTrace) { - errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext); - isLoading = false; - }, - onLoaded: (_, _) { - hasLoadedData = true; - tryFocus(); - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onDataLoaded!(); - }); - } - }, - onError: (error, stackTrace) { - loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext); - }, + errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), + onLoaded: (_, _) => markItemsLoaded(), ); } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 9e6b7848..12127c31 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -7,6 +7,7 @@ import '../../../media/media_kind.dart'; import '../../../media/media_playlist.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../mixins/paginated_item_loader.dart'; +import '../../../mixins/standard_paginated_view.dart'; import '../../../services/settings_service.dart'; import '../../../utils/error_message_utils.dart'; import '../../../utils/layout_constants.dart'; @@ -45,6 +46,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState, PaginatedItemLoader, + StandardPaginatedView, SkeletonUpgradeScheduler { static const int _pageSize = 200; @@ -84,35 +86,11 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future loadItems() { + return loadStandardPaginatedItems( pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, stackTrace) { - errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext); - isLoading = false; - }, - onLoaded: (_, _) { - hasLoadedData = true; - tryFocus(); - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onDataLoaded!(); - }); - } - }, - onError: (error, stackTrace) { - loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext); - }, + errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), + onLoaded: (_, _) => markItemsLoaded(), ); } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 5826a780..b997ede5 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -15,7 +15,8 @@ import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../services/settings_service.dart'; import '../../../utils/deletion_notifier.dart'; -import '../../../utils/global_key_utils.dart'; +import '../../../utils/hub_icons.dart'; +import '../../../utils/media_event_keys.dart'; import '../../../utils/platform_detector.dart'; import '../../../utils/provider_extensions.dart'; import '../../../utils/watch_state_notifier.dart'; @@ -46,7 +47,7 @@ class LibraryRecommendedTab extends BaseLibraryTab { } class _LibraryRecommendedTabState extends BaseLibraryTabState - with ItemUpdatable, WatchStateAware, DeletionAware { + with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState { /// GlobalKeys for each hub section to enable vertical navigation final List> _hubKeys = []; final _tvBrowseRailKey = GlobalKey(); @@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState widget.library.serverId; - @override - String? get deletionServerId => widget.library.serverId; + /// Every item on screen, across all hubs. + Iterable get _visibleItems => items.expand((hub) => hub.items); - // Deletion filtering needs the same id sets as watch state: each visible - // item plus its parents, so deleting a season/show also matches the - // episodes it contains here. + // Deletion mirrors these via DeletionMirrorsWatchState: each visible item + // plus its parents, so deleting a season/show also matches the episodes it + // contains here. @override - Set? get deletionIds => watchedIds; + Set? get watchedIds => hierarchicalEventIds(_visibleItems); @override - Set? get deletionGlobalKeys => watchedGlobalKeys; - - @override - Set? get watchedIds { - final keys = {}; - for (final hub in items) { - for (final item in hub.items) { - keys.add(item.id); - if (item.parentId != null) keys.add(item.parentId!); - if (item.grandparentId != null) keys.add(item.grandparentId!); - } - } - return keys; - } - - @override - Set? get watchedGlobalKeys { - final keys = {}; - for (final hub in items) { - for (final item in hub.items) { - final serverId = item.serverId ?? widget.library.serverId; - if (serverId == null) return null; - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - } - } - return keys; - } + Set? get watchedGlobalKeys => + hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId); @override void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { @@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _getHubIcon(hub), + iconForHub: (hub, _) => hubIconFor(hub), onFocusedItemChanged: _setSpotlightItem, onRefresh: updateItem, onRemoveFromContinueWatching: _refreshContinueWatching, @@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState await _recordingsTabKey.currentState?.reload(); return; } - await _serverReloadGuide(); + await _broadcastToDvrs( + actionLabel: 'Reload guide', + successMessage: t.liveTv.guideReloadRequested, + action: (dvr, serverInfo) => dvr.reloadGuide(serverInfo.dvrKey), + ); await _loadChannels(); } - Future _serverReloadGuide() async { + /// Runs [action] on every DVR-capable Live TV server in parallel, then reports + /// [successMessage]. Per-DVR failures are non-fatal — 403 (admin only) and + /// transient errors are logged under [actionLabel] and swallowed, since + /// callers re-fetch their own client-side state regardless. Returns `true` + /// once at least one DVR was reached and this widget is still mounted. + Future _broadcastToDvrs({ + required String actionLabel, + required String successMessage, + required Future Function(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) action, + }) async { final multiServer = context.read(); + Future runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async { + try { + await action(dvr, serverInfo); + } catch (e) { + appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e'); + } + } + final futures = >[]; for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null || client.liveTvDvr == null) continue; - futures.add(_reloadGuideSafe(client, serverInfo.dvrKey)); + final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr; + if (dvr == null) continue; + futures.add(runSafely(dvr, serverInfo)); } - if (futures.isEmpty) return; + if (futures.isEmpty) return false; await Future.wait(futures); - if (!mounted) return; - showSnackBar(context, t.liveTv.guideReloadRequested); - } - - Future _reloadGuideSafe(MediaServerClient client, String dvrId) async { - try { - final dvr = client.liveTvDvr; - if (dvr == null) return; - await dvr.reloadGuide(dvrId); - } catch (e) { - // 403 (admin only) and transient errors are non-fatal — caller still - // re-fetches client-side channels. - appLogger.d('Reload guide failed for DVR $dvrId: $e'); - } + if (!mounted) return false; + showSnackBar(context, successMessage); + return true; } Future _processRecordingRules() async { - final multiServer = context.read(); - final futures = >[]; - for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null || client.liveTvDvr == null) continue; - futures.add(_processRulesSafe(client)); - } - if (futures.isEmpty) return; - await Future.wait(futures); - if (!mounted) return; - showSnackBar(context, t.liveTv.rulesProcessRequested); + final reached = await _broadcastToDvrs( + actionLabel: 'processRecordingRules', + successMessage: t.liveTv.rulesProcessRequested, + action: (dvr, _) => dvr.processRecordingRules(), + ); + if (!reached) return; await _recordingsTabKey.currentState?.reload(); } - Future _processRulesSafe(MediaServerClient client) async { - try { - final dvr = client.liveTvDvr; - if (dvr == null) return; - await dvr.processRecordingRules(); - } catch (e) { - appLogger.d('processRecordingRules failed: $e'); - } - } - /// Recompute visible tabs from the current MultiServerProvider state. /// Re-inits the tab controller when the visible set changes (matches the /// libraries-screen pattern at libraries_screen.dart:365). diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index 83dd05e7..f1f0be53 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import '../../media/ids.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../focus/dpad_navigator.dart'; +import '../../focus/dpad_reorder_mixin.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; -import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../providers/multi_server_provider.dart'; @@ -34,18 +32,33 @@ class ReorderFavoritesSheet extends StatefulWidget { State createState() => _ReorderFavoritesSheetState(); } -class _ReorderFavoritesSheetState extends State { +class _ReorderFavoritesSheetState extends State + with DpadReorderListMixin { late List _tempFavorites; - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = remove button - int? _movingIndex; - int? _originalIndex; - List? _originalOrder; final FocusNode _listFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); - bool _backKeyDownSeen = false; + + // Keyboard navigation: column 0 = row, column 1 = remove button. + @override + List get reorderItems => _tempFavorites; + + @override + set reorderItems(List value) => _tempFavorites = value; + + @override + int get lastReorderColumn => 1; + + @override + ScrollController? get reorderScrollController => _scrollController; + + @override + void onReorderMoveConfirmed() => widget.onReorder(_tempFavorites); + + @override + void onReorderColumnActivated(int column, int index) { + if (column == 1) _removeItem(index); + } @override void initState() { @@ -60,139 +73,6 @@ class _ReorderFavoritesSheetState extends State { super.dispose(); } - void _ensureFocusedVisible() { - if (!_scrollController.hasClients) return; - - const double itemHeight = 72.0; - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _scrollController.offset; - final double viewportHeight = _scrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - - _scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - if (key.isBackKey) { - if (event is KeyDownEvent) { - _backKeyDownSeen = true; - } else if (event is KeyUpEvent && !_backKeyDownSeen) { - return KeyEventResult.handled; - } - if (event is KeyUpEvent) { - _backKeyDownSeen = false; - } - } - - final backResult = handleBackKeyAction(event, () { - if (_movingIndex != null) { - setState(() { - if (_originalOrder != null) { - _tempFavorites = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempFavorites.removeAt(_movingIndex!); - _tempFavorites.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempFavorites.length - 1) { - setState(() { - final item = _tempFavorites.removeAt(_movingIndex!); - _tempFavorites.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - widget.onReorder(_tempFavorites); - setState(() { - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } - } else { - if (key.isUpKey && _focusedIndex > 0) { - setState(() { - _focusedIndex--; - _focusedColumn = 0; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _focusedIndex < _tempFavorites.length - 1) { - setState(() { - _focusedIndex++; - _focusedColumn = 0; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isLeftKey && _focusedColumn > 0) { - setState(() => _focusedColumn--); - return KeyEventResult.handled; - } - if (key.isRightKey && _focusedColumn < 1) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempFavorites); - }); - } else if (_focusedColumn == 1) { - _removeItem(_focusedIndex); - } - return KeyEventResult.handled; - } - } - - if (key.isDpadDirection) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - void _onReorder(int oldIndex, int newIndex) { setState(() { final item = _tempFavorites.removeAt(oldIndex); @@ -205,8 +85,8 @@ class _ReorderFavoritesSheetState extends State { final removed = _tempFavorites[index]; setState(() { _tempFavorites.removeAt(index); - if (_focusedIndex >= _tempFavorites.length) { - _focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); + if (focusedIndex >= _tempFavorites.length) { + focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); } }); widget.onRemove(removed); @@ -229,7 +109,7 @@ class _ReorderFavoritesSheetState extends State { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: isKeyboardMode, - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleReorderKeyEvent, child: ReorderableListView.builder( scrollController: _scrollController, onReorderItem: _onReorder, @@ -239,8 +119,8 @@ class _ReorderFavoritesSheetState extends State { itemBuilder: (context, index) { final fav = _tempFavorites[index]; final channel = widget.channelMap[fav.stableKey]; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; + final isFocused = isKeyboardMode && index == focusedIndex; + final isMoving = index == movingIndex; return _buildFavoriteTile( key: ValueKey(fav.stableKey), @@ -249,7 +129,7 @@ class _ReorderFavoritesSheetState extends State { index: index, isFocused: isFocused, isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, + focusedColumn: isFocused ? focusedColumn : null, ); }, ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5158fd02..35b59f9c 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import '../media/ids.dart'; +import '../media/media_server_client.dart'; import '../navigation/main_screen_scope.dart'; import 'dart:io' show Platform, exit; @@ -34,6 +35,7 @@ import '../profiles/active_profile_binder.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; +import '../profiles/profile_selection_policy.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -233,13 +235,12 @@ class _MainScreenState extends State bool _isShowingProfileSelection = false; late List _screens; - final GlobalKey> _discoverKey = GlobalKey(); - final GlobalKey> _exploreKey = GlobalKey(); - final GlobalKey> _librariesKey = GlobalKey(); - final GlobalKey> _liveTvKey = GlobalKey(); - final GlobalKey> _searchKey = GlobalKey(); - final GlobalKey> _downloadsKey = GlobalKey(); - final GlobalKey> _settingsKey = GlobalKey(); + + /// One [GlobalKey] per tab, so a tab's live [State] can be reached from + /// anywhere in this class via [_onScreen]. Deliberately untyped: every + /// consumer discards the concrete `State` type and pattern-matches on a + /// capability mixin (Refreshable, FocusableTab, …) instead. + final Map _screenKeys = {for (final id in NavigationTabId.values) id: GlobalKey()}; final GlobalKey _sideNavKey = GlobalKey(); /// Measures the mobile bottom navigation area for the music mini-player. @@ -441,23 +442,11 @@ class _MainScreenState extends State } void tryDownloadResume() { - if (_downloadResumeFired || !mounted) return; // Wait for any online client before firing the resume — the download // pipeline is backend-neutral (resumeQueuedDownloads accepts a // MediaServerClient and per-item resolution picks up the right // backend), so a Jellyfin-only setup can resume too. - final onlineClient = manager.onlineClients.values.firstOrNull; - if (onlineClient == null) return; - _downloadResumeFired = true; - _serverStatusSub?.cancel(); - _serverStatusSub = null; - final downloadProvider = context.read(); - unawaited( - downloadProvider.ensureInitialized().then((_) { - if (!mounted) return; - downloadProvider.resumeQueuedDownloads(onlineClient); - }), - ); + _resumeQueuedDownloadsOnce(manager.onlineClients.values.firstOrNull); } // Listen for binding-settle so the once-only priming runs after both @@ -495,36 +484,35 @@ class _MainScreenState extends State if (!mounted) return; context.read().onServersConnected(); unawaited(context.read().refreshMetadataFromCache()); - _resumeQueuedDownloadsIfPossible(mp); + _resumeQueuedDownloadsOnce( + mp.onlineServerIds.map((id) => mp.getClientForServer(ServerId(id))).nonNulls.firstOrNull, + ); } } if (!mounted) return; - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } + _fullRefreshContentTabs(); } - void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) { + /// Single-shot "resume queued downloads once any client is online" rule, + /// shared by the startup status-stream path and [_primeOnlineServices] — + /// each caller resolves its own candidate client (unfiltered manager view + /// vs the visibility-filtered provider) and hands it here. No-op once the + /// resume has fired, or while no client is online yet. + void _resumeQueuedDownloadsOnce(MediaServerClient? onlineClient) { if (_downloadResumeFired || !mounted) return; - for (final serverId in mp.onlineServerIds) { - final onlineClient = mp.getClientForServer(ServerId(serverId)); - if (onlineClient == null) continue; - _downloadResumeFired = true; - unawaited( - context.read().ensureInitialized().then((_) { - if (!mounted) return; - context.read().resumeQueuedDownloads(onlineClient); - }), - ); - return; - } + if (onlineClient == null) return; + _downloadResumeFired = true; + // The status subscription exists only to drive this one-shot. + _serverStatusSub?.cancel(); + _serverStatusSub = null; + final downloadProvider = context.read(); + unawaited( + downloadProvider.ensureInitialized().then((_) { + if (!mounted) return; + downloadProvider.resumeQueuedDownloads(onlineClient); + }), + ); } void _onActiveProfileChanged() { @@ -594,11 +582,15 @@ class _MainScreenState extends State // has no profile to bind, and the user lands on an empty screen with // no way back to the picker. final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; - final requireOnOpen = - settingsService.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; - if (!hasNoActive && !requireOnOpen) return; + if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return; + await _pushProfileSelection(); + } + + /// Push the picker in "must choose" mode, suppressing the tvOS menu-button + /// passthrough for as long as it is up. + Future _pushProfileSelection() async { _isShowingProfileSelection = true; _setTvosMenuPassthrough(false); await Navigator.of( @@ -838,9 +830,7 @@ class _MainScreenState extends State _selectTab(NavigationTabId.search, focusSearchInput: !hasQuery); if (hasQuery) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.submitSearchQuery(trimmed); - } + _onScreen(NavigationTabId.search, (screen) => screen.submitSearchQuery(trimmed)); }); } }; @@ -925,21 +915,11 @@ class _MainScreenState extends State Future _showProfileSelectionOnResume() async { final settingsService = await SettingsService.getInstance(); - if (!settingsService.read(SettingsService.requireProfileSelectionOnOpen)) return; if (!mounted) return; - final activeProfile = context.read(); - if (!activeProfile.hasMultipleProfiles) return; + if (!context.read().requiresSelectionOnOpen(settingsService)) return; - _isShowingProfileSelection = true; - _setTvosMenuPassthrough(false); - await Navigator.of( - context, - rootNavigator: true, - ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); - if (!mounted) return; - _isShowingProfileSelection = false; - _updateTvosMenuPassthrough(); + await _pushProfileSelection(); } /// IndexedStack that disables tickers for offscreen children to prevent @@ -965,17 +945,17 @@ class _MainScreenState extends State return [ for (final tab in _getVisibleTabs(offline)) switch (tab.id) { - NavigationTabId.discover => DiscoverScreen(key: _discoverKey), - NavigationTabId.explore => ExploreScreen(key: _exploreKey), + NavigationTabId.discover => DiscoverScreen(key: _screenKeys[tab.id]), + NavigationTabId.explore => ExploreScreen(key: _screenKeys[tab.id]), NavigationTabId.libraries => LibrariesScreen( - key: _librariesKey, + key: _screenKeys[tab.id], onLibraryOrderChanged: _onLibraryOrderChanged, onLibrarySelected: _handleLibrariesScreenSelected, ), - NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey), - NavigationTabId.search => SearchScreen(key: _searchKey), - NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey), - NavigationTabId.settings => SettingsScreen(key: _settingsKey), + NavigationTabId.liveTv => LiveTvScreen(key: _screenKeys[tab.id]), + NavigationTabId.search => SearchScreen(key: _screenKeys[tab.id]), + NavigationTabId.downloads => DownloadsScreen(key: _screenKeys[tab.id]), + NavigationTabId.settings => SettingsScreen(key: _screenKeys[tab.id]), }, ]; } @@ -1030,16 +1010,22 @@ class _MainScreenState extends State }()); } - void _handleLiveTvChanged() { - final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; - if (hasLiveTv == _lastHasLiveTv) return; - _lastHasLiveTv = hasLiveTv; - + /// Rebuilds navigation after a tab's availability flipped: _currentTab may + /// need normalizing, and passthrough depends on it being the first tab. + void _handleTabAvailabilityChanged() { setState(() { _screens = _buildScreens(_isOffline); _currentTab = _normalizeTabForMode(_currentTab, _isOffline); }); _updateTvosMenuPassthrough(); + } + + void _handleLiveTvChanged() { + final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; + if (hasLiveTv == _lastHasLiveTv) return; + _lastHasLiveTv = hasLiveTv; + + _handleTabAvailabilityChanged(); // A preferred startup section (only Live TV can be deferred) just became // available — switch to it via _selectTab so it gets the usual visibility @@ -1055,13 +1041,7 @@ class _MainScreenState extends State if (hasExplore == _lastHasExplore) return; _lastHasExplore = hasExplore; - setState(() { - _screens = _buildScreens(_isOffline); - _currentTab = _normalizeTabForMode(_currentTab, _isOffline); - }); - // Same as the live-TV handler: the passthrough flag depends on whether - // _currentTab is the first tab, which the normalize above can change. - _updateTvosMenuPassthrough(); + _handleTabAvailabilityChanged(); } void _handleOfflineStatusChanged() { @@ -1154,17 +1134,8 @@ class _MainScreenState extends State // This preserves the user's focus position when returning from sidebar. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (restorePreviousFocus) { - if (_contentFocusScope.focusedChild == null) { - if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } - } - } else { - if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } - } + if (restorePreviousFocus && _contentFocusScope.focusedChild != null) return; + _onScreen(_currentTab, (screen) => screen.focusActiveTabIfReady()); }); } @@ -1363,9 +1334,7 @@ class _MainScreenState extends State if (_isSidebarFocused) _focusContent(); // Schedule focus after the frame so the search screen is visible in the IndexedStack WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); - } + _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); return KeyEventResult.handled; } @@ -1386,9 +1355,7 @@ class _MainScreenState extends State _miniPlayerInsets?.setNavBarSuspended(true); // Called when a child route is pushed on top (e.g., video player) if (_currentTab == NavigationTabId.discover) { - if (_discoverKey.currentState case final TabVisibilityAware aware) { - aware.onTabHidden(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.onTabHidden()); } } @@ -1407,9 +1374,7 @@ class _MainScreenState extends State _updateTvosMenuPassthrough(); _miniPlayerInsets?.setNavBarSuspended(false); if (_currentTab == NavigationTabId.discover) { - if (_discoverKey.currentState case final TabVisibilityAware aware) { - aware.onTabShown(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.onTabShown()); _onDiscoverBecameVisible(); } } @@ -1417,9 +1382,7 @@ class _MainScreenState extends State void _onDiscoverBecameVisible() { appLogger.d('Navigated to home'); // Refresh content when returning to discover page - if (_discoverKey.currentState case final Refreshable refreshable) { - refreshable.refresh(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.refresh()); } void _onLibraryOrderChanged() { @@ -1464,15 +1427,7 @@ class _MainScreenState extends State playbackStateProvider.clearShuffle(); - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } + _fullRefreshContentTabs(); // Refresh user-level settings (audio/sub defaults) for the new identity. if (mounted) { @@ -1500,14 +1455,9 @@ class _MainScreenState extends State if (previousTab != tab) { // Notify previous screen it's being hidden - if (_screenKeyFor(previousTab)?.currentState case final TabVisibilityAware aware) { - aware.onTabHidden(); - } + _onScreen(previousTab, (screen) => screen.onTabHidden()); // Notify and focus new screen - final newState = _screenKeyFor(tab)?.currentState; - if (newState case final TabVisibilityAware aware) { - aware.onTabShown(); - } + _onScreen(tab, (screen) => screen.onTabShown()); // Back-to-home keeps the sidebar focused (chain: content → sidebar → // home → exit); stealing focus here left _isSidebarFocused stuck true // while real focus sat on a content card (#1411). @@ -1515,9 +1465,7 @@ class _MainScreenState extends State // search input, since focusing it auto-opens the on-screen keyboard; the // query submit focuses results instead. if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) { - if (newState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } + _onScreen(tab, (screen) => screen.focusActiveTabIfReady()); } } @@ -1531,9 +1479,7 @@ class _MainScreenState extends State // submit runs the search and focuses results without opening the keyboard. if (tab == NavigationTabId.search && focusSearchInput) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); - } + _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); } } @@ -1543,12 +1489,8 @@ class _MainScreenState extends State _selectedLibraryGlobalKey = libraryGlobalKey; _selectTab(NavigationTabId.libraries); // Tell LibrariesScreen to load this library after tab switch - if (_librariesKey.currentState case final LibraryLoadable loadable) { - loadable.loadLibraryByKey(libraryGlobalKey); - } - if (_librariesKey.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } + _onScreen(NavigationTabId.libraries, (screen) => screen.loadLibraryByKey(libraryGlobalKey)); + _onScreen(NavigationTabId.libraries, (screen) => screen.focusActiveTabIfReady()); } void _openSettings() { @@ -1637,17 +1579,20 @@ class _MainScreenState extends State ); } - /// Get the GlobalKey for a given tab. - GlobalKey? _screenKeyFor(NavigationTabId tab) { - return switch (tab) { - NavigationTabId.discover => _discoverKey, - NavigationTabId.explore => _exploreKey, - NavigationTabId.libraries => _librariesKey, - NavigationTabId.liveTv => _liveTvKey, - NavigationTabId.search => _searchKey, - NavigationTabId.downloads => _downloadsKey, - NavigationTabId.settings => _settingsKey, - }; + /// Invoke [fn] on the tab's current [State] when it exists and implements + /// the capability [T]. Screens are only built for visible tabs and mount a + /// frame later, so a missing key or a non-matching state is a no-op. + void _onScreen(NavigationTabId tab, void Function(T state) fn) { + if (_screenKeys[tab]?.currentState case final T state) fn(state); + } + + /// Full-refresh the primary content tabs. Shared by the online-entry hook + /// ([_primeOnlineServices]) and the profile-switch invalidation + /// ([_invalidateAllScreens]), which refresh the same set. + void _fullRefreshContentTabs() { + for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) { + _onScreen(tab, (screen) => screen.fullRefresh()); + } } Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index e692356f..44776ddc 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -267,7 +267,13 @@ PageRoute mediaDetailRoute({ } class _MediaDetailScreenState extends State - with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware { + with + WatchStateAware, + DeletionAware, + DeletionMirrorsWatchState, + MountedSetStateMixin, + ServerBoundMediaMixin, + RouteAware { /// Public input alias — used as the live source of truth until the detail /// fetch returns. Holds backend-neutral [MediaItem] data. MediaItem get _metadata => _fullMetadata ?? widget.metadata; @@ -393,7 +399,9 @@ class _MediaDetailScreenState extends State @override bool get isServerBoundOffline => widget.isOffline; - // WatchStateAware: watch the show/movie and all season/episode ratingKeys + // WatchStateAware: watch the show/movie and all season/episode ratingKeys. + // DeletionMirrorsWatchState reuses these three getters for deletion events — + // the same items are on screen either way. @override Set? get watchedIds { final keys = {_metadata.id}; @@ -533,36 +541,6 @@ class _MediaDetailScreenState extends State } } - @override - Set? get deletionIds { - final keys = {_metadata.id}; - for (final season in _seasons) { - keys.add(season.id); - } - for (final ep in _episodes) { - keys.add(ep.id); - } - return keys; - } - - @override - String? get deletionServerId => serverBoundServerId; - - @override - Set? get deletionGlobalKeys { - final serverId = serverBoundServerId; - if (serverId == null) return null; - - final keys = {toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))}; - for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId))); - } - for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId))); - } - return keys; - } - @override void onDeletionEvent(DeletionEvent event) { // Download-only deletions should only remove items when viewing offline content diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 68e2e46c..813524be 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -122,23 +122,14 @@ class _MetadataEditScreenState extends State { final draft = _draft; if (draft == null || _isCommitting) return; final currentValue = draft.value(field.id) ?? ''; - final result = multiline - ? await showTextInputDialog( - context, - title: field.label, - labelText: field.label, - initialValue: currentValue, - allowEmpty: true, - multiline: true, - ) - : await showTextInputDialog( - context, - title: field.label, - labelText: field.label, - hintText: '', - initialValue: currentValue, - allowEmpty: true, - ); + final result = await showTextInputDialog( + context, + title: field.label, + labelText: field.label, + initialValue: currentValue, + allowEmpty: true, + multiline: multiline, + ); if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index c096137b..7895fc63 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -27,14 +27,12 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/download_status_icon.dart'; -import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/media_context_menu.dart'; import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/music/track_row.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -388,35 +386,15 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), - ), - ], - ), - ), - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.album.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + if (hasItems) _buildTrackList(), + // Keep the last rows reachable above the floating mini-player. + SliverToBoxAdapter(child: SizedBox(height: context.watch()?.overlayHeight ?? 0)), + ], ); } } diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index 60b31c68..ee8ad628 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/media_item.dart'; @@ -16,17 +15,14 @@ import '../../utils/formatters.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; -import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/collapsible_text.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -81,31 +77,17 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen _playAll({bool shuffle = false}) async { - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - List tracks; - try { - tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id); - } catch (e, stackTrace) { - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle); - showErrorSnackBar(context, message); - return; - } - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - if (tracks.isEmpty) { - showAppSnackBar(context, emptyMessage); - return; - } - await playTracks( + await playFetchedTracks( context, - tracks: tracks, + fetch: () => mediaClient.fetchPlayableDescendants(widget.artist.id), playContext: MusicPlayContext( id: widget.artist.id, title: widget.artist.displayTitle, kind: MusicPlayContextKind.artist, ), + onError: (e, stackTrace) => + showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle)), + onEmpty: () => showAppSnackBar(context, emptyMessage), shuffle: shuffle, ); } @@ -193,36 +175,16 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), - ), - ], - ), - ), - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.artist.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + // Albums arrive newest-first from both backends — no client-side sort. + if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square), + // Keep the last rows reachable above the floating mini-player. + SliverToBoxAdapter(child: SizedBox(height: context.watch()?.overlayHeight ?? 0)), + ], ); } } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 612b86b5..061c70a7 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,11 +1,9 @@ import 'dart:async'; -import '../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/focusable_button.dart'; import '../../media/library_query.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; @@ -34,6 +32,7 @@ import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/listenable_selector.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; +import '../libraries/content_state_builder.dart'; import '../../mixins/grid_focus_node_mixin.dart'; import '../../widgets/overlay_sheet.dart'; @@ -92,24 +91,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen(); - final intent = service.beginPlayIntent(); - List tracks; - if (_isPlaylistFullyLoaded) { - tracks = items; - } else { - try { - tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id); - } catch (e, stackTrace) { - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title); - showErrorSnackBar(context, message); - return; - } - } - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle); + await playFetchedTracks( + context, + fetch: () async => _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id), + playContext: _musicPlayContext, + onError: (e, stackTrace) => + showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)), + startTrack: startTrack, + shuffle: shuffle, + ); } @override @@ -117,7 +107,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen((p) => p.hasSyncRule(ruleKey)); @@ -127,19 +117,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen().syncRuleKeyForClient( - mediaClient, - widget.playlist.id, - serverId: ServerId(serverId), - ); - } - - Future _managePlaylistSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _playlistSyncRuleKey()); - - Future _removePlaylistSyncRule() => removeSyncRuleAndSnack( - context, - downloadProvider: context.read(), - globalKey: _playlistSyncRuleKey(), - displayTitle: widget.playlist.title, - ); - // Focus management for regular (non-smart) reorderable lists final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list'); final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry'); @@ -780,7 +746,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen { candidateSliver = SliverList( delegate: SliverChildBuilderDelegate((context, index) { final cand = candidates[index]; - // M3E connected-group geometry: large outer corners, small - // inner corners, hairline gaps between tiles. final tokensRef = tokens(context); - final tileRadii = BorderRadius.vertical( - top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(index == candidates.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); + final tileRadii = groupItemRadii(context, index, candidates.length); return Padding( padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0), child: FocusableWrapper( @@ -299,6 +294,8 @@ class _BorrowConnectionScreenState extends State { final parentId = cand.source.parentConnectionId; final homeUuid = cand.source.plexHomeUserUuid; if (parentId == null || homeUuid == null) return false; + // Built before the await: capturing the prompt needs a live element. + final promptForPin = dialogPinPrompt(context, cand.source.displayName); final parent = await context.read().getPlexAccount(parentId); if (parent == null) { if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount); @@ -308,10 +305,7 @@ class _BorrowConnectionScreenState extends State { account: parent, homeUserUuid: homeUuid, requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, + promptForPin: promptForPin, logLabel: cand.source.displayName, ); if (!result.succeeded) { @@ -330,10 +324,7 @@ class _BorrowConnectionScreenState extends State { account: account, homeUserUuid: cand.pc.userIdentifier, requiresPin: cand.source.plexProtected, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, + promptForPin: dialogPinPrompt(context, cand.source.displayName), persistTo: pcRegistry, persistProfileId: widget.targetProfile.id, logLabel: cand.source.displayName, @@ -344,14 +335,7 @@ class _BorrowConnectionScreenState extends State { } return; } - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); - } + _finishBorrow(); } Future _borrowJellyfin(_BorrowCandidate cand) async { @@ -366,14 +350,19 @@ class _BorrowConnectionScreenState extends State { tokenAcquiredAt: DateTime.now(), ), ); - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); + _finishBorrow(); + } + + /// Shared tail of every successful borrow: rebind the target profile when + /// it is the active one, then pop with the result or confirm in place. + void _finishBorrow() { + if (!mounted) return; + unawaited(context.read().rebindIfActive(widget.targetProfile.id)); + if (widget.popOnSuccess) { + Navigator.of(context).pop(true); + return; } + showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); } } diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index fd9a693c..5eee1f67 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -8,6 +8,7 @@ import '../../focus/key_event_utils.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; +import '../../profiles/plex_home_switch.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/clickable_cursor.dart'; @@ -698,6 +699,14 @@ Future showPinEntryDialog(BuildContext context, String userName, {Strin ); } +/// The [PlexHomeSwitchPinPrompt] every UI-side `mintPlexHomeUserToken` caller +/// needs: show [showPinEntryDialog] for [displayName], or cancel the switch +/// once [context] is gone. Only the *use* is guarded — build it before the +/// caller's first await, while [context] is still live. +PlexHomeSwitchPinPrompt dialogPinPrompt(BuildContext context, String displayName) => + ({String? errorMessage}) async => + context.mounted ? showPinEntryDialog(context, displayName, errorMessage: errorMessage) : null; + /// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null /// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch] /// (or no-op if not provided) and returns null — the helper keeps the UX diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index bf652cef..bfae71ee 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -10,21 +10,13 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/plex/plex_home_user.dart'; import '../../profiles/active_profile_binder.dart'; -import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; -import '../../profiles/profile_connection_cleanup.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; -import '../../providers/download_provider.dart'; -import '../../providers/discover_provider.dart'; -import '../../providers/hidden_libraries_provider.dart'; -import '../../providers/multi_server_provider.dart'; -import '../../services/storage_service.dart'; -import '../../services/system_shelf_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; @@ -167,20 +159,11 @@ class _ProfileDetailScreenState extends State with Controll isDestructive: true, ); if (!confirmed || !mounted) return; - final downloads = context.read(); - final pcRegistry = context.read(); - final connRegistry = context.read(); - final storage = context.read(); - final multiServer = context.read(); - final hiddenLibraries = context.read(); - final discover = context.read(); - final binder = context.read(); - final active = context.read(); - final shelf = SystemShelfService(); - final endedOwner = active.activeId == _profile.id ? _profile.id : null; + final scope = SessionTeardownScope.of(context); + final endedOwner = scope.active.activeId == _profile.id ? _profile.id : null; if (endedOwner != null) { - await shelf.endProfileSession(endedOwner); + await scope.shelf.endProfileSession(endedOwner); } try { @@ -189,38 +172,26 @@ class _ProfileDetailScreenState extends State with Controll // Plex account sharing the server, another Jellyfin user). final retainedServerIds = await _retainedServerIds( excludingConnectionId: conn.id, - profileConnections: pcRegistry, - connections: connRegistry, + profileConnections: scope.profileConnections, + connections: scope.connections, ); - await downloads.releaseDownloadsForProfileServers( + await scope.downloads.releaseDownloadsForProfileServers( _profile.id, _serverIdsForConnection(conn).difference(retainedServerIds), ); - await removeProfileConnectionAndCleanup( - profileId: _profile.id, - connection: conn, - profileConnections: pcRegistry, - connections: connRegistry, - storage: storage, - serverManager: multiServer.serverManager, - ); - await hiddenLibraries?.refresh(); - await binder.rebindIfActive(_profile.id); - if (endedOwner != null && active.activeId == endedOwner) { - shelf.beginProfileSession(endedOwner); - if (multiServer.hasConnectedServers) await discover?.load(); + await scope.cleanup.removeProfileConnection(profileId: _profile.id, connection: conn); + await scope.hiddenLibraries?.refresh(); + // Deliberately not `resumeFreshSystemShelf`: a rebind failure on the + // success path must reach the catch below so the recovery attempt — + // and the rethrow — still run. + await scope.binder.rebindIfActive(_profile.id); + if (endedOwner != null && scope.active.activeId == endedOwner) { + scope.shelf.beginProfileSession(endedOwner); + if (scope.multiServer.hasConnectedServers) await scope.discover?.load(); } } catch (_) { - if (endedOwner != null && active.activeId == endedOwner) { - try { - await binder.rebindIfActive(endedOwner); - if (active.activeId == endedOwner) { - shelf.beginProfileSession(endedOwner); - if (multiServer.hasConnectedServers) await discover?.load(); - } - } catch (_) { - // Keep the shelf empty when the surviving profile cannot be rebound. - } + if (endedOwner != null) { + await resumeFreshSystemShelf(scope, endedOwner); } rethrow; } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index b4abf2c2..56424695 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -224,13 +224,8 @@ class _ProfileSwitchScreenState extends State with MountedS delegate: SliverChildBuilderDelegate((context, index) { final profile = profiles[index]; final isActive = profile.id == activeId; - // M3E connected-group geometry: large outer corners, small inner - // corners, hairline gaps between tiles. final tokensRef = tokens(context); - final tileRadii = BorderRadius.vertical( - top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(index == profiles.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); + final tileRadii = groupItemRadii(context, index, profiles.length); final isFirstSelectable = autofocusFirst && index == 0; final profileFocusNode = _profileFocusNode(profile); final menuFocusNode = _profileMenuFocusNode(profile); diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index 52b4a70d..e1d2df77 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -49,6 +49,13 @@ class SessionTeardownScope { MultiServerManager get serverManager => multiServer.serverManager; + ProfileConnectionCleanup get cleanup => ProfileConnectionCleanup( + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); + SessionTeardownScope.of(BuildContext context) : active = context.read(), binder = context.read(), @@ -80,13 +87,9 @@ Future settleSessionAfterRemoval( bool rebindIfActiveKept = false, String? endedShelfOwner, }) async { - final result = await resolvePostRemovalState( + final result = await scope.cleanup.resolvePostRemovalState( profileRegistry: scope.profileRegistry, - profileConnections: scope.profileConnections, - connections: scope.connections, plexHomeUsers: scope.plexHome.current, - storage: scope.storage, - serverManager: scope.serverManager, ); if (result.route == PostRemovalRoute.signedOut) { @@ -199,13 +202,7 @@ Future deleteProfile(BuildContext context, Profile profile) async { await scope.downloads.deleteDownloadsForProfile(profile.id); await scope.database.deleteSyncRulesForProfile(profile.id); await scope.database.deleteWatchActionsForProfile(profile.id); - await removeAllProfileConnectionsAndCleanup( - profileId: profile.id, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - ); + await scope.cleanup.removeAllProfileConnections(profile.id); await scope.profileRegistry.remove(profile.id); await scope.storage.clearProfileLastUsed(profile.id); await scope.storage.clearUserScopedPreferencesForProfile(profile.id); @@ -263,14 +260,7 @@ Future confirmAndSignOutPlexAccount(BuildContext context, {required String await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds); } - await removePlexAccountConnectionAndCleanup( - account: account, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - plannedRemoval: removal, - ); + await scope.cleanup.removePlexAccountConnection(account, plannedRemoval: removal); for (final profileId in removal.removedVirtualProfileIds) { await scope.database.deleteSyncRulesForProfile(profileId); await scope.database.deleteWatchActionsForProfile(profileId); diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart index 9feeed0a..5d9e5d84 100644 --- a/lib/screens/settings/add_connection_screen.dart +++ b/lib/screens/settings/add_connection_screen.dart @@ -55,12 +55,6 @@ class AddConnectionScreen extends StatelessWidget { ), ]; final tokensRef = tokens(context); - // M3E connected-group geometry: large outer corners, small inner corners, - // hairline gaps. - BorderRadius radiiFor(int i) => BorderRadius.vertical( - top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(i == options.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); return FocusedScrollScaffold( title: Text( scoped @@ -75,7 +69,7 @@ class AddConnectionScreen extends StatelessWidget { for (var i = 0; i < options.length; i++) ...[ if (i > 0) SizedBox(height: tokensRef.groupGap), _BackendCard( - borderRadius: radiiFor(i), + borderRadius: groupItemRadii(context, i, options.length), leading: options[i].backend != null ? BackendBadge(backend: options[i].backend!, size: 28) : const AppIcon(Symbols.share_rounded, fill: 1, size: 28), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 8604bf63..aa98305a 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -342,13 +342,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _discoveredServerFocusNodes[_localServers.last.id]?.requestFocus(); } - List _enteredUrls() { - return _urlController.text - .split(RegExp(r'[\n,]+')) - .map((url) => url.trim()) - .where((url) => url.isNotEmpty) - .toList(growable: false); - } + List _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlController.text); /// Shared persistence path for both username/password and Quick Connect: /// atomically provision the optional first-run profile, connection, and @@ -642,12 +636,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (_localServers.isEmpty) return const []; final tokensRef = tokens(context); - // M3E connected-group geometry: large outer corners, small inner corners, - // hairline gaps between tiles. - BorderRadius radiiFor(int i) => BorderRadius.vertical( - top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(i == _localServers.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); return [ const SizedBox(height: 16), Text(t.addServer.localServers, style: theme.textTheme.titleSmall), @@ -656,7 +644,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (i > 0) SizedBox(height: tokensRef.groupGap), _DiscoveredJellyfinServerTile( server: server, - borderRadius: radiiFor(i), + borderRadius: groupItemRadii(context, i, _localServers.length), focusNode: _discoveredServerFocusNodes[server.id], onNavigateUp: () { final index = _localServers.indexOf(server); diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 0207fd97..404a3fd2 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -86,12 +86,11 @@ class _AddPlexAccountScreenState extends State with AsyncF // to the profile, remove it again so a cancelled attach doesn't // leave a global account behind. if (!registration.existedBefore) { - await removePlexAccountConnectionAndCleanup( - account: connection, + await ProfileConnectionCleanup( profileConnections: pcRegistry, connections: connRegistry, storage: storage, - ); + ).removePlexAccountConnection(connection); } if (mounted) Navigator.of(context).pop(false); return true; diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 95d7723b..4dc430d5 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -212,14 +212,12 @@ class AppearanceSettingsScreen extends StatelessWidget { Widget _themeSelector() { return Consumer( builder: (context, themeProvider, _) { - return SettingSelectionTile( + return SettingSelectionTile( pref: SettingsService.themeMode, icon: themeProvider.themeModeIcon, title: t.settings.theme, subtitleBuilder: themeModeLabel, options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(), - decode: (v) => v, - encode: (v) => v, ); }, ); @@ -293,7 +291,7 @@ class AppearanceSettingsScreen extends StatelessWidget { ); } - Widget _viewModeSelector() => SettingSegmentedTile( + Widget _viewModeSelector() => SettingSegmentedTile( pref: SettingsService.viewMode, icon: Symbols.view_list_rounded, title: t.settings.viewMode, @@ -301,11 +299,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)), ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _episodePosterModeSelector() => SettingSegmentedTile( + Widget _episodePosterModeSelector() => SettingSegmentedTile( pref: SettingsService.episodePosterMode, icon: Symbols.image_rounded, title: t.settings.episodePosterMode, @@ -314,11 +310,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _continueWatchingActionSelector() => SettingSegmentedTile( + Widget _continueWatchingActionSelector() => SettingSegmentedTile( pref: SettingsService.continueWatchingAction, icon: Symbols.play_circle_rounded, title: t.settings.continueWatchingAction, @@ -326,11 +320,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)), ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _episodeActionSelector() => SettingSegmentedTile( + Widget _episodeActionSelector() => SettingSegmentedTile( pref: SettingsService.episodeAction, icon: Symbols.tv_rounded, title: t.settings.episodeAction, @@ -338,8 +330,6 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)), ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)), ], - decode: (v) => v, - encode: (v) => v, ); // Sections offered as a startup destination, in display order. Live TV is @@ -353,14 +343,12 @@ class AppearanceSettingsScreen extends StatelessWidget { String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel(); - Widget _startupSectionSelector() => SettingSelectionTile( + Widget _startupSectionSelector() => SettingSelectionTile( pref: SettingsService.startupSection, icon: Symbols.start_rounded, title: t.settings.startupSection, subtitleBuilder: _startupSectionLabel, options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(), - decode: (v) => v, - encode: (v) => v, ); String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) { @@ -369,32 +357,29 @@ class AppearanceSettingsScreen extends StatelessWidget { VisualEffectsSetting.reduced => t.settings.visualEffectsReduced, }; - Widget _visualEffectsSelector(BuildContext context) => - SettingSelectionTile( - pref: SettingsService.visualEffects, - icon: Symbols.animation_rounded, - title: t.settings.visualEffects, - subtitleBuilder: _visualEffectsLabel, - options: [ - DialogOption( - value: VisualEffectsSetting.auto, - title: t.settings.visualEffectsAuto, - subtitle: t.settings.visualEffectsAutoDescription, - ), - DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), - DialogOption( - value: VisualEffectsSetting.reduced, - title: t.settings.visualEffectsReduced, - subtitle: t.settings.visualEffectsReducedDescription, - ), - ], - decode: (v) => v, - encode: (v) => v, - onAfterWrite: (value) { - DevicePerformance.setOverrideSync(value); - _restartApp(context); - }, - ); + Widget _visualEffectsSelector(BuildContext context) => SettingSelectionTile( + pref: SettingsService.visualEffects, + icon: Symbols.animation_rounded, + title: t.settings.visualEffects, + subtitleBuilder: _visualEffectsLabel, + options: [ + DialogOption( + value: VisualEffectsSetting.auto, + title: t.settings.visualEffectsAuto, + subtitle: t.settings.visualEffectsAutoDescription, + ), + DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), + DialogOption( + value: VisualEffectsSetting.reduced, + title: t.settings.visualEffectsReduced, + subtitle: t.settings.visualEffectsReducedDescription, + ), + ], + onAfterWrite: (value) { + DevicePerformance.setOverrideSync(value); + _restartApp(context); + }, + ); String _getLanguageDisplayName(AppLocale locale) { switch (locale) { diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 7cb961fb..0b096cc2 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -69,13 +69,7 @@ class _EditJellyfinConnectionScreenState extends State _enteredUrls() { - return _urlsController.text - .split(RegExp(r'[\n,]+')) - .map((url) => url.trim()) - .where((url) => url.isNotEmpty) - .toList(growable: false); - } + List _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlsController.text); @override Widget build(BuildContext context) { diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index ab34ccc1..786e711c 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -5,7 +5,7 @@ import '../../i18n/strings.g.dart'; import '../../models/hotkey_model.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../utils/app_logger.dart'; -import '../../services/shader_service.dart'; +import '../../services/shortcut_action.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; @@ -26,9 +26,7 @@ class KeyboardShortcutsScreen extends StatelessWidget { listenable: keyboardService, builder: (context, _) { final hotkeys = keyboardService.hotkeys; - final actions = hotkeys.keys - .where((action) => action != 'shader_toggle' || ShaderService.isPlatformSupported) - .toList(); + final actions = hotkeys.keys.where((action) => ShortcutAction.fromId(action)?.isSupported ?? true).toList(); return FocusedScrollScaffold( title: Text(t.settings.keyboardShortcuts), slivers: [ diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index fc6f6c41..3f7fd680 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -266,7 +266,7 @@ class _PlaybackSettingsScreenState extends State { ], ); - Widget _playerBackendSelector() => SettingSegmentedTile( + Widget _playerBackendSelector() => SettingSegmentedTile( pref: SettingsService.useExoPlayer, icon: Symbols.play_circle_rounded, title: t.settings.playerBackend, @@ -274,8 +274,6 @@ class _PlaybackSettingsScreenState extends State { ButtonSegment(value: true, label: Text(t.settings.exoPlayer)), ButtonSegment(value: false, label: Text(t.settings.mpv)), ], - decode: (s) => s, - encode: (s) => s, ); Widget _externalPlayerTile() => SettingsBuilder( @@ -391,7 +389,7 @@ class _PlaybackSettingsScreenState extends State { subtitle: t.settings.tunneledPlaybackDescription, ); - Widget _dvConversionModeTile() => SettingSelectionTile( + Widget _dvConversionModeTile() => SettingSelectionTile( pref: SettingsService.dvConversionMode, icon: Symbols.hdr_strong_rounded, title: t.settings.dvConversionMode, @@ -399,8 +397,6 @@ class _PlaybackSettingsScreenState extends State { options: DvConversionModePreference.values .map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m))) .toList(), - decode: (m) => m, - encode: (m) => m, ); String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) { @@ -412,7 +408,7 @@ class _PlaybackSettingsScreenState extends State { Widget _bufferSizeTile() { final bufferOptions = const [0, 64, 128, 256, 512, 1024]; - return SettingSelectionTile( + return SettingSelectionTile( pref: SettingsService.bufferSize, icon: Symbols.memory_rounded, title: t.settings.bufferSize, @@ -420,8 +416,6 @@ class _PlaybackSettingsScreenState extends State { options: bufferOptions .map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB')) .toList(), - decode: (s) => s, - encode: (s) => s, onAfterWrite: (value) async { if (Platform.isAndroid && value > 0) { final heapMB = await PlayerAndroid.getHeapSize(); @@ -433,7 +427,7 @@ class _PlaybackSettingsScreenState extends State { ); } - Widget _defaultQualityTile() => SettingSelectionTile( + Widget _defaultQualityTile() => SettingSelectionTile( pref: SettingsService.defaultQualityPreset, icon: Symbols.high_quality_rounded, title: t.settings.defaultQualityTitle, @@ -441,18 +435,14 @@ class _PlaybackSettingsScreenState extends State { options: TranscodeQualityPreset.displayOrder .map((p) => DialogOption(value: p, title: qualityPresetLabel(p))) .toList(), - decode: (p) => p, - encode: (p) => p, ); - Widget _musicQualityTile() => SettingSelectionTile( + Widget _musicQualityTile() => SettingSelectionTile( pref: SettingsService.musicQualityPreset, icon: Symbols.music_note_rounded, title: t.settings.musicQualityTitle, subtitleBuilder: _musicQualityLabel, options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(), - decode: (p) => p, - encode: (p) => p, ); String _musicQualityLabel(AudioQualityPreset preset) => diff --git a/lib/screens/settings/services_settings_screen.dart b/lib/screens/settings/services_settings_screen.dart index 0db542aa..835749ab 100644 --- a/lib/screens/settings/services_settings_screen.dart +++ b/lib/screens/settings/services_settings_screen.dart @@ -3,9 +3,8 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../models/catalog/catalog_item.dart'; import '../../providers/seerr_account_provider.dart'; -import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/catalog_source_logo.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -13,8 +12,7 @@ import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import 'seerr_connect_screen.dart'; import 'seerr_settings_screen.dart'; -import 'tracker_settings_screen.dart'; -import 'trakt_settings_screen.dart'; +import 'tracker_service_info.dart'; /// Unified hub for all connected services: the watch-progress trackers /// (Trakt, MyAnimeList, AniList, Simkl) and the Seerr request server. Each @@ -38,7 +36,7 @@ class ServicesSettingsScreen extends StatelessWidget { ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), - SettingsGroup(children: [_trakt(), _mal(), _anilist(), _simkl(), _seerr()]), + SettingsGroup(children: [for (final info in TrackerServiceInfo.all) _TrackerHubRow(info), _seerr()]), const SizedBox(height: 24), ]), ), @@ -46,78 +44,9 @@ class ServicesSettingsScreen extends StatelessWidget { ); } - Widget _trakt() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/trakt_circlemark.svg', size: 24), - title: t.trakt.title, - username: account.isConnected ? account.username : null, - onTap: () { - if (account.isConnected) { - Navigator.push(context, MaterialPageRoute(builder: (_) => const TraktSettingsScreen())); - } else { - startTraktConnection(context); - } - }, - ), - ); - - Widget _mal() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/mal_mark.svg', size: 24), - title: t.services.names.mal, - username: account.isMalConnected ? account.malUsername : null, - onTap: () { - if (account.isMalConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.mal())), - ); - } else { - startMalConnection(context); - } - }, - ), - ); - - Widget _anilist() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/anilist_mark.svg', size: 24), - title: t.services.names.anilist, - username: account.isAnilistConnected ? account.anilistUsername : null, - onTap: () { - if (account.isAnilistConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.anilist())), - ); - } else { - startAnilistConnection(context); - } - }, - ), - ); - - Widget _simkl() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/simkl_mark.svg', size: 24), - title: t.services.names.simkl, - username: account.isSimklConnected ? account.simklUsername : null, - onTap: () { - if (account.isSimklConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.simkl())), - ); - } else { - startSimklConnection(context); - } - }, - ), - ); - Widget _seerr() => Consumer( builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/seerr_mark.svg', size: 24), + leading: const CatalogSourceLogo(CatalogSourceId.seerr, size: 24), title: t.services.names.seerr, username: account.isConnected ? account.displayName : null, onTap: () { @@ -132,6 +61,31 @@ class ServicesSettingsScreen extends StatelessWidget { ); } +/// Hub row for a watch tracker. Owns the `watch` on that service's account +/// provider so only this row rebuilds when the connection state changes. +class _TrackerHubRow extends StatelessWidget { + final TrackerServiceInfo info; + + const _TrackerHubRow(this.info); + + @override + Widget build(BuildContext context) { + final connected = info.isConnected(context); + return _ServiceHubRow( + leading: CatalogSourceLogo(info.logoSource, size: 24), + title: info.displayName, + username: connected ? info.username(context) : null, + onTap: () { + if (connected) { + Navigator.push(context, MaterialPageRoute(builder: (_) => info.buildSettingsScreen())); + } else { + info.startConnection(context); + } + }, + ); + } +} + class _ServiceHubRow extends StatelessWidget { final Widget leading; final String title; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 0954ad35..26267f55 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -26,12 +26,9 @@ import '../../services/saf_storage_service.dart'; import '../../services/settings_export_service.dart'; import '../../providers/theme_provider.dart'; import '../../providers/seerr_account_provider.dart'; -import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart' as settings; import '../../services/update_service.dart'; -import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; @@ -55,6 +52,7 @@ import 'playback_settings_screen.dart'; import '../profile/profile_switch_screen.dart'; import 'services_settings_screen.dart'; import 'settings_utils.dart'; +import 'tracker_service_info.dart'; import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { @@ -263,13 +261,12 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildServicesTile() { - return Consumer3( - builder: (context, trakt, trackers, seerr, _) { + // The tracker account providers are watched through [TrackerServiceInfo]. + return Consumer( + builder: (context, seerr, _) { final connectedNames = [ - if (trakt.isConnected) t.trakt.title, - if (trackers.isMalConnected) t.services.names.mal, - if (trackers.isAnilistConnected) t.services.names.anilist, - if (trackers.isSimklConnected) t.services.names.simkl, + for (final info in TrackerServiceInfo.all) + if (info.isConnected(context)) info.displayName, if (seerr.isConnected) t.services.names.seerr, ]; final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · '); @@ -614,65 +611,50 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _selectDownloadLocation() async { - try { - String? selectedPath; - String pathType = 'file'; + final changed = await guardSettingsOperation( + context, + operation: 'Download directory selection', + body: () async { + String? selectedPath; + String pathType = 'file'; - if (Platform.isAndroid) { - final safStorage = SafStorageService.instance; - if (!safStorage.supportsDirectoryPicker) { - showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); - return false; + if (Platform.isAndroid) { + final safStorage = SafStorageService.instance; + if (!safStorage.supportsDirectoryPicker) { + showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); + return false; + } + selectedPath = await safStorage.pickDirectory(); + if (!mounted) return false; + if (selectedPath != null) pathType = 'saf'; + } else { + selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); + if (!mounted) return false; } - selectedPath = await safStorage.pickDirectory(); - if (!mounted) return false; - if (selectedPath != null) pathType = 'saf'; - } else { - selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); - if (!mounted) return false; - } - if (selectedPath == null) return false; + if (selectedPath == null) return false; - if (pathType == 'file') { - final dir = Directory(selectedPath); - final isWritable = - await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)(dir); - if (!mounted) return false; - if (!isWritable) { - showErrorSnackBar(context, t.settings.downloadLocationInvalid); - return false; + if (pathType == 'file') { + final dir = Directory(selectedPath); + final writableChecker = + widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable; + final isWritable = await writableChecker(dir); + if (!mounted) return false; + if (!isWritable) { + showErrorSnackBar(context, t.settings.downloadLocationInvalid); + return false; + } } - } - await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); - if (!mounted) return false; + await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); + if (!mounted) return false; - // ignore: no-empty-block - setState triggers rebuild to reflect new download path - setState(() {}); - showSuccessSnackBar(context, t.settings.downloadLocationChanged); - return true; - } on DownloadStorageException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } + // ignore: no-empty-block - setState triggers rebuild to reflect new download path + setState(() {}); + showSuccessSnackBar(context, t.settings.downloadLocationChanged); + return true; + }, + ); + return changed ?? false; } Future _resetDownloadLocation() async { @@ -720,29 +702,15 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _handleExportSettings() async { - try { - final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); - if (!mounted || path == null) return; - showSuccessSnackBar(context, t.settings.exportSettingsSuccess); - } on SettingsExportException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } + await guardSettingsOperation( + context, + operation: 'Settings export', + body: () async { + final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); + if (!mounted || path == null) return; + showSuccessSnackBar(context, t.settings.exportSettingsSuccess); + }, + ); } Future _showImportSettingsDialog() async { @@ -757,51 +725,41 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _handleImportSettings() async { - try { - final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); - if (!mounted) return; - if (result == null) return; // user cancelled file picker + await guardSettingsOperation( + context, + operation: 'Settings import', + body: () async { + // The two typed import failures carry their own message, so they are + // handled here instead of falling through to the generic guard. + try { + final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); + if (!mounted) return; + if (result == null) return; // user cancelled file picker - final themeProvider = context.read(); - final hiddenLibrariesProvider = context.read(); - final librariesProvider = context.read(); + final themeProvider = context.read(); + final hiddenLibrariesProvider = context.read(); + final librariesProvider = context.read(); - // Import wrote directly to SharedPreferences, bypassing `write`. Push - // fresh values into active listenables before providers re-read settings. - _settingsService.refreshListenables(); - unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); - await Future.wait([ - themeProvider.reload(), - hiddenLibrariesProvider.refresh(), - if (_keyboardService != null) _keyboardService!.refreshFromStorage(), - ]); - unawaited(librariesProvider.refresh()); + // Import wrote directly to SharedPreferences, bypassing `write`. Push + // fresh values into active listenables before providers re-read settings. + _settingsService.refreshListenables(); + unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); + await Future.wait([ + themeProvider.reload(), + hiddenLibrariesProvider.refresh(), + if (_keyboardService != null) _keyboardService!.refreshFromStorage(), + ]); + unawaited(librariesProvider.refresh()); - if (!mounted) return; - showSuccessSnackBar(context, t.settings.importSettingsSuccess); - } on NoUserSignedInException { - if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); - } on InvalidExportFileException { - if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); - } on SettingsExportException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } + if (!mounted) return; + showSuccessSnackBar(context, t.settings.importSettingsSuccess); + } on NoUserSignedInException { + if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); + } on InvalidExportFileException { + if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); + } + }, + ); } Future _checkForUpdates() async { diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 8443923d..7004f78e 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -56,6 +56,32 @@ void showSettingsFailure( if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed); } +/// Runs [body] and reports the recoverable failures that every settings +/// file/platform operation shares — [PlatformException], [FileSystemException] +/// and the site-specific domain exception [E] — through [showSettingsFailure]. +/// Any other exception type is rethrown so programming errors are not swallowed. +/// +/// [context] is resolved before [body] starts, so a failure that lands after the +/// caller was disposed is still logged; only the snackbar is skipped. Returns +/// `null` when the operation failed. +Future guardSettingsOperation( + BuildContext context, { + required String operation, + required Future Function() body, +}) async { + try { + return await body(); + } on Object catch (error, stackTrace) { + if (error is! E && error is! PlatformException && error is! FileSystemException) rethrow; + if (context.mounted) { + showSettingsFailure(context, operation: operation, error: error, stackTrace: stackTrace); + } else { + appLogger.e('$operation failed', error: error, stackTrace: stackTrace); + } + return null; + } +} + void _showSettingsInputDialog({ required BuildContext context, required String title, diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 6562e064..2ae4c00a 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -48,18 +48,16 @@ class SubtitleStylingScreen extends StatelessWidget { SettingsGroup( title: t.subtitlingStyling.text, children: [ - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subAssOverride, icon: Symbols.subtitles_rounded, title: t.subtitlingStyling.assOverride, subtitleBuilder: _assOverrideLabel, options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), // iOS/tvOS avfoundation VO: screen vs video-resolution basis. if (Platform.isIOS) - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, @@ -68,13 +66,11 @@ class SubtitleStylingScreen extends StatelessWidget { SubtitleRenderResolution.screen, SubtitleRenderResolution.video, ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), // Android libass overlay: full or a fractional render scale (perf knob for // render-bound low-end TVs; heavy/animated signs raster faster at < 1). if (Platform.isAndroid) - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, @@ -86,8 +82,6 @@ class SubtitleStylingScreen extends StatelessWidget { SubtitleRenderResolution.third, SubtitleRenderResolution.quarter, ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), SettingNumberTile( pref: SettingsService.subtitleFontSize, diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index e16a4344..88ba1082 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -86,7 +86,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget { ), SettingsGroup( children: [ - SettingSegmentedTile( + SettingSegmentedTile( pref: modePref, icon: Symbols.filter_list_rounded, title: t.services.libraryFilter.mode, @@ -100,8 +100,6 @@ class TrackerLibraryFilterScreen extends StatelessWidget { label: Text(t.services.libraryFilter.modeWhitelist), ), ], - decode: (v) => v, - encode: (v) => v, ), ], ), diff --git a/lib/screens/settings/tracker_service_info.dart b/lib/screens/settings/tracker_service_info.dart new file mode 100644 index 00000000..63c81222 --- /dev/null +++ b/lib/screens/settings/tracker_service_info.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../i18n/strings.g.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../providers/trackers_provider.dart'; +import '../../providers/trakt_account_provider.dart'; +import '../../services/trackers/anilist/anilist_tracker.dart'; +import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/simkl/simkl_tracker.dart'; +import '../../services/trackers/tracker.dart'; +import '../../services/trackers/tracker_constants.dart'; +import '../../services/trakt/trakt_scrobble_service.dart'; +import 'tracker_settings_screen.dart'; +import 'trakt_settings_screen.dart'; + +/// One watch tracker, described once for every place that lists services: the +/// services hub, the rating sheet, and the settings summary line. +/// +/// [isConnected] and [username] take a [BuildContext] because each service +/// keeps its account state on a different provider; they read it with `watch`, +/// so the calling element rebuilds exactly like the per-service `Consumer` +/// these entries replaced. +class TrackerServiceInfo { + final TrackerService service; + final String displayName; + + /// Which brand mark to draw; the asset path itself lives only in + /// `CatalogSourceLogo`. + final CatalogSourceId logoSource; + + final TrackerRatingSource ratingSource; + final bool Function(BuildContext) isConnected; + final String? Function(BuildContext) username; + final Future Function(BuildContext) startConnection; + final Widget Function() buildSettingsScreen; + + const TrackerServiceInfo({ + required this.service, + required this.displayName, + required this.logoSource, + required this.ratingSource, + required this.isConnected, + required this.username, + required this.startConnection, + required this.buildSettingsScreen, + }); + + /// Entry for a service that shares [TrackerSettingsScreen]: [config] already + /// carries the name and the [TrackersProvider] accessors. + TrackerServiceInfo.shared( + TrackerConfig config, { + required this.logoSource, + required this.ratingSource, + required this.startConnection, + }) : service = config.service, + displayName = config.displayName, + isConnected = ((context) => config.isConnected(context.watch())), + username = ((context) => config.username(context.watch())), + buildSettingsScreen = (() => TrackerSettingsScreen(config: config)); + + /// Display order shared by every list. Built per call because [displayName] + /// reads the active locale. + static List get all => [ + TrackerServiceInfo( + service: TrackerService.trakt, + displayName: t.trakt.title, + logoSource: CatalogSourceId.trakt, + ratingSource: TraktScrobbleService.instance, + isConnected: (context) => context.watch().isConnected, + username: (context) => context.watch().username, + startConnection: startTraktConnection, + buildSettingsScreen: () => const TraktSettingsScreen(), + ), + TrackerServiceInfo.shared( + TrackerConfig.mal(), + logoSource: CatalogSourceId.mal, + ratingSource: MalTracker.instance, + startConnection: startMalConnection, + ), + TrackerServiceInfo.shared( + TrackerConfig.anilist(), + logoSource: CatalogSourceId.anilist, + ratingSource: AnilistTracker.instance, + startConnection: startAnilistConnection, + ), + TrackerServiceInfo.shared( + TrackerConfig.simkl(), + logoSource: CatalogSourceId.simkl, + ratingSource: SimklTracker.instance, + startConnection: startSimklConnection, + ), + ]; +} diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index 3802dffc..659f3b15 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -67,7 +67,6 @@ class TrackerConfig { final String displayName; final bool Function(TrackersProvider) isConnected; final String? Function(TrackersProvider) username; - final Pref scrobblePref; final Future Function(bool) onScrobbleChanged; final Future Function(TrackersProvider) disconnect; @@ -76,17 +75,17 @@ class TrackerConfig { required this.displayName, required this.isConnected, required this.username, - required this.scrobblePref, required this.onScrobbleChanged, required this.disconnect, }); + Pref get scrobblePref => SettingsService.scrobblePref(service); + static TrackerConfig mal() => TrackerConfig( service: TrackerService.mal, displayName: t.services.names.mal, isConnected: (a) => a.isMalConnected, username: (a) => a.malUsername, - scrobblePref: SettingsService.enableMalScrobble, onScrobbleChanged: MalTracker.instance.setEnabled, disconnect: (a) => a.disconnectMal(), ); @@ -96,7 +95,6 @@ class TrackerConfig { displayName: t.services.names.anilist, isConnected: (a) => a.isAnilistConnected, username: (a) => a.anilistUsername, - scrobblePref: SettingsService.enableAnilistScrobble, onScrobbleChanged: AnilistTracker.instance.setEnabled, disconnect: (a) => a.disconnectAnilist(), ); @@ -106,7 +104,6 @@ class TrackerConfig { displayName: t.services.names.simkl, isConnected: (a) => a.isSimklConnected, username: (a) => a.simklUsername, - scrobblePref: SettingsService.enableSimklScrobble, onScrobbleChanged: SimklTracker.instance.setEnabled, disconnect: (a) => a.disconnectSimkl(), ); diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 2cbe95db..e1aab594 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -71,7 +71,7 @@ class TraktSettingsScreen extends StatelessWidget { service: TrackerService.trakt, toggles: [ TrackerSettingsToggle( - pref: SettingsService.enableTraktScrobble, + pref: SettingsService.scrobblePref(TrackerService.trakt), icon: Symbols.auto_timer_rounded, title: t.trakt.scrobble, subtitle: t.trakt.scrobbleDescription, diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 9baf4e0e..bce65d67 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -42,7 +42,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { _lastVideoLayoutSize = pendingSize; _lastVideoLayoutPlayer = currentPlayer; _videoFilterManager?.updatePlayerSize(pendingSize); - _videoPIPManager?.updatePlayerSize(pendingSize); _updateAmbientLightingOnResize(pendingSize); unawaited(currentPlayer.updateFrame()); }); diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index 9d3ec897..aa05c811 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -44,7 +44,10 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { unawaited(_videoFilterManager!.updateVideoFilter()); } - _videoPIPManager ??= VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize); + _videoPIPManager ??= VideoPIPManager( + player: currentPlayer, + playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null, + ); _videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry; _attachPipStateListener(); } @@ -92,7 +95,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { return; } - final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value; + final isInPip = PipService().isPipActive.value; _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 7ce9b50d..dcafffe7 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -468,7 +468,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { final mediaControlsManager = MediaControlsManager(); _mediaControlsManager = mediaControlsManager; - final mediaControlRouter = VideoPlayerMediaControlRouter( + final mediaControlRouter = MediaControlRouter( canControlPlayback: _canControlPlayback, canNavigateMediaItems: _canNavigateMediaItems, onPlay: () { diff --git a/lib/screens/video_player/parts/shader.dart b/lib/screens/video_player/parts/shader.dart index 2f2ec96a..4361102c 100644 --- a/lib/screens/video_player/parts/shader.dart +++ b/lib/screens/video_player/parts/shader.dart @@ -22,6 +22,36 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { } } + /// Enable ambient lighting for the current video/player geometry. + /// Returns false when the aspect ratios cannot be determined yet. + Future _enableAmbientLighting(AmbientLightingService ambientLighting, ShaderProvider shaderProvider) async { + // Get video display aspect ratio + final dwidth = await player?.getProperty('dwidth'); + final dheight = await player?.getProperty('dheight'); + if (dwidth == null || dheight == null) return false; + final w = double.tryParse(dwidth); + final h = double.tryParse(dheight); + if (w == null || h == null || h == 0) return false; + final videoAspect = w / h; + + // Get player widget aspect ratio + final playerSize = _videoFilterManager?.playerSize; + if (playerSize == null || playerSize.height == 0) return false; + final outputAspect = playerSize.width / playerSize.height; + + // Clear shaders — ambient lighting and shaders are mutually exclusive + if (shaderProvider.isShaderEnabled) { + await _shaderService!.applyPreset(ShaderPreset.none); + shaderProvider.setCurrentPreset(ShaderPreset.none); + } + + // Force contain mode when enabling ambient lighting + _videoFilterManager?.resetToContain(); + + await ambientLighting.enable(videoAspect, outputAspect); + return true; + } + /// Restore ambient lighting from persisted setting Future _restoreAmbientLighting() async { if (!mounted) return; @@ -34,27 +64,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { final ambientLighting = _ambientLightingService; if (ambientLighting == null || !ambientLighting.isSupported) return; - // Same enable logic as _toggleAmbientLighting - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - _videoFilterManager?.resetToContain(); - await ambientLighting.enable(videoAspect, outputAspect); + if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return; if (mounted) _setPlayerState(() {}); } @@ -117,30 +127,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { await ambientLighting.disable(); unawaited(_videoFilterManager?.updateVideoFilter()); } else { - // Get video display aspect ratio - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - // Get player widget aspect ratio - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - // Force contain mode when enabling ambient lighting - _videoFilterManager?.resetToContain(); - - await ambientLighting.enable(videoAspect, outputAspect); + if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return; } // Persist ambient lighting state diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index 05ebbf50..698aa541 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -188,85 +188,31 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: PipService().isPipActive, - builder: (context, isInPip, child) { - final episode = nextEpisode; - if (isInPip || !visible || episode == null) { - return const SizedBox.shrink(); - } - return _VideoPlayerPromptPosition( - chromeController: chromeController, - child: _VideoPlayerPromptInteractionHold( - chromeController: chromeController, - focusNodes: [cancelFocusNode, confirmFocusNode], - child: _VideoPlayerPromptCard( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - _PlayNextEpisodeHeader(episode: episode), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: cancelFocusNode, - onPressed: onCancel, - autoScroll: false, - onNavigateRight: () => confirmFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onCancel, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.common.cancel), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: confirmFocusNode, - onPressed: onPlayNext, - autoScroll: false, - onNavigateLeft: () => cancelFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - useBackgroundFocus: true, - child: FilledButton( - onPressed: onPlayNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - if (autoPlayCountdown > 0) ...[ - Text('$autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), - ] else - Text(t.videoControls.playNext), - ], - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, + final episode = nextEpisode; + if (episode == null) return const SizedBox.shrink(); + return _VideoPlayerPromptShell( + visible: visible, + chromeController: chromeController, + focusNodes: [cancelFocusNode, confirmFocusNode], + children: [ + _PlayNextEpisodeHeader(episode: episode), + const SizedBox(height: 12), + _VideoPlayerPromptActions( + cancelLabel: t.common.cancel, + cancelFocusNode: cancelFocusNode, + onCancel: onCancel, + confirmFocusNode: confirmFocusNode, + onConfirm: onPlayNext, + confirmChildren: [ + if (autoPlayCountdown > 0) ...[ + Text('$autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ] else + Text(t.videoControls.playNext), + ], + ), + ], ); } } @@ -344,6 +290,49 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { required this.onContinue, }); + @override + Widget build(BuildContext context) { + return _VideoPlayerPromptShell( + visible: visible, + chromeController: chromeController, + focusNodes: [pauseFocusNode, continueFocusNode], + children: [ + Text( + t.videoControls.stillWatching, + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), + ), + const SizedBox(height: 4), + Text( + t.videoControls.pausingIn(seconds: '$countdown'), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), + ), + const SizedBox(height: 12), + _VideoPlayerPromptActions( + cancelLabel: t.videoControls.pauseButton, + cancelFocusNode: pauseFocusNode, + onCancel: onPause, + confirmFocusNode: continueFocusNode, + onConfirm: onContinue, + confirmChildren: [Text('$countdown'), const SizedBox(width: 4), Text(t.videoControls.continueWatching)], + ), + ], + ); + } +} + +class _VideoPlayerPromptShell extends StatelessWidget { + final bool visible; + final PlayerChromeController chromeController; + final List focusNodes; + final List children; + + const _VideoPlayerPromptShell({ + required this.visible, + required this.chromeController, + required this.focusNodes, + required this.children, + }); + @override Widget build(BuildContext context) { return ValueListenableBuilder( @@ -356,75 +345,9 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { chromeController: chromeController, child: _VideoPlayerPromptInteractionHold( chromeController: chromeController, - focusNodes: [pauseFocusNode, continueFocusNode], + focusNodes: focusNodes, child: _VideoPlayerPromptCard( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - t.videoControls.stillWatching, - style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), - ), - const SizedBox(height: 4), - Text( - t.videoControls.pausingIn(seconds: '$countdown'), - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: pauseFocusNode, - onPressed: onPause, - autoScroll: false, - onNavigateRight: () => continueFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onPause, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.videoControls.pauseButton), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: continueFocusNode, - onPressed: onContinue, - autoScroll: false, - onNavigateLeft: () => pauseFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - useBackgroundFocus: true, - child: FilledButton( - onPressed: onContinue, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - Text('$countdown'), - const SizedBox(width: 4), - Text(t.videoControls.continueWatching), - ], - ), - ), - ), - ), - ], - ), - ], - ), + child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children), ), ), ); @@ -433,6 +356,72 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { } } +class _VideoPlayerPromptActions extends StatelessWidget { + final String cancelLabel; + final FocusNode cancelFocusNode; + final VoidCallback onCancel; + final FocusNode confirmFocusNode; + final VoidCallback onConfirm; + final List confirmChildren; + + const _VideoPlayerPromptActions({ + required this.cancelLabel, + required this.cancelFocusNode, + required this.onCancel, + required this.confirmFocusNode, + required this.onConfirm, + required this.confirmChildren, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: cancelFocusNode, + onPressed: onCancel, + autoScroll: false, + onNavigateRight: () => confirmFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( + onPressed: onCancel, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(cancelLabel), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: confirmFocusNode, + onPressed: onConfirm, + autoScroll: false, + onNavigateLeft: () => cancelFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + useBackgroundFocus: true, + child: FilledButton( + onPressed: onConfirm, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row(mainAxisAlignment: .center, children: confirmChildren), + ), + ), + ), + ], + ); + } +} + class _VideoPlayerPromptPosition extends StatelessWidget { final PlayerChromeController chromeController; final Widget child; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 3910c919..03924649 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -58,6 +58,7 @@ import '../services/playback_source_resolver.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/display_mode_service.dart'; +import '../services/media_control_router.dart'; import '../services/settings_service.dart'; import '../services/sleep_timer_service.dart'; import '../services/track_manager.dart'; @@ -87,7 +88,6 @@ import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_stream_retry.dart'; import 'video_player/live_timeline_report.dart'; -import 'video_player/media_control_router.dart'; import 'video_player/wakelock_controller.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; @@ -1368,6 +1368,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin return exitPosition; } + /// Pause/hide the player, flush stopped progress, restore system UI and + /// orientation, then leave the player route. No-op when the route cannot pop. + Future _exitPlayerRoute({required bool navigateHome}) async { + final navigator = Navigator.of(context); + if (!navigator.canPop()) return; + + _isExiting.value = true; + final exitPosition = await _pauseAndHidePlayerForRouteExit(); + if (!mounted) return; + await _sendStoppedProgressOnce(positionOverride: exitPosition); + if (!mounted) return; + await _restoreSystemUiAndOrientation(); + if (!mounted) return; + _finishPlayerNavigation(navigator, navigateHome: navigateHome); + } + /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton({bool navigateHome = false}) async { @@ -1390,36 +1406,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (confirmed && mounted) { await _watchTogetherProvider!.leaveSession(); - if (mounted) { - final navigator = Navigator.of(context); - if (navigator.canPop()) { - _isExiting.value = true; - final exitPosition = await _pauseAndHidePlayerForRouteExit(); - if (!mounted) return; - await _sendStoppedProgressOnce(positionOverride: exitPosition); - if (!mounted) return; - await _restoreSystemUiAndOrientation(); - if (!mounted) return; - _finishPlayerNavigation(navigator, navigateHome: navigateHome); - } - } + if (mounted) await _exitPlayerRoute(navigateHome: navigateHome); } return; } // Default behavior for hosts or non-session users if (!mounted) return; - final navigator = Navigator.of(context); - if (navigator.canPop()) { - _isExiting.value = true; - final exitPosition = await _pauseAndHidePlayerForRouteExit(); - if (!mounted) return; - await _sendStoppedProgressOnce(positionOverride: exitPosition); - if (!mounted) return; - await _restoreSystemUiAndOrientation(); - if (!mounted) return; - _finishPlayerNavigation(navigator, navigateHome: navigateHome); - } + await _exitPlayerRoute(navigateHome: navigateHome); } finally { _isHandlingBack = false; } diff --git a/lib/services/companion_remote/companion_remote_host_controller.dart b/lib/services/companion_remote/companion_remote_host_controller.dart index 822f592e..0c2f8882 100644 --- a/lib/services/companion_remote/companion_remote_host_controller.dart +++ b/lib/services/companion_remote/companion_remote_host_controller.dart @@ -9,33 +9,40 @@ import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../utils/app_logger.dart'; +/// Resolves the active profile's Plex identity and primes companion-remote +/// crypto with it, returning whether crypto ended up ready. +/// +/// Crypto is an app-level service, not bound to any one widget: everything the +/// bootstrap needs is captured up front, so an unmount mid-await must not abort +/// work the user asked for. Hence no `context.mounted` guards below. +Future ensureCompanionRemoteCryptoFromContext(BuildContext context) async { + final companionRemote = context.read(); + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); + return companionRemote.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); +} + Future startCompanionRemoteHost(BuildContext context) async { final companionRemote = context.read(); if (companionRemote.isHostServerRunning) return true; try { - // The host is an app-level service, not bound to this widget: everything - // it needs is captured up front, so an unmount mid-await must not abort a - // start the user asked for. Hence no `context.mounted` guards below. - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); - final ok = await companionRemote.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); - if (!ok) return false; + if (!await ensureCompanionRemoteCryptoFromContext(context)) return false; await companionRemote.startHostServer(); return companionRemote.isHostServerRunning; diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index 7b50dabc..3c5a4304 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; +import '../utils/async_singleton.dart'; +import '../utils/device_channel.dart'; import '../utils/platform_detector.dart'; /// User override for the visual-effects tier (stored by SettingsService). @@ -19,11 +21,9 @@ enum VisualEffectsSetting { auto, full, reduced } class DevicePerformance { DevicePerformance._(); - static DevicePerformance? _instance; - static Future? _initialization; + static final AsyncSingleton _singleton = AsyncSingleton(); @visibleForTesting - static Future? debugDetectionGate; - static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); + static set debugDetectionGate(Future? value) => _singleton.debugGate = value; /// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel /// reservations), below 3 GB Shield-class devices (~2.8 GiB). @@ -39,35 +39,13 @@ class DevicePerformance { /// Get the singleton, detecting hardware signals on first call. /// [override] is the persisted SettingsService.visualEffects value. - static Future getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async { - final existing = _instance; - if (existing != null) { - final initialization = _initialization; - if (initialization != null) await initialization; - return existing; - } - - final instance = DevicePerformance._().._override = override; - _instance = instance; - final initialization = instance._detect(); - _initialization = initialization; - try { - await initialization; - } catch (_) { - if (identical(_instance, instance)) _instance = null; - rethrow; - } finally { - if (identical(_initialization, initialization)) _initialization = null; - } - return instance; - } + static Future getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) => + _singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect()); Future _detect() async { - final gate = debugDetectionGate; - if (gate != null) await gate; if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier try { - final result = await _deviceChannel.invokeMapMethod('getPerformanceSignals'); + final result = await deviceChannel.invokeMapMethod('getPerformanceSignals'); if (result == null) return; _is64Bit = result['is64Bit'] == true; _isLowRam = result['isLowRamDevice'] == true; @@ -85,7 +63,7 @@ class DevicePerformance { /// Total device RAM as reported by the platform, or null off-Android / /// before init. Used to scale memory-watchdog thresholds to the device. - static int? get totalMemBytes => _instance?._totalMemBytes; + static int? get totalMemBytes => _singleton.instance?._totalMemBytes; /// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB), /// independent of the visual-effects override. Use this for decisions tied to @@ -93,11 +71,11 @@ class DevicePerformance { /// boxes lagging a GL subtitle overlay — where a user's effects preference is /// irrelevant. Safe before init (returns false). See [isReduced] for the /// effects-tier gate that the override can force. - static bool get isLowEndHardware => _instance?._autoReduced ?? false; + static bool get isLowEndHardware => _singleton.instance?._autoReduced ?? false; /// Primary gate for effect chokepoints. Safe before init (full tier). static bool get isReduced { - final instance = _instance; + final instance = _singleton.instance; if (instance == null) return false; return switch (instance._override) { VisualEffectsSetting.auto => instance._autoReduced, @@ -112,7 +90,7 @@ class DevicePerformance { /// Update the user override from the settings screen and re-apply the /// budgets that were computed at boot. static void setOverrideSync(VisualEffectsSetting value) { - _instance?._override = value; + _singleton.instance?._override = value; applyImageCacheBudget(); } @@ -142,7 +120,7 @@ class DevicePerformance { /// Raw signals are always included (even when the tier is forced) so an /// uploaded log answers "did the reduced tier engage, and why / why not". static String describeSync() { - final instance = _instance; + final instance = _singleton.instance; if (instance == null) return 'unknown'; final tier = isReduced ? 'reduced' : 'full'; final signals = [ @@ -158,14 +136,13 @@ class DevicePerformance { @visibleForTesting static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) { - _initialization = null; - debugDetectionGate = null; if (autoReduced == null && override == null) { - _instance = null; + _singleton.debugReset(); return; } - _instance ??= DevicePerformance._(); - if (autoReduced != null) _instance!._autoReduced = autoReduced; - if (override != null) _instance!._override = override; + final instance = _singleton.instance ?? DevicePerformance._(); + _singleton.debugReset(instance: instance); + if (autoReduced != null) instance._autoReduced = autoReduced; + if (override != null) instance._override = override; } } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index e9d04e40..f3103e66 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1323,46 +1323,23 @@ class DownloadManagerService { ); } - var artworkSettled = !queueItem.downloadArtwork; - if (queueItem.downloadArtwork) { - final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); - final chapterArtworkSettled = metadata.serverId == null - ? false - : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); - artworkSettled = itemArtworkSettled && chapterArtworkSettled; - } - - var subtitlesSettled = !queueItem.downloadSubtitles; - if (queueItem.downloadSubtitles) { - try { - final resolution = await client.resolveDownload( - metadata, - mediaIndex: record?.mediaIndex ?? 0, - mediaSourceId: record?.mediaSourceId, - ); - if (resolution.externalSubtitlesResolved) { - subtitlesSettled = await _downloadSubtitles( - globalKey, - metadata, - resolution.externalSubtitles, - client, - showYear: showYear, - ); - } else { - appLogger.d('Subtitle enrichment remains deferred for $globalKey'); - } - } catch (e, st) { - appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e, stackTrace: st); - } - } - if (artworkSettled && subtitlesSettled) { + final settled = await _runSupplementaryDownloads( + globalKey, + metadata, + client, + downloadArtwork: queueItem.downloadArtwork, + downloadSubtitles: queueItem.downloadSubtitles, + record: record, + showYear: showYear, + ); + if (settled.artwork && settled.subtitles) { await _database.removeFromQueue(globalKey); appLogger.i('Deferred supplementary downloads completed for $globalKey'); } else { await _database.updateSupplementaryQueueIntent( globalKey, - downloadSubtitles: !subtitlesSettled, - downloadArtwork: !artworkSettled, + downloadSubtitles: !settled.subtitles, + downloadArtwork: !settled.artwork, ); } } catch (e, st) { @@ -1591,17 +1568,8 @@ class DownloadManagerService { if (client != null) unawaited(_processQueue(client)); } - Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) async { - if (!downloadsSupported || taskId.isEmpty) return; - try { - final cancelled = await FileDownloader().cancelTaskWithId(taskId); - if (cancelled) { - appLogger.d('Cancelled native task $taskId for $globalKey ($reason)'); - } - } catch (e) { - appLogger.w('Failed to cancel native task $taskId for $globalKey ($reason)', error: e); - } - } + Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) => + _cancelNativeTaskIds(globalKey, [taskId], reason: reason); Future _cancelNativeTasksForGlobalKey( String globalKey, { @@ -1624,16 +1592,7 @@ class DownloadManagerService { appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e); } - if (taskIds.isEmpty) return; - - try { - final cancelled = await FileDownloader().cancelTasksWithIds(taskIds); - if (cancelled) { - appLogger.d('Cancelled ${taskIds.length} native task(s) for $globalKey ($reason): ${taskIds.join(', ')}'); - } - } catch (e) { - appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${taskIds.join(', ')}', error: e); - } + await _cancelNativeTaskIds(globalKey, taskIds, reason: reason); } Future _downloadForCurrentTaskSession( @@ -2390,36 +2349,20 @@ class DownloadManagerService { try { final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey); final client = ctx?.client ?? await _getClientForDownloadKey(globalKey); - final showYear = ctx?.showYear; if (metadata != null && client != null) { - if (downloadArtwork) { - final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); - final chapterArtworkSettled = metadata.serverId == null - ? false - : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); - artworkSettled = itemArtworkSettled && chapterArtworkSettled; - } - if (downloadSubtitles) { - var subtitles = ctx?.subtitles; - if (subtitles == null) { - try { - final resolution = await client.resolveDownload( - metadata, - mediaIndex: existingCheck.mediaIndex, - mediaSourceId: existingCheck.mediaSourceId, - ); - if (resolution.externalSubtitlesResolved) { - subtitles = resolution.externalSubtitles; - } - } catch (e, st) { - appLogger.w('Could not re-resolve subtitles for $globalKey', error: e, stackTrace: st); - } - } - if (subtitles != null) { - subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); - } - } + final settled = await _runSupplementaryDownloads( + globalKey, + metadata, + client, + downloadArtwork: downloadArtwork, + downloadSubtitles: downloadSubtitles, + record: existingCheck, + showYear: ctx?.showYear, + preresolvedSubtitles: ctx?.subtitles, + ); + artworkSettled = settled.artwork; + subtitlesSettled = settled.subtitles; } } catch (e, st) { appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st); @@ -2516,6 +2459,58 @@ class DownloadManagerService { return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId); } + /// Best-effort supplementary work for an already-stored video (artwork, + /// chapter thumbnails, external subtitles); reports which half settled so the + /// caller can do its own queue-row bookkeeping. Shared by the completion path + /// and the deferred-repair path: [record] carries the media-source + /// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that + /// re-resolve, and [showYear] is caller-supplied because the paths differ. + Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads( + String globalKey, + MediaItem metadata, + MediaServerClient client, { + required bool downloadArtwork, + required bool downloadSubtitles, + required DownloadedMediaItem? record, + required int? showYear, + List? preresolvedSubtitles, + }) async { + var artworkSettled = !downloadArtwork; + if (downloadArtwork) { + final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); + final chapterArtworkSettled = metadata.serverId == null + ? false + : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); + artworkSettled = itemArtworkSettled && chapterArtworkSettled; + } + + var subtitlesSettled = !downloadSubtitles; + if (downloadSubtitles) { + try { + var subtitles = preresolvedSubtitles; + if (subtitles == null) { + final resolution = await client.resolveDownload( + metadata, + mediaIndex: record?.mediaIndex ?? 0, + mediaSourceId: record?.mediaSourceId, + ); + if (resolution.externalSubtitlesResolved) { + subtitles = resolution.externalSubtitles; + } else { + appLogger.d('Subtitle enrichment remains deferred for $globalKey'); + } + } + if (subtitles != null) { + subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); + } + } catch (e, st) { + appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st); + } + } + + return (artwork: artworkSettled, subtitles: subtitlesSettled); + } + Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return false; @@ -3266,24 +3261,39 @@ class DownloadManagerService { } } - Future _deleteMovieStorageDirectory(MediaItem movie) async { + /// Delete one media directory and everything under it, on either storage backend. + /// [safComponents] and [fileDirectory] are thunks so only the branch that runs + /// resolves its path — the file-mode getters create the directory as a side effect. + Future _deleteStorageDirectory({ + required List Function() safComponents, + required Future Function() fileDirectory, + required String label, + }) async { if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; - final movieDir = await _safStorage.getChild(safBaseUri, _storageService.getMovieSafPathComponents(movie)); - if (movieDir != null) { - await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory'); + final dir = await _safStorage.getChild(safBaseUri, safComponents()); + if (dir != null) { + await _deleteSafDirRecursive(dir.uri, description: '$label directory'); } return; } - final movieDir = await _storageService.getMovieDirectory(movie); - if (await movieDir.exists()) { - await movieDir.delete(recursive: true); - appLogger.i('Deleted movie directory: ${movieDir.path}'); + final dir = await fileDirectory(); + if (await dir.exists()) { + await dir.delete(recursive: true); + appLogger.i('Deleted $label directory: ${dir.path}'); } } + Future _deleteMovieStorageDirectory(MediaItem movie) { + return _deleteStorageDirectory( + safComponents: () => _storageService.getMovieSafPathComponents(movie), + fileDirectory: () => _storageService.getMovieDirectory(movie), + label: 'movie', + ); + } + Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo( MediaItem episode, { required int? showYear, @@ -3330,50 +3340,32 @@ class DownloadManagerService { } Future _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async { + await _deleteStorageDirectory( + safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear), + fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear), + label: 'season', + ); + + // Drop the parent show directory too if the deleted season left it empty. if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; - final seasonDir = await _safStorage.getChild( - safBaseUri, - _storageService.getSeasonSafPathComponents(season, showYear: showYear), - ); - if (seasonDir != null) { - await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory'); - } final showDir = await _safStorage.getChild( safBaseUri, _storageService.getShowSafPathComponents(season, showYear: showYear), ); - if (showDir != null) { - await _deleteEmptySafDirsInOrder([showDir.uri]); - } + await _deleteEmptySafDirsInOrder([showDir?.uri]); return; } - - final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear); - if (await seasonDir.exists()) { - await seasonDir.delete(recursive: true); - appLogger.i('Deleted season directory: ${seasonDir.path}'); - } await _cleanupShowDirectory(season, showYear); } - Future _deleteShowStorageDirectory(MediaItem show) async { - if (_storageService.isUsingSaf) { - final safBaseUri = _storageService.safBaseUri; - if (safBaseUri == null) return; - final showDir = await _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(show)); - if (showDir != null) { - await _deleteSafDirRecursive(showDir.uri, description: 'show directory'); - } - return; - } - - final showDir = await _storageService.getShowDirectory(show); - if (await showDir.exists()) { - await showDir.delete(recursive: true); - appLogger.i('Deleted show directory: ${showDir.path}'); - } + Future _deleteShowStorageDirectory(MediaItem show) { + return _deleteStorageDirectory( + safComponents: () => _storageService.getShowSafPathComponents(show), + fileDirectory: () => _storageService.getShowDirectory(show), + label: 'show', + ); } /// Safety net: after metadata-based deletion, verify the actual DB-recorded diff --git a/lib/services/downloaded_video_source.dart b/lib/services/downloaded_video_source.dart new file mode 100644 index 00000000..c343e2e3 --- /dev/null +++ b/lib/services/downloaded_video_source.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import '../database/app_database.dart'; +import '../models/download_models.dart'; +import '../utils/app_logger.dart'; +import '../utils/downloaded_version_match.dart'; +import 'download_storage_service.dart'; + +/// A downloaded copy resolved to a playable location, plus the version that is +/// actually on disk — which can differ from the requested one when +/// [resolveDownloadedVideoSource] was allowed to fall back. +typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId}); + +/// Single source of truth for "where is the playable copy of this downloaded +/// row, and is it the version that was asked for". +/// +/// Returns null when the row cannot back playback: the download is not +/// complete, it holds a different version than requested (unless +/// [allowAnyDownloadedVersion]), it has no stored video path, or the stored +/// file is gone from disk. +/// +/// Version matching is strict by default so online flows keep streaming an +/// explicitly requested non-downloaded version (issue #1440). With +/// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch +/// instead — for offline flows where the alternative is failing outright. +/// +/// Callers own their own preconditions (profile ownership, how the row was +/// looked up); this only judges the row itself. +Future resolveDownloadedVideoSource( + DownloadedMediaItem row, { + int? requestedMediaIndex, + String? requestedMediaSourceId, + bool allowAnyDownloadedVersion = false, +}) async { + if (row.status != DownloadStatus.completed.index) { + appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}'); + return null; + } + + if (!downloadedVersionMatches( + row, + requestedMediaIndex: requestedMediaIndex, + requestedMediaSourceId: requestedMediaSourceId, + )) { + if (!allowAnyDownloadedVersion) { + appLogger.d( + '[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} ' + '(source ${row.mediaSourceId}), but requested version $requestedMediaIndex ' + '(source ${requestedMediaSourceId?.trim()}) — skipping offline', + ); + return null; + } + appLogger.d( + '[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) ' + 'is not downloaded — falling back to downloaded version ${row.mediaIndex} ' + '(source ${row.mediaSourceId})', + ); + } + + final storedPath = row.videoFilePath; + if (storedPath == null) { + appLogger.d('Video file path is null for ${row.globalKey}'); + return null; + } + + final storageService = DownloadStorageService.instance; + // SAF URIs (content://) are already playable and come back untouched; file + // paths may be stored relative, so resolve them and confirm they still exist. + final readablePath = await storageService.getReadablePath(storedPath); + if (!storageService.isSafUri(storedPath) && !await File(readablePath).exists()) { + appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)'); + return null; + } + + appLogger.d('Found offline video: $readablePath'); + return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId); +} diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 3eec20be..60b044e3 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -30,70 +30,22 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future toggleFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - final isCurrentlyFullscreen = await MacOSWindowService.isFullscreen(); - if (isCurrentlyFullscreen) { - await MacOSWindowService.exitFullscreen(); - } else { - await MacOSWindowService.enterFullscreen(); - } - } else if (Platform.isWindows) { - // Route through the native Win32 runner, which restores to the monitor - // the window is currently on (window_manager 0.5.1 picks the wrong one - // on multi-monitor setups — see issue #880). The native code also - // preserves maximized state internally, so no unmaximize dance here. - final isCurrentlyFullscreen = await NativeWindowService.isFullScreen(); - await NativeWindowService.setFullScreen(!isCurrentlyFullscreen); - } else { - final isCurrentlyFullscreen = await windowManager.isFullScreen(); - if (isCurrentlyFullscreen) { - await windowManager.setFullScreen(false); - if (_wasMaximized) { - await windowManager.maximize(); - _wasMaximized = false; - } - } else { - _wasMaximized = await windowManager.isMaximized(); - if (_wasMaximized) { - await windowManager.unmaximize(); - } - await windowManager.setFullScreen(true); - } - } + final isCurrentlyFullscreen = await _platformIsFullscreen(); + await _platformSetFullscreen(!isCurrentlyFullscreen); } /// Enter fullscreen, preserving maximized state on Windows/Linux for restoration on exit. Future enterFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - await MacOSWindowService.enterFullscreen(); - } else if (Platform.isWindows) { - await NativeWindowService.setFullScreen(true); - } else { - _wasMaximized = await windowManager.isMaximized(); - if (_wasMaximized) { - await windowManager.unmaximize(); - } - await windowManager.setFullScreen(true); - } + await _platformSetFullscreen(true); } /// Exit fullscreen, restoring maximized state if needed Future exitFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - await MacOSWindowService.exitFullscreen(); - } else if (Platform.isWindows) { - await NativeWindowService.setFullScreen(false); - } else { - await windowManager.setFullScreen(false); - if (_wasMaximized) { - await windowManager.maximize(); - _wasMaximized = false; - } - } + await _platformSetFullscreen(false); } /// Exits fullscreen when the platform window is currently fullscreen. @@ -103,17 +55,47 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future exitFullscreenIfActive() async { if (!PlatformDetector.isDesktopOS()) return false; - final isActive = Platform.isMacOS - ? await MacOSWindowService.isFullscreen() - : Platform.isWindows - ? await NativeWindowService.isFullScreen() - : await windowManager.isFullScreen(); + final isActive = await _platformIsFullscreen(); if (!isActive) return false; - await exitFullscreen(); + await _platformSetFullscreen(false); return true; } + Future _platformIsFullscreen() { + if (Platform.isMacOS) return MacOSWindowService.isFullscreen(); + if (Platform.isWindows) return NativeWindowService.isFullScreen(); + return windowManager.isFullScreen(); + } + + Future _platformSetFullscreen(bool value) async { + if (Platform.isMacOS) { + if (value) { + await MacOSWindowService.enterFullscreen(); + } else { + await MacOSWindowService.exitFullscreen(); + } + } else if (Platform.isWindows) { + // Route through the native Win32 runner, which restores to the monitor + // the window is currently on (window_manager 0.5.1 picks the wrong one + // on multi-monitor setups — see issue #880). The native code also + // preserves maximized state internally, so no unmaximize dance here. + await NativeWindowService.setFullScreen(value); + } else if (value) { + _wasMaximized = await windowManager.isMaximized(); + if (_wasMaximized) { + await windowManager.unmaximize(); + } + await windowManager.setFullScreen(true); + } else { + await windowManager.setFullScreen(false); + if (_wasMaximized) { + await windowManager.maximize(); + _wasMaximized = false; + } + } + } + void startMonitoring() { if (!_shouldMonitor() || _isListening) return; diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 9353ad80..a34205cf 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -76,6 +76,20 @@ part 'jellyfin_client/parts/live_tv.dart'; part 'jellyfin_client/parts/images_downloads.dart'; part 'jellyfin_client/parts/metadata_edit.dart'; +/// Canonical declarations of the [JellyfinClient] internals that the `part` +/// mixins call into. +/// +/// Every part mixin is `on _JellyfinClientInternals`, so each shared member is +/// declared exactly once here instead of being re-declared per file. Members +/// used by a single part stay declared in that part. +mixin _JellyfinClientInternals on MediaServerCacheMixin { + JellyfinConnection get connection; + FailoverHttpClient get _http; + MediaItem? _mapItem(Map json); + List _mapItems(Iterable> items); + String? _absolutizeImagePath(String? path); +} + /// [MediaServerClient] over a Jellyfin server. /// /// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the @@ -85,6 +99,7 @@ part 'jellyfin_client/parts/metadata_edit.dart'; class JellyfinClient with MediaServerCacheMixin, + _JellyfinClientInternals, _JellyfinBrowseMethods, _JellyfinMusicMethods, _JellyfinPlaybackMethods, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index f53ddc61..86d7c143 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -30,6 +30,27 @@ List> _itemsArray(Object? data) { return const []; } +/// Builds a [LibraryPage] from an `/Items`-shaped response: the `Items` array +/// run through [map], plus the server's `TotalRecordCount` when it reports one. +/// Responses that omit it (or return a non-int) fall back to +/// [fallbackPageTotal], whose full-page sentinel keeps pagination enabled; +/// [singlePage] endpoints return everything at once, so a full page there means +/// the end of the list, not "there may be more". +LibraryPage _pagedItems( + Object? data, { + required int offset, + required List Function(List>) map, + int? requestedSize, + bool singlePage = false, +}) { + final rawItems = _itemsArray(data); + final rawTotal = data is Map ? data['TotalRecordCount'] : null; + final fallbackTotal = singlePage + ? offset + rawItems.length + : fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); + return LibraryPage(items: map(rawItems), totalCount: rawTotal is int ? rawTotal : fallbackTotal, offset: offset); +} + /// Slim field set for grid/list browsing — what the card UI actually /// renders (title, year, watched badge, episode count for series). /// @@ -145,12 +166,7 @@ const _detailFields = // any extra round-trip. 'ProviderIds'; -mixin _JellyfinBrowseMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - MediaItem? _mapItem(Map json); - List _mapItems(Iterable> items); - +mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Endpoint conventions follow what the official Jellyfin Kotlin SDK // generates (cross-checked against the Findroid client). The SDK mixes // `/Users/{userId}/...` for "user library" / "views" / "latest" / "single @@ -700,7 +716,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final items = _itemsArray(data); final rawTotal = data is Map ? data['TotalRecordCount'] : null; if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) { - return _pagedMediaItems(data, offset: offset, requestedSize: pageSize); + return _pagedItems(data, offset: offset, requestedSize: pageSize, map: _mapItems); } } } on MediaServerHttpException { @@ -722,7 +738,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } Future> fetchSeasonEpisodesPage( @@ -754,7 +770,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } /// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query @@ -925,27 +941,19 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { required String includeItemTypes, bool byAlbumArtist = false, AbortController? abort, - }) async { - final all = []; - var start = 0; - while (true) { - abort?.throwIfAborted(); - final page = await _fetchPlayableDescendantsPage( + }) { + return drainPages( + (start, size) => _fetchPlayableDescendantsPage( parentId, start: start, - size: _pagedListPageSize, + size: size, abort: abort, includeItemTypes: includeItemTypes, byAlbumArtist: byAlbumArtist, - ); - abort?.throwIfAborted(); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - abort?.throwIfAborted(); - return all; + ), + pageSize: _pagedListPageSize, + abort: abort, + ); } @override @@ -991,7 +999,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } /// All episodes of a series in the app's **aired watch order** — primarily by @@ -1146,18 +1154,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } @override - Future> fetchPersonMedia(String personId) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPersonMediaPage(personId, start: start, size: _pagedListPageSize); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchPersonMedia(String personId) => drainPages( + (start, size) => fetchPersonMediaPage(personId, start: start, size: size), + pageSize: _pagedListPageSize, + ); @override Future> fetchPersonMediaPage( @@ -1186,7 +1186,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } @override @@ -1637,16 +1637,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { try { final response = await _http.get(path, queryParameters: queryParameters, abort: abort); throwIfHttpError(response); - final data = response.data; - final rawItems = data is List ? data.whereType>().toList() : _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = singlePage - ? offset + rawItems.length - : fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - return LibraryPage( - items: _mapItems(rawItems), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, + return _pagedItems( + response.data, offset: offset, + requestedSize: requestedSize, + singlePage: singlePage, + map: _mapItems, ); } catch (e, st) { appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st); @@ -1654,17 +1650,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } } - LibraryPage _pagedMediaItems(Object? data, {required int offset, required int requestedSize}) { - final rawItems = _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - return LibraryPage( - items: _mapItems(rawItems), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, - offset: offset, - ); - } - @override Future> fetchRelatedHubs(String id, {int count = 10}) async { final response = await _http.get( diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart index bdfa44c2..8e3f8f29 100644 --- a/lib/services/jellyfin_client/parts/collections.dart +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -1,27 +1,15 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinCollectionMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - List _mapItems(Iterable> items); - +mixin _JellyfinCollectionMethods on _JellyfinClientInternals { static const int _collectionsPageSize = 36; String? _boxSetsViewId; @override - Future> fetchCollections(String libraryId) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchCollectionsPage(libraryId, start: start, size: _collectionsPageSize); - all.addAll(page.items); - if (page.items.isEmpty) break; - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchCollections(String libraryId) => drainPages( + (start, size) => fetchCollectionsPage(libraryId, start: start, size: size), + pageSize: _collectionsPageSize, + ); @override Future> fetchCollectionsPage( @@ -54,7 +42,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _itemsPage(response.data, offset: s, requestedSize: pageSize); + return _pagedItems(response.data, offset: s, requestedSize: pageSize, map: _mapItems); } Future _fetchBoxSetsViewId({AbortController? abort}) async { @@ -73,14 +61,6 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { return null; } - LibraryPage _itemsPage(Object? data, {required int offset, int? requestedSize}) { - final rawItems = _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - final total = rawTotal is int ? rawTotal : fallbackTotal; - return LibraryPage(items: _mapItems(rawItems), totalCount: total, offset: offset); - } - @override Future> fetchCollectionPage( String collectionId, { @@ -104,7 +84,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _itemsPage(response.data, offset: s, requestedSize: size); + return _pagedItems(response.data, offset: s, requestedSize: size, map: _mapItems); } @override diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index 0360cc64..77d738b5 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -1,6 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { +mixin _JellyfinFileInfoMethods on _JellyfinClientInternals { @override Future getFileInfo(MediaItem item) async { // Lightweight browse responses omit `MediaSources`; detail and some cached diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 5d8ea676..9e26c7ab 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -1,7 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { - JellyfinConnection get connection; +mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals { Future fetchPlaybackBundle( String itemId, { int sourceIndex = 0, diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 471f9353..c828e97a 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinLiveTvMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - String? _absolutizeImagePath(String? path); +mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { Future>> _safeFetchItemsArray( String path, Map queryParameters, { diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart index 926cf395..5e8a29d2 100644 --- a/lib/services/jellyfin_client/parts/metadata_edit.dart +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals { Future?> fetchEditableMetadataItem(String itemId) async { if (isOfflineMode) return null; final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}'); diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index ec556d94..81f293ac 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -4,11 +4,7 @@ part of '../../jellyfin_client.dart'; /// listings, instant mix, and lyrics. Endpoint conventions follow the /// Jellyfin web client's music surface (cross-checked against the Kotlin /// SDK), mirroring the style notes at the top of `browse.dart`. -mixin _JellyfinMusicMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - List _mapItems(Iterable> items); - +mixin _JellyfinMusicMethods on _JellyfinClientInternals { /// Albums credited to [artist], newest first. Queries `AlbumArtistIds` /// rather than `ParentId` because Jellyfin links albums to artists via /// tags — an artist's albums are usually not its folder children. diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 861588bc..060ae2b9 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -9,36 +9,7 @@ bool _canUseJellyfinStaticStreamFallback(Object error) { return true; } -PlaybackException _classifyJellyfinPlaybackFailure(Object error) { - if (error is MediaServerAuthException || - error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { - return PlaybackException( - t.messages.playbackAuthenticationRequired, - reason: PlaybackFailureReason.authenticationRequired, - ); - } - if (error is MediaServerHttpException) { - if (error.isCancellation) { - return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); - } - final status = error.statusCode; - if (error.isTransient || status != null && status >= 500) { - return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); - } - if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - } - if (error is FormatException || error is TypeError) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - return PlaybackException(t.messages.playbackFailed); -} - -mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters /// at the item level (`raw['Chapters']`) and native skip segments through a /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort @@ -230,7 +201,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId); } catch (error, stackTrace) { if (!_canUseJellyfinStaticStreamFallback(error)) { - Error.throwWithStackTrace(_classifyJellyfinPlaybackFailure(error), stackTrace); + Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace); } appLogger.w( 'Jellyfin playback negotiation unavailable; using the static stream', @@ -750,20 +721,16 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { @override Map get streamHeaders => const {}; - /// Tell the server the user has started playing [itemId]. Body shape - /// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the - /// same fields, and Jellyfin's session tracker drops events that omit - /// `PlayMethod` because it has no way to associate progress with an - /// active session row. - /// - /// [duration] is accepted for interface symmetry with Plex but ignored — - /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes - /// are still sent so the active session reflects the chosen tracks. - @override - Future reportPlaybackStarted({ + /// Shared body for the `/Sessions/Playing[/Progress]` pair — only [path] and + /// [isPaused] differ between start and progress. Shape mirrors the Jellyfin + /// SDK's `PlaybackStartInfo`/`PlaybackProgressInfo`: Findroid sends the same + /// fields, and Jellyfin's session tracker drops events that omit `PlayMethod` + /// because it has no way to associate progress with an active session row. + Future _postPlayingState( + String path, { required String itemId, required Duration position, - Duration? duration, + required bool isPaused, String? playSessionId, String? playMethod, String? liveStreamId, @@ -772,44 +739,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { int? subtitleStreamIndex, }) async { final response = await _http.post( - '/Sessions/Playing', - body: { - 'ItemId': itemId, - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), - 'CanSeek': true, - 'IsPaused': false, - 'IsMuted': false, - 'PlayMethod': playMethod ?? 'DirectPlay', - 'RepeatMode': 'RepeatNone', - 'PlaybackOrder': 'Default', - 'PlaySessionId': ?playSessionId, - 'LiveStreamId': ?liveStreamId, - }, - ); - throwIfHttpError(response); - } - - /// Periodic progress ping (5–10s cadence is typical). Server uses this to - /// drive the resume position, detect idle sessions, and save remembered - /// audio/subtitle stream indexes when enabled in Jellyfin user settings. - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final response = await _http.post( - '/Sessions/Playing/Progress', + path, body: { 'ItemId': itemId, 'MediaSourceId': ?mediaSourceId, @@ -829,6 +759,63 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { throwIfHttpError(response); } + /// Tell the server the user has started playing [itemId]. + /// + /// [duration] is accepted for interface symmetry with Plex but ignored — + /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes + /// are still sent so the active session reflects the chosen tracks. + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => _postPlayingState( + '/Sessions/Playing', + itemId: itemId, + position: position, + isPaused: false, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + + /// Periodic progress ping (5–10s cadence is typical). Server uses this to + /// drive the resume position, detect idle sessions, and save remembered + /// audio/subtitle stream indexes when enabled in Jellyfin user settings. + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => _postPlayingState( + '/Sessions/Playing/Progress', + itemId: itemId, + position: position, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + /// End-of-playback signal. Final position becomes the resume bookmark. /// [duration] is accepted for interface symmetry with Plex but ignored. @override diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart index 614cd82f..0e82d83d 100644 --- a/lib/services/jellyfin_client/parts/playlists.dart +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -1,31 +1,13 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - String? _absolutizeImagePath(String? path); - List _mapItems(Iterable> items); - +mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { static const int _playlistsPageSize = 200; @override - Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPlaylistsPage( - playlistType: playlistType, - smart: smart, - start: start, - size: _playlistsPageSize, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) => drainPages( + (start, size) => fetchPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size), + pageSize: _playlistsPageSize, + ); @override Future> fetchPlaylistsPage({ @@ -70,15 +52,11 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - final items = _itemsArray(response.data).map(_playlistFromJson).toList(); - final rawTotal = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage( - items: items, - totalCount: rawTotal is int ? rawTotal : fallbackTotal, + return _pagedItems( + response.data, offset: offset, + requestedSize: pageSize, + map: (raw) => raw.map(_playlistFromJson).toList(), ); } @@ -125,16 +103,7 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - final items = _itemsArray(response.data); - final rawTotal = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage( - items: _mapItems(items), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, - offset: offset, - ); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } @override diff --git a/lib/services/jellyfin_client/parts/watch_state.dart b/lib/services/jellyfin_client/parts/watch_state.dart index 2c1e4daa..eb362295 100644 --- a/lib/services/jellyfin_client/parts/watch_state.dart +++ b/lib/services/jellyfin_client/parts/watch_state.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinWatchStateMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinWatchStateMethods on _JellyfinClientInternals { @override Future markWatched(MediaItem item) async { final response = await _http.post( diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 89469811..f0960a59 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -455,6 +455,16 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } + /// Splits a raw add/edit form field into the individual URLs the user typed. + /// Entries are separated by newlines and/or commas; blanks are dropped. + static List parseUserEnteredUrls(String raw) { + return raw + .split(RegExp(r'[\n,]+')) + .map((url) => url.trim()) + .where((url) => url.isNotEmpty) + .toList(growable: false); + } + static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { final probeBaseUrls = []; final explicitBaseUrls = []; diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 250d9326..b6a4c4d7 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -137,6 +137,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { /// Launch playback from a Jellyfin folder row. Jellyfin has no server-side /// queue resource, so folders use the same local queue path as collections. /// The client query is video-only; music-only folders return [PlayQueueEmpty]. + @override Future launchFromFolder({ required MediaItem folder, required bool shuffle, diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 06ad7c30..52d79410 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -8,12 +8,11 @@ import '../i18n/strings.g.dart'; import '../mpv/mpv.dart'; import 'settings_binding_owner.dart'; import 'settings_service.dart'; +import 'shortcut_action.dart'; import '../utils/platform_detector.dart'; import '../utils/player_utils.dart'; class KeyboardShortcutsService extends ChangeNotifier { - static const Set _repeatableVideoActions = {'zoom_in', 'zoom_out'}; - static KeyboardShortcutsService? _instance; static Future? _initialization; late final SettingsBindingOwner _settingsBinding; @@ -218,12 +217,15 @@ class KeyboardShortcutsService extends ChangeNotifier { final isMetaPressed = HardwareKeyboard.instance.isMetaPressed; for (final entry in _hotkeys.entries) { - final action = entry.key; final hotkey = entry.value; if (hotkey == null) continue; if (physicalKey != hotkey.key) continue; + // Null for an id this build does not know: the event is still consumed so + // a stale binding never leaks through to another handler. + final action = ShortcutAction.fromId(entry.key); + final requiredModifiers = hotkey.modifiers ?? []; bool modifiersMatch = true; @@ -265,30 +267,13 @@ class KeyboardShortcutsService extends ChangeNotifier { continue; } - if (isRepeat && !_repeatableVideoActions.contains(action)) { + if (isRepeat && !(action?.repeatable ?? false)) { return KeyEventResult.handled; } - const playbackControlledActions = { - 'play_pause', - 'seek_forward', - 'seek_backward', - 'seek_forward_large', - 'seek_backward_large', - 'audio_track_next', - 'subtitle_track_next', - 'chapter_next', - 'chapter_previous', - 'speed_increase', - 'speed_decrease', - 'speed_reset', - 'sub_seek_next', - 'sub_seek_prev', - 'skip_marker', - }; - const mediaItemActions = {'episode_next', 'episode_previous'}; - if ((playbackControlledActions.contains(action) && !canControlPlayback) || - (mediaItemActions.contains(action) && !canNavigateMediaItems)) { + if (action == null || + (action.requiresPlayback && !canControlPlayback) || + (action.requiresMediaNavigation && !canNavigateMediaItems)) { return KeyEventResult.handled; } @@ -326,7 +311,7 @@ class KeyboardShortcutsService extends ChangeNotifier { } void _executeAction( - String action, + ShortcutAction action, Player player, VoidCallback? onToggleFullscreen, VoidCallback? onToggleSubtitles, @@ -363,154 +348,72 @@ class KeyboardShortcutsService extends ChangeNotifier { } switch (action) { - case 'play_pause': + case ShortcutAction.playPause: (onPlayPause ?? player.playOrPause).call(); - break; - case 'volume_up': + case ShortcutAction.volumeUp: onVolumeUp?.call(); - break; - case 'volume_down': + case ShortcutAction.volumeDown: onVolumeDown?.call(); - break; - case 'seek_forward': + case ShortcutAction.seekForward: performSeek(_seekTimeSmall); - break; - case 'seek_backward': + case ShortcutAction.seekBackward: performSeek(-_seekTimeSmall); - break; - case 'seek_forward_large': + case ShortcutAction.seekForwardLarge: performSeek(_seekTimeLarge); - break; - case 'seek_backward_large': + case ShortcutAction.seekBackwardLarge: performSeek(-_seekTimeLarge); - break; - case 'fullscreen_toggle': + case ShortcutAction.fullscreenToggle: onToggleFullscreen?.call(); - break; - case 'mute_toggle': + case ShortcutAction.muteToggle: onToggleMute?.call(); - break; - case 'subtitle_toggle': + case ShortcutAction.subtitleToggle: onToggleSubtitles?.call(); - break; - case 'audio_track_next': + case ShortcutAction.audioTrackNext: onNextAudioTrack?.call(); - break; - case 'subtitle_track_next': + case ShortcutAction.subtitleTrackNext: onNextSubtitleTrack?.call(); - break; - case 'chapter_next': + case ShortcutAction.chapterNext: onNextChapter?.call(); - break; - case 'chapter_previous': + case ShortcutAction.chapterPrevious: onPreviousChapter?.call(); - break; - case 'episode_next': + case ShortcutAction.episodeNext: onNextEpisode?.call(); - break; - case 'episode_previous': + case ShortcutAction.episodePrevious: onPreviousEpisode?.call(); - break; - case 'speed_increase': + case ShortcutAction.speedIncrease: final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); player.setRate(newRateUp); _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); - break; - case 'speed_decrease': + case ShortcutAction.speedDecrease: final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); player.setRate(newRateDown); _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); - break; - case 'speed_reset': + case ShortcutAction.speedReset: player.setRate(1.0); _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); - break; - case 'sub_seek_next': + case ShortcutAction.subSeekNext: player.command(['sub-seek', '1']); - break; - case 'sub_seek_prev': + case ShortcutAction.subSeekPrev: player.command(['sub-seek', '-1']); - break; - case 'shader_toggle': + case ShortcutAction.shaderToggle: onToggleShader?.call(); - break; - case 'skip_marker': + case ShortcutAction.skipMarker: onSkipMarker?.call(); - break; - case 'screenshot': + case ShortcutAction.screenshot: unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); - break; - case 'zoom_in': + case ShortcutAction.zoomIn: onZoomIn?.call(); - break; - case 'zoom_out': + case ShortcutAction.zoomOut: onZoomOut?.call(); - break; - case 'zoom_reset': + case ShortcutAction.zoomReset: onZoomReset?.call(); - break; } } String getActionDisplayName(String action) { - switch (action) { - case 'play_pause': - return t.hotkeys.actions.playPause; - case 'volume_up': - return t.hotkeys.actions.volumeUp; - case 'volume_down': - return t.hotkeys.actions.volumeDown; - case 'seek_forward': - return t.hotkeys.actions.seekForward(seconds: _seekTimeSmall); - case 'seek_backward': - return t.hotkeys.actions.seekBackward(seconds: _seekTimeSmall); - case 'seek_forward_large': - return t.hotkeys.actions.seekForward(seconds: _seekTimeLarge); - case 'seek_backward_large': - return t.hotkeys.actions.seekBackward(seconds: _seekTimeLarge); - case 'fullscreen_toggle': - return t.hotkeys.actions.fullscreenToggle; - case 'mute_toggle': - return t.hotkeys.actions.muteToggle; - case 'subtitle_toggle': - return t.hotkeys.actions.subtitleToggle; - case 'audio_track_next': - return t.hotkeys.actions.audioTrackNext; - case 'subtitle_track_next': - return t.hotkeys.actions.subtitleTrackNext; - case 'chapter_next': - return t.hotkeys.actions.chapterNext; - case 'chapter_previous': - return t.hotkeys.actions.chapterPrevious; - case 'episode_next': - return t.hotkeys.actions.episodeNext; - case 'episode_previous': - return t.hotkeys.actions.episodePrevious; - case 'speed_increase': - return t.hotkeys.actions.speedIncrease; - case 'speed_decrease': - return t.hotkeys.actions.speedDecrease; - case 'speed_reset': - return t.hotkeys.actions.speedReset; - case 'sub_seek_next': - return t.hotkeys.actions.subSeekNext; - case 'sub_seek_prev': - return t.hotkeys.actions.subSeekPrev; - case 'shader_toggle': - return t.hotkeys.actions.shaderToggle; - case 'skip_marker': - return t.hotkeys.actions.skipMarker; - case 'screenshot': - return t.hotkeys.actions.screenshot; - case 'zoom_in': - return t.hotkeys.actions.zoomIn; - case 'zoom_out': - return t.hotkeys.actions.zoomOut; - case 'zoom_reset': - return t.hotkeys.actions.zoomReset; - default: - return action; - } + final shortcut = ShortcutAction.fromId(action); + if (shortcut == null) return action; + return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge); } // Check if a hotkey is already assigned to another action diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart index 9f7f413a..bc338289 100644 --- a/lib/services/macos_window_service.dart +++ b/lib/services/macos_window_service.dart @@ -2,27 +2,6 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; import 'fullscreen_state_manager.dart'; -/// Abstract class for receiving macOS window delegate callbacks. -/// Extend this class and register with [MacOSWindowService] to receive -/// fullscreen transition events. -abstract class MacOSWindowDelegate { - /// Called when the window is about to enter fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillEnterFullScreen() {} - - /// Called when the window has entered fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidEnterFullScreen() {} - - /// Called when the window is about to exit fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillExitFullScreen() {} - - /// Called when the window has exited fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidExitFullScreen() {} -} - /// Service for manipulating macOS window properties. /// This is a native implementation replacing the macos_window_utils package. /// @@ -31,35 +10,25 @@ abstract class MacOSWindowDelegate { /// This service only exposes what's needed externally: /// - Traffic light visibility (for video controls) /// - Fullscreen enter/exit (for video controls) -/// - Delegate registration (for FullscreenStateManager updates) +/// - Fullscreen state tracking (for FullscreenStateManager updates) class MacOSWindowService { static const _channel = MethodChannel('com.plezy/window_utils'); static bool _initialized = false; static bool _delegateEnabled = false; - static final List _delegates = []; - static final MacOSWindowDelegate _fullscreenDelegate = _FullscreenWindowDelegate(); static Future _invoke(String method, [Map? args]) async { if (!Platform.isMacOS) return; await _channel.invokeMethod(method, args); } - static void _notifyDelegates(void Function(MacOSWindowDelegate) callback) { - for (final delegate in _delegates) { - callback(delegate); - } - } - + /// Window manipulation (toolbar, titlebar, traffic lights) is handled directly + /// in Swift's WindowDelegate; this only mirrors the transition into Dart state. static Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'windowWillEnterFullScreen': - _notifyDelegates((d) => d.windowWillEnterFullScreen()); - case 'windowDidEnterFullScreen': - _notifyDelegates((d) => d.windowDidEnterFullScreen()); - case 'windowWillExitFullScreen': - _notifyDelegates((d) => d.windowWillExitFullScreen()); + FullscreenStateManager().setFullscreen(true); case 'windowDidExitFullScreen': - _notifyDelegates((d) => d.windowDidExitFullScreen()); + FullscreenStateManager().setFullscreen(false); } } @@ -81,7 +50,6 @@ class MacOSWindowService { } await initialize(enableWindowDelegate: true); - addWindowDelegate(_fullscreenDelegate); await syncWindowChrome(); FullscreenStateManager().setFullscreen(await isFullscreen()); } @@ -104,12 +72,6 @@ class MacOSWindowService { } } - static void addWindowDelegate(MacOSWindowDelegate delegate) { - if (!_delegates.contains(delegate)) { - _delegates.add(delegate); - } - } - static Future setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible}); static Future syncWindowChrome() => _invoke('syncWindowChrome'); @@ -123,18 +85,3 @@ class MacOSWindowService { return await _channel.invokeMethod('isFullscreen') ?? false; } } - -/// Internal window delegate that manages fullscreen state. -/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled -/// directly in Swift's WindowDelegate. This class only updates Dart-side state. -class _FullscreenWindowDelegate extends MacOSWindowDelegate { - @override - void windowWillEnterFullScreen() { - FullscreenStateManager().setFullscreen(true); - } - - @override - void windowDidExitFullScreen() { - FullscreenStateManager().setFullscreen(false); - } -} diff --git a/lib/screens/video_player/media_control_router.dart b/lib/services/media_control_router.dart similarity index 80% rename from lib/screens/video_player/media_control_router.dart rename to lib/services/media_control_router.dart index 9e657f35..100eb3b8 100644 --- a/lib/screens/video_player/media_control_router.dart +++ b/lib/services/media_control_router.dart @@ -1,12 +1,14 @@ import 'package:os_media_controls/os_media_controls.dart'; -/// Screen-owned authorization boundary for user-originated OS media commands. +/// Authorization boundary for user-originated OS media commands, owned by +/// whoever holds the transport (the video screen, the music session). /// -/// Lifecycle/audio-route events are handled before this router. Recognized -/// commands are consumed even when denied so they cannot reach a background -/// route or stale player owner. -final class VideoPlayerMediaControlRouter { - const VideoPlayerMediaControlRouter({ +/// Lifecycle/audio-route events are handled before this router: [route] +/// reports `false` for what it does not recognize. Recognized commands are +/// consumed even when denied so they cannot reach a background route or stale +/// player owner. Both gates stay required — every owner states its policy. +final class MediaControlRouter { + const MediaControlRouter({ required this.canControlPlayback, required this.canNavigateMediaItems, required this.onPlay, diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index effba026..09e3b069 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -73,6 +73,17 @@ abstract class MediaListPlaybackLauncher { /// queue from `EpisodeNavigationService`). Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}); + /// Launch playback from a folder row of the library tree. Everything each + /// backend needs is stamped onto [folder]: Plex builds a server-side + /// `/playQueues` from [MediaItem.backendFolderKey] (returning a + /// [PlayQueueError] when the row carries none), Jellyfin fetches the + /// folder's playable descendants and publishes a local queue. + Future launchFromFolder({ + required MediaItem folder, + required bool shuffle, + bool showLoadingIndicator = true, + }); + /// Pick the right implementation for [item]. Reads /// [MediaItem.backend] / [MediaPlaylist.backend]. static MediaListPlaybackLauncher forItem(BuildContext context, Object item) { diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 38b065f5..50ddf00b 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -77,6 +77,10 @@ class MultiServerManager { Stream> get statusStream => _statusController.stream; + /// Publish a snapshot of the per-server online map — subscribers must never + /// receive the live [_serverStatus] instance. + void _emitStatus() => _statusController.add(Map.from(_serverStatus)); + /// Per-server connect progress during a bind. Unlike [statusStream] — whose /// first emission means "the binder's first connect pass finished" and which /// triggers libraries/live-tv work per emission — this fires as each @@ -106,6 +110,26 @@ class MultiServerManager { String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId]; + /// Record the Plex identity a server is bound under — the single writer for + /// all three per-server Plex registrations. A null [scope] (only + /// [markPlexConnectionAuthError], which has no profile yet) leaves any + /// previously recorded scope in place. + void _registerPlexServer( + String serverId, + PlexServer server, { + required String clientIdentifier, + PlexProfileScopeId? scope, + }) { + _clientIdByServer[serverId] = clientIdentifier; + _plexServers[serverId] = server; + if (scope != null) _plexScopeByServer[serverId] = scope; + } + + /// Whether [compoundId] is still the client bound as the active user for + /// [machineId]. Async Jellyfin work must re-check this before publishing a + /// result — a profile switch can rebind the machine mid-probe. + bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId; + /// All Jellyfin clients ever added, keyed by the compound connection id /// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin /// server coexist — adding the second user's client won't tear down the @@ -231,7 +255,7 @@ class MultiServerManager { void debugMarkAuthErrorForTesting(ServerId serverId) { _serverStatus[serverId] = false; _authErrorServers.add(serverId); - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } /// Mark every cached Plex server on [connection] as auth-rejected without @@ -240,12 +264,11 @@ class MultiServerManager { void markPlexConnectionAuthError(PlexAccountConnection connection) { for (final server in connection.servers) { final id = server.clientIdentifier; - _clientIdByServer[id] = connection.clientIdentifier; - _plexServers[id] = server; + _registerPlexServer(id, server, clientIdentifier: connection.clientIdentifier); _serverStatus[id] = false; _authErrorServers.add(id); } - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } /// Plex-specific server config (name, machineId, connection candidates, @@ -457,37 +480,43 @@ class MultiServerManager { .where((entry) => entry.value.connection.serverMachineId == serverId) .map((entry) => entry.key) .toList(); + final activeClient = _forgetServer(serverId); if (jellyfinCompoundIds.isNotEmpty) { final closed = {}; - _clients.remove(serverId); - _activeJellyfinMachine.remove(serverId); for (final compoundId in jellyfinCompoundIds) { final client = _jellyfinByCompoundId.remove(compoundId); _jellyfinHealthByCompoundId.remove(compoundId); if (client != null && closed.add(client)) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); } } - } else { - final client = _clients.remove(serverId); - if (client != null) _closeClient(client); + } else if (activeClient != null) { + // Jellyfin's clients were all closed above. + unawaited(_closeClientGracefully(activeClient)); } - _plexServers.remove(serverId); - _plexScopeByServer.remove(serverId); - _serverStatus.remove(serverId); - _authErrorServers.remove(serverId); - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); appLogger.i('Removed server: $serverId'); } - void _closeClient(MediaServerClient client) { - if (client case final GracefullyCloseable graceful) { - unawaited(graceful.closeGracefully()); - } else { - client.close(); - } + /// Drop every registration keyed by [serverId], cancel its pending exhaustion + /// retry, and return the client that was bound (the caller closes it). The + /// single teardown for both removal paths, so they cannot drift apart again. + /// The in-flight guards ([_activeOptimizations], [_endpointHealthChecks]) are + /// deliberately left alone — they are owned by the futures that set them. + MediaServerClient? _forgetServer(String serverId) { + _reconnectDebounce.remove(serverId)?.cancel(); + final client = _clients.remove(serverId); + _activeJellyfinMachine.remove(serverId); + _plexServers.remove(serverId); + _clientIdByServer.remove(serverId); + _plexScopeByServer.remove(serverId); + _serverStatus.remove(serverId); + _authErrorServers.remove(serverId); + return client; } + /// Close [client], draining in-flight requests when it supports it. Callers + /// that do not need to wait wrap the call in `unawaited(...)`. Future _closeClientGracefully( MediaServerClient client, { Duration drainTimeout = const Duration(seconds: 2), @@ -539,9 +568,7 @@ class MultiServerManager { ); if (!applied || isStale() || !identical(_clients[serverId], existing)) return; - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; + _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId); _authErrorServers.remove(serverId); _serverStatus[serverId] = true; bound.add(serverId); @@ -556,9 +583,7 @@ class MultiServerManager { return; } - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; + _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId); try { final client = await _createClientForServer( server: server, @@ -566,11 +591,11 @@ class MultiServerManager { profileScopeId: profileScopeId, ).namedTimeout(timeout, operation: 'connect to ${server.name}'); if (isStale() || !identical(_plexServers[serverId], server)) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); return; } final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _clients[serverId] = client; _serverStatus[serverId] = true; _authErrorServers.remove(serverId); @@ -586,7 +611,7 @@ class MultiServerManager { }); await Future.wait(futures); if (isStale()) return const {}; - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); if (bound.isNotEmpty && _connectivitySubscription == null) { _startNetworkMonitoring(); } @@ -682,7 +707,7 @@ class MultiServerManager { // the connection materially changed (token refresh, URL-set edit); an // unchanged re-add was already handled by the reuse branch above. final oldClient = _jellyfinByCompoundId[compoundId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _jellyfinByCompoundId[compoundId] = client; // Bind this user as the active client for its machine. A previously @@ -739,13 +764,13 @@ class MultiServerManager { Future _reuseJellyfinClient(JellyfinClient client) async { final compoundId = client.connection.id; final machineId = client.connection.serverMachineId; - final rebound = _activeJellyfinMachine[machineId] != compoundId; + final rebound = !_isActiveJellyfin(machineId, compoundId); _clients[machineId] = client; _activeJellyfinMachine[machineId] = compoundId; final health = await client.checkHealth(); _jellyfinHealthByCompoundId[compoundId] = health; - if (_activeJellyfinMachine[machineId] != compoundId) { + if (!_isActiveJellyfin(machineId, compoundId)) { // A concurrent remove/re-add won while the probe was in flight. appLogger.d('Ignoring stale Jellyfin reuse result for ${client.connection.serverName}'); return health == HealthStatus.online; @@ -754,7 +779,7 @@ class MultiServerManager { if (rebound) { // The machine's active user changed even if its online status didn't; // client-map consumers need to observe the swap. - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } final healthy = health == HealthStatus.online; appLogger.i( @@ -784,7 +809,7 @@ class MultiServerManager { appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st); } } - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); }; } @@ -802,13 +827,10 @@ class MultiServerManager { final machineId = connection.serverMachineId; final client = _jellyfinByCompoundId.remove(compoundId); _jellyfinHealthByCompoundId.remove(compoundId); - if (client != null) _closeClient(client); - if (_activeJellyfinMachine[machineId] == compoundId) { - _activeJellyfinMachine.remove(machineId); - _clients.remove(machineId); - _serverStatus.remove(machineId); - _authErrorServers.remove(machineId); - _statusController.add(Map.from(_serverStatus)); + if (client != null) unawaited(_closeClientGracefully(client)); + if (_isActiveJellyfin(machineId, compoundId)) { + _forgetServer(machineId); + _emitStatus(); } } @@ -816,15 +838,8 @@ class MultiServerManager { /// /// Clears the auth-error flag — callers that observed an auth failure /// should use [_applyHealth] instead. - void updateServerStatus(ServerId serverId, bool isOnline) { - final prevOnline = _serverStatus[serverId]; - final hadAuthError = _authErrorServers.remove(serverId); - if (prevOnline != isOnline || hadAuthError) { - _serverStatus[serverId] = isOnline; - _statusController.add(Map.from(_serverStatus)); - appLogger.d('Server $serverId status changed to: $isOnline'); - } - } + void updateServerStatus(ServerId serverId, bool isOnline) => + _applyHealth(serverId, isOnline ? HealthStatus.online : HealthStatus.offline); /// Apply a health-probe outcome to both online state and auth-error /// tracking. Used by the manager's own health checks; external callers @@ -844,7 +859,7 @@ class MultiServerManager { final changed = prevOnline != isOnline || hadAuthError != isAuthError; if (changed) { - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); if (isAuthError) { appLogger.w('Server $serverId auth rejected — token expired or revoked'); } else { @@ -880,7 +895,7 @@ class MultiServerManager { if (client is JellyfinClient) { final compoundId = expectedJellyfinCompoundId ?? client.connection.id; _jellyfinHealthByCompoundId[compoundId] = status; - if (_activeJellyfinMachine[serverId] != compoundId) { + if (!_isActiveJellyfin(serverId, compoundId)) { appLogger.d('Ignoring stale Jellyfin health result for ${client.connection.serverName}'); return; } @@ -949,6 +964,33 @@ class MultiServerManager { appLogger.i('Stopped network monitoring'); } + /// Run [taskBuilder] as the single in-flight optimize/reconnect task for + /// [serverId] — the sole owner of the [_activeOptimizations] invariant. + /// + /// While an entry exists the builder is never invoked and a completed future + /// is returned, so a caller awaiting a batch never waits on work it did not + /// start. The registered future always clears its own entry. [timeout] bounds + /// the task, logging ` timed out for ` when it fires. + Future _runServerTask( + String serverId, + Future Function() taskBuilder, { + Duration? timeout, + String? timeoutLabel, + }) { + if (_activeOptimizations.containsKey(serverId)) return Future.value(); + + var task = taskBuilder(); + if (timeout != null) { + task = task.timeout(timeout, onTimeout: () => appLogger.d('$timeoutLabel timed out for $serverId')); + } + // Must not *return* the removed entry — whenComplete would then await this very future. + final registered = task.whenComplete(() { + _activeOptimizations.remove(serverId); + }); + _activeOptimizations[serverId] = registered; + return registered; + } + /// Re-optimize all connected servers and attempt reconnection for offline ones void _reoptimizeAllServers({required String reason}) { for (final entry in _plexServers.entries) { @@ -961,33 +1003,27 @@ class MultiServerManager { continue; } - if (!isServerOnline(ServerId(serverId))) { - // Attempt reconnection for offline servers - _activeOptimizations[serverId] = _reconnectServer(ServerId(serverId), server).whenComplete(() { - _activeOptimizations.remove(serverId); - }); - } else { - // Re-optimize online servers - _activeOptimizations[serverId] = _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) - .whenComplete(() { - _activeOptimizations.remove(serverId); - }); - } + // Online servers get their endpoints re-raced; offline ones a full reconnect. + unawaited( + _runServerTask( + serverId, + () => isServerOnline(ServerId(serverId)) + ? _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) + : _reconnectServer(ServerId(serverId), server), + ), + ); } // Jellyfin re-probes offline servers here. Online clients keep their current // endpoint and can still fail over per request through JellyfinClient. for (final entry in _activeJellyfinMachine.entries) { final serverId = entry.key; - if (_activeOptimizations.containsKey(serverId)) continue; if (isServerOnline(ServerId(serverId))) continue; final client = _jellyfinByCompoundId[entry.value]; if (client == null) continue; - _activeOptimizations[serverId] = _reconnectJellyfinServer(serverId, client).whenComplete(() { - _activeOptimizations.remove(serverId); - }); + unawaited(_runServerTask(serverId, () => _reconnectJellyfinServer(serverId, client))); } } @@ -1064,13 +1100,13 @@ class MultiServerManager { if (!identical(_plexServers[serverId], server) || _resolveClientIdentifier(serverId) != clientId || _plexScopeByServer[serverId] != profileScopeId) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); appLogger.d('Ignoring stale reconnection result for ${server.name}'); return; } final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _clients[serverId] = client; updateServerStatus(serverId, true); appLogger.i('Successfully reconnected to ${server.name}'); @@ -1093,7 +1129,7 @@ class MultiServerManager { appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}'); final status = await client.checkHealth(); _jellyfinHealthByCompoundId[expectedCompoundId] = status; - if (_activeJellyfinMachine[machineId] != expectedCompoundId) { + if (!_isActiveJellyfin(machineId, expectedCompoundId)) { appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}'); return; } @@ -1144,22 +1180,14 @@ class MultiServerManager { } final futures = offline.map((serverId) { - // Skip if already running - if (_activeOptimizations.containsKey(serverId)) return Future.value(); - final server = _plexServers[serverId]; if (server != null) { - final future = _reconnectServer(ServerId(serverId), server) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); - - _activeOptimizations[serverId] = future; - return future; + return _runServerTask( + serverId, + () => _reconnectServer(ServerId(serverId), server), + timeout: const Duration(seconds: 15), + timeoutLabel: 'Reconnection', + ); } // Jellyfin offline path — no `_plexServers` entry, but the active @@ -1167,21 +1195,14 @@ class MultiServerManager { // `_activeJellyfinMachine`. Run the same auth probe used at add time. final activeCompoundId = _activeJellyfinMachine[serverId]; final jellyfinClient = activeCompoundId != null ? _jellyfinByCompoundId[activeCompoundId] : null; - if (jellyfinClient != null) { - final future = _reconnectJellyfinServer(serverId, jellyfinClient) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Jellyfin reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); + if (jellyfinClient == null) return Future.value(); - _activeOptimizations[serverId] = future; - return future; - } - - return Future.value(); + return _runServerTask( + serverId, + () => _reconnectJellyfinServer(serverId, jellyfinClient), + timeout: const Duration(seconds: 15), + timeoutLabel: 'Jellyfin reconnection', + ); }); await Future.wait(futures); @@ -1232,13 +1253,14 @@ class MultiServerManager { appLogger.i('Health probe confirmed $serverId offline, triggering reconnection'); - if (_activeOptimizations.containsKey(serverId)) return; - final reconnect = plexServer != null - ? _reconnectServer(serverId, plexServer) - : _reconnectJellyfinServer(serverId, jellyfinClient!); - _activeOptimizations[serverId] = reconnect.whenComplete(() { - _activeOptimizations.remove(serverId); - }); + unawaited( + _runServerTask( + serverId, + () => plexServer != null + ? _reconnectServer(serverId, plexServer) + : _reconnectJellyfinServer(serverId, jellyfinClient!), + ), + ); } finally { _endpointHealthChecks.remove(serverId); } @@ -1248,7 +1270,7 @@ class MultiServerManager { /// client stays in [_jellyfinByCompoundId]); only the currently bound /// client's exhaustion may verify and flip the machine's status. void _onJellyfinEndpointsExhausted(String machineId, String compoundId) { - if (_activeJellyfinMachine[machineId] != compoundId) { + if (!_isActiveJellyfin(machineId, compoundId)) { appLogger.d('Ignoring endpoint exhaustion from inactive Jellyfin client', error: compoundId); return; } @@ -1264,13 +1286,12 @@ class MultiServerManager { @visibleForTesting void debugTriggerEndpointsExhaustedForTesting(ServerId serverId) => _onServerEndpointsExhausted(serverId); - /// Disconnect all servers + /// Disconnect all servers, fire-and-forget. + /// + /// Registrations are dropped synchronously ([_detachAllClients] runs before + /// the first await); only the socket drain is left running in the background. void disconnectAll() { - appLogger.i('Disconnecting all servers'); - final clients = _detachAllClients(); - for (final client in clients) { - _closeClient(client); - } + unawaited(disconnectAllGracefully(drainTimeout: const Duration(seconds: 2))); } Future disconnectAllGracefully({Duration drainTimeout = const Duration(seconds: 5)}) async { diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 0293c5b2..d8c81eef 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -14,6 +14,7 @@ import '../../mpv/player/player.dart'; import '../../utils/app_logger.dart'; import '../../utils/notification_permission.dart'; import '../../utils/platform_detector.dart'; +import '../media_control_router.dart'; import '../media_controls_manager.dart'; import '../multi_server_manager.dart'; import '../offline_watch_sync_service.dart'; @@ -783,27 +784,32 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO ); } + /// OS transport commands. Music has no authorization gate: the session only + /// exists while a track is loaded, and that is checked in [_onControlEvent]. + late final _mediaControlRouter = MediaControlRouter( + canControlPlayback: () => true, + canNavigateMediaItems: () => true, + onPlay: () => unawaited(play()), + onPause: () => unawaited(pause()), + onTogglePlayPause: () => unawaited(togglePlayPause()), + onSeek: (position) => unawaited(seek(position)), + onNext: () => unawaited(next()), + onPrevious: () => unawaited(previous()), + onStop: () => unawaited(stop()), + onSkipForward: (interval) => unawaited(_seekRelative(interval ?? _defaultSkipInterval)), + onSkipBackward: (interval) => unawaited(_seekRelative(-(interval ?? _defaultSkipInterval))), + // Speed is deliberately ignored: music always plays at 1.0 and the control + // is not advertised — but Linux MPRIS exposes an always-writable Rate + // property, so the event can still arrive. The periodic playback-state + // update reasserts speed 1.0. + onSetSpeed: (_) {}, + ); + void _onControlEvent(MediaControlEvent event) { if (_disposed || _currentTrack == null) return; - if (event is PlayEvent) { - unawaited(play()); - } else if (event is PauseEvent) { - unawaited(pause()); - } else if (event is TogglePlayPauseEvent) { - unawaited(togglePlayPause()); - } else if (event is NextTrackEvent) { - unawaited(next()); - } else if (event is PreviousTrackEvent) { - unawaited(previous()); - } else if (event is SeekEvent) { - unawaited(seek(event.position)); - } else if (event is StopEvent) { - unawaited(stop()); - } else if (event is SkipForwardEvent) { - unawaited(_seekRelative(event.interval ?? _defaultSkipInterval)); - } else if (event is SkipBackwardEvent) { - unawaited(_seekRelative(-(event.interval ?? _defaultSkipInterval))); - } else if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) { + if (_mediaControlRouter.route(event)) return; + + if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) { // Remember whether we were playing so interruption-end/route-return // can resume. Unlike video, music resumes even while backgrounded — // background audio is the product. @@ -822,10 +828,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO unawaited(play()); } } - // SetSpeedEvent is deliberately unhandled: music always plays at 1.0 and - // the control is not advertised — but Linux MPRIS exposes an always- - // writable Rate property, so the event can still arrive. The periodic - // playback-state update reasserts speed 1.0. } static const _defaultSkipInterval = Duration(seconds: 15); @@ -1148,10 +1150,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _queueSessionRevision++; _generation++; _invalidateArmRequests(); - _completedConfirmTimer?.cancel(); - _completedConfirmTimer = null; - _cancelSleepTimer(); - _finalizeCurrentTrack(); + _cancelTimersAndFinalizeTrack(); _queue.clear(); _currentTrack = null; _currentSource = null; @@ -1161,27 +1160,56 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _resumeAfterInterruption = false; _setStatus(endStatus, forceNotify: true); - final player = _player; - _player = null; + await _teardownPlayerAndControls(awaitStop: true); + } + + /// Kills the completion/sleep timers and flushes the track's final progress + /// report — done before [_setStatus] so listeners never see a live timer. + void _cancelTimersAndFinalizeTrack() { + _completedConfirmTimer?.cancel(); + _completedConfirmTimer = null; + _cancelSleepTimer(); + _finalizeCurrentTrack(); + } + + /// Detaches the player streams, shuts the player down and drops the OS media + /// session — the teardown shared by [_stopSession] and [dispose]. + /// + /// [awaitStop] stops the player and awaits every step, so callers know the + /// audio core is gone once the future resolves. The `false` path must never + /// suspend: [dispose] is a synchronous override and needs the whole teardown + /// to run in the caller's turn, before `super.dispose()`. + Future _teardownPlayerAndControls({required bool awaitStop}) async { for (final sub in _playerSubs) { unawaited(sub.cancel()); } _playerSubs.clear(); + final player = _player; + _player = null; if (player != null && !player.disposed) { - try { - await player.stop(); - } catch (e) { - appLogger.d('Audio player stop failed during session teardown', error: e); - } - try { - await player.abandonAudioFocus(); - } catch (e) { - appLogger.d('Audio focus abandon failed during session teardown', error: e); - } - try { - await player.dispose(); - } catch (e) { - appLogger.w('Audio player dispose failed during session teardown', error: e); + if (awaitStop) { + try { + await player.stop(); + } catch (e) { + appLogger.d('Audio player stop failed during session teardown', error: e); + } + try { + await player.abandonAudioFocus(); + } catch (e) { + appLogger.d('Audio focus abandon failed during session teardown', error: e); + } + try { + await player.dispose(); + } catch (e) { + appLogger.w('Audio player dispose failed during session teardown', error: e); + } + } else { + unawaited( + player.abandonAudioFocus().catchError((Object e) { + appLogger.d('Audio focus abandon failed during dispose', error: e); + }), + ); + unawaited(player.dispose()); } } @@ -1229,33 +1257,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _observesLifecycle = false; } _coordinator.unregisterMusicSession(_stopForVideoClaim); - _completedConfirmTimer?.cancel(); - _completedConfirmTimer = null; - _cancelSleepTimer(); - _finalizeCurrentTrack(); - for (final sub in _playerSubs) { - unawaited(sub.cancel()); - } - _playerSubs.clear(); - unawaited(_controlEventsSub?.cancel()); - _controlEventsSub = null; - final player = _player; - _player = null; - if (player != null && !player.disposed) { - unawaited( - player.abandonAudioFocus().catchError((Object e) { - appLogger.d('Audio focus abandon failed during dispose', error: e); - }), - ); - unawaited(player.dispose()); - } - final controls = _mediaControls; - _mediaControls = null; - if (controls != null) { - unawaited(controls.setBackgroundMode(false)); - unawaited(controls.clear()); - controls.dispose(); - } + _cancelTimersAndFinalizeTrack(); + // Runs to completion synchronously — see the awaitStop: false contract. + unawaited(_teardownPlayerAndControls(awaitStop: false)); unawaited(_positionController.close()); unawaited(_errorsController.close()); _volumeNotifier.dispose(); diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 21fe222c..911ea261 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -27,9 +27,8 @@ export 'media_list_playback_launcher.dart' /// 3. Navigating to the video player /// 4. Handling errors with appropriate feedback /// -/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for -/// the backend-neutral entry point. Flows outside that abstraction, such as -/// [launchFromFolder], live directly on this class. +/// Implements the backend-neutral [MediaListPlaybackLauncher] entry points +/// on top of that resource. class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final BuildContext context; final PlexClient client; @@ -219,14 +218,22 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - /// Launch playback from a folder's contents. + /// Launch playback from a folder's contents. The `/folder` key and the + /// owning library are both stamped onto the folder row by the listing + /// fetch, so the neutral [MediaItem] carries everything Plex needs. + @override Future launchFromFolder({ - required String folderKey, + required MediaItem folder, required bool shuffle, - String? libraryId, - String? libraryTitle, bool showLoadingIndicator = true, }) async { + final folderKey = folder.backendFolderKey; + if (folderKey == null) { + return PlayQueueError(Exception('Folder is missing its backend folder key')); + } + final libraryId = folder.libraryId; + final libraryTitle = folder.libraryTitle; + return executeWithLoading( context: context, showLoading: showLoadingIndicator, diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 79850eb1..9a600db9 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -9,14 +9,13 @@ import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; import '../models/audio_quality_preset.dart'; -import '../models/download_models.dart'; import '../models/transcode_quality_preset.dart'; import '../mpv/models.dart'; import '../utils/app_logger.dart'; -import '../utils/downloaded_version_match.dart'; import '../utils/global_key_utils.dart'; import 'cached_playback_metadata_service.dart'; import 'download_storage_service.dart'; +import 'downloaded_video_source.dart'; import 'playback_initialization_types.dart'; // Re-export so existing callers (video_player_screen) can keep importing @@ -70,7 +69,7 @@ class PlaybackInitializationService { /// streaming an explicitly requested non-downloaded version. With /// [allowAnyDownloadedVersion] the single downloaded version is returned on /// mismatch instead — for offline flows where the alternative is failing. - Future<({String path, int mediaIndex, String? mediaSourceId})?> _resolveOfflineVideoSource( + Future _resolveOfflineVideoSource( ServerId serverId, String ratingKey, { required int mediaIndex, @@ -89,55 +88,16 @@ class PlaybackInitializationService { ..where((tbl) => tbl.globalKey.equals(buildGlobalKey(ServerId(serverId), ratingKey))); final downloadedItem = await query.getSingleOrNull(); - - // Return null if not found or not completed - if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) { + if (downloadedItem == null) { return null; } - final matches = downloadedVersionMatches( + return await resolveDownloadedVideoSource( downloadedItem, requestedMediaIndex: mediaIndex, requestedMediaSourceId: selectedMediaSourceId, + allowAnyDownloadedVersion: allowAnyDownloadedVersion, ); - if (!matches) { - if (!allowAnyDownloadedVersion) { - appLogger.d( - '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex} ' - '(source ${downloadedItem.mediaSourceId}), but requested version ' - '$mediaIndex (source ${selectedMediaSourceId?.trim()}) — skipping offline', - ); - return null; - } - appLogger.d( - '[VersionTrace] Requested version $mediaIndex (source ${selectedMediaSourceId?.trim()}) ' - 'is not downloaded — falling back to downloaded version ' - '${downloadedItem.mediaIndex} (source ${downloadedItem.mediaSourceId})', - ); - } - - // Return null if no video file path - if (downloadedItem.videoFilePath == null) { - return null; - } - - final storageService = DownloadStorageService.instance; - final storedPath = downloadedItem.videoFilePath!; - - // Get readable path (handles both SAF URIs and file paths) - final readablePath = await storageService.getReadablePath(storedPath); - - // For file paths (not SAF), verify the file exists - if (!storageService.isSafUri(storedPath)) { - final file = File(readablePath); - if (!await file.exists()) { - appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)'); - return null; - } - } - - appLogger.d('Found offline video: $readablePath'); - return (path: readablePath, mediaIndex: downloadedItem.mediaIndex, mediaSourceId: downloadedItem.mediaSourceId); } catch (e) { appLogger.w('Error checking offline video path', error: e); return null; @@ -165,7 +125,7 @@ class PlaybackInitializationService { }) async { final serverId = metadata.serverId ?? client?.serverId; - ({String path, int mediaIndex, String? mediaSourceId})? offlineSource; + DownloadedVideoSource? offlineSource; if (serverId != null && (preferOffline || client == null) && database != null) { offlineSource = await _resolveOfflineVideoSource( ServerId(serverId), diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index c6a07eff..1ca83374 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -1,3 +1,5 @@ +import '../exceptions/media_server_exceptions.dart'; +import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_source_info.dart'; import '../media/media_version.dart'; @@ -180,3 +182,34 @@ class PlaybackException implements Exception { @override String toString() => message; } + +/// Maps a transport-level failure raised while initializing playback onto a +/// display-safe [PlaybackException]. +/// +/// Backend-neutral on purpose: Plex and Jellyfin both throw the same +/// [MediaServerException] hierarchy, so both clients classify identically. +PlaybackException classifyPlaybackFailure(Object error) { + if (error is MediaServerAuthException || + error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { + return PlaybackException( + t.messages.playbackAuthenticationRequired, + reason: PlaybackFailureReason.authenticationRequired, + ); + } + if (error is MediaServerHttpException) { + if (error.isCancellation) { + return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); + } + final status = error.statusCode; + if (error.isTransient || status != null && status >= 500) { + return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); + } + if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + } + if (error is FormatException || error is TypeError) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + return PlaybackException(t.messages.playbackFailed); +} diff --git a/lib/services/playlist_items_loader.dart b/lib/services/playlist_items_loader.dart index fb1b709b..e9f38316 100644 --- a/lib/services/playlist_items_loader.dart +++ b/lib/services/playlist_items_loader.dart @@ -1,3 +1,4 @@ +import '../media/library_query.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../utils/media_server_http_client.dart'; @@ -10,20 +11,11 @@ Future> fetchAllPlaylistItems( String playlistId, { int pageSize = playlistItemsPageSize, AbortController? abort, -}) async { - final all = []; - var offset = 0; - while (true) { - abort?.throwIfAborted(); - final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize, abort: abort); - abort?.throwIfAborted(); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount) break; - offset += page.items.length; - } - return all; -} +}) => drainPages( + (start, size) => client.fetchPlaylistPage(playlistId, start: start, size: size, abort: abort), + pageSize: pageSize, + abort: abort, +); /// Page through every item in a collection via the backend-neutral client API. Future> fetchAllCollectionItemsPaged( @@ -32,21 +24,14 @@ Future> fetchAllCollectionItemsPaged( int pageSize = 100, String? libraryId, String? libraryTitle, -}) async { - final all = []; - var offset = 0; - while (true) { - final page = await client.fetchCollectionPage( - collectionId, - start: offset, - size: pageSize, - libraryId: libraryId, - libraryTitle: libraryTitle, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount || page.items.length < pageSize) break; - offset += page.items.length; - } - return all; -} +}) => drainPages( + (start, size) => client.fetchCollectionPage( + collectionId, + start: start, + size: size, + libraryId: libraryId, + libraryTitle: libraryTitle, + ), + pageSize: pageSize, + stopOnShortPage: true, +); diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a6a46fa1..4db143bc 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -7,6 +7,7 @@ import 'plex_client.dart'; import '../exceptions/media_server_exceptions.dart'; import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; +import '../models/plex/plex_home_user.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/device_identity.dart'; @@ -15,6 +16,7 @@ import '../utils/json_utils.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/media_server_http_client.dart'; import '../utils/poll_with_backoff.dart'; +import '../utils/url_utils.dart'; /// Redacts the middle of an IP address or hostname for safe logging. /// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`. @@ -162,11 +164,7 @@ class PlexAuthService { String getAuthUrl(String pinCode) { final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName}; - final queryString = params.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); - - return 'https://app.plex.tv/auth#?$queryString'; + return 'https://app.plex.tv/auth#?${encodeQueryParameters(params)}'; } /// Poll the PIN to check if it has been claimed @@ -1016,6 +1014,19 @@ class PlexConnection { } } +/// Default implementation of the `Future> Function(String)` +/// fetcher seam injected into `PlexHomeService` and `ConnectionBootstrap`: +/// spins up a throwaway [PlexAuthService] for a single `/home/users` call. +Future> fetchPlexHomeUsers(String accountToken) async { + final auth = await PlexAuthService.create(); + try { + final home = await auth.getHomeUsers(accountToken); + return home.users; + } finally { + auth.dispose(); + } +} + String? _optionalScalarString(Object? value) => switch (value) { null => null, final String value => value, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 207e2319..0de09dd3 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -29,6 +29,7 @@ import 'settings_service.dart'; import 'library_query_translator.dart'; import 'scrub_preview_source.dart'; import '../utils/media_server_http_client.dart'; +import '../utils/url_utils.dart'; import '../exceptions/media_server_exceptions.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; @@ -164,6 +165,20 @@ List _processHubResponse( return hubs; } +/// Library-hub item filter. Music-section hubs carry artist/album/track items — +/// the default video-only filter would empty them out. +bool _videoOrMusicHubItem(PlexMetadataDto item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type); +} + +/// Related-hub item filter: related rows include collection entries alongside +/// the usual video items. +bool _videoOrCollectionHubItem(PlexMetadataDto item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; +} + int? _librarySectionIdFromJson(Map? json) => plexLibrarySectionIdFromJson(json); int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId); @@ -252,9 +267,54 @@ class _PlexMediaProviderState { final String? continueWatchingHubKey; } +/// Canonical declarations of the [PlexClient] internals that the `part` +/// mixins below call into. +/// +/// Every part mixin is `on _PlexClientInternals`, so each shared member is +/// declared exactly once here instead of being re-declared (and drifting) +/// per file. Members used by a single part stay declared in that part. +mixin _PlexClientInternals on MediaServerCacheMixin { + FailoverHttpClient get _http; + + Future _getWithFailover( + String path, { + Map? queryParameters, + // ignore: unused_element_parameter + Map? headers, + // ignore: unused_element_parameter + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + Map? _getMediaContainer(MediaServerResponse response); + + Map _buildPaginationParams(int? start, int? size); + + Future<_LibraryContentResult> _fetchPaginatedList( + String path, { + int? start, + int? size, + AbortController? abort, + int? librarySectionID, + String? librarySectionTitle, + }); + + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); + + Future> _wrapListApiCall( + Future Function() apiCall, + List Function(MediaServerResponse response) parseResponse, + String errorMessage, + ); + + Future buildMetadataUri(String ratingKey); +} + class PlexClient with MediaServerCacheMixin, + _PlexClientInternals, _PlexLiveTvClientMethods, _PlexPlaylistMethods, _PlexCollectionMethods, @@ -1220,23 +1280,18 @@ class PlexClient static const int _fetchAllPageSize = 200; /// Iterate every page of a paginated endpoint and concatenate the results. - /// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page - /// returns no items. Errors propagate. + /// Adapts Plex's [_LibraryContentResult] onto the shared [drainPages] drain, + /// so it stops as soon as [_LibraryContentResult.totalSize] is reached or a + /// page returns no items. Errors propagate. @override Future> _fetchAllPages( Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { AbortController? abort, - }) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPage(start, _fetchAllPageSize, abort); - all.addAll(page.items); - start += page.items.length; - if (page.items.isEmpty) break; - if (start >= page.totalSize) break; - } - return all; + }) { + return drainPages((start, size) async { + final page = await fetchPage(start, size, abort); + return LibraryPage(items: page.items, totalCount: page.totalSize, offset: start); + }, pageSize: _fetchAllPageSize); } /// Walk every page of [path] and return a single synthesized response whose @@ -2027,109 +2082,90 @@ class PlexClient return fallbackSorts; } + /// Shared transport for the hub endpoints: bounded transient retry with no + /// endpoint failover (a hub row is not worth flipping the active endpoint), + /// isolate-offloaded parsing, and log-and-empty on failure so one dead hub + /// row never takes down the screen around it. + /// + /// [failureLabel] names the hub set in the failure log line. + Future> _fetchHubs({ + required String path, + required Map queryParameters, + required String operation, + required List attemptTimeouts, + required String failureLabel, + int? librarySectionID, + String? librarySectionTitle, + bool Function(PlexMetadataDto)? filter, + }) async { + try { + final response = await retryTransientMediaServerCall( + operation: operation, + attemptTimeouts: attemptTimeouts, + call: (timeout, abort) => _getWithFailover( + path, + queryParameters: queryParameters, + timeout: timeout, + abort: abort, + allowEndpointFailover: false, + ), + ); + final sid = serverId; + final sname = serverName; + final data = response.data as Map; + return await tryIsolateRun( + () => _processHubResponse( + data, + sid, + sname, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + filter: filter, + ), + ); + } catch (e) { + appLogger.e('Failed to get $failureLabel: $e'); + } + return []; + } + /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> _getLibraryHubs( String sectionId, { int limit = defaultHubPreviewLimit, String? libraryName, - }) async { - try { - final response = await retryTransientMediaServerCall( - operation: 'Plex library hubs', - attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - '/hubs/sections/$sectionId', - queryParameters: {'count': limit, 'includeGuids': 1}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun( - () => _processHubResponse( - data, - sid, - sname, - librarySectionID: _librarySectionIdFromString(sectionId), - librarySectionTitle: libraryName, - // Music-section hubs carry artist/album/track items — the default - // video-only filter would empty them out. - filter: (item) { - final type = item.type?.toLowerCase(); - return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type); - }, - ), - ); - } catch (e) { - appLogger.e('Failed to get library hubs: $e'); - } - return []; - } + }) => _fetchHubs( + path: '/hubs/sections/$sectionId', + queryParameters: {'count': limit, 'includeGuids': 1}, + operation: 'Plex library hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + failureLabel: 'library hubs', + librarySectionID: _librarySectionIdFromString(sectionId), + librarySectionTitle: libraryName, + filter: _videoOrMusicHubItem, + ); /// Get global hubs (home page recommendations) /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc. /// This matches the official Plex client's home page layout. - Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) async { - try { - final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs'; - final response = await retryTransientMediaServerCall( - operation: 'Plex global hubs', - attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - hubKey, - queryParameters: {'count': limit, 'includeGuids': 1}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun(() => _processHubResponse(data, sid, sname)); - } catch (e) { - appLogger.e('Failed to get global hubs: $e'); - } - return []; - } + Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) => _fetchHubs( + path: _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs', + queryParameters: {'count': limit, 'includeGuids': 1}, + operation: 'Plex global hubs', + attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, + failureLabel: 'global hubs', + ); /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) - Future> _getRelatedHubs(String ratingKey, {int count = 10}) async { - try { - final response = await retryTransientMediaServerCall( - operation: 'Plex related hubs', - attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - '/hubs/metadata/$ratingKey/related', - queryParameters: {'count': count}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun( - () => _processHubResponse( - data, - sid, - sname, - filter: (item) { - final type = item.type?.toLowerCase(); - return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; - }, - ), - ); - } catch (e) { - appLogger.e('Failed to get related hubs: $e'); - } - return []; - } + Future> _getRelatedHubs(String ratingKey, {int count = 10}) => _fetchHubs( + path: '/hubs/metadata/$ratingKey/related', + queryParameters: {'count': count}, + operation: 'Plex related hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + failureLabel: 'related hubs', + filter: _videoOrCollectionHubItem, + ); /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub @@ -3136,36 +3172,10 @@ class PlexClient ); } catch (error, stackTrace) { if (error is PlaybackException) rethrow; - Error.throwWithStackTrace(_classifyPlaybackFailure(error), stackTrace); + Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace); } } - PlaybackException _classifyPlaybackFailure(Object error) { - if (error is MediaServerAuthException || - error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { - return PlaybackException( - t.messages.playbackAuthenticationRequired, - reason: PlaybackFailureReason.authenticationRequired, - ); - } - if (error is MediaServerHttpException) { - if (error.isCancellation) { - return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); - } - final status = error.statusCode; - if (error.isTransient || status != null && status >= 500) { - return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); - } - if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - } - if (error is FormatException || error is TypeError) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - return PlaybackException(t.messages.playbackFailed); - } - /// Direct-play result for a transcode decision that fell back (failed or /// said direct-play only), surfacing the reason so the UI can notify the /// user. Shared by the video and music branches of diff --git a/lib/services/plex_client/parts/collections.dart b/lib/services/plex_client/parts/collections.dart index bc0d0e7c..3318a240 100644 --- a/lib/services/plex_client/parts/collections.dart +++ b/lib/services/plex_client/parts/collections.dart @@ -1,23 +1,6 @@ part of '../../plex_client.dart'; -mixin _PlexCollectionMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - Map _buildPaginationParams(int? start, int? size); - +mixin _PlexCollectionMethods on _PlexClientInternals { _LibraryContentResult _extractLibraryContentResult( MediaServerResponse response, { int? librarySectionID, @@ -27,25 +10,12 @@ mixin _PlexCollectionMethods on MediaServerCacheMixin { int? requestedSize, }); - Future<_LibraryContentResult> _fetchPaginatedList( - String path, { - int? start, - int? size, - AbortController? abort, - int? librarySectionID, - String? librarySectionTitle, - }); - Future> _fetchAllPages( Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { // ignore: unused_element_parameter AbortController? abort, }); - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future buildMetadataUri(String ratingKey); - Future<_LibraryContentResult> _getLibraryCollectionsPage( String sectionId, { int? start, diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index d3aa59d2..33eadb1e 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -3,39 +3,13 @@ part of '../../plex_client.dart'; const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; -mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport, LiveTvDvrSupport { +mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, LiveTvDvrSupport { PlexConfig get config; - MediaServerHttpClient get _http; - - @override - ServerId get serverId; - - @override - String? get serverName; List<({String identifier, String gridEndpoint})> get _providerEpg; - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); PlexMetadataDto _createTaggedMetadata(Map json); - Future> _wrapListApiCall( - Future Function() apiCall, - List Function(MediaServerResponse response) parseResponse, - String errorMessage, - ); - /// POST the tune endpoint with one retry on transient HTTP failure. Future _postTuneWithRetry(String path, String sessionIdentifier) async { final query = {'X-Plex-Session-Identifier': sessionIdentifier}; @@ -97,7 +71,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport for (final entry in request.prefs.entries) 'prefs[${entry.key}]': entry.value, for (final entry in request.params.entries) 'params[${entry.key}]': entry.value, }; - final encoded = MediaServerHttpClient.encodeQueryParameters(flat); + final encoded = encodeQueryParameters(flat); if (encoded.isNotEmpty) parts.add(encoded); return parts.join('&'); } @@ -750,10 +724,9 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport if (config.token != null) 'X-Plex-Token': config.token!, }; - // Manual query encoding — use '%20' for spaces as Plex requires. - final queryString = allParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); + // '%20' for spaces as Plex requires — not `Uri.queryParameters`, which + // emits `+`. + final queryString = encodeQueryParameters(allParams); // Decision — wrapper around the same transport so no default X-Plex-* // HTTP headers leak through (everything travels in the query string). @@ -783,9 +756,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport // Token is added by the caller via .withPlexToken() final startParams = Map.from(allParams)..remove('X-Plex-Token'); - final startQuery = startParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); + final startQuery = encodeQueryParameters(startParams); return '$_plexVideoHlsStartEndpoint?$startQuery'; } catch (e, st) { diff --git a/lib/services/plex_client/parts/metadata_edit.dart b/lib/services/plex_client/parts/metadata_edit.dart index 472dc273..f5e00d81 100644 --- a/lib/services/plex_client/parts/metadata_edit.dart +++ b/lib/services/plex_client/parts/metadata_edit.dart @@ -1,33 +1,7 @@ part of '../../plex_client.dart'; -mixin _PlexMetadataEditMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; +mixin _PlexMetadataEditMethods on _PlexClientInternals { PlexApiCache get _cache; - @override - ServerId get serverId; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future> _wrapListApiCall( - Future Function() apiCall, - List Function(MediaServerResponse response) parseResponse, - String errorMessage, - ); Future updateMetadata({ required int sectionId, diff --git a/lib/services/plex_client/parts/play_queues.dart b/lib/services/plex_client/parts/play_queues.dart index ee501e70..d6800ecc 100644 --- a/lib/services/plex_client/parts/play_queues.dart +++ b/lib/services/plex_client/parts/play_queues.dart @@ -1,29 +1,12 @@ part of '../../plex_client.dart'; -mixin _PlexPlayQueueMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - +mixin _PlexPlayQueueMethods on _PlexClientInternals { PlexMetadataDto _createTaggedMetadataWithLibrary( Map json, { int? librarySectionID, String? librarySectionTitle, }); - Future buildMetadataUri(String ratingKey); - PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) { final container = data is Map && data['MediaContainer'] is Map ? data['MediaContainer'] as Map diff --git a/lib/services/plex_client/parts/playlists.dart b/lib/services/plex_client/parts/playlists.dart index 566fe0fe..6123ee9b 100644 --- a/lib/services/plex_client/parts/playlists.dart +++ b/lib/services/plex_client/parts/playlists.dart @@ -1,62 +1,24 @@ part of '../../plex_client.dart'; -mixin _PlexPlaylistMethods on MediaServerCacheMixin { +mixin _PlexPlaylistMethods on _PlexClientInternals { static const int _playlistPageSize = 200; static const int _defaultPlaylistContainerSize = 100; - FailoverHttpClient get _http; - @override - ServerId get serverId; - @override - String? get serverName; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - Map _buildPaginationParams(int? start, int? size); - - Future<_LibraryContentResult> _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}); - ({List items, int totalSize}) _extractPlaylistListResult( MediaServerResponse response, { int? start, int? size, }); - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future buildMetadataUri(String ratingKey); - Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort); Future> _getPlaylists({String playlistType = 'video', bool? smart}) async { try { - final all = []; - var start = 0; - while (true) { - final page = await _getPlaylistsPage( - playlistType: playlistType, - smart: smart, - start: start, - size: _playlistPageSize, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalSize) break; - } - return all; + return await drainPages((start, size) async { + final page = await _getPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size); + return LibraryPage(items: page.items, totalCount: page.totalSize, offset: start); + }, pageSize: _playlistPageSize); } catch (e, st) { appLogger.e('Failed to get playlists', error: e, stackTrace: st); return []; diff --git a/lib/services/seerr/seerr_http_client.dart b/lib/services/seerr/seerr_http_client.dart index ea70f065..a06e3052 100644 --- a/lib/services/seerr/seerr_http_client.dart +++ b/lib/services/seerr/seerr_http_client.dart @@ -7,6 +7,7 @@ import '../../utils/app_logger.dart'; import '../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../utils/platform_http_client_io.dart' as platform; +import '../../utils/url_utils.dart'; import '../trackers/tracker_http_client.dart'; import 'seerr_constants.dart'; import 'seerr_exceptions.dart'; @@ -26,9 +27,8 @@ class SeerrResponse { /// Adds the two things the tracker HTTP layer doesn't cover: /// 1. `connect.sid` cookie capture from `Set-Cookie` on login, replayed as /// `Cookie:` on every subsequent request — Express session auth. -/// 2. Query encoding with `%20` for spaces: Seerr proxies `/search` to -/// TMDB, which rejects `+` in the query value, so `Uri.queryParameters` -/// (which emits `+`) cannot be used. +/// 2. Query encoding via [encodeQueryParameters] (`%20` for spaces): Seerr +/// proxies `/search` to TMDB, which rejects `+` in the query value. class SeerrHttpClient { final String baseUrl; final http.Client _http; @@ -110,12 +110,8 @@ class SeerrHttpClient { Uri _uri(String path, Map? query) { final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path'); - if (query == null || query.isEmpty) return base; - final parts = [ - for (final entry in query.entries) - if (entry.value != null) '${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value.toString())}', - ]; - return parts.isEmpty ? base : base.replace(query: parts.join('&')); + final encoded = encodeQueryParameters(query); + return encoded.isEmpty ? base : base.replace(query: encoded); } /// Throw the mapped exception for a 4xx/5xx response; no-op on success. diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index 13f3987b..531f38aa 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -17,7 +17,6 @@ import '../utils/platform_detector.dart'; import 'file_picker_service.dart'; import 'settings_service.dart'; import 'storage_service.dart'; -import 'trackers/tracker_constants.dart'; class ImportResult { final int keysImported; @@ -87,120 +86,13 @@ class SettingsExportService { static const Set _nonPortableDeviceStorageKeys = {'custom_download_path', 'custom_download_path_type'}; static const String _tvosDatabaseRecoveryPrefix = 'tvos_db_recovery_'; - /// Closed registry of portable, user-facing settings. The [Pref] declarations - /// are the source of truth for both keys and stored types; credentials, - /// runtime state, device paths, history, endpoints, and user-authored player + /// Closed registry of portable, user-facing settings, keyed by preference + /// key. [SettingsService.portablePrefs] is the source of truth for membership + /// and the [Pref] declarations for the stored types; credentials, runtime + /// state, device paths, history, endpoints, and user-authored player /// configuration are intentionally absent. static final Map _portablePreferences = { - for (final pref in >[ - SettingsService.enableDebugLogging, - SettingsService.enableHardwareDecoding, - SettingsService.enableHDR, - SettingsService.preferredVideoCodec, - SettingsService.preferredAudioCodec, - SettingsService.viewMode, - SettingsService.seekTimeSmall, - SettingsService.seekTimeLarge, - SettingsService.rewindOnResume, - SettingsService.showHeroSection, - SettingsService.tvFullCardLayout, - SettingsService.focusGlow, - SettingsService.useGlobalHubs, - SettingsService.showServerNameOnHubs, - SettingsService.groupLibrariesByServer, - SettingsService.sleepTimerDuration, - SettingsService.audioSyncOffset, - SettingsService.subtitleSyncOffset, - SettingsService.subtitleSearchLanguage, - SettingsService.volume, - SettingsService.rotationLocked, - SettingsService.subtitleFontSize, - SettingsService.subtitleTextColor, - SettingsService.subtitleBorderSize, - SettingsService.subtitleBorderColor, - SettingsService.subtitleBackgroundColor, - SettingsService.subtitleBackgroundOpacity, - SettingsService.subAssOverride, - SettingsService.subtitleRenderResolution, - SettingsService.subtitleBold, - SettingsService.subtitleItalic, - SettingsService.rememberTrackSelections, - SettingsService.showChapterMarkersOnTimeline, - SettingsService.clickVideoTogglesPlayback, - SettingsService.autoSkipIntro, - SettingsService.autoSkipCredits, - SettingsService.forceSkipMarkerFallback, - SettingsService.autoSkipDelay, - SettingsService.introPattern, - SettingsService.creditsPattern, - SettingsService.downloadOnWifiOnly, - SettingsService.autoRemoveWatchedDownloads, - SettingsService.downloadIncludeSpecials, - SettingsService.autoCheckUpdatesOnStartup, - SettingsService.showPerformanceOverlay, - SettingsService.autoHidePerformanceOverlay, - SettingsService.enableDiscordRPC, - SettingsService.enableTraktScrobble, - SettingsService.enableTraktWatchedSync, - SettingsService.enableMalScrobble, - SettingsService.enableAnilistScrobble, - SettingsService.enableSimklScrobble, - SettingsService.matchContentFrameRate, - SettingsService.tunneledPlayback, - SettingsService.dvConversionMode, - SettingsService.defaultQualityPreset, - SettingsService.musicQualityPreset, - SettingsService.musicVolume, - SettingsService.autoPlayNextEpisode, - SettingsService.useExoPlayer, - SettingsService.startupSection, - SettingsService.alwaysKeepSidebarOpen, - SettingsService.showUnwatchedCount, - SettingsService.showEpisodeNumberOnCards, - SettingsService.showSeasonPostersOnTabs, - SettingsService.hideSpoilers, - SettingsService.showNavBarLabels, - SettingsService.globalShaderPreset, - SettingsService.requireProfileSelectionOnOpen, - SettingsService.useExternalPlayer, - SettingsService.forceTvMode, - SettingsService.visualEffects, - SettingsService.ambientLighting, - SettingsService.audioPassthrough, - SettingsService.audioNormalization, - SettingsService.audioDownmix, - SettingsService.audioDownmixNormalize, - SettingsService.liveTvDefaultFavorites, - SettingsService.matchRefreshRate, - SettingsService.matchDynamicRange, - SettingsService.appLocale, - SettingsService.autoPip, - SettingsService.maxVolume, - SettingsService.downmixCenterBoost, - SettingsService.subtitlePosition, - SettingsService.defaultPlaybackSpeed, - SettingsService.defaultBoxFitMode, - SettingsService.displaySwitchDelay, - SettingsService.themeMode, - SettingsService.videoPlayerNavigationEnabled, - SettingsService.enableCompanionRemoteServer, - SettingsService.startInFullscreen, - SettingsService.exitFullscreenOnPlayerClose, - SettingsService.bufferSize, - SettingsService.libraryDensity, - SettingsService.tvCornerSpotlightBackdrop, - SettingsService.episodePosterMode, - SettingsService.continueWatchingAction, - SettingsService.episodeAction, - SettingsService.keyboardHotkeys, - ]) - pref.key: _PreferencePolicy(_storageTypeFor(pref)), - for (final service in TrackerService.values) - for (final pref in >[ - SettingsService.trackerFilterModePref(service), - SettingsService.trackerFilterIdsPref(service), - ]) - pref.key: _PreferencePolicy(_storageTypeFor(pref)), + for (final pref in SettingsService.portablePrefs) pref.key: _PreferencePolicy(_storageTypeFor(pref)), }; static const Set _jsonStringListPreferenceKeys = {'hidden_libraries', 'library_order'}; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index ed651dd2..a9a345de 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -14,6 +14,7 @@ import '../models/mpv_config_models.dart'; import '../models/external_player_models.dart'; import 'base_shared_preferences_service.dart'; import 'device_performance.dart'; +import 'shortcut_action.dart'; export 'base_shared_preferences_service.dart' show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; import '../models/audio_quality_preset.dart'; @@ -278,33 +279,7 @@ List _decodeMpvPresets(dynamic raw) { } Map _defaultKeyboardHotkeys() => { - 'play_pause': const HotKey(key: PhysicalKeyboardKey.space), - 'volume_up': const HotKey(key: PhysicalKeyboardKey.arrowUp), - 'volume_down': const HotKey(key: PhysicalKeyboardKey.arrowDown), - 'seek_forward': const HotKey(key: PhysicalKeyboardKey.arrowRight), - 'seek_backward': const HotKey(key: PhysicalKeyboardKey.arrowLeft), - 'seek_forward_large': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), - 'seek_backward_large': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), - 'fullscreen_toggle': const HotKey(key: PhysicalKeyboardKey.keyF), - 'mute_toggle': const HotKey(key: PhysicalKeyboardKey.keyM), - 'subtitle_toggle': const HotKey(key: PhysicalKeyboardKey.keyS), - 'audio_track_next': const HotKey(key: PhysicalKeyboardKey.keyA), - 'subtitle_track_next': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), - 'chapter_next': const HotKey(key: PhysicalKeyboardKey.keyN), - 'chapter_previous': const HotKey(key: PhysicalKeyboardKey.keyP), - 'episode_next': const HotKey(key: PhysicalKeyboardKey.keyN, modifiers: [HotKeyModifier.shift]), - 'episode_previous': const HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.shift]), - 'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal), - 'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus), - 'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR), - 'zoom_in': const HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]), - 'zoom_out': const HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]), - 'zoom_reset': const HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt]), - 'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), - 'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), - 'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG), - 'skip_marker': const HotKey(key: PhysicalKeyboardKey.enter), - 'screenshot': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.control]), + for (final action in ShortcutAction.values) action.id: action.defaultHotKey, }; Map _decodeKeyboardHotkeys(dynamic raw) { @@ -391,11 +366,7 @@ class SettingsService extends BaseSharedPreferencesService { static const showPerformanceOverlay = BoolPref('show_performance_overlay'); static const autoHidePerformanceOverlay = BoolPref('auto_hide_performance_overlay', defaultValue: true); static const enableDiscordRPC = BoolPref('enable_discord_rpc'); - static const enableTraktScrobble = BoolPref('enable_trakt_scrobble', defaultValue: true); static const enableTraktWatchedSync = BoolPref('enable_trakt_watched_sync', defaultValue: true); - static const enableMalScrobble = BoolPref('enable_mal_scrobble', defaultValue: true); - static const enableAnilistScrobble = BoolPref('enable_anilist_scrobble', defaultValue: true); - static const enableSimklScrobble = BoolPref('enable_simkl_scrobble', defaultValue: true); static const matchContentFrameRate = BoolPref('match_content_frame_rate'); static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: true); static const dvConversionMode = EnumPref( @@ -567,6 +538,11 @@ class SettingsService extends BaseSharedPreferencesService { /// keeps applying until the user chooses). static IntPref dvrTargetSectionPref(ServerId serverId, int type) => IntPref('dvr_target_section_${type}_$serverId'); + /// Per-service "scrobble to this tracker" toggle. Trakt's second toggle + /// ([enableTraktWatchedSync]) has no counterpart on the other services and + /// stays a standalone constant. + static BoolPref scrobblePref(TrackerService s) => BoolPref('enable_${s.name}_scrobble', defaultValue: true); + static EnumPref trackerFilterModePref(TrackerService s) => EnumPref( 'tracker_library_filter_mode_${s.name}', values: TrackerLibraryFilterMode.values, @@ -825,55 +801,47 @@ class SettingsService extends BaseSharedPreferencesService { return null; } - /// Settings that "Reset All Settings" actually resets. Mirrors the original - /// reset surface — notably excludes user-customized data (intro/credits regex - /// patterns) and opt-in toggles prior versions didn't reset, so behavior - /// stays identical for users. - static List> _resettablePrefs() => [ + /// Preference registry behind "Reset All Settings" and settings export. + /// Every participating preference is named exactly once, in the group that + /// states its policy; anything absent from all three groups takes part in + /// neither surface (credentials, runtime state, migration sentinels). + /// + /// Group one: reset *and* exported — the ordinary case. + static final List> _resetAndPortablePrefs = [ enableDebugLogging, - bufferSize, enableHardwareDecoding, enableHDR, preferredVideoCodec, preferredAudioCodec, viewMode, - showHeroSection, - continueWatchingAction, - episodeAction, seekTimeSmall, seekTimeLarge, + showHeroSection, sleepTimerDuration, audioSyncOffset, subtitleSyncOffset, subtitleSearchLanguage, volume, - maxVolume, subtitleFontSize, subtitleTextColor, subtitleBorderSize, subtitleBorderColor, subtitleBackgroundColor, subtitleBackgroundOpacity, - subtitlePosition, rememberTrackSelections, - customDownloadPathType, downloadOnWifiOnly, downloadIncludeSpecials, autoCheckUpdatesOnStartup, showPerformanceOverlay, autoHidePerformanceOverlay, enableDiscordRPC, - enableTraktScrobble, enableTraktWatchedSync, - enableMalScrobble, - enableAnilistScrobble, - enableSimklScrobble, + // Scrobble toggle, one per tracker service. + for (final s in TrackerService.values) scrobblePref(s), matchContentFrameRate, tunneledPlayback, dvConversionMode, musicVolume, - defaultPlaybackSpeed, - defaultBoxFitMode, autoPlayNextEpisode, useExoPlayer, startupSection, @@ -893,20 +861,71 @@ class SettingsService extends BaseSharedPreferencesService { audioNormalization, audioDownmix, audioDownmixNormalize, + appLocale, + autoPip, + maxVolume, downmixCenterBoost, + subtitlePosition, + defaultPlaybackSpeed, + defaultBoxFitMode, themeMode, - keyboardHotkeys, + videoPlayerNavigationEnabled, + bufferSize, libraryDensity, tvCornerSpotlightBackdrop, episodePosterMode, + continueWatchingAction, + episodeAction, + keyboardHotkeys, + // Library filters, one pair per tracker service. + for (final s in TrackerService.values) ...[trackerFilterModePref(s), trackerFilterIdsPref(s)], + ]; + + /// Group two: exported but *not* reset. Mirrors the original reset surface — + /// user-customized data (intro/credits regex patterns) and opt-in toggles + /// prior versions didn't reset, so behavior stays identical for users. + static final List> _portableOnlyPrefs = [ + rewindOnResume, + tvFullCardLayout, + focusGlow, + useGlobalHubs, + showServerNameOnHubs, + groupLibrariesByServer, + rotationLocked, + subAssOverride, + subtitleRenderResolution, + subtitleBold, + subtitleItalic, + showChapterMarkersOnTimeline, + clickVideoTogglesPlayback, + autoSkipIntro, + autoSkipCredits, + forceSkipMarkerFallback, + autoSkipDelay, + introPattern, + creditsPattern, + autoRemoveWatchedDownloads, + defaultQualityPreset, + musicQualityPreset, + liveTvDefaultFavorites, + matchRefreshRate, + matchDynamicRange, + displaySwitchDelay, + enableCompanionRemoteServer, + startInFullscreen, + exitFullscreenOnPlayerClose, + ]; + + /// Group three: reset but *not* exported — device-local paths, endpoints and + /// per-device state plus user-authored player configuration, none of which + /// should travel between installations. + static final List> _resetOnlyPrefs = [ + customDownloadPathType, mediaVersionPreferences, localLastPlayedAt, - appLocale, customDownloadPath, - videoPlayerNavigationEnabled, mpvConfigText, mpvPresets, - autoPip, customShaderPresets, selectedExternalPlayer, customExternalPlayers, @@ -914,17 +933,19 @@ class SettingsService extends BaseSharedPreferencesService { companionRemoteLastHostAddress, ]; + /// Settings that "Reset All Settings" actually resets. + static List> get _resettablePrefs => [..._resetAndPortablePrefs, ..._resetOnlyPrefs]; + + /// Settings carried by settings export/import files. + static List> get portablePrefs => [..._resetAndPortablePrefs, ..._portableOnlyPrefs]; + Future resetAllSettings() async { - final resettable = _resettablePrefs(); await Future.wait([ - ...resettable.map((p) => prefs.remove(p.key)), + ..._resettablePrefs.map((p) => prefs.remove(p.key)), // Legacy migration sentinels — removed alongside the keys they guarded. prefs.remove(_legacyUseSeasonPosterKey), prefs.remove(_legacyMpvConfigEntriesKey), prefs.remove(_bufferSizeMigratedKey), - ...TrackerService.values.expand( - (s) => [prefs.remove(trackerFilterModePref(s).key), prefs.remove(trackerFilterIdsPref(s).key)], - ), ]); refreshListenables(); } diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index 56a27252..b9ce944a 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -174,12 +174,7 @@ class ShaderAssetLoader { /// Get the shader file path for an ArtCNN preset. /// Returns a list containing exactly one ArtCNN shader path. static Future> getArtCNNShaders(ArtCNNConfig config) async { - final variantId = switch (config.variant) { - ArtCNNVariant.neutral => 'neutral', - ArtCNNVariant.denoise => 'dn', - ArtCNNVariant.denoiseSharpen => 'ds', - }; - final shaderPath = await _extractShader(_artcnnShaders['${config.model.name}_$variantId']!); + final shaderPath = await _extractShader(_artcnnShaders['${config.model.name}_${config.variant.slug}']!); if (shaderPath == null) return []; return [shaderPath]; } diff --git a/lib/services/shortcut_action.dart b/lib/services/shortcut_action.dart new file mode 100644 index 00000000..34f9364e --- /dev/null +++ b/lib/services/shortcut_action.dart @@ -0,0 +1,139 @@ +import 'package:flutter/services.dart'; + +import '../i18n/strings.g.dart'; +import '../models/hotkey_model.dart'; +import 'shader_service.dart'; + +/// Every keyboard shortcut the video player understands. +/// +/// One row per action carries everything about it except the behaviour: the +/// persisted [id], the [defaultHotKey] shipped with the app, the localized +/// [label], and the capability flags that gate dispatch. Adding a shortcut is +/// one entry here plus a case in `KeyboardShortcutsService._executeAction`, +/// which the analyzer demands because that switch is exhaustive over this enum. +/// +/// Declaration order is the order shortcuts are listed in settings, and [id] is +/// persisted in preferences — do not reorder or rename existing entries. +enum ShortcutAction { + playPause('play_pause', HotKey(key: PhysicalKeyboardKey.space), requiresPlayback: true), + volumeUp('volume_up', HotKey(key: PhysicalKeyboardKey.arrowUp)), + volumeDown('volume_down', HotKey(key: PhysicalKeyboardKey.arrowDown)), + seekForward('seek_forward', HotKey(key: PhysicalKeyboardKey.arrowRight), requiresPlayback: true), + seekBackward('seek_backward', HotKey(key: PhysicalKeyboardKey.arrowLeft), requiresPlayback: true), + seekForwardLarge( + 'seek_forward_large', + HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + seekBackwardLarge( + 'seek_backward_large', + HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + fullscreenToggle('fullscreen_toggle', HotKey(key: PhysicalKeyboardKey.keyF)), + muteToggle('mute_toggle', HotKey(key: PhysicalKeyboardKey.keyM)), + subtitleToggle('subtitle_toggle', HotKey(key: PhysicalKeyboardKey.keyS)), + audioTrackNext('audio_track_next', HotKey(key: PhysicalKeyboardKey.keyA), requiresPlayback: true), + subtitleTrackNext( + 'subtitle_track_next', + HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + chapterNext('chapter_next', HotKey(key: PhysicalKeyboardKey.keyN), requiresPlayback: true), + chapterPrevious('chapter_previous', HotKey(key: PhysicalKeyboardKey.keyP), requiresPlayback: true), + episodeNext( + 'episode_next', + HotKey(key: PhysicalKeyboardKey.keyN, modifiers: [HotKeyModifier.shift]), + requiresMediaNavigation: true, + ), + episodePrevious( + 'episode_previous', + HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.shift]), + requiresMediaNavigation: true, + ), + speedIncrease('speed_increase', HotKey(key: PhysicalKeyboardKey.equal), requiresPlayback: true), + speedDecrease('speed_decrease', HotKey(key: PhysicalKeyboardKey.minus), requiresPlayback: true), + speedReset('speed_reset', HotKey(key: PhysicalKeyboardKey.keyR), requiresPlayback: true), + zoomIn('zoom_in', HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]), repeatable: true), + zoomOut('zoom_out', HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]), repeatable: true), + zoomReset('zoom_reset', HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt])), + subSeekNext( + 'sub_seek_next', + HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), + requiresPlayback: true, + ), + subSeekPrev( + 'sub_seek_prev', + HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), + requiresPlayback: true, + ), + shaderToggle('shader_toggle', HotKey(key: PhysicalKeyboardKey.keyG), requiresShaderSupport: true), + skipMarker('skip_marker', HotKey(key: PhysicalKeyboardKey.enter), requiresPlayback: true), + screenshot('screenshot', HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.control])); + + const ShortcutAction( + this.id, + this.defaultHotKey, { + this.repeatable = false, + this.requiresPlayback = false, + this.requiresMediaNavigation = false, + this.requiresShaderSupport = false, + }); + + /// Stable key this action is stored under in preferences. + final String id; + + /// Shortcut used until the user assigns their own. + final HotKey defaultHotKey; + + /// Whether holding the key repeats the action instead of swallowing repeats. + final bool repeatable; + + /// Whether the action drives playback and needs playback authority. + final bool requiresPlayback; + + /// Whether the action switches media item and needs navigation authority. + final bool requiresMediaNavigation; + + /// Whether the action is only meaningful where shaders are available. + final bool requiresShaderSupport; + + static final Map _byId = {for (final action in values) action.id: action}; + + /// The action stored under [id], or null for an id this build does not know. + static ShortcutAction? fromId(String id) => _byId[id]; + + /// Whether this action can be used on the current platform. + bool get isSupported => !requiresShaderSupport || ShaderService.isPlatformSupported; + + /// Localized name shown in settings; seek labels embed the configured steps. + String label({required int seekTimeSmall, required int seekTimeLarge}) => switch (this) { + ShortcutAction.playPause => t.hotkeys.actions.playPause, + ShortcutAction.volumeUp => t.hotkeys.actions.volumeUp, + ShortcutAction.volumeDown => t.hotkeys.actions.volumeDown, + ShortcutAction.seekForward => t.hotkeys.actions.seekForward(seconds: seekTimeSmall), + ShortcutAction.seekBackward => t.hotkeys.actions.seekBackward(seconds: seekTimeSmall), + ShortcutAction.seekForwardLarge => t.hotkeys.actions.seekForward(seconds: seekTimeLarge), + ShortcutAction.seekBackwardLarge => t.hotkeys.actions.seekBackward(seconds: seekTimeLarge), + ShortcutAction.fullscreenToggle => t.hotkeys.actions.fullscreenToggle, + ShortcutAction.muteToggle => t.hotkeys.actions.muteToggle, + ShortcutAction.subtitleToggle => t.hotkeys.actions.subtitleToggle, + ShortcutAction.audioTrackNext => t.hotkeys.actions.audioTrackNext, + ShortcutAction.subtitleTrackNext => t.hotkeys.actions.subtitleTrackNext, + ShortcutAction.chapterNext => t.hotkeys.actions.chapterNext, + ShortcutAction.chapterPrevious => t.hotkeys.actions.chapterPrevious, + ShortcutAction.episodeNext => t.hotkeys.actions.episodeNext, + ShortcutAction.episodePrevious => t.hotkeys.actions.episodePrevious, + ShortcutAction.speedIncrease => t.hotkeys.actions.speedIncrease, + ShortcutAction.speedDecrease => t.hotkeys.actions.speedDecrease, + ShortcutAction.speedReset => t.hotkeys.actions.speedReset, + ShortcutAction.zoomIn => t.hotkeys.actions.zoomIn, + ShortcutAction.zoomOut => t.hotkeys.actions.zoomOut, + ShortcutAction.zoomReset => t.hotkeys.actions.zoomReset, + ShortcutAction.subSeekNext => t.hotkeys.actions.subSeekNext, + ShortcutAction.subSeekPrev => t.hotkeys.actions.subSeekPrev, + ShortcutAction.shaderToggle => t.hotkeys.actions.shaderToggle, + ShortcutAction.skipMarker => t.hotkeys.actions.skipMarker, + ShortcutAction.screenshot => t.hotkeys.actions.screenshot, + }; +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ebf309e3..34f4cccf 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -113,20 +113,38 @@ class StorageService extends BaseSharedPreferencesService { String _userPrefixForProfileId(String profileId) => 'user_${userScopeForProfileId(profileId)}_'; - /// Read a string with user-scoped key, migrating from legacy key if needed. - String? _getScopedString(String baseKey) { - final scopedKey = '$_userPrefix$baseKey'; - final value = prefs.getString(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - // One-time migration from legacy global key - final legacy = prefs.getString(baseKey); + /// Read [baseKey] from the [prefix]-scoped slot, adopting the legacy + /// unscoped value once: the scoped key wins; otherwise the unscoped value is + /// copied into it and the unscoped key removed. [read]/[write] carry the + /// per-type codec. Adoption is skipped when [prefix] is empty (no scope to + /// migrate into) or [allowLegacyAdoption] is false (a scope other than the + /// active profile's, which must not steal legacy prefs). + T? _readScopedWithLegacyMigration( + String baseKey, { + required String prefix, + required T? Function(String key) read, + required void Function(String key, T value) write, + bool allowLegacyAdoption = true, + }) { + final scopedKey = '$prefix$baseKey'; + final value = read(scopedKey); + if (value != null || prefix.isEmpty || !allowLegacyAdoption) return value; + final legacy = read(baseKey); if (legacy != null) { - prefs.setString(scopedKey, legacy); + write(scopedKey, legacy); prefs.remove(baseKey); } return legacy; } + /// Read a string with user-scoped key, migrating from legacy key if needed. + String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration( + baseKey, + prefix: _userPrefix, + read: prefs.getString, + write: prefs.setString, + ); + // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(ServerId serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); @@ -217,19 +235,12 @@ class StorageService extends BaseSharedPreferencesService { await _setJsonMap('$_userPrefix$_prefixLibrarySort$sectionId', sortData); } - Map? getLibrarySort(String sectionId) { - final baseKey = '$_prefixLibrarySort$sectionId'; - final scopedKey = '$_userPrefix$baseKey'; - var result = _readJsonMap(scopedKey, legacyStringOk: true); - if (result != null || _userPrefix.isEmpty) return result; - // One-time migration from legacy key - result = _readJsonMap(baseKey, legacyStringOk: true); - if (result != null) { - _setJsonMap(scopedKey, result); - prefs.remove(baseKey); - } - return result; - } + Map? getLibrarySort(String sectionId) => _readScopedWithLegacyMigration>( + '$_prefixLibrarySort$sectionId', + prefix: _userPrefix, + read: (key) => _readJsonMap(key, legacyStringOk: true), + write: _setJsonMap, + ); // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { @@ -270,22 +281,18 @@ class StorageService extends BaseSharedPreferencesService { return _decodeStringSet(jsonString); } - Set getHiddenLibrariesForProfile(String profileId) { - final scopedKey = '${_userPrefixForProfileId(profileId)}$_keyHiddenLibraries'; - var jsonString = prefs.getString(scopedKey); - if (jsonString == null && getActiveProfileId() == profileId) { - // One-time migration from the legacy unscoped key, but only for the - // currently active profile. Otherwise merely opening another profile's - // scoped provider could steal legacy preferences into the wrong scope. - final legacy = prefs.getString(_keyHiddenLibraries); - if (legacy != null) { - prefs.setString(scopedKey, legacy); - prefs.remove(_keyHiddenLibraries); - jsonString = legacy; - } - } - return _decodeStringSet(jsonString); - } + Set getHiddenLibrariesForProfile(String profileId) => _decodeStringSet( + _readScopedWithLegacyMigration( + _keyHiddenLibraries, + prefix: _userPrefixForProfileId(profileId), + read: prefs.getString, + write: prefs.setString, + // Only the active profile may adopt the legacy unscoped value. Otherwise + // merely opening another profile's scoped provider could steal legacy + // preferences into the wrong scope. + allowLegacyAdoption: getActiveProfileId() == profileId, + ), + ); Set _decodeStringSet(String? jsonString) { if (jsonString == null) return {}; @@ -366,19 +373,12 @@ class StorageService extends BaseSharedPreferencesService { await _setStringList('$_userPrefix$_keyLibraryOrder', libraryKeys); } - List? getLibraryOrder() { - final baseKey = _keyLibraryOrder; - final scopedKey = '$_userPrefix$baseKey'; - final value = _getStringList(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - // One-time migration from legacy key - final legacy = _getStringList(baseKey); - if (legacy != null) { - _setStringList(scopedKey, legacy); - prefs.remove(baseKey); - } - return legacy; - } + List? getLibraryOrder() => _readScopedWithLegacyMigration>( + _keyLibraryOrder, + prefix: _userPrefix, + read: _getStringList, + write: _setStringList, + ); // Current User UUID — read once by [ConnectionBootstrap._promoteActiveProfileFromLegacy] // on the upgrade run, then cleared. Replaced by @@ -546,13 +546,18 @@ class StorageService extends BaseSharedPreferencesService { } } - Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) async { + /// Run [op] over every slot holding [baseKey]: the legacy unscoped key plus + /// each `user_{scope}_{baseKey}` variant. + Future _forEachScopedKey(String baseKey, Future Function(String key) op) async { final keys = prefs.keys .where((key) => key == baseKey || (key.startsWith('user_') && key.endsWith('_$baseKey'))) .toList(growable: false); - await Future.wait(keys.map((key) => _filterServerEntriesFromStringList(key, serverId))); + await Future.wait(keys.map(op)); } + Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) => + _forEachScopedKey(baseKey, (key) => _filterServerEntriesFromStringList(key, serverId)); + Future _clearSelectedLibraryForServer(String key, ServerId serverId) async { final selected = prefs.getString(key); if (selected != null && _belongsToServer(selected, serverId)) { @@ -560,15 +565,8 @@ class StorageService extends BaseSharedPreferencesService { } } - Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) async { - final keys = prefs.keys - .where( - (key) => - key == _keySelectedLibraryKey || (key.startsWith('user_') && key.endsWith('_$_keySelectedLibraryKey')), - ) - .toList(growable: false); - await Future.wait(keys.map((key) => _clearSelectedLibraryForServer(key, serverId))); - } + Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) => + _forEachScopedKey(_keySelectedLibraryKey, (key) => _clearSelectedLibraryForServer(key, serverId)); Future _clearKeysWithPrefixForServer(String keyPrefix, ServerId serverId) async { final serverPrefix = '$serverId:'; diff --git a/lib/services/system_shelf_service.dart b/lib/services/system_shelf_service.dart index e21a7a6a..31c63ae1 100644 --- a/lib/services/system_shelf_service.dart +++ b/lib/services/system_shelf_service.dart @@ -110,19 +110,13 @@ class SystemShelfService { await _enqueueMutation(() async { final channel = _channel; if (channel == null) return; - try { - await channel.invokeMethod('clear', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - }); - } on MissingPluginException catch (e) { - appLogger.e('Failed to clear system shelf: native channel missing', error: e); - } on PlatformException catch (e) { - appLogger.e('Failed to clear system shelf: native platform error', error: e); - } catch (e) { - appLogger.e('Failed to clear system shelf', error: e); - } + await _invokeGuarded( + channel, + 'clear', + arguments: _envelope(profileId, generation), + label: 'Failed to clear system shelf', + severe: true, + ); }); } @@ -146,19 +140,45 @@ class SystemShelfService { return completer.future; } + /// Owner-scoped envelope every mutating native call carries. + static Map _envelope(String profileId, int generation, [Map? extra]) { + return {'schemaVersion': schemaVersion, 'ownerId': profileId, 'generation': generation, ...?extra}; + } + + /// Invokes [method] on [channel], logging channel failures under [label] (at + /// error level when [severe]) and returning null instead of throwing. + /// Errors that are not channel failures log [failureLabel] when given. + Future _invokeGuarded( + MethodChannel channel, + String method, { + Map? arguments, + required String label, + String? failureLabel, + bool severe = false, + }) async { + final log = severe ? appLogger.e : appLogger.w; + try { + return await channel.invokeMethod(method, arguments); + } on MissingPluginException catch (e) { + log('$label: native channel missing', error: e); + } on PlatformException catch (e) { + log('$label: native platform error', error: e); + } catch (e) { + log(failureLabel ?? label, error: e); + } + return null; + } + /// Get a pending deep link from cold start (consumed on first call). Future getInitialDeepLink() async { final channel = _channel; if (channel == null) return null; - try { - return await channel.invokeMethod('getInitialDeepLink'); - } on MissingPluginException catch (e) { - appLogger.w('System shelf initial deep link failed: native channel missing', error: e); - return null; - } catch (e) { - appLogger.w('Failed to get system shelf initial deep link', error: e); - return null; - } + return _invokeGuarded( + channel, + 'getInitialDeepLink', + label: 'System shelf initial deep link failed', + failureLabel: 'Failed to get system shelf initial deep link', + ); } /// Check whether the current platform has a launcher shelf integration. @@ -167,18 +187,13 @@ class SystemShelfService { if (override != null) return override(); final channel = _channel; if (channel == null) return false; - try { - return await channel.invokeMethod('isSupported') ?? false; - } on MissingPluginException catch (e) { - appLogger.w('System shelf unsupported: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.w('System shelf unsupported: native platform error', error: e); - return false; - } catch (e) { - appLogger.w('System shelf unsupported: native support check failed', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'isSupported', + label: 'System shelf unsupported', + failureLabel: 'System shelf unsupported: native support check failed', + ) ?? + false; } /// Sync Continue Watching items for the currently active [profileId]. @@ -204,24 +219,14 @@ class SystemShelfService { final result = await _enqueueMutation(() async { if (!_owns(profileId, generation)) return false; - try { - return await channel.invokeMethod('sync', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - 'items': items, - }) ?? - false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to sync system shelf: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to sync system shelf: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to sync system shelf', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'sync', + arguments: _envelope(profileId, generation, {'items': items}), + label: 'Failed to sync system shelf', + severe: true, + ) ?? + false; }); return result ?? false; } @@ -233,24 +238,14 @@ class SystemShelfService { final generation = _generation; final result = await _enqueueMutation(() async { if (!_owns(profileId, generation)) return false; - try { - return await channel.invokeMethod('remove', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - 'contentId': _buildContentId(serverId, ratingKey), - }) ?? - false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to remove system shelf item: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to remove system shelf item: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to remove system shelf item', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'remove', + arguments: _envelope(profileId, generation, {'contentId': _buildContentId(serverId, ratingKey)}), + label: 'Failed to remove system shelf item', + severe: true, + ) ?? + false; }); return result ?? false; } diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart index d916fd16..eb4a2b52 100644 --- a/lib/services/trackers/anilist/anilist_tracker.dart +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -3,7 +3,6 @@ import 'package:http/http.dart' as http; import '../../../models/trackers/anime_ids.dart'; import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; -import '../../settings_service.dart'; import '../anime_list_tracker_base.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -25,18 +24,9 @@ class AnilistTracker extends TrackerBase with ClientBackedTracker @override TrackerService get service => TrackerService.anilist; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableAnilistScrobble); - @override String get logLabel => 'AniList'; - @override - String get idLogName => 'anilist'; - - @override - String get ratingUnavailableName => 'AniList'; - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/anime_list_tracker_base.dart b/lib/services/trackers/anime_list_tracker_base.dart index 55a60d50..0cacb5e8 100644 --- a/lib/services/trackers/anime_list_tracker_base.dart +++ b/lib/services/trackers/anime_list_tracker_base.dart @@ -12,8 +12,6 @@ mixin AnimeListTrackerBase on TrackerBa bool get needsFribb => true; String get logLabel; - String get idLogName; - String get ratingUnavailableName; int? animeId(AnimeIds? anime); Future loadAnimeEpisodeCount(TClient client, int animeId); @@ -81,7 +79,7 @@ mixin AnimeListTrackerBase on TrackerBa (TClient, int) _ratingTarget(TrackerRatingContext ctx) { final activeClient = client; final id = animeId(ctx.ids.anime); - if (activeClient == null || id == null) throw TrackerRatingUnavailableException(ratingUnavailableName); + if (activeClient == null || id == null) throw TrackerRatingUnavailableException(logLabel); return (activeClient, id); } @@ -94,7 +92,7 @@ mixin AnimeListTrackerBase on TrackerBa if (identical(_episodeCountLoads[id], loading)) { final _ = _episodeCountLoads.remove(id); } - appLogger.d('$logLabel: failed to fetch anime episode count ($idLogName=$id)', error: e); + appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e); return null; }); _episodeCountLoads[id] = loading; diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart index 5eb4b128..9dd35f9e 100644 --- a/lib/services/trackers/mal/mal_tracker.dart +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -2,7 +2,6 @@ import 'package:http/http.dart' as http; import '../../../models/trackers/anime_ids.dart'; import '../../../utils/app_logger.dart'; -import '../../settings_service.dart'; import '../anime_list_tracker_base.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -28,18 +27,9 @@ class MalTracker extends TrackerBase with ClientBackedTracker, AnimeL @override TrackerService get service => TrackerService.mal; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableMalScrobble); - @override String get logLabel => 'MAL'; - @override - String get idLogName => 'mal'; - - @override - String get ratingUnavailableName => 'MAL'; - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index deec235f..44f13b28 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -5,7 +5,6 @@ import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/external_ids.dart'; import '../../../utils/json_utils.dart'; -import '../../settings_service.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; import '../tracker_id_resolver.dart'; @@ -34,9 +33,6 @@ class SimklTracker extends TrackerBase with ClientBackedTracker imp @override bool get needsFribb => false; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableSimklScrobble); - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 4e4665a7..44416b92 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -54,16 +54,14 @@ class TrackerRatingUnavailableException implements Exception { String toString() => 'TrackerRatingUnavailableException($trackerName)'; } -/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient], -/// [readEnabledSetting], and [markWatched]. +/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient] +/// and [markWatched]. abstract class TrackerBase implements Tracker { bool _isInitialized = false; bool _isEnabled = false; bool get hasActiveClient; - bool readEnabledSetting(SettingsService settings); - @override bool get canScrobble => _isEnabled && hasActiveClient; @@ -71,7 +69,8 @@ abstract class TrackerBase implements Tracker { Future initialize() async { if (_isInitialized) return; _isInitialized = true; - _isEnabled = readEnabledSetting(await SettingsService.getInstance()); + final settings = await SettingsService.getInstance(); + _isEnabled = settings.read(SettingsService.scrobblePref(service)); } @override diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index a3ba1ef0..d7530ac0 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -118,23 +118,19 @@ class TrackerCoordinator { animeProgress: _debugAnimeProgress, ); - Future markWatched(MediaItem item, MediaServerClient client) async { + Future markWatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: true); + + Future markUnwatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: false); + + Future _markManual(MediaItem item, MediaServerClient client, {required bool watched}) async { try { - await _markWatched(item, client); + await _applyManualMark(item, client, watched: watched); } catch (e) { - appLogger.d('Trackers: manual markWatched failed for ${item.id}', error: e); + appLogger.d('Trackers: manual ${watched ? 'markWatched' : 'markUnwatched'} failed for ${item.id}', error: e); } } - Future markUnwatched(MediaItem item, MediaServerClient client) async { - try { - await _markUnwatched(item, client); - } catch (e) { - appLogger.d('Trackers: manual markUnwatched failed for ${item.id}', error: e); - } - } - - Future _markWatched(MediaItem item, MediaServerClient client) async { + Future _applyManualMark(MediaItem item, MediaServerClient client, {required bool watched}) async { final kind = item.kind; if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { return; @@ -146,38 +142,18 @@ class TrackerCoordinator { final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); if (kind == MediaKind.movie || kind == MediaKind.episode) { - await _markSingleWatched(item, resolver); + await (watched ? _markSingleWatched(item, resolver) : _markSingleUnwatched(item, resolver)); return; } final episodes = []; await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes'); + final expansion = watched ? 'expanded' : 'unwatched expanded'; + appLogger.d('Trackers: manual ${kind.name} ${item.id} $expansion to ${episodes.length} episodes'); - await _markContainerEpisodesWatched(episodes, resolver); - } - - Future _markUnwatched(MediaItem item, MediaServerClient client) async { - final kind = item.kind; - if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { - return; - } - - final libraryGlobalKey = item.libraryGlobalKey; - if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return; - - final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); - - if (kind == MediaKind.movie || kind == MediaKind.episode) { - await _markSingleUnwatched(item, resolver); - return; - } - - final episodes = []; - await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes'); - - await _markContainerEpisodesUnwatched(episodes, resolver); + await (watched + ? _markContainerEpisodesWatched(episodes, resolver) + : _markContainerEpisodesUnwatched(episodes, resolver)); } Future _markContainerEpisodesWatched(List episodes, TrackerIdResolver resolver) async { @@ -189,7 +165,7 @@ class TrackerCoordinator { if (ctx == null) continue; resolved++; - await _dispatchToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: true); final key = _animeGroupKey(ctx); if (key == null) continue; @@ -200,7 +176,7 @@ class TrackerCoordinator { for (final group in animeGroups.values) { final ctx = group.context; - if (ctx != null) await _dispatchToTrackers([MalTracker.instance, AnilistTracker.instance], ctx); + if (ctx != null) await _dispatch([MalTracker.instance, AnilistTracker.instance], ctx, watched: true); } appLogger.d('Trackers: manual container resolved ${animeGroups.length} anime entries'); } @@ -220,7 +196,7 @@ class TrackerCoordinator { if (ctx == null) continue; resolved++; - await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: false); final anime = ctx.anime; if (anime == null) continue; @@ -279,7 +255,7 @@ class TrackerCoordinator { appLogger.d('Trackers: no external IDs for manually watched ${item.id}'); return; } - await _dispatchMarkWatched(ctx); + await _dispatch(_trackers, ctx, watched: true); } Future _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver) async { @@ -289,9 +265,9 @@ class TrackerCoordinator { return; } if (ctx.isMovie) { - await _dispatchMarkUnwatched(ctx); + await _dispatch(_trackers, ctx, watched: false); } else { - await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: false); } } @@ -301,7 +277,7 @@ class TrackerCoordinator { final shouldMarkWatched = ctx != null && !_thresholdCrossed && _timeline.watchedThresholdReached; _reset(); if (ctx != null && shouldMarkWatched) { - await _dispatchMarkWatched(ctx); + await _dispatch(_trackers, ctx, watched: true); } } @@ -311,7 +287,7 @@ class TrackerCoordinator { if (ctx == null || _thresholdCrossed) return; if (!_timeline.watchedThresholdReached) return; _thresholdCrossed = true; - unawaited(_dispatchMarkWatched(ctx)); + unawaited(_dispatch(_trackers, ctx, watched: true)); } void updateDuration(Duration duration) { @@ -340,40 +316,17 @@ class TrackerCoordinator { _thresholdCrossed = false; } - Future _dispatchMarkWatched(TrackerContext ctx) async { - final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); - await _dispatchToTrackers(active, ctx); - } - - Future _dispatchMarkUnwatched(TrackerContext ctx) async { - final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); - await _dispatchUnwatchedToTrackers(active, ctx); - } - bool _isActive(Tracker tracker, String? libraryGlobalKey) => tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey); - Future _dispatchToTrackers(Iterable trackers, TrackerContext ctx) async { + Future _dispatch(Iterable trackers, TrackerContext ctx, {required bool watched}) async { final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); await Future.wait( active.map((t) async { try { - await t.markWatched(ctx); + await (watched ? t.markWatched(ctx) : t.markUnwatched(ctx)); } catch (e) { - appLogger.d('${t.name}: markWatched failed', error: e); - } - }), - ); - } - - Future _dispatchUnwatchedToTrackers(Iterable trackers, TrackerContext ctx) async { - final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); - await Future.wait( - active.map((t) async { - try { - await t.markUnwatched(ctx); - } catch (e) { - appLogger.d('${t.name}: markUnwatched failed', error: e); + appLogger.d('${t.name}: ${watched ? 'markWatched' : 'markUnwatched'} failed', error: e); } }), ); diff --git a/lib/services/trackers/tracker_session.dart b/lib/services/trackers/tracker_session.dart index 45c42114..e105e59e 100644 --- a/lib/services/trackers/tracker_session.dart +++ b/lib/services/trackers/tracker_session.dart @@ -3,7 +3,7 @@ import 'tracker_constants.dart'; import 'tracker_exceptions.dart'; import 'tracker_session_utils.dart'; -class TrackerSession with EncodedTrackerSession { +class TrackerSession { final String accessToken; final String? refreshToken; final int? expiresAt; @@ -43,7 +43,6 @@ class TrackerSession with EncodedTrackerSession { ); } - @override Map toJson() => { 'access_token': accessToken, 'refresh_token': refreshToken, @@ -53,6 +52,8 @@ class TrackerSession with EncodedTrackerSession { 'created_at': createdAt, }; + String encode() => encodeTrackerSessionJson(toJson()); + factory TrackerSession.fromJson(Map json, {TrackerService? service}) { final session = TrackerSession( accessToken: json['access_token'] as String, diff --git a/lib/services/trackers/tracker_session_utils.dart b/lib/services/trackers/tracker_session_utils.dart index 15ec06fc..b99ee847 100644 --- a/lib/services/trackers/tracker_session_utils.dart +++ b/lib/services/trackers/tracker_session_utils.dart @@ -9,12 +9,6 @@ bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) => bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) => (nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds; -mixin EncodedTrackerSession { - Map toJson(); - - String encode() => encodeTrackerSessionJson(toJson()); -} - String encodeTrackerSessionJson(Map value) => convert.json.encode(value); T decodeTrackerSessionJson(String raw, T Function(Map json) fromJson) { diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index 4facd12f..e59040b4 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -70,10 +70,10 @@ class TraktClient implements DisposableTrackerClient { _request('POST', '/scrobble/stop', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses); Future addToHistory(TraktScrobbleRequest item, {String? watchedAt}) => - _request('POST', '/sync/history', body: item.toHistoryAddBody(watchedAt: watchedAt)); + _request('POST', '/sync/history', body: item.toHistoryBody(watchedAt: watchedAt)); Future removeFromHistory(TraktScrobbleRequest item) => - _request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody()); + _request('POST', '/sync/history/remove', body: item.toHistoryBody()); Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body, allowStatuses: const {200, 201}); diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 97fdf498..e407e8a0 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -59,7 +59,7 @@ class TraktScrobbleService implements TrackerRatingSource { if (_isInitialized) return; _isInitialized = true; final settings = await SettingsService.getInstance(); - _isEnabled = settings.read(SettingsService.enableTraktScrobble); + _isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt)); } Future setEnabled(bool enabled) async { diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index b9207559..7d0a554b 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -7,22 +7,15 @@ import '../utils/app_logger.dart'; class VideoPIPManager { final Player player; - Size? _playerSize; - VideoPIPManager({required this.player, Size? initialPlayerSize}) : _playerSize = initialPlayerSize; + /// Current viewport size, used as the PiP aspect ratio fallback. + final Size? Function() playerSize; - Size? get playerSize => _playerSize; + VideoPIPManager({required this.player, required this.playerSize}); /// Callback to prepare video filter before entering PiP VoidCallback? onBeforeEnterPip; - /// Update player size for PiP aspect ratio calculation - void updatePlayerSize(Size size) { - _playerSize = size; - } - - ValueNotifier get isPipActive => PipService().isPipActive; - /// Get current video dimensions (display or storage or fallback to viewport) Future<(int? width, int? height)> _getVideoDimensions() async { int? width; @@ -52,8 +45,9 @@ class VideoPIPManager { } } - width ??= _playerSize?.width.toInt(); - height ??= _playerSize?.height.toInt(); + final viewport = playerSize(); + width ??= viewport?.width.toInt(); + height ??= viewport?.height.toInt(); return (width, height); } @@ -63,7 +57,7 @@ class VideoPIPManager { if (!supported) return (false, 'PiP not supported on this device'); // If PiP is already active, exit it - if (isPipActive.value) { + if (PipService().isPipActive.value) { await PipService.exit(); return (true, null); } diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart index f2949d92..819d6ed4 100644 --- a/lib/services/watch_state_resolver.dart +++ b/lib/services/watch_state_resolver.dart @@ -1,7 +1,10 @@ +import 'package:flutter/foundation.dart'; + import '../database/app_database.dart'; import '../media/media_item.dart'; import '../utils/watch_state_notifier.dart'; +@immutable class WatchStateSnapshot { final bool? isWatched; final bool hasViewOffsetMs; @@ -21,6 +24,17 @@ class WatchStateSnapshot { } return updated; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WatchStateSnapshot && + other.isWatched == isWatched && + other.hasViewOffsetMs == hasViewOffsetMs && + other.viewOffsetMs == viewOffsetMs; + + @override + int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); } class WatchStateResolver { diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart index 8b8d7422..481c3165 100644 --- a/lib/theme/mono_tokens.dart +++ b/lib/theme/mono_tokens.dart @@ -3,6 +3,17 @@ import 'package:flutter/material.dart'; MonoTokens tokens(BuildContext context) => Theme.of(context).extension()!; +/// M3E connected-group geometry for item [index] of a [count]-item group: +/// large radii on the group's outer corners, small radii between adjacent +/// items. Pair with `MonoTokens.groupGap` spacing for the hairline gaps. +BorderRadius groupItemRadii(BuildContext context, int index, int count) { + final t = tokens(context); + return BorderRadius.vertical( + top: Radius.circular(index == 0 ? t.radiusLg : t.radiusXs), + bottom: Radius.circular(index == count - 1 ? t.radiusLg : t.radiusXs), + ); +} + @immutable class MonoTokens extends ThemeExtension { /// Effectively-stadium radius for pill shapes; the renderer proportionally diff --git a/lib/utils/android_exit_diagnostics.dart b/lib/utils/android_exit_diagnostics.dart index bff08dd8..c52f19e7 100644 --- a/lib/utils/android_exit_diagnostics.dart +++ b/lib/utils/android_exit_diagnostics.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'app_logger.dart'; +import 'device_channel.dart'; enum AndroidStartupPhase { nativeOnCreate('native_on_create'), @@ -34,7 +34,6 @@ enum AndroidUiState { /// Best-effort bridge for the newest Android 11+ historical process exit. abstract final class AndroidExitDiagnostics { - static const _channel = MethodChannel('com.plezy/device'); static const _allowedReasons = {'crash', 'native_crash', 'anr', 'low_memory', 'user_requested', 'other'}; static const _allowedAbis = {'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86', 'unknown'}; static const _allowedCodecContexts = { @@ -132,7 +131,7 @@ abstract final class AndroidExitDiagnostics { static Future _persistStartupPhase(String phase) async { try { - await _channel.invokeMethod('setStartupPhase', phase); + await deviceChannel.invokeMethod('setStartupPhase', phase); } catch (_) { // Native phase persistence is best-effort. } @@ -141,7 +140,7 @@ abstract final class AndroidExitDiagnostics { static Future markUiState(AndroidUiState state) async { if (!Platform.isAndroid) return; try { - await _channel.invokeMethod('setRuntimeUiState', state.id); + await deviceChannel.invokeMethod('setRuntimeUiState', state.id); } catch (_) { // Runtime diagnostics are best-effort and must never affect navigation. } @@ -155,7 +154,7 @@ abstract final class AndroidExitDiagnostics { static Future logPreviousExit() async { if (!Platform.isAndroid) return; try { - final raw = await _channel.invokeMapMethod('getPreviousExit'); + final raw = await deviceChannel.invokeMapMethod('getPreviousExit'); final report = _validate(raw); if (report == null) return; diff --git a/lib/utils/async_singleton.dart b/lib/utils/async_singleton.dart new file mode 100644 index 00000000..d3a68401 --- /dev/null +++ b/lib/utils/async_singleton.dart @@ -0,0 +1,62 @@ +/// Memoizes a `static Future getInstance()` singleton whose construction is +/// cheap but whose initialization is async. +/// +/// The instance is published *before* initialization runs, so sync accessors +/// (`isTVSync`, `isReduced`, ...) see it immediately. Concurrent callers await +/// the one in-flight initialization, and a failed initialization rolls the +/// instance back so the next call retries — the `identical` guards keep that +/// rollback safe once a later call has replaced the memoized state. +/// +/// The `debug*` members are test hooks; owners re-expose them behind their own +/// `@visibleForTesting` forwarders. +class AsyncSingleton { + T? _instance; + Future? _initialization; + + /// Awaited before each initialization run, to hold initialization open while + /// a test exercises concurrent callers. + Future? debugGate; + + /// The memoized instance, which may still be initializing. Null before the + /// first [getInstance] call and after a failed initialization. + T? get instance => _instance; + + /// Returns the memoized instance, building it with [create] and running + /// [initialize] on it the first time. + Future getInstance(T Function() create, Future Function(T instance) initialize) async { + final existing = _instance; + if (existing != null) { + final inFlight = _initialization; + if (inFlight != null) await inFlight; + return existing; + } + + final instance = create(); + _instance = instance; + final initialization = _initialize(instance, initialize); + _initialization = initialization; + try { + await initialization; + } catch (_) { + if (identical(_instance, instance)) _instance = null; + rethrow; + } finally { + if (identical(_initialization, initialization)) _initialization = null; + } + return instance; + } + + Future _initialize(T instance, Future Function(T instance) initialize) async { + final gate = debugGate; + if (gate != null) await gate; + await initialize(instance); + } + + /// Drops the memoized state and the gate, optionally seeding [instance] so + /// sync accessors can be exercised without initializing. + void debugReset({T? instance}) { + _instance = instance; + _initialization = null; + debugGate = null; + } +} diff --git a/lib/utils/device_channel.dart b/lib/utils/device_channel.dart new file mode 100644 index 00000000..056884fa --- /dev/null +++ b/lib/utils/device_channel.dart @@ -0,0 +1,5 @@ +import 'package:flutter/services.dart'; + +/// Native device bridge (TV detection, device name, performance signals, +/// process-exit diagnostics). Implemented per platform under `com.plezy/device`. +const MethodChannel deviceChannel = MethodChannel('com.plezy/device'); diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 0e0c3fd3..757c78fc 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../media/ids.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import '../focus/focusable_action_bar.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -13,6 +15,7 @@ import '../services/sync_rule_executor.dart'; import 'content_utils.dart'; import 'dialogs.dart'; import 'download_version_utils.dart'; +import 'platform_detector.dart'; import 'snackbar_helper.dart'; @visibleForTesting @@ -461,3 +464,44 @@ Future removeSyncRuleAndSnack( showSuccessSnackBar(context, t.downloads.syncRuleRemoved); } } + +/// The download / manage-sync-rule app-bar pair shared by the collection and +/// playlist detail screens: one entry that downloads (or edits the existing +/// rule) and, when a rule exists, one that removes it. Both are hidden on +/// Apple TV, which has no downloads UI. +/// +/// [hasRule] stays caller-computed so each screen keeps its own +/// `context.select` short-circuit, and [showDownload] carries the screen's +/// own visibility predicate for the first entry. +List buildSyncRuleActions( + BuildContext context, { + required String ruleKey, + required String displayTitle, + required bool hasRule, + required bool showDownload, + required VoidCallback onDownload, +}) { + if (PlatformDetector.isAppleTV()) return const []; + return [ + if (showDownload) + FocusableAction( + icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, + tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, + onPressed: hasRule + ? () => manageSyncRule(context, downloadProvider: context.read(), globalKey: ruleKey) + : onDownload, + iconColor: hasRule ? Colors.teal : null, + ), + if (hasRule) + FocusableAction( + icon: Symbols.sync_disabled_rounded, + tooltip: t.downloads.removeSyncRule, + onPressed: () => removeSyncRuleAndSnack( + context, + downloadProvider: context.read(), + globalKey: ruleKey, + displayTitle: displayTitle, + ), + ), + ]; +} diff --git a/lib/utils/hub_icons.dart b/lib/utils/hub_icons.dart new file mode 100644 index 00000000..b79b1d40 --- /dev/null +++ b/lib/utils/hub_icons.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../media/media_hub.dart'; + +/// Leading icon for a hub row, shared by every surface that renders hubs from +/// the same backend rows (Discover and a library's Recommended tab). +/// +/// Continue Watching is matched on the hub key first so synthesized rows and +/// section-specific `*.inprogress.*` hubs are covered, then on title for +/// backends whose resume row is only recognizable by name (Plex "On Deck"). +/// Everything else is keyword-matched on the title; the first match wins, so +/// the more specific keywords are checked before the broader ones. +IconData hubIconFor(MediaHub hub) { + final title = hub.title.toLowerCase(); + + if (hub.isContinueWatchingHub || title.contains('continue watching') || title.contains('on deck')) { + return Symbols.play_circle_rounded; + } + for (final (keywords, icon) in _titleKeywordIcons) { + if (keywords.any(title.contains)) return icon; + } + return _defaultHubIcon; +} + +const _defaultHubIcon = Symbols.auto_awesome_rounded; + +/// Title keywords in match order — see [hubIconFor]. +const _titleKeywordIcons = <(List, IconData)>[ + // Trending/Popular + (['trending'], Symbols.trending_up_rounded), + (['popular', 'imdb'], Symbols.whatshot_rounded), + // Seasonal/Time-based + (['seasonal'], Symbols.calendar_month_rounded), + (['newly', 'new release'], Symbols.new_releases_rounded), + (['recently released', 'recent'], Symbols.schedule_rounded), + // Top/Rated + (['top rated', 'highest rated'], Symbols.star_rounded), + (['top '], Symbols.military_tech_rounded), + // Genre-specific + (['thriller'], Symbols.warning_amber_rounded), + (['comedy', 'comedier'], Symbols.mood_rounded), + (['action'], Symbols.flash_on_rounded), + (['drama'], Symbols.theater_comedy_rounded), + (['fantasy'], Symbols.auto_fix_high_rounded), + (['science', 'sci-fi'], Symbols.rocket_launch_rounded), + (['horror', 'skräck'], Symbols.nights_stay_rounded), + (['romance', 'romantic'], Symbols.favorite_border_rounded), + (['adventure', 'äventyr'], Symbols.explore_rounded), + // Watchlist/Playlists + (['playlist', 'watchlist'], Symbols.playlist_play_rounded), + (['unwatched', 'unplayed'], Symbols.visibility_off_rounded), + (['watched', 'played'], Symbols.visibility_rounded), + // Network/Studio + (['network', 'more from'], Symbols.tv_rounded), + // Actor/Director + (['actor', 'director'], Symbols.person_rounded), + // Decades (80s, 90s, etc.) + (['80', '90', '00'], Symbols.history_rounded), + // Rediscover/Start Watching + (['rediscover', 'start watching'], Symbols.play_arrow_rounded), + // Broad library-hub keywords, last so the specific rows above keep their icons. + (['rated'], Symbols.star_rounded), + (['recommended'], Symbols.thumb_up_rounded), + (['genre'], Symbols.category_rounded), +]; diff --git a/lib/utils/media_event_keys.dart b/lib/utils/media_event_keys.dart new file mode 100644 index 00000000..2d9f2069 --- /dev/null +++ b/lib/utils/media_event_keys.dart @@ -0,0 +1,36 @@ +import '../media/ids.dart'; +import '../media/media_item.dart'; +import 'global_key_utils.dart'; + +/// Builds the id filter for a screen showing [items]. +/// +/// Each item contributes itself plus its parent and grandparent, because an +/// event on a season or show also changes how its episodes render. +Set hierarchicalEventIds(Iterable items) { + final keys = {}; + for (final item in items) { + keys.add(item.id); + if (item.parentId != null) keys.add(item.parentId!); + if (item.grandparentId != null) keys.add(item.grandparentId!); + } + return keys; +} + +/// The [hierarchicalEventIds] filter expressed as `serverId:ratingKey` keys. +/// +/// Items without a server id fall back to [fallbackServerId]; if that is also +/// missing the whole filter collapses to `null`, which callers use to fall back +/// to id-only matching rather than silently under-matching. +Set? hierarchicalEventGlobalKeys(Iterable items, {String? fallbackServerId}) { + final keys = {}; + for (final item in items) { + final rawServerId = item.serverId ?? fallbackServerId; + if (rawServerId == null) return null; + + final serverId = ServerId(rawServerId); + keys.add(buildGlobalKey(serverId, item.id)); + if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!)); + if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!)); + } + return keys; +} diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 52e61524..0162f011 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -10,6 +10,7 @@ import 'future_extensions.dart'; import 'isolate_helper.dart'; import 'log_redaction_manager.dart'; import 'managed_http_client.dart'; +import 'url_utils.dart'; import '../exceptions/media_server_exceptions.dart'; // Platform-specific imports are conditional @@ -412,39 +413,13 @@ class MediaServerHttpClient { /// Append query parameters to an already-parsed URI. Uri _appendQuery(Uri uri, Map? queryParameters) { if (queryParameters == null || queryParameters.isEmpty) return uri; - final query = MediaServerHttpClient.encodeQueryParameters(queryParameters); + final query = encodeQueryParameters(queryParameters); if (query.isEmpty) return uri; final existing = uri.query; final combined = existing.isEmpty ? query : '$existing&$query'; return uri.replace(query: combined); } - /// Encode query params with `%20` for spaces (not `+`). - /// Null values are omitted and iterable values are emitted as repeated keys. - static String encodeQueryParameters(Map? params) { - if (params == null || params.isEmpty) return ''; - final parts = []; - - void add(String key, Object? value) { - if (value == null) return; - if (value is Iterable) { - for (final item in value) { - add(key, item); - } - return; - } - parts.add( - '${Uri.encodeComponent(key)}=' - '${Uri.encodeComponent(value.toString())}', - ); - } - - for (final entry in params.entries) { - add(entry.key, entry.value); - } - return parts.join('&'); - } - static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://'); /// Set the request body, choosing encoding based on the body type. @@ -461,14 +436,11 @@ class MediaServerHttpClient { return; } + // Content type comes from the caller's headers (Jellyfin/Plex put + // `application/json` in their defaults); `request.body` falls back to + // text/plain. Don't add one here — `request.headers` is case-insensitive, + // and the setter above has already filled the key in either way. request.body = jsonEncode(body); - // http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415 - // if both `Content-Type` (from defaults) and `content-type` (added below) - // end up coexisting, so check both casings before adding. - final hasContentType = request.headers.keys.any((k) => k.toLowerCase() == 'content-type'); - if (!hasContentType) { - request.headers['content-type'] = 'application/json'; - } } /// Decode the response body: lenient UTF-8, then JSON parse if applicable. diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart index a5904911..25bb53a9 100644 --- a/lib/utils/music_navigation.dart +++ b/lib/utils/music_navigation.dart @@ -118,9 +118,54 @@ Future playTracks( if (context.mounted) _autoOpenNowPlayingOnTv(context); } +/// Fetch a track list with [fetch], then play it — the shape every music +/// entry point that needs a server round-trip before playback repeats: +/// availability gate → [MusicPlaybackService.beginPlayIntent] → fetch → +/// mounted/intent re-check → [playTracks]. Guarding the round-trip with the +/// intent keeps a slow fetch from replacing a queue the user started later. +/// +/// [onError] reports a failed fetch and runs only while the intent is still +/// current and [context] mounted; passing null instead lets the failure +/// propagate to the caller's own error boundary. [onEmpty] handles a +/// successful but empty fetch; passing null hands the empty list to +/// [playTracks] unchanged. +Future playFetchedTracks( + BuildContext context, { + required Future> Function() fetch, + required MusicPlayContext playContext, + void Function(Object error, StackTrace stackTrace)? onError, + VoidCallback? onEmpty, + MediaItem? startTrack, + bool shuffle = false, +}) async { + if (!ensureMusicPlaybackAvailable(context)) return; + final service = context.read(); + final intent = service.beginPlayIntent(); + final List tracks; + try { + tracks = await fetch(); + } catch (error, stackTrace) { + if (!service.isPlayIntentCurrent(intent)) return; + if (onError == null) rethrow; + if (!context.mounted) return; + onError(error, stackTrace); + return; + } + if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; + if (tracks.isEmpty && onEmpty != null) { + onEmpty(); + return; + } + await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle); +} + /// Play [track] within its album queue: fetch the album's tracks and start /// at [track]. Falls back to single-track playback when the track has no /// album, isn't found in it, or the album fetch fails. +/// +/// Hand-written rather than routed through [playFetchedTracks]: the fallback +/// must play under the *same* intent as the album fetch, so a stale fallback +/// can never supersede a newer request. Future playTrackWithAlbumContext(BuildContext context, MediaItem track) async { if (!ensureMusicPlaybackAvailable(context)) return; final service = context.read(); diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 17320db9..ca05ca19 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -5,6 +5,9 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'async_singleton.dart'; +import 'device_channel.dart'; + const _androidFeatureTelevision = 'android.hardware.type.television'; const _androidFeatureLeanback = 'android.software.leanback'; const _androidFeatureFireTv = 'amazon.hardware.fire_tv'; @@ -30,10 +33,9 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable fea /// Service for detecting if the app is running on Android TV or Apple TV. class TvDetectionService { - static TvDetectionService? _instance; - static Future? _initialization; + static final AsyncSingleton _singleton = AsyncSingleton(); @visibleForTesting - static Future? debugDetectionGate; + static set debugDetectionGate(Future? value) => _singleton.debugGate = value; static bool? _debugAppleTVOverride; bool _detected = false; bool _forceTv = false; @@ -46,35 +48,12 @@ class TvDetectionService { /// Get the singleton instance, initializing if needed. /// Pass [forceTv] to combine a user override with the system-feature check. - static Future getInstance({bool forceTv = false}) async { - final existing = _instance; - if (existing != null) { - final initialization = _initialization; - if (initialization != null) await initialization; - return existing; - } - - final instance = TvDetectionService._(); - _instance = instance; - final initialization = instance._detect(forceTv); - _initialization = initialization; - try { - await initialization; - } catch (_) { - if (identical(_instance, instance)) _instance = null; - rethrow; - } finally { - if (identical(_initialization, initialization)) _initialization = null; - } - return instance; - } + static Future getInstance({bool forceTv = false}) => + _singleton.getInstance(TvDetectionService._, (instance) => instance._detect(forceTv)); static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); - static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); Future _detect(bool forceTv) async { - final gate = debugDetectionGate; - if (gate != null) await gate; if (_initialized) return; final deviceInfo = DeviceInfoPlugin(); @@ -120,7 +99,7 @@ class TvDetectionService { Future _getNativeAndroidTvDetection() async { try { - final result = await _deviceChannel.invokeMapMethod('getTvDetection'); + final result = await deviceChannel.invokeMapMethod('getTvDetection'); if (result == null) return null; final reasonsValue = result['reasons']; final reasons = reasonsValue is Iterable ? reasonsValue.whereType().toList() : []; @@ -139,7 +118,7 @@ class TvDetectionService { static Future getAndroidDeviceName() async { if (!Platform.isAndroid) return null; try { - final name = (await _deviceChannel.invokeMethod('getDeviceName'))?.trim(); + final name = (await deviceChannel.invokeMethod('getDeviceName'))?.trim(); return (name == null || name.isEmpty) ? null : name; } on MissingPluginException { return null; @@ -155,10 +134,10 @@ class TvDetectionService { } /// Synchronous access after initialization (returns false if not initialized) - static bool isTVSync() => _debugAppleTVOverride ?? _instance?._isTV ?? false; + static bool isTVSync() => _debugAppleTVOverride ?? _singleton.instance?._isTV ?? false; /// Synchronous Apple TV check (returns false if not initialized or not tvOS). - static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _instance?._isAppleTV == true); + static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _singleton.instance?._isAppleTV == true); @visibleForTesting static void debugSetAppleTVOverride(bool? value) { @@ -167,16 +146,14 @@ class TvDetectionService { @visibleForTesting static void debugReset() { - _instance = null; - _initialization = null; - debugDetectionGate = null; + _singleton.debugReset(); _debugAppleTVOverride = null; } - static List tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const []; + static List tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const []; /// Convenience setter that forwards to the singleton if available. - static void setForceTVSync(bool value) => _instance?.setForceTv(value); + static void setForceTVSync(bool value) => _singleton.instance?.setForceTv(value); } class PlatformDetector { diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart index f352f03d..e1fae749 100644 --- a/lib/utils/url_utils.dart +++ b/lib/utils/url_utils.dart @@ -14,6 +14,36 @@ String stripTrailingSlash(String input) { return trimmed; } +/// Encode query params with `%20` for spaces (not `+`). +/// Null values are omitted and iterable values are emitted as repeated keys. +/// +/// Used instead of `Uri.queryParameters` (which emits `+` for spaces) wherever +/// the server rejects `+` — Plex's transcode endpoints and Seerr's TMDB-backed +/// `/search` proxy both do. +String encodeQueryParameters(Map? params) { + if (params == null || params.isEmpty) return ''; + final parts = []; + + void add(String key, Object? value) { + if (value == null) return; + if (value is Iterable) { + for (final item in value) { + add(key, item); + } + return; + } + parts.add( + '${Uri.encodeComponent(key)}=' + '${Uri.encodeComponent(value.toString())}', + ); + } + + for (final entry in params.entries) { + add(entry.key, entry.value); + } + return parts.join('&'); +} + final RegExp _schemePattern = RegExp(r'^[A-Za-z][A-Za-z\d+.-]*://'); /// Canonicalizes a server base URL: trims, strips one trailing `/`, and diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index cdc05bdb..80081b84 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -227,38 +227,60 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta ); } + /// Persists [code] as a recent room for the active profile and refreshes the + /// list. A null [controlMode] leaves any stored mode untouched. + Future _recordRecentRoom(String code, {ControlMode? controlMode}) async { + final profileId = _profileId; + if (profileId == null || profileId.isEmpty) return; + await RecentRoomsService.addOrUpdateRoom( + code, + profileId: profileId, + endpoint: _relayEndpoint, + controlMode: controlMode, + ); + setStateIfMounted(() => _recentRooms = _loadRecentRooms()); + } + + /// Runs [action] behind a busy flag, logging [logMessage] and showing + /// [failureMessage] in a snackbar when it throws. + Future _runSessionAction({ + required void Function(bool busy) setBusy, + required String logMessage, + required String failureMessage, + required Future Function() action, + }) async { + setState(() => setBusy(true)); + try { + await action(); + } catch (e) { + appLogger.e(logMessage, error: e); + if (mounted) { + showErrorSnackBar(context, '$failureMessage: $e'); + } + } finally { + if (mounted) { + setState(() => setBusy(false)); + } + } + } + Future _createSession() async { final controlMode = await _showControlModeDialog(); if (controlMode == null || !mounted) return; - setState(() => _isCreating = true); - - try { - final sessionId = await widget.watchTogether.createSession( - controlMode: controlMode, - relayEndpoint: _relayEndpoint, - displayName: _plexDisplayName, - ); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom( - sessionId, - profileId: profileId, - endpoint: _relayEndpoint, + await _runSessionAction( + setBusy: (busy) => _isCreating = busy, + logMessage: 'Failed to create session', + failureMessage: t.watchTogether.failedToCreate, + action: () async { + final sessionId = await widget.watchTogether.createSession( controlMode: controlMode, + relayEndpoint: _relayEndpoint, + displayName: _plexDisplayName, ); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to create session', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToCreate}: $e'); - } - } finally { - if (mounted) { - setState(() => _isCreating = false); - } - } + await _recordRecentRoom(sessionId, controlMode: controlMode); + }, + ); } Future _showControlModeDialog() { @@ -287,52 +309,32 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta final sessionId = await showJoinSessionDialog(context); if (sessionId == null || !mounted) return; - setState(() => _isJoining = true); - - try { - await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom(sessionId, profileId: profileId, endpoint: _relayEndpoint); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to join session', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); - } - } finally { - if (mounted) { - setState(() => _isJoining = false); - } - } + await _runSessionAction( + setBusy: (busy) => _isJoining = busy, + logMessage: 'Failed to join session', + failureMessage: t.watchTogether.failedToJoin, + action: () async { + await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName); + await _recordRecentRoom(sessionId); + }, + ); } Future _enterRoom(RecentRoom room) async { - setState(() => _enteringRoomCode = room.code); - - try { - await widget.watchTogether.enterRoom( - room.code, - relayEndpoint: _relayEndpoint, - controlMode: room.controlMode ?? ControlMode.anyone, - displayName: _plexDisplayName, - ); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom(room.code, profileId: profileId, endpoint: _relayEndpoint); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to enter room', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); - } - } finally { - if (mounted) { - setState(() => _enteringRoomCode = null); - } - } + await _runSessionAction( + setBusy: (busy) => _enteringRoomCode = busy ? room.code : null, + logMessage: 'Failed to enter room', + failureMessage: t.watchTogether.failedToJoin, + action: () async { + await widget.watchTogether.enterRoom( + room.code, + relayEndpoint: _relayEndpoint, + controlMode: room.controlMode ?? ControlMode.anyone, + displayName: _plexDisplayName, + ); + await _recordRecentRoom(room.code); + }, + ); } Future _renameRoom(RecentRoom room) async { diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index bc591b7c..a49b0fc9 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -301,16 +301,7 @@ class WatchTogetherPeerService with KeepaliveMixin { final rejectedSetup = _setupCompleter; if (rejectedSetup == null || rejectedSetup.isCompleted) return; - final leaveCompleter = Completer(); - _setupCompleter = leaveCompleter; - _setupRequestType = RelayProtocol.leave; - _sendRaw({ - 'type': RelayProtocol.leave, - 'sessionId': _sessionId, - 'peerId': _myPeerId, - 'reconnectToken': _reconnectToken, - 'protocolVersion': _relayProtocolVersion, - }); + final leaveCompleter = _announce(RelayProtocol.leave); unawaited(() async { try { await leaveCompleter.future.namedTimeout( @@ -803,16 +794,7 @@ class WatchTogetherPeerService with KeepaliveMixin { ); } - final releaseCompleter = Completer(); - _setupCompleter = releaseCompleter; - _setupRequestType = _isHost ? RelayProtocol.endSession : RelayProtocol.leave; - _sendRaw({ - 'type': _isHost ? RelayProtocol.endSession : RelayProtocol.leave, - 'sessionId': _sessionId, - 'peerId': _myPeerId, - 'reconnectToken': _reconnectToken, - 'protocolVersion': _relayProtocolVersion, - }); + final releaseCompleter = _announce(_isHost ? RelayProtocol.endSession : RelayProtocol.leave); await releaseCompleter.future.namedTimeout( const Duration(seconds: 10), operation: _isHost ? 'WatchTogether end session' : 'WatchTogether leave session', diff --git a/lib/widgets/catalog_source_logo.dart b/lib/widgets/catalog_source_logo.dart index c0ad7e8d..f3b0044e 100644 --- a/lib/widgets/catalog_source_logo.dart +++ b/lib/widgets/catalog_source_logo.dart @@ -3,33 +3,28 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../models/catalog/catalog_item.dart'; -/// Brand mark of a catalog source — or any service SVG via -/// [CatalogSourceLogo.asset] — tinted with the ambient icon color. Uses -/// [SvgTheme.currentColor] so SVGs with multiple explicit fills (AniList -/// keeps its brand-blue L while the A follows the theme) render correctly -/// alongside single-color wordmarks. +/// Brand mark of a service, tinted with the ambient icon color. The single +/// table of brand asset paths: every surface that shows a service logo goes +/// through here, including services that do not participate in the Explore +/// catalog. Uses [SvgTheme.currentColor] so SVGs with multiple explicit fills +/// (AniList keeps its brand-blue L while the A follows the theme) render +/// correctly alongside single-color wordmarks. class CatalogSourceLogo extends StatelessWidget { - final CatalogSourceId? id; - final String? assetPath; + final CatalogSourceId id; final double size; - const CatalogSourceLogo(CatalogSourceId this.id, {super.key, this.size = 20}) : assetPath = null; - - /// For services that do not participate in the Explore catalog. - const CatalogSourceLogo.asset(String this.assetPath, {super.key, this.size = 20}) : id = null; + const CatalogSourceLogo(this.id, {super.key, this.size = 20}); @override Widget build(BuildContext context) { - final asset = - assetPath ?? - switch (id!) { - CatalogSourceId.plex => 'assets/plex_chevron.svg', - CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', - CatalogSourceId.mal => 'assets/mal_mark.svg', - CatalogSourceId.anilist => 'assets/anilist_mark.svg', - CatalogSourceId.simkl => 'assets/simkl_mark.svg', - CatalogSourceId.seerr => 'assets/seerr_mark.svg', - }; + final asset = switch (id) { + CatalogSourceId.plex => 'assets/plex_chevron.svg', + CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', + CatalogSourceId.mal => 'assets/mal_mark.svg', + CatalogSourceId.anilist => 'assets/anilist_mark.svg', + CatalogSourceId.simkl => 'assets/simkl_mark.svg', + CatalogSourceId.seerr => 'assets/seerr_mark.svg', + }; final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; return SvgPicture.asset( asset, diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 282f54d4..10b8b939 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -4,20 +4,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../connection/connection_registry.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../mixins/mounted_set_state_mixin.dart'; -import '../../models/plex/plex_home.dart'; -import '../../profiles/active_plex_identity.dart'; -import '../../profiles/active_profile_provider.dart'; -import '../../profiles/plex_home_service.dart'; -import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../services/base_peer_service.dart'; +import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/settings_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; @@ -93,26 +88,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi Future _initCryptoAndDiscover() async { try { - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - if (!mounted) return; - final home = await _resolveHome(identity?.account.id); - if (!mounted) return; - await _provider.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); + await ensureCompanionRemoteCryptoFromContext(context); } catch (e) { appLogger.e('CompanionRemote: crypto init failed', error: e); } @@ -137,11 +113,6 @@ class _DiscoveryViewState extends State with ControllerDisposerMi } } - Future _resolveHome(String? connectionId) { - if (connectionId == null) return Future.value(); - return context.read().materializePlexHomeForConnection(connectionId); - } - void _startDiscovery() { final stream = _provider.discoverHosts(); if (stream == null) return; diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 2dbef406..989ce6ad 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -10,7 +10,6 @@ import '../media/media_kind.dart'; import '../models/download_models.dart'; import '../utils/dialogs.dart'; import '../utils/global_key_utils.dart'; -import 'clickable_cursor.dart'; import 'download_status_icon.dart'; /// Represents a node in the download tree @@ -439,7 +438,10 @@ class _DownloadTreeViewState extends State { /// Pause all active (downloading and queued) children of a container node void _pauseAllChildren(DownloadTreeNode node) { - final keys = _getActiveChildKeys(node); + final keys = _leafKeys( + node, + where: (leaf) => leaf.status == DownloadStatus.downloading || leaf.status == DownloadStatus.queued, + ); for (final key in keys) { widget.onPause?.call(key); } @@ -447,38 +449,12 @@ class _DownloadTreeViewState extends State { /// Resume all paused children of a container node void _resumeAllChildren(DownloadTreeNode node) { - final keys = _getPausedChildKeys(node); + final keys = _leafKeys(node, where: (leaf) => leaf.status == DownloadStatus.paused); for (final key in keys) { widget.onResume?.call(key); } } - /// Get all active (downloading or queued) child keys from a container node - List _getActiveChildKeys(DownloadTreeNode node) { - final List keys = []; - for (final child in node.children) { - if (child.hasChildren) { - keys.addAll(_getActiveChildKeys(child)); - } else if (child.status == DownloadStatus.downloading || child.status == DownloadStatus.queued) { - keys.add(child.key); - } - } - return keys; - } - - /// Get all paused child keys from a container node - List _getPausedChildKeys(DownloadTreeNode node) { - final List keys = []; - for (final child in node.children) { - if (child.hasChildren) { - keys.addAll(_getPausedChildKeys(child)); - } else if (child.status == DownloadStatus.paused) { - keys.add(child.key); - } - } - return keys; - } - /// Delete all children of a container node via the container's globalKey /// so deleteDownload's transitive show/season path cleans up all maps. void _deleteAllChildren(DownloadTreeNode node) { @@ -489,23 +465,22 @@ class _DownloadTreeViewState extends State { } // Container globalKey unresolvable; fall back to per-leaf delete. - for (final key in _getAllChildKeys(node)) { + for (final key in _leafKeys(node)) { widget.onDelete?.call(key); } } - /// Get all leaf node keys from a container node - List _getAllChildKeys(DownloadTreeNode node) { + /// Get the keys of every leaf below a container node, in tree order. + /// [where] filters which leaves are collected; unset collects all of them. + List _leafKeys(DownloadTreeNode node, {bool Function(DownloadTreeNode leaf)? where}) { final List keys = []; - for (final child in node.children) { if (child.hasChildren) { - keys.addAll(_getAllChildKeys(child)); - } else { + keys.addAll(_leafKeys(child, where: where)); + } else if (where == null || where(child)) { keys.add(child.key); } } - return keys; } } @@ -556,6 +531,11 @@ class _FlatNode { const _FlatNode({required this.node, required this.depth}); } +/// A single action button of a tree row: the guards that decide which actions +/// exist live in one place ([_DownloadTreeItemState._actions]), so the focus +/// node count and the rendered buttons can never disagree. +typedef _RowAction = ({IconData icon, String tooltip, VoidCallback onPressed}); + /// A single tree item with focusable row content and action buttons class _DownloadTreeItem extends StatefulWidget { final DownloadTreeNode node; @@ -628,7 +608,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { void didUpdateWidget(_DownloadTreeItem oldWidget) { super.didUpdateWidget(oldWidget); // Reinitialize focus nodes if action count changed - if (_getActionCount() != _buttonFocusNodes.length) { + if (_actions().length != _buttonFocusNodes.length) { _disposeButtonFocusNodes(); _initButtonFocusNodes(); } @@ -641,7 +621,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { } void _initButtonFocusNodes() { - final actionCount = _getActionCount(); + final actionCount = _actions().length; for (int i = 0; i < actionCount; i++) { _buttonFocusNodes.add(FocusNode(debugLabel: 'download_action_$i')); } @@ -661,40 +641,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { super.dispose(); } - int _getActionCount() { - final isContainer = - widget.node.type == DownloadNodeType.show || - widget.node.type == DownloadNodeType.season || - widget.node.type == DownloadNodeType.album; - if (isContainer) { - return _getContainerActionCount(); - } - return _getItemActionCount(); - } - - int _getItemActionCount() { - int count = 0; - final status = widget.node.status; - if (status == DownloadStatus.downloading && widget.onPause != null) count++; - if (status == DownloadStatus.paused && widget.onResume != null) count++; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) count++; - if (status == DownloadStatus.failed && widget.onRetry != null) count++; - if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && - widget.onDelete != null) { - count++; - } - return count; - } - - int _getContainerActionCount() { - int count = 0; - final status = widget.node.status; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) count++; - if (status == DownloadStatus.paused && widget.onResume != null) count++; - if (widget.onDelete != null) count++; - return count; - } - void _focusFirstButton() { if (_buttonFocusNodes.isNotEmpty) { _buttonFocusNodes.first.requestFocus(); @@ -709,7 +655,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Widget build(BuildContext context) { final theme = Theme.of(context); final canExpand = widget.node.hasChildren; - final hasActions = _buttonFocusNodes.isNotEmpty; + final actions = _actions(); return Padding( padding: .only(left: widget.depth * 16.0), @@ -718,7 +664,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { autofocus: widget.autofocus, onSelect: canExpand ? widget.onToggleExpansion : null, onNavigateLeft: widget.onNavigateLeft, - onNavigateRight: hasActions ? _focusFirstButton : null, + onNavigateRight: actions.isNotEmpty ? _focusFirstButton : null, onBack: widget.onBack, borderRadius: 8.0, disableScale: true, @@ -734,7 +680,11 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Expanded(child: _buildRowContent(theme, canExpand)), // Action buttons - if (hasActions) _buildActions(), + if (actions.isNotEmpty) + Row( + mainAxisSize: .min, + children: [for (int i = 0; i < actions.length; i++) _buildActionButton(actions[i], i)], + ), ], ), ), @@ -755,7 +705,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { const SizedBox(width: 8), // Status icon - _buildStatusIcon(_effectiveStatus), + DownloadStatusIcon(status: _effectiveStatus, size: 20), const SizedBox(width: 12), @@ -826,180 +776,121 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { ); } - Widget _buildStatusIcon(DownloadStatus status) { - return DownloadStatusIcon(status: status, size: 20); - } - String _getNodeSummary() { final total = widget.node.children.length; final completed = widget.node.completedChildrenCount; return '$completed/$total completed'; } - Widget _buildActions() { + /// The actions this row offers, in render order. Single source of truth: + /// both the button widgets and the focus nodes sizing come from this list, + /// so they cannot drift apart. Uses the raw node status, not + /// [_effectiveStatus] (which only remaps the row content). + List<_RowAction> _actions() { + final status = widget.node.status; final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season || widget.node.type == DownloadNodeType.album; + final actions = <_RowAction>[]; - final actions = isContainer ? _buildContainerActions() : _buildItemActions(); + if (isContainer) { + // Pause all button + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { + actions.add(( + icon: Symbols.pause_rounded, + tooltip: t.downloads.pauseAll, + onPressed: () => widget.pauseAllChildren(widget.node), + )); + } - return Row(mainAxisSize: .min, children: actions); - } + // Resume all button + if (status == DownloadStatus.paused && widget.onResume != null) { + actions.add(( + icon: Symbols.play_arrow_rounded, + tooltip: t.downloads.resumeAll, + onPressed: () => widget.resumeAllChildren(widget.node), + )); + } + + // Delete all button + if (widget.onDelete != null) { + actions.add(( + icon: Symbols.delete_sweep_rounded, + tooltip: t.downloads.deleteAll, + onPressed: () async { + if (await _confirmDelete()) widget.deleteAllChildren(widget.node); + }, + )); + } + + return actions; + } - List _buildItemActions() { final globalKey = widget.node.key; - final status = widget.node.status; - final actions = []; - int buttonIndex = 0; // Pause button for downloading items if (status == DownloadStatus.downloading && widget.onPause != null) { - actions.add( - _buildActionButton( - icon: Symbols.pause_rounded, - tooltip: t.common.pause, - onPressed: () => widget.onPause!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add((icon: Symbols.pause_rounded, tooltip: t.common.pause, onPressed: () => widget.onPause!(globalKey))); } // Resume button for paused items if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: t.common.resume, - onPressed: () => widget.onResume!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.play_arrow_rounded, + tooltip: t.common.resume, + onPressed: () => widget.onResume!(globalKey), + )); } // Cancel button for downloading/queued items if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) { - actions.add( - _buildActionButton( - icon: Symbols.close_rounded, - tooltip: t.common.cancel, - onPressed: () => widget.onCancel!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.close_rounded, + tooltip: t.common.cancel, + onPressed: () => widget.onCancel!(globalKey), + )); } // Retry button for failed items if (status == DownloadStatus.failed && widget.onRetry != null) { - actions.add( - _buildActionButton( - icon: Symbols.refresh_rounded, - tooltip: t.downloads.retryDownload, - onPressed: () => widget.onRetry!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.refresh_rounded, + tooltip: t.downloads.retryDownload, + onPressed: () => widget.onRetry!(globalKey), + )); } // Delete button for completed/failed/cancelled items if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_rounded, - tooltip: t.common.delete, - onPressed: () async { - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: widget.node.title), - ); - if (confirmed) widget.onDelete!(globalKey); - }, - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.delete_rounded, + tooltip: t.common.delete, + onPressed: () async { + if (await _confirmDelete()) widget.onDelete!(globalKey); + }, + )); } return actions; } - List _buildContainerActions() { - final status = widget.node.status; - final actions = []; - int buttonIndex = 0; - - // Pause all button - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { - actions.add( - _buildActionButton( - icon: Symbols.pause_rounded, - tooltip: t.downloads.pauseAll, - onPressed: () => widget.pauseAllChildren(widget.node), - buttonIndex: buttonIndex++, - ), - ); - } - - // Resume all button - if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: t.downloads.resumeAll, - onPressed: () => widget.resumeAllChildren(widget.node), - buttonIndex: buttonIndex++, - ), - ); - } - - // Delete all button - if (widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_sweep_rounded, - tooltip: t.downloads.deleteAll, - onPressed: () async { - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: widget.node.title), - ); - if (confirmed) widget.deleteAllChildren(widget.node); - }, - buttonIndex: buttonIndex++, - ), - ); - } - - return actions; + Future _confirmDelete() { + return showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: widget.node.title), + ); } - Widget _buildActionButton({ - required IconData icon, - required String tooltip, - required VoidCallback onPressed, - required int buttonIndex, - }) { - // Guard against race condition where action count changed between didUpdateWidget and build - if (buttonIndex >= _buttonFocusNodes.length) { - return Tooltip( - message: tooltip, - child: ClickableCursor( - child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), - ), - ), - ); - } - + Widget _buildActionButton(_RowAction action, int buttonIndex) { final isFirst = buttonIndex == 0; final isLast = buttonIndex == _buttonFocusNodes.length - 1; return FocusableWrapper( focusNode: _buttonFocusNodes[buttonIndex], - onSelect: onPressed, + onSelect: action.onPressed, onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(), onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(), onBack: widget.onBack, @@ -1008,10 +899,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { useBackgroundFocus: true, autoScroll: false, child: Tooltip( - message: tooltip, + message: action.tooltip, child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), + onTap: action.onPressed, + child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(action.icon, fill: 1, size: 20)), ), ), ); diff --git a/lib/widgets/focusable_filter_chip.dart b/lib/widgets/focusable_filter_chip.dart index 8c2afb92..feccce88 100644 --- a/lib/widgets/focusable_filter_chip.dart +++ b/lib/widgets/focusable_filter_chip.dart @@ -56,24 +56,6 @@ class _FocusableFilterChipState extends State with Focusabl @override String get debugLabel => 'filter_chip_${widget.label}'; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableFilterChip oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { return handleChipKeyEvent( node, diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index bbf26ed5..619107d9 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -91,24 +91,6 @@ class _FocusableTabChipState extends State with FocusableChipS @override String get debugLabel => 'tab_chip_${widget.label}'; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableTabChip oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { return handleChipKeyEvent( node, diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 6f1b6cd0..c72590c4 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -1,21 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; +import '../focus/dpad_reorder_mixin.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; -import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_library.dart'; import '../media/media_server_client.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; -import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/content_utils.dart'; import '../utils/dialogs.dart'; @@ -182,48 +179,24 @@ Future _handleLibraryMenuAction(BuildContext context, String action, Media } } -Future _performLibraryAction( +/// Runs a library admin action, wrapping it in progress/success/failure +/// snackbars. +/// +/// [resolveClient] picks the client flavour: `getPlexClientForLibrary` for the +/// Plex-only endpoints (scan / analyze / empty trash), `getMediaClientForLibrary` +/// for ops that exist on the backend-neutral [MediaServerClient] interface +/// (currently just refresh metadata). Both resolvers require the library's exact +/// owning server and throw the same error when it isn't available. +Future _performLibraryAction( BuildContext context, { - required MediaLibrary library, - required Future Function(PlexClient client) action, + required T Function(BuildContext context) resolveClient, + required Future Function(T client) action, required String progressMessage, required String successMessage, required String Function(Object error) failureMessage, }) async { try { - final client = context.getPlexClientForLibrary(library); - - if (context.mounted) { - showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); - } - - await action(client); - - if (context.mounted) { - showSuccessSnackBar(context, successMessage); - } - } catch (e) { - appLogger.e('Library action failed', error: e); - if (context.mounted) { - showErrorSnackBar(context, failureMessage(e)); - } - } -} - -/// Backend-neutral counterpart to [_performLibraryAction] for ops that exist -/// on the [MediaServerClient] interface (currently just refresh metadata). -/// Resolves the client through `getMediaClientForLibrary` so the action requires -/// the library's exact owning server. -Future _performMediaLibraryAction( - BuildContext context, { - required MediaLibrary library, - required Future Function(MediaServerClient client) action, - required String progressMessage, - required String successMessage, - required String Function(Object error) failureMessage, -}) async { - try { - final client = context.getMediaClientForLibrary(library); + final client = resolveClient(context); if (context.mounted) { showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); @@ -245,7 +218,7 @@ Future _performMediaLibraryAction( Future _scanLibrary(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.scanLibrary(library.id), progressMessage: t.messages.libraryScanning(title: library.title), successMessage: t.messages.libraryScanStarted(title: library.title), @@ -254,9 +227,9 @@ Future _scanLibrary(BuildContext context, MediaLibrary library) { } Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) { - return _performMediaLibraryAction( + return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getMediaClientForLibrary(library), action: (client) => client.refreshLibraryMetadata(library.id), progressMessage: t.messages.metadataRefreshing(title: library.title), successMessage: t.messages.metadataRefreshStarted(title: library.title), @@ -267,7 +240,7 @@ Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.emptyLibraryTrash(library.id), progressMessage: t.libraries.emptyingTrash(title: library.title), successMessage: t.libraries.trashEmptied(title: library.title), @@ -278,7 +251,7 @@ Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { Future _analyzeLibrary(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.analyzeLibrary(library.id), progressMessage: t.libraries.analyzing(title: library.title), successMessage: t.libraries.analysisStarted(title: library.title), @@ -309,19 +282,41 @@ class _LibraryManagementSheet extends StatefulWidget { State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState(); } -class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { +class _LibraryManagementSheetState extends State<_LibraryManagementSheet> + with DpadReorderListMixin { late List _tempLibraries; - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button - int? _movingIndex; // Non-null when in move mode - int? _originalIndex; // Original position before move (for cancel) - List? _originalOrder; // Original order before move (for cancel) final FocusNode _listFocusNode = FocusNode(); final ScrollController _dialogScrollController = ScrollController(); final ScrollController _sheetScrollController = ScrollController(); - bool _backKeyDownSeen = false; + + // Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button. + @override + List get reorderItems => _tempLibraries; + + @override + set reorderItems(List value) => _tempLibraries = value; + + @override + int get lastReorderColumn => 2; + + /// Only the TV dialog scrolls the focused row into view; the bottom sheet + /// list is not keyboard-driven. + @override + ScrollController? get reorderScrollController => widget.isDialog ? _dialogScrollController : null; + + @override + void onReorderMoveConfirmed() => widget.onReorder(_tempLibraries); + + @override + void onReorderColumnActivated(int column, int index) { + final library = _tempLibraries[index]; + if (column == 1) { + widget.onToggleVisibility(library); + } else if (column == 2) { + _showLibraryMenuBottomSheet(context, library); + } + } @override void initState() { @@ -337,157 +332,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { super.dispose(); } - void _ensureFocusedVisible() { - if (!widget.isDialog) return; - if (!_dialogScrollController.hasClients) return; - - const double itemHeight = 72.0; // Material ListTile with subtitle - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _dialogScrollController.offset; - final double viewportHeight = _dialogScrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - // Already fully visible — skip - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - // Place item at ~25% from top of viewport - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _dialogScrollController.position.maxScrollExtent, - ); - - _dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Track back key down/up pairing. If focus was elsewhere during KeyDown - // (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray - // KeyUp that would incorrectly pop the dialog. Consume it instead. - if (key.isBackKey) { - if (event is KeyDownEvent) { - _backKeyDownSeen = true; - } else if (event is KeyUpEvent && !_backKeyDownSeen) { - return KeyEventResult.handled; - } - if (event is KeyUpEvent) { - _backKeyDownSeen = false; - } - } - - final backResult = handleBackKeyAction(event, () { - if (_movingIndex != null) { - // Cancel move - restore original position - setState(() { - if (_originalOrder != null) { - _tempLibraries = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - // Move mode - arrows reorder the item - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - // Confirm move - apply the reorder - widget.onReorder(_tempLibraries); - setState(() { - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } - } else { - // Navigation mode - if (key.isUpKey && _focusedIndex > 0) { - setState(() { - _focusedIndex--; - _focusedColumn = 0; // Reset to row when changing rows - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) { - setState(() { - _focusedIndex++; - _focusedColumn = 0; // Reset to row when changing rows - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isLeftKey && _focusedColumn > 0) { - setState(() => _focusedColumn--); - return KeyEventResult.handled; - } - if (key.isRightKey && _focusedColumn < 2) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - // Enter move mode - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempLibraries); - }); - } else if (_focusedColumn == 1) { - // Toggle visibility - final library = _tempLibraries[_focusedIndex]; - widget.onToggleVisibility(library); - } else if (_focusedColumn == 2) { - // Show options menu - final library = _tempLibraries[_focusedIndex]; - _showLibraryMenuBottomSheet(context, library); - } - return KeyEventResult.handled; - } - } - - // Block d-pad keys at boundaries so focus doesn't escape the dialog - if (key.isDpadDirection) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - void _reorderLibraries(int oldIndex, int newIndex) { setState(() { final library = _tempLibraries.removeAt(oldIndex); @@ -527,7 +371,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { if (widget.isDialog) { return Dialog( child: PopScope( - canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent + canPop: false, // Prevent system back from double-popping; handled by handleReorderKeyEvent // ignore: no-empty-block - required callback, blocks system back on Android TV onPopInvokedWithResult: (didPop, result) {}, child: Scaffold( @@ -551,8 +395,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, - child: _buildFlatLibraryListDialog(hiddenLibraryKeys), + onKeyEvent: handleReorderKeyEvent, + child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys), ), ), ), @@ -567,7 +411,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleReorderKeyEvent, child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys), ), ), @@ -575,37 +419,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ); } - /// Build library list for dialog (TV) using ListView with scroll-into-view support - Widget _buildFlatLibraryListDialog(Set hiddenLibraryKeys) { - final showServerNames = _hasMultipleServers(); - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return ReorderableListView.builder( - scrollController: _dialogScrollController, - onReorderItem: _reorderLibraries, - itemCount: _tempLibraries.length, - padding: const EdgeInsets.symmetric(vertical: 8), - buildDefaultDragHandles: false, - itemBuilder: (context, index) { - final library = _tempLibraries[index]; - final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; - - return _buildLibraryTile( - library, - index, - hiddenLibraryKeys, - showServerName: showServerName, - isFocused: isFocused, - isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, - ); - }, - ); - } - - /// Build flat library list with a server subtitle when multiple servers are connected + /// Build flat library list with a server subtitle when multiple servers are + /// connected. The TV dialog passes [_dialogScrollController] so focused rows + /// can be scrolled into view; the bottom sheet passes its own controller. Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { final showServerNames = _hasMultipleServers(); final isKeyboardMode = InputModeTracker.isKeyboardMode(context); @@ -619,8 +435,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { itemBuilder: (context, index) { final library = _tempLibraries[index]; final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; + final isFocused = isKeyboardMode && index == focusedIndex; + final isMoving = index == movingIndex; return _buildLibraryTile( library, index, @@ -628,7 +444,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { showServerName: showServerName, isFocused: isFocused, isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, + focusedColumn: isFocused ? focusedColumn : null, ); }, ); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1a92c4c2..cd2f8059 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1060,22 +1060,11 @@ class MediaContextMenuState extends State { await playTrackWithAlbumContext(context, item); return; } - // Availability gate before the container fetch so the stub costs no - // server round-trip. - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - List tracks; - try { - tracks = await _musicTracksForItem(item); - } catch (_) { - if (!service.isPlayIntentCurrent(intent)) return; - rethrow; - } - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - await playTracks( + // No onError: a failed container fetch falls through to this menu's own + // error boundary, which logs it and shows the snackbar. + await playFetchedTracks( context, - tracks: tracks, + fetch: () => _musicTracksForItem(item), playContext: MusicPlayContext( id: item.id, title: item.displayTitle, @@ -1402,29 +1391,15 @@ class MediaContextMenuState extends State { Future _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async { // Match PlaylistDetailScreen: fail the availability gate before paying // for a full playlist fetch, then hand the tracks to the music session. - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - - List tracks; - try { - tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id); - } catch (e, st) { - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - return; - } - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - if (tracks.isEmpty) { - showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); - return; - } - - await playTracks( + await playFetchedTracks( context, - tracks: tracks, + fetch: () => fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id), playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist), + onError: (e, st) { + appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + }, + onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems), shuffle: shuffle, ); } diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index e1b8c74e..b12727fd 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -12,20 +11,18 @@ import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; +import '../models/catalog/catalog_item.dart'; import '../providers/trackers_provider.dart'; -import '../providers/trakt_account_provider.dart'; -import '../services/trackers/anilist/anilist_tracker.dart'; -import '../services/trackers/mal/mal_tracker.dart'; -import '../services/trackers/simkl/simkl_tracker.dart'; +import '../screens/settings/tracker_service_info.dart'; import '../services/trackers/tracker.dart'; import '../services/trackers/tracker_constants.dart'; import '../services/trackers/tracker_id_resolver.dart'; -import '../services/trakt/trakt_scrobble_service.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'backend_badge.dart'; import 'bottom_sheet_header.dart'; +import 'catalog_source_logo.dart'; import 'clickable_cursor.dart'; class RatingBottomSheet extends StatefulWidget { @@ -92,9 +89,10 @@ class _RatingBottomSheetState extends State { final size = MediaQuery.sizeOf(context); final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74); - return Consumer2( - builder: (context, trakt, trackers, _) { - final allTrackerSources = _trackerSources(trakt, trackers); + // Trakt's account provider is watched by [_trackerSources] via `context`. + return Consumer( + builder: (context, trackers, _) { + final allTrackerSources = _trackerSources(context); final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList(); _updateTrackerSourceMap(trackerSources); _resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected; @@ -227,7 +225,7 @@ class _RatingBottomSheetState extends State { return _RatingRow( focusNode: focusNode, autofocus: autofocus, - leading: _TrackerLogo(source.logoAsset), + leading: CatalogSourceLogo(source.logoSource, size: 24), title: source.title, subtitle: source.username != null ? t.services.connectedAs(username: source.username!) : source.connectedLabel, loading: loading, @@ -247,58 +245,20 @@ class _RatingBottomSheetState extends State { ); } - List<_TrackerRatingSource> _trackerSources(TraktAccountProvider trakt, TrackersProvider trackers) { - final sources = <_TrackerRatingSource>[]; - if (trakt.isConnected) { - sources.add( + /// Snapshot of every connected tracker, in the shared display order. Must be + /// called from a build so the provider reads register a dependency. + List<_TrackerRatingSource> _trackerSources(BuildContext context) => [ + for (final info in TrackerServiceInfo.all) + if (info.isConnected(context)) _TrackerRatingSource( - service: TrackerService.trakt, - title: t.trakt.title, - username: trakt.username, + service: info.service, + title: info.displayName, + username: info.username(context), connectedLabel: t.trakt.connected, - logoAsset: 'assets/trakt_circlemark.svg', - ratingSource: TraktScrobbleService.instance, + logoSource: info.logoSource, + ratingSource: info.ratingSource, ), - ); - } - if (trackers.isMalConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.mal, - title: t.services.names.mal, - username: trackers.malUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/mal_mark.svg', - ratingSource: MalTracker.instance, - ), - ); - } - if (trackers.isAnilistConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.anilist, - title: t.services.names.anilist, - username: trackers.anilistUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/anilist_mark.svg', - ratingSource: AnilistTracker.instance, - ), - ); - } - if (trackers.isSimklConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.simkl, - title: t.services.names.simkl, - username: trackers.simklUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/simkl_mark.svg', - ratingSource: SimklTracker.instance, - ), - ); - } - return sources; - } + ]; void _updateTrackerSourceMap(List<_TrackerRatingSource> sources) { _trackerSourcesByKey @@ -618,7 +578,7 @@ class _TrackerRatingSource { final String title; final String? username; final String connectedLabel; - final String logoAsset; + final CatalogSourceId logoSource; final TrackerRatingSource ratingSource; const _TrackerRatingSource({ @@ -626,7 +586,7 @@ class _TrackerRatingSource { required this.title, required this.username, required this.connectedLabel, - required this.logoAsset, + required this.logoSource, required this.ratingSource, }); } @@ -889,15 +849,3 @@ class _FavoriteControl extends StatelessWidget { ); } } - -class _TrackerLogo extends StatelessWidget { - final String asset; - - const _TrackerLogo(this.asset); - - @override - Widget build(BuildContext context) { - final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; - return SvgPicture.asset(asset, width: 24, height: 24, theme: SvgTheme(currentColor: color)); - } -} diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index f8af24c2..bdf62aab 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -13,8 +13,46 @@ import 'settings_section.dart'; /// Eliminates the field-mirror + setState + manual reload pattern that used to /// surround every settings row. -class _TileBase { - static SettingsService get _svc => SettingsService.instance; +/// Shared commit path for every tile: persist [value] under [pref], then hand +/// it to the tile's optional [onAfterWrite] callback. +Future _writeAndNotify(Pref pref, T value, FutureOr Function(T)? onAfterWrite) async { + await SettingsService.instance.write(pref, value); + if (onAfterWrite != null) await onAfterWrite(value); +} + +/// Shared scaffold for the tiles that render a tappable settings row: same +/// leading icon, title style and row density everywhere. [trailing] defaults +/// to the chevron used by every row that opens a dialog. +class _SettingRow extends StatelessWidget { + final IconData icon; + final String title; + final Widget? subtitle; + final Widget? trailing; + final VoidCallback onTap; + final FocusNode? focusNode; + + const _SettingRow({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + this.trailing, + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + return FocusableListTile( + focusNode: focusNode, + leading: AppIcon(icon, fill: 1), + title: Text(title, style: settingsOptionTitleStyle(context)), + subtitle: subtitle, + trailing: trailing ?? const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: onTap, + dense: settingsRowDense(context), + visualDensity: settingsRowVisualDensity(context), + ); + } } /// SwitchListTile bound to a [Pref]. @@ -40,9 +78,8 @@ class SettingSwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), + valueListenable: SettingsService.instance.listenable(pref), builder: (_, value, _) => FocusableSwitchListTile( focusNode: focusNode, secondary: AppIcon(icon, fill: 1), @@ -51,13 +88,7 @@ class SettingSwitchTile extends StatelessWidget { value: value, dense: settingsRowDense(context), visualDensity: settingsRowVisualDensity(context), - onChanged: enabled - ? (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - } - : null, + onChanged: enabled ? (v) => _writeAndNotify(pref, v, onAfterWrite) : null, ), ); } @@ -86,15 +117,13 @@ class SettingNavigationTile extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusableListTile( + return _SettingRow( focusNode: focusNode, - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + icon: icon, + title: title, subtitle: subtitle != null ? Text(subtitle!) : null, trailing: AppIcon(trailingIcon, fill: 1), onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), ); } } @@ -126,16 +155,12 @@ class SettingNumberTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, value, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showNumericInputDialog( context: context, title: title, @@ -144,11 +169,7 @@ class SettingNumberTile extends StatelessWidget { min: min, max: max, currentValue: value, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); @@ -156,16 +177,12 @@ class SettingNumberTile extends StatelessWidget { } /// ListTile that opens [showSelectionDialog] and writes the chosen value. -/// [encode]/[decode] map between the [Pref] storage type and the option -/// type [T] (e.g. enum-stored-as-string preset → [TranscodeQualityPreset]). -class SettingSelectionTile extends StatelessWidget { - final Pref pref; +class SettingSelectionTile extends StatelessWidget { + final Pref pref; final IconData icon; final String title; final String Function(T) subtitleBuilder; final List> options; - final T Function(S) decode; - final S Function(T) encode; final FutureOr Function(T)? onAfterWrite; const SettingSelectionTile({ @@ -175,39 +192,28 @@ class SettingSelectionTile extends StatelessWidget { required this.title, required this.subtitleBuilder, required this.options, - required this.decode, - required this.encode, this.onAfterWrite, }); @override Widget build(BuildContext context) { - final svc = _TileBase._svc; - return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, raw, _) { - final value = decode(raw); - return FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), - onTap: () async { - final picked = await showSelectionDialog( - context: context, - title: title, - options: options, - currentValue: value, - ); - if (picked == null) return; - await svc.write(pref, encode(picked)); - final callback = onAfterWrite; - if (callback != null) await callback(picked); - }, - ); - }, + return ValueListenableBuilder( + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, + subtitle: Text(subtitleBuilder(value)), + onTap: () async { + final picked = await showSelectionDialog( + context: context, + title: title, + options: options, + currentValue: value, + ); + if (picked == null) return; + await _writeAndNotify(pref, picked, onAfterWrite); + }, + ), ); } } @@ -233,42 +239,30 @@ class SettingRegexTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, value, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showRegexInputDialog( context: context, title: title, currentValue: value, defaultValue: defaultValue, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); } } -/// SegmentedSetting bound to a [Pref]. Use [encode]/[decode] when the -/// stored type differs from the segment type (e.g. bool stored, segments -/// over enum). -class SettingSegmentedTile extends StatelessWidget { - final Pref pref; +/// SegmentedSetting bound to a [Pref]. +class SettingSegmentedTile extends StatelessWidget { + final Pref pref; final IconData icon; final String title; final List> segments; - final T Function(S) decode; - final S Function(T) encode; final FutureOr Function(T)? onAfterWrite; const SettingSegmentedTile({ @@ -277,30 +271,20 @@ class SettingSegmentedTile extends StatelessWidget { required this.icon, required this.title, required this.segments, - required this.decode, - required this.encode, this.onAfterWrite, }); @override Widget build(BuildContext context) { - final svc = _TileBase._svc; - return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, raw, _) { - final value = decode(raw); - return SegmentedSetting( - icon: icon, - title: title, - segments: segments, - selected: value, - onChanged: (v) async { - await svc.write(pref, encode(v)); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, - ); - }, + return ValueListenableBuilder( + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => SegmentedSetting( + icon: icon, + title: title, + segments: segments, + selected: value, + onChanged: (v) => _writeAndNotify(pref, v, onAfterWrite), + ), ); } } @@ -325,12 +309,11 @@ class SettingColorTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, hex, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, hex, _) => _SettingRow( + icon: icon, + title: title, subtitle: subtitle != null ? Text(subtitle!) : null, trailing: Container( width: 28, @@ -341,17 +324,11 @@ class SettingColorTile extends StatelessWidget { border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), ), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showColorInputDialog( context: context, title: title, currentHex: hex, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index 8d8fd6fc..84dcd37a 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -62,13 +62,6 @@ class SettingsGroup extends StatelessWidget { this.margin = const EdgeInsets.symmetric(horizontal: 16), }); - BorderRadius _radiusFor(int i, MonoTokens t) { - return BorderRadius.vertical( - top: Radius.circular(i == 0 ? t.radiusLg : t.radiusXs), - bottom: Radius.circular(i == children.length - 1 ? t.radiusLg : t.radiusXs), - ); - } - @override Widget build(BuildContext context) { final t = tokens(context); @@ -85,7 +78,7 @@ class SettingsGroup extends StatelessWidget { Material( color: t.surface, clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: _radiusFor(i, t)), + shape: RoundedRectangleBorder(borderRadius: groupItemRadii(context, i, children.length)), child: children[i], ), ], diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 0563db84..163ed362 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -23,6 +23,7 @@ import '../../models/livetv_capture_buffer.dart'; import 'models/track_controls_state.dart'; import 'player_chrome_controller.dart'; import 'widgets/content_strip.dart'; +import 'widgets/content_strip_panel.dart'; import 'widgets/live_timeline_bar.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; @@ -614,51 +615,28 @@ class DesktopVideoControlsState extends State { _buildBottomControlsContent(context, hasFrame: true), // Down arrow hint when strip content is available if (widget.useDpadNavigation && _hasStripContent) - const Positioned( - left: 0, - right: 0, - bottom: 12, - child: AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24), - ), + const ContentStripHint(Symbols.keyboard_arrow_down_rounded), ], ), // Content strip (TV/dpad only) — replaces normal controls if (_contentStripVisible && widget.useDpadNavigation) - Container( + ContentStripPanel( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8, top: 32), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.65), - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.42, 1.0], - ), - ), - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), - const SizedBox(height: 4), - ContentStrip( - key: _contentStripKey, - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - serverId: widget.serverId, - canControl: _canControl, - showQueueTab: widget.showQueueTab, - onQueueItemSelected: widget.onQueueItemSelected, - onSeekRequested: widget.onSeekRequested, - onSeekCompleted: widget.onSeekCompleted, - useFocusNavigation: true, - onNavigateUp: _onContentStripNavigateUp, - onFocusActivity: widget.onFocusActivity, - ), - ], + chevron: Symbols.keyboard_arrow_up_rounded, + child: ContentStrip( + key: _contentStripKey, + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + serverId: widget.serverId, + canControl: _canControl, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, + useFocusNavigation: true, + onNavigateUp: _onContentStripNavigateUp, + onFocusActivity: widget.onFocusActivity, ), ), ], diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 99ea3da5..32df0e97 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../widgets/app_icon.dart'; import '../../media/media_item.dart'; import '../../mpv/mpv.dart'; import '../../models/livetv_capture_buffer.dart'; @@ -12,6 +11,7 @@ import '../../i18n/strings.g.dart'; import 'player_chrome_controller.dart'; import 'widgets/circular_control_button.dart'; import 'widgets/content_strip.dart'; +import 'widgets/content_strip_panel.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; import 'widgets/live_timeline_bar.dart'; @@ -270,12 +270,7 @@ class _MobileVideoControlsState extends State with SingleTi _buildBottomBar(context), ], ), - const Positioned( - left: 0, - right: 0, - bottom: 12, - child: AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24), - ), + const ContentStripHint(Symbols.keyboard_arrow_up_rounded), ], ), ), @@ -290,37 +285,19 @@ class _MobileVideoControlsState extends State with SingleTi ignoring: t < 0.5, child: Opacity( opacity: (t * 2).clamp(0.0, 1.0), - child: Container( + child: ContentStripPanel( padding: const EdgeInsets.only(top: 32), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.65), - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.42, 1.0], - ), - ), - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), - const SizedBox(height: 4), - ContentStrip( - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - canControl: widget.canControl, - serverId: widget.serverId, - showQueueTab: widget.showQueueTab, - onQueueItemSelected: widget.onQueueItemSelected, - onSeekRequested: widget.onSeekRequested, - onSeekCompleted: widget.onSeekCompleted, - ), - ], + chevron: Symbols.keyboard_arrow_down_rounded, + child: ContentStrip( + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + canControl: widget.canControl, + serverId: widget.serverId, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, ), ), ), diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 57e3a49e..5f55a247 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -34,7 +34,6 @@ class TrackControlsState { final int audioSyncOffset; final int subtitleSyncOffset; final bool isRotationLocked; - final bool isScreenLocked; final bool isFullscreen; final bool isAlwaysOnTop; final VoidCallback? onTogglePIPMode; @@ -96,7 +95,6 @@ class TrackControlsState { this.audioSyncOffset = 0, this.subtitleSyncOffset = 0, this.isRotationLocked = false, - this.isScreenLocked = false, this.isFullscreen = false, this.isAlwaysOnTop = false, this.onTogglePIPMode, diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 0c2bb81b..f366dde8 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -100,6 +100,37 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return KeyEventResult.ignored; } + KeyEventResult _dispatchShortcut(KeyEvent event, {VoidCallback? onSkipMarker}) { + return _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + canControlPlayback: widget.canControl, + canNavigateMediaItems: widget.canNavigateMediaItems, + onPlayPause: () => unawaited(_playOrPause()), + onToggleShader: _toggleShader, + onSkipMarker: onSkipMarker, + onNextEpisode: widget.onNext, + onPreviousEpisode: widget.onPrevious, + onScreenshot: _showScreenshotToast, + onZoomIn: widget.onZoomIn, + onZoomOut: widget.onZoomOut, + onZoomReset: widget.onResetVideoZoom, + onVolumeUp: () => widget.volumeController.adjust(10), + onVolumeDown: () => widget.volumeController.adjust(-10), + onToggleMute: widget.volumeController.toggleMute, + currentPositionEpoch: widget.currentPositionEpoch, + onLiveSeek: widget.onLiveSeek, + onLiveSeekBy: widget.onLiveSeekBy, + onSeekRequested: widget.onSeekRequested, + ); + } + /// Global key event handler for focus-independent shortcuts (desktop only) bool _handleGlobalKeyEvent(KeyEvent event) { if (!mounted) return false; @@ -140,33 +171,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { // (e.g. after controls auto-hide). The !hasFocus guard prevents // double-handling when the Focus onKeyEvent already processes the event. if (!_focusNode.hasFocus && _keyboardService != null) { - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - canControlPlayback: widget.canControl, - canNavigateMediaItems: widget.canNavigateMediaItems, - onPlayPause: () => unawaited(_playOrPause()), - onToggleShader: _toggleShader, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - onScreenshot: _showScreenshotToast, - onZoomIn: widget.onZoomIn, - onZoomOut: widget.onZoomOut, - onZoomReset: widget.onResetVideoZoom, - onVolumeUp: () => widget.volumeController.adjust(10), - onVolumeDown: () => widget.volumeController.adjust(-10), - onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onLiveSeekBy: widget.onLiveSeekBy, - onSeekRequested: widget.onSeekRequested, - ); + final result = _dispatchShortcut(event); if (result == KeyEventResult.handled) { _focusNode.requestFocus(); // self-heal focus return true; @@ -270,34 +275,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored; } - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - canControlPlayback: widget.canControl, - canNavigateMediaItems: widget.canNavigateMediaItems, - onPlayPause: () => unawaited(_playOrPause()), - onToggleShader: _toggleShader, - onSkipMarker: _performAutoSkip, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - onScreenshot: _showScreenshotToast, - onZoomIn: widget.onZoomIn, - onZoomOut: widget.onZoomOut, - onZoomReset: widget.onResetVideoZoom, - onVolumeUp: () => widget.volumeController.adjust(10), - onVolumeDown: () => widget.volumeController.adjust(-10), - onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onLiveSeekBy: widget.onLiveSeekBy, - onSeekRequested: widget.onSeekRequested, - ); + final result = _dispatchShortcut(event, onSkipMarker: _performAutoSkip); if (!event.logicalKey.isNavigationKey) return result; // Never return .ignored for navigation keys — prevent leaking to previous routes. return result == KeyEventResult.ignored ? KeyEventResult.handled : result; diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index d117162d..e25faf2f 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -142,7 +142,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { audioSyncOffset: _audioSyncOffset, subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, - isScreenLocked: _isScreenLocked, isFullscreen: _isFullscreen, isAlwaysOnTop: _isAlwaysOnTop, onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null, diff --git a/lib/widgets/video_controls/sheets/sheet_selection_column.dart b/lib/widgets/video_controls/sheets/sheet_selection_column.dart new file mode 100644 index 00000000..64edc8cf --- /dev/null +++ b/lib/widgets/video_controls/sheets/sheet_selection_column.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../utils/scroll_utils.dart'; +import '../../../widgets/overlay_sheet.dart'; +import 'sheet_column_header.dart'; + +/// Per-row handle handed to [SheetSelectionColumn.itemBuilder]. +abstract class SheetSelectionColumnScope { + /// Key for the row at [index]. Only the first row is keyed, so the one-time + /// initial scroll can measure a real item height. + Key? keyFor(int index); + + /// Runs an async selection: re-entrant taps are ignored while one is in + /// flight, a progress bar is shown meanwhile, and the sheet is closed once + /// [action] completes. + void runExclusive(Future Function() action); +} + +/// Shared scaffold for the selectable columns inside the video control sheets: +/// an optional header, a one-shot scroll to the selected row, the async +/// selection guard, the scrolling list, and an optional footer. +class SheetSelectionColumn extends StatefulWidget { + /// Header text, or null to omit the header entirely. + final String? headerLabel; + final int itemCount; + + /// Row to scroll into view on first build; ignored when null or <= 0. + final int? initialIndex; + final Widget Function(BuildContext context, int index, SheetSelectionColumnScope scope) itemBuilder; + final List footer; + + const SheetSelectionColumn({ + super.key, + this.headerLabel, + required this.itemCount, + required this.initialIndex, + required this.itemBuilder, + this.footer = const [], + }); + + @override + State createState() => _SheetSelectionColumnState(); +} + +class _SheetSelectionColumnState extends State implements SheetSelectionColumnScope { + final _initialScroll = InitialItemScrollController(); + bool _selectionPending = false; + + @override + void dispose() { + _initialScroll.dispose(); + super.dispose(); + } + + @override + Key? keyFor(int index) => index == 0 ? _initialScroll.firstItemKey : null; + + @override + void runExclusive(Future Function() action) => unawaited(_select(action)); + + Future _select(Future Function() action) async { + if (_selectionPending) return; + setState(() => _selectionPending = true); + try { + await action(); + if (mounted) OverlaySheetController.of(context).close(); + } finally { + if (mounted) setState(() => _selectionPending = false); + } + } + + @override + Widget build(BuildContext context) { + _initialScroll.maybeScrollTo(widget.initialIndex); + + return Column( + children: [ + if (widget.headerLabel != null) SheetColumnHeader(label: widget.headerLabel!), + if (_selectionPending) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: ListView.builder( + controller: _initialScroll.controller, + itemCount: widget.itemCount, + itemBuilder: (context, index) => widget.itemBuilder(context, index, this), + ), + ), + ...widget.footer, + ], + ); + } +} diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index b43a4f0f..56af4738 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -7,13 +5,12 @@ import '../../../media/media_source_info.dart'; import '../../../mpv/mpv.dart'; import '../../../services/playback_subtitle_resolver.dart'; import '../../../i18n/strings.g.dart'; -import '../../../utils/scroll_utils.dart'; import '../../../utils/track_label_builder.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import 'sheet_column_header.dart'; +import 'sheet_selection_column.dart'; import 'subtitle_search_sheet.dart'; import '../models/track_controls_state.dart'; import '../helpers/track_filter_helper.dart'; @@ -134,7 +131,7 @@ class TrackSheet extends StatelessWidget { } } -class _SourceAudioColumn extends StatefulWidget { +class _SourceAudioColumn extends StatelessWidget { final List tracks; final int? selectedStreamId; final Future Function(int) onSelected; @@ -147,157 +144,94 @@ class _SourceAudioColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_SourceAudioColumn> createState() => _SourceAudioColumnState(); -} - -class _SourceAudioColumnState extends State<_SourceAudioColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _select(int streamId) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.onSelected(streamId); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { final selectedId = _effectiveSelectedStreamId(); - final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId); - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = selectedId == null ? null : tracks.indexWhere((t) => t.id == selectedId); - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length, - itemBuilder: (context, index) { - final track = widget.tracks[index]; - final isSelected = track.id == selectedId; - return TrackSelectionHelper.buildTrackTile( - context: context, - key: index == 0 ? _initialScroll.firstItemKey : null, - label: track.label, - isSelected: isSelected, - onTap: () => unawaited(_select(track.id)), - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.audioLabel : null, + itemCount: tracks.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final track = tracks[index]; + return TrackSelectionHelper.buildTrackTile( + context: context, + key: scope.keyFor(index), + label: track.label, + isSelected: track.id == selectedId, + onTap: () => scope.runExclusive(() => onSelected(track.id)), + ); + }, ); } int? _effectiveSelectedStreamId() { - final explicit = widget.selectedStreamId; - if (explicit != null && widget.tracks.any((track) => track.id == explicit)) return explicit; - for (final track in widget.tracks) { + final explicit = selectedStreamId; + if (explicit != null && tracks.any((track) => track.id == explicit)) return explicit; + for (final track in tracks) { if (track.selected) return track.id; } return null; } } -class _SourceSubtitleColumn extends StatefulWidget { +class _SourceSubtitleColumn extends StatelessWidget { final List tracks; final TrackControlsState trackControlsState; final bool showHeader; const _SourceSubtitleColumn({required this.tracks, required this.trackControlsState, required this.showHeader}); - @override - State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState(); -} - -class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _select(PlaybackSourceSubtitleChoice choice) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.trackControlsState.onSwitchSubtitle!(choice); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { final selectedChoice = _effectiveSelectedChoice(); final selectedId = selectedChoice.sourceStreamId; - final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1; - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = selectedChoice.isOff ? 0 : tracks.indexWhere((t) => t.id == selectedId) + 1; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return TrackSelectionHelper.buildOffTile( - context: context, - key: _initialScroll.firstItemKey, - isSelected: selectedChoice.isOff, - onTap: () => unawaited(_select(const PlaybackSourceSubtitleChoice.off())), - ); - } + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.subtitlesLabel : null, + itemCount: tracks.length + 1, + initialIndex: selectedIndex, + footer: _buildSubtitleSearchFooter(context, trackControlsState), + itemBuilder: (context, index, scope) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: scope.keyFor(index), + isSelected: selectedChoice.isOff, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off()), + ), + ); + } - final track = widget.tracks[index - 1]; - return TrackSelectionHelper.buildTrackTile( - context: context, - label: track.labelForIndex(index - 1), - isSelected: track.id == selectedId, - onTap: () => unawaited(_select(PlaybackSourceSubtitleChoice.source(track.id))), - ); - }, + final track = tracks[index - 1]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: track.labelForIndex(index - 1), + isSelected: track.id == selectedId, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id)), ), - ), - ..._buildSubtitleSearchFooter(context, widget.trackControlsState), - ], + ); + }, ); } PlaybackSourceSubtitleChoice _effectiveSelectedChoice() { - final explicit = widget.trackControlsState.selectedSubtitleChoice; - if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) { + final explicit = trackControlsState.selectedSubtitleChoice; + if (explicit != null && (explicit.isOff || tracks.any((track) => track.id == explicit.sourceStreamId))) { return explicit; } - for (final track in widget.tracks) { + for (final track in tracks) { if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id); } return const PlaybackSourceSubtitleChoice.off(); } } -class _AudioColumn extends StatefulWidget { +class _AudioColumn extends StatelessWidget { final List tracks; final TrackSelection selection; final Player player; @@ -312,61 +246,41 @@ class _AudioColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_AudioColumn> createState() => _AudioColumnState(); -} - -class _AudioColumnState extends State<_AudioColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final selectedId = widget.selection.audio?.id ?? ''; - final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId); - _initialScroll.maybeScrollTo(selectedIndex); + final selectedId = selection.audio?.id ?? ''; + final selectedIndex = tracks.indexWhere((t) => t.id == selectedId); - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length, - itemBuilder: (context, index) { - final track = widget.tracks[index]; - final label = TrackLabelBuilder.audioLabel( - title: track.title, - language: track.language, - codec: track.codec, - channels: track.channelsCount, - index: index, - ); - return TrackSelectionHelper.buildTrackTile( - context: context, - key: index == 0 ? _initialScroll.firstItemKey : null, - label: label, - isSelected: track.id == selectedId, - onTap: () { - widget.player.selectAudioTrack(track); - widget.onTrackChanged?.call(track); - OverlaySheetController.of(context).close(); - }, - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.audioLabel : null, + itemCount: tracks.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final track = tracks[index]; + final label = TrackLabelBuilder.audioLabel( + title: track.title, + language: track.language, + codec: track.codec, + channels: track.channelsCount, + index: index, + ); + return TrackSelectionHelper.buildTrackTile( + context: context, + key: scope.keyFor(index), + label: label, + isSelected: track.id == selectedId, + onTap: () { + player.selectAudioTrack(track); + onTrackChanged?.call(track); + OverlaySheetController.of(context).close(); + }, + ); + }, ); } } -class _SubtitleColumn extends StatefulWidget { +class _SubtitleColumn extends StatelessWidget { final List tracks; final TrackSelection selection; final Player player; @@ -385,166 +299,135 @@ class _SubtitleColumn extends StatefulWidget { this.sourceSidecars = const [], }); - @override - State<_SubtitleColumn> createState() => _SubtitleColumnState(); -} - -class _SubtitleColumnState extends State<_SubtitleColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _selectSourceSidecar(int streamId) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(streamId)); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { - final selectedSub = widget.selection.subtitle; - final secondarySub = widget.selection.secondarySubtitle; + final selectedSub = selection.subtitle; + final secondarySub = selection.secondarySubtitle; final isOffSelected = selectedSub == null || selectedSub.id == 'no'; - final hasSecondary = widget.supportsSecondary && secondarySub != null; - final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId; - final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId; - final unloadedSourceSidecars = widget.sourceSidecars + final hasSecondary = supportsSecondary && secondarySub != null; + final selectedSourceId = trackControlsState.selectedSubtitleChoice?.sourceStreamId; + final selectedSecondarySourceId = trackControlsState.selectedSecondarySubtitleStreamId; + final unloadedSourceSidecars = sourceSidecars .where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId) .toList(growable: false); // +1 for "Off" row. The selected direct-play sidecar is already present // in [tracks], so only the other server sidecars are appended. - final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1; + final itemCount = tracks.length + unloadedSourceSidecars.length + 1; - final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = isOffSelected ? null : tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: itemCount, - itemBuilder: (context, index) { - if (index == 0) { - return TrackSelectionHelper.buildOffTile( - context: context, - key: _initialScroll.firstItemKey, - isSelected: isOffSelected, - onTap: () { - // Turning off primary also clears secondary - if (hasSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - widget.player.selectSubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); - OverlaySheetController.of(context).close(); - }, - onLongPress: widget.supportsSecondary && hasSecondary - ? () { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - : null, - onSecondaryTap: widget.supportsSecondary && hasSecondary - ? () { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - : null, - ); + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.subtitlesLabel : null, + itemCount: itemCount, + initialIndex: selectedIndex, + footer: _buildSubtitleSearchFooter(context, trackControlsState), + itemBuilder: (context, index, scope) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: scope.keyFor(index), + isSelected: isOffSelected, + onTap: () { + // Turning off primary also clears secondary + if (hasSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } - - final trackIndex = index - 1; - if (trackIndex >= widget.tracks.length) { - final sourceIndex = trackIndex - widget.tracks.length; - final sourceTrack = unloadedSourceSidecars[sourceIndex]; - return TrackSelectionHelper.buildTrackTile( - context: context, - label: sourceTrack.labelForIndex(trackIndex), - isSelected: false, - onTap: () => unawaited(_selectSourceSidecar(sourceTrack.id)), - ); - } - - final track = widget.tracks[trackIndex]; - final isPrimary = !isOffSelected && track.id == selectedSub.id; - final isSecondary = hasSecondary && track.id == secondarySub.id; - final label = TrackLabelBuilder.subtitleLabel( - title: track.title, - language: track.language, - codec: track.codec, - forced: track.isForced, - index: trackIndex, - ); - - Widget? badge; - if (widget.supportsSecondary && hasSecondary) { - if (isPrimary) { - badge = TrackSelectionHelper.buildTrackBadge(context, 1); - } else if (isSecondary) { - badge = TrackSelectionHelper.buildTrackBadge(context, 2); - } - } - - return TrackSelectionHelper.buildTrackTile( - context: context, - label: label, - isSelected: isPrimary, - badge: badge, - onTap: () { - // If tapping a track that is currently the secondary, clear secondary first - if (isSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - widget.player.selectSubtitleTrack(track); - widget.trackControlsState.onSubtitleTrackChanged?.call(track); - OverlaySheetController.of(context).close(); - }, - onLongPress: widget.supportsSecondary - ? () { - if (isSecondary) { - // Already secondary — clear it - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } else if (!isPrimary) { - // Set as secondary (don't close sheet so user sees badge update) - widget.player.selectSecondarySubtitleTrack(track); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); - } - } - : null, - onSecondaryTap: widget.supportsSecondary - ? () { - if (isSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } else if (!isPrimary) { - widget.player.selectSecondarySubtitleTrack(track); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); - } - } - : null, - ); + player.selectSubtitleTrack(SubtitleTrack.off); + trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); + OverlaySheetController.of(context).close(); }, - ), - ), - ..._buildSubtitleSearchFooter(context, widget.trackControlsState), - ], + onLongPress: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + : null, + onSecondaryTap: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + : null, + ); + } + + final trackIndex = index - 1; + if (trackIndex >= tracks.length) { + final sourceIndex = trackIndex - tracks.length; + final sourceTrack = unloadedSourceSidecars[sourceIndex]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: sourceTrack.labelForIndex(trackIndex), + isSelected: false, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id)), + ), + ); + } + + final track = tracks[trackIndex]; + final isPrimary = !isOffSelected && track.id == selectedSub.id; + final isSecondary = hasSecondary && track.id == secondarySub.id; + final label = TrackLabelBuilder.subtitleLabel( + title: track.title, + language: track.language, + codec: track.codec, + forced: track.isForced, + index: trackIndex, + ); + + Widget? badge; + if (supportsSecondary && hasSecondary) { + if (isPrimary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 1); + } else if (isSecondary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 2); + } + } + + return TrackSelectionHelper.buildTrackTile( + context: context, + label: label, + isSelected: isPrimary, + badge: badge, + onTap: () { + // If tapping a track that is currently the secondary, clear secondary first + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + player.selectSubtitleTrack(track); + trackControlsState.onSubtitleTrackChanged?.call(track); + OverlaySheetController.of(context).close(); + }, + onLongPress: supportsSecondary + ? () { + if (isSecondary) { + // Already secondary — clear it + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + // Set as secondary (don't close sheet so user sees badge update) + player.selectSecondarySubtitleTrack(track); + trackControlsState.onSecondarySubtitleTrackChanged?.call(track); + } + } + : null, + onSecondaryTap: supportsSecondary + ? () { + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + player.selectSecondarySubtitleTrack(track); + trackControlsState.onSecondarySubtitleTrackChanged?.call(track); + } + } + : null, + ); + }, ); } } diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index e3b4ad1e..ef978e3d 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -6,10 +6,9 @@ import '../../../i18n/strings.g.dart'; import '../../../media/media_version.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../utils/quality_preset_labels.dart'; -import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; -import 'sheet_column_header.dart'; +import 'sheet_selection_column.dart'; String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) { return showQuality @@ -114,7 +113,7 @@ class VersionQualityPicker extends StatelessWidget { } } -class _VersionColumn extends StatefulWidget { +class _VersionColumn extends StatelessWidget { final List versions; final int selectedIndex; final ValueChanged onSelected; @@ -127,48 +126,27 @@ class _VersionColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_VersionColumn> createState() => _VersionColumnState(); -} - -class _VersionColumnState extends State<_VersionColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - _initialScroll.maybeScrollTo(widget.selectedIndex); - - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.versions.length, - itemBuilder: (context, index) { - final version = widget.versions[index]; - final isSelected = index == widget.selectedIndex; - return _SelectionTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - label: version.displayLabel, - isSelected: isSelected, - onTap: () => widget.onSelected(index), - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.versionColumnHeader : null, + itemCount: versions.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final version = versions[index]; + final isSelected = index == selectedIndex; + return _SelectionTile( + key: scope.keyFor(index), + label: version.displayLabel, + isSelected: isSelected, + onTap: () => onSelected(index), + ); + }, ); } } -class _QualityColumn extends StatefulWidget { +class _QualityColumn extends StatelessWidget { final TranscodeQualityPreset selected; final bool enabledForTranscoding; final int? sourceBitrateKbps; @@ -187,58 +165,36 @@ class _QualityColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_QualityColumn> createState() => _QualityColumnState(); -} - -class _QualityColumnState extends State<_QualityColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final presets = TranscodeQualityPreset.displayOrder; - final selectedIndex = presets.indexOf(widget.selected); - _initialScroll.maybeScrollTo(selectedIndex); + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.qualityColumnHeader : null, + itemCount: presets.length, + initialIndex: presets.indexOf(selected), + itemBuilder: (context, index, scope) { + final preset = presets[index]; + final isSelected = preset == selected; + final isOriginal = preset.isOriginal; + final enabled = isOriginal || enabledForTranscoding; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: presets.length, - itemBuilder: (context, index) { - final preset = presets[index]; - final isSelected = preset == widget.selected; - final isOriginal = preset.isOriginal; - final enabled = isOriginal || widget.enabledForTranscoding; + final trailing = qualityPresetSizeEstimate( + preset: preset, + sourceBitrateKbps: sourceBitrateKbps, + sourceDurationMs: sourceDurationMs, + sourceSizeBytes: sourceSizeBytes, + ); - final trailing = qualityPresetSizeEstimate( - preset: preset, - sourceBitrateKbps: widget.sourceBitrateKbps, - sourceDurationMs: widget.sourceDurationMs, - sourceSizeBytes: widget.sourceSizeBytes, - ); - - return _SelectionTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - label: qualityPresetLabel(preset), - trailingText: trailing, - isSelected: isSelected, - enabled: enabled, - onTap: enabled ? () => widget.onSelected(preset) : null, - ); - }, - ), - ), - ], + return _SelectionTile( + key: scope.keyFor(index), + label: qualityPresetLabel(preset), + trailingText: trailing, + isSelected: isSelected, + enabled: enabled, + onTap: enabled ? () => onSelected(preset) : null, + ); + }, ); } } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 3c6d3f41..49bb89a4 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -12,13 +12,10 @@ import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import '../../../models/shader_preset.dart'; -import '../../../models/transcode_quality_preset.dart'; -import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../providers/shader_provider.dart'; import '../../../services/file_picker_service.dart'; import '../../../services/settings_service.dart'; -import '../../../services/shader_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../services/video_filter_manager.dart'; import '../../../focus/focusable_wrapper.dart'; @@ -32,6 +29,7 @@ import '../../../utils/snackbar_helper.dart'; import '../../../theme/mono_tokens.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../models/track_controls_state.dart'; import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; @@ -190,73 +188,17 @@ class VideoSettingsSheet extends StatefulWidget { /// Defaults to the native platform capability, but can be supplied by /// embedders whose capability is known independently of the host platform. final bool? supportsHdrControl; - final int audioSyncOffset; - final int subtitleSyncOffset; - final double videoZoomScale; - final ValueChanged? onVideoZoomChanged; - final VoidCallback? onResetVideoZoom; - /// Whether the user can control playback (false hides speed option in host-only mode). - final bool canControl; - - /// Whether this is a live TV stream (hides speed settings). - final bool isLive; - - /// Available media versions and quality controls shown inside playback settings. - final List availableVersions; - final int selectedMediaIndex; - final TranscodeQualityPreset selectedQualityPreset; - final bool serverSupportsTranscoding; - final int? sourceDurationMs; - final ValueChanged? onVersionSelected; - final ValueChanged? onQualitySelected; - - /// Optional shader service for MPV shader control - final ShaderService? shaderService; - - /// Called when shader preset changes - final VoidCallback? onShaderChanged; - - /// Whether ambient lighting is currently enabled - final bool isAmbientLightingEnabled; - - /// Called to toggle ambient lighting on/off (null if unsupported) - final VoidCallback? onToggleAmbientLighting; - - /// Called to cancel the video controls auto-hide timer. - final VoidCallback? onCancelAutoHide; - - /// Called to restart the video controls auto-hide timer. - final VoidCallback? onStartAutoHide; - - /// Called when a sync offset changes (so the parent can update its state). - final void Function(String propertyName, int offset)? onSyncOffsetChanged; + /// Shared player-control state. Every playback value and callback this sheet + /// shows (sync offsets, zoom, versions/quality, shaders, ambient lighting, + /// auto-hide) is read straight off it. + final TrackControlsState trackControlsState; const VideoSettingsSheet({ super.key, required this.player, this.supportsHdrControl, - required this.audioSyncOffset, - required this.subtitleSyncOffset, - this.videoZoomScale = 1.0, - this.onVideoZoomChanged, - this.onResetVideoZoom, - this.canControl = true, - this.isLive = false, - this.availableVersions = const [], - this.selectedMediaIndex = 0, - this.selectedQualityPreset = TranscodeQualityPreset.original, - this.serverSupportsTranscoding = false, - this.sourceDurationMs, - this.onVersionSelected, - this.onQualitySelected, - this.shaderService, - this.onShaderChanged, - this.isAmbientLightingEnabled = false, - this.onToggleAmbientLighting, - this.onCancelAutoHide, - this.onStartAutoHide, - this.onSyncOffsetChanged, + required this.trackControlsState, }); @override @@ -271,6 +213,8 @@ class _VideoSettingsSheetState extends State { String _dvConversionMode = 'auto'; int _dvConversionWriteGeneration = 0; + TrackControlsState get _state => widget.trackControlsState; + bool get _supportsHdrControl => widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows); @@ -283,16 +227,16 @@ class _VideoSettingsSheetState extends State { @override void initState() { super.initState(); - _audioSyncOffset = widget.audioSyncOffset; - _subtitleSyncOffset = widget.subtitleSyncOffset; - _zoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale); + _audioSyncOffset = _state.audioSyncOffset; + _subtitleSyncOffset = _state.subtitleSyncOffset; + _zoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale); _loadDebugDvConversionMode(); } @override void didUpdateWidget(covariant VideoSettingsSheet oldWidget) { super.didUpdateWidget(oldWidget); - final nextZoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale); + final nextZoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale); if (_zoomScale != nextZoomScale) { _zoomScale = nextZoomScale; } @@ -372,19 +316,19 @@ class _VideoSettingsSheetState extends State { } else { await settings.write(SettingsService.audioSyncOffset, offset); } - widget.onSyncOffsetChanged?.call(propertyName, offset); + _state.onSyncOffsetChanged?.call(propertyName, offset); }, ), ) .whenComplete(() { sliderFocusNode.dispose(); - widget.onStartAutoHide?.call(); + _state.onStartAutoHide?.call(); }); // Cancel auto-hide after show() — the previous sheet's whenComplete // fires as a microtask and restarts the timer, so schedule our cancel // to run after that microtask. - Future.microtask(() => widget.onCancelAutoHide?.call()); + Future.microtask(() => _state.onCancelAutoHide?.call()); } void _navigateBack() { @@ -476,44 +420,44 @@ class _VideoSettingsSheetState extends State { setState(() { _zoomScale = next; }); - widget.onVideoZoomChanged?.call(next); + _state.onVideoZoomChanged?.call(next); } void _resetZoomScale() { setState(() { _zoomScale = 1.0; }); - final reset = widget.onResetVideoZoom; + final reset = _state.onResetVideoZoom; if (reset != null) { reset(); } else { - widget.onVideoZoomChanged?.call(1.0); + _state.onVideoZoomChanged?.call(1.0); } } bool get _hasVersionQuality { - return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) && - (widget.onVersionSelected != null || widget.onQualitySelected != null); + return (_state.availableVersions.length > 1 || _state.serverSupportsTranscoding) && + (_state.onSwitchVersion != null || _state.onSwitchQualityPreset != null); } String _versionQualityTitle() { return versionQualityPickerTitle( - showVersions: widget.availableVersions.length > 1, - showQuality: widget.serverSupportsTranscoding, + showVersions: _state.availableVersions.length > 1, + showQuality: _state.serverSupportsTranscoding, ); } String _versionQualityValueText() { final values = []; - if (widget.availableVersions.length > 1) values.add(_selectedVersionLabel()); - if (widget.serverSupportsTranscoding) values.add(qualityPresetLabel(widget.selectedQualityPreset)); + if (_state.availableVersions.length > 1) values.add(_selectedVersionLabel()); + if (_state.serverSupportsTranscoding) values.add(qualityPresetLabel(_state.selectedQualityPreset)); return values.join(' / '); } String _selectedVersionLabel() { - final index = widget.selectedMediaIndex; - if (index >= 0 && index < widget.availableVersions.length) { - return widget.availableVersions[index].displayLabel; + final index = _state.selectedMediaIndex; + if (index >= 0 && index < _state.availableVersions.length) { + return _state.availableVersions[index].displayLabel; } return t.videoControls.versionColumnHeader; } @@ -525,7 +469,7 @@ class _VideoSettingsSheetState extends State { return ListView( children: [ // Playback Speed - hidden for live TV and when user cannot control playback - if (widget.canControl && !widget.isLive) + if (_state.canControl && !_state.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, @@ -540,7 +484,7 @@ class _VideoSettingsSheetState extends State { }, ), - if (widget.onVideoZoomChanged != null || widget.onResetVideoZoom != null) + if (_state.onVideoZoomChanged != null || _state.onResetVideoZoom != null) _SettingsMenuItem( icon: Symbols.zoom_in_rounded, title: t.videoSettings.zoom, @@ -656,36 +600,36 @@ class _VideoSettingsSheetState extends State { ), // Shader Preset (MPV only) - if (widget.shaderService != null && widget.shaderService!.isSupported) + if (_state.shaderService != null && _state.shaderService!.isSupported) _SettingsMenuItem( icon: Symbols.auto_fix_high_rounded, title: t.shaders.title, - valueText: widget.shaderService!.currentPreset.id == ShaderPreset.none.id + valueText: _state.shaderService!.currentPreset.id == ShaderPreset.none.id ? t.common.off - : widget.shaderService!.currentPreset.name, - isHighlighted: widget.shaderService!.currentPreset.isEnabled, + : _state.shaderService!.currentPreset.name, + isHighlighted: _state.shaderService!.currentPreset.isEnabled, onTap: () => _navigateTo(_SettingsView.shader), ), // Ambient Lighting (MPV only) - if (widget.onToggleAmbientLighting != null) + if (_state.onToggleAmbientLighting != null) FocusableListTile( leading: AppIcon( Symbols.blur_on_rounded, fill: 1, - color: widget.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, + color: _state.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, ), title: Text(t.videoControls.ambientLighting), trailing: Switch( - value: widget.isAmbientLightingEnabled, + value: _state.isAmbientLightingEnabled, onChanged: (_) { - widget.onToggleAmbientLighting?.call(); + _state.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, activeThumbColor: Colors.amber, ), onTap: () { - widget.onToggleAmbientLighting?.call(); + _state.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, ), @@ -831,13 +775,13 @@ class _VideoSettingsSheetState extends State { Widget _buildVersionQualityView() { return VersionQualityPicker( - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - selectedQualityPreset: widget.selectedQualityPreset, - serverSupportsTranscoding: widget.serverSupportsTranscoding, - sourceDurationMs: widget.sourceDurationMs, - onVersionSelected: (index) => widget.onVersionSelected?.call(index), - onQualitySelected: (preset) => widget.onQualitySelected?.call(preset), + availableVersions: _state.availableVersions, + selectedMediaIndex: _state.selectedMediaIndex, + selectedQualityPreset: _state.selectedQualityPreset, + serverSupportsTranscoding: _state.serverSupportsTranscoding, + sourceDurationMs: _state.sourceDurationMs, + onVersionSelected: (index) => _state.onSwitchVersion?.call(index), + onQualitySelected: (preset) => _state.onSwitchQualityPreset?.call(preset), ); } @@ -942,11 +886,11 @@ class _VideoSettingsSheetState extends State { } Widget _buildShaderView() { - if (widget.shaderService == null) return const SizedBox.shrink(); + if (_state.shaderService == null) return const SizedBox.shrink(); return Consumer( builder: (context, shaderProvider, _) { - final currentPreset = widget.shaderService!.currentPreset; + final currentPreset = _state.shaderService!.currentPreset; final presets = shaderProvider.allPresets; // +1 for the import button at the end @@ -986,13 +930,13 @@ class _VideoSettingsSheetState extends State { ), onTap: () async { // Disable ambient lighting when selecting a shader - if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { - widget.onToggleAmbientLighting?.call(); + if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) { + _state.onToggleAmbientLighting?.call(); } - await widget.shaderService!.applyPreset(preset); + await _state.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!context.mounted) return; - widget.onShaderChanged?.call(); + _state.onShaderChanged?.call(); OverlaySheetController.of(context).close(); }, ); @@ -1014,14 +958,14 @@ class _VideoSettingsSheetState extends State { final displayName = path.basenameWithoutExtension(filePath); final preset = await shaderProvider.importCustomShader(filePath, displayName); - if (widget.shaderService != null && mounted) { - if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { - widget.onToggleAmbientLighting?.call(); + if (_state.shaderService != null && mounted) { + if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) { + _state.onToggleAmbientLighting?.call(); } - await widget.shaderService!.applyPreset(preset); + await _state.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!mounted) return; - widget.onShaderChanged?.call(); + _state.onShaderChanged?.call(); } if (mounted) showSuccessSnackBar(context, t.shaders.shaderImported); @@ -1039,9 +983,9 @@ class _VideoSettingsSheetState extends State { if (!confirmed || !mounted) return; // If the deleted shader is active, clear it from the player first - if (widget.shaderService!.currentPreset.id == preset.id) { - await widget.shaderService!.applyPreset(ShaderPreset.none); - if (mounted) widget.onShaderChanged?.call(); + if (_state.shaderService!.currentPreset.id == preset.id) { + await _state.shaderService!.applyPreset(ShaderPreset.none); + if (mounted) _state.onShaderChanged?.call(); } await shaderProvider.deleteCustomShader(preset); @@ -1080,7 +1024,7 @@ class _VideoSettingsSheetState extends State { @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); - final isShaderActive = widget.shaderService != null && widget.shaderService!.currentPreset.isEnabled; + final isShaderActive = _state.shaderService != null && _state.shaderService!.currentPreset.isEnabled; final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001; final isIconActive = _currentView == _SettingsView.menu && diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index f8cb894f..b9ceee4b 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -76,9 +76,7 @@ class ContentStripState extends State { late _StripTab _activeTab; final ScrollController _chapterScrollController = ScrollController(); final ScrollController _queueScrollController = ScrollController(); - int? _lastAutoScrolledChapterIndex; - int? _lastAutoScrolledQueueItemID; - int? _lastAutoScrolledQueueIndex; + final Map<_StripTab, Object?> _lastAutoScrolled = {}; final Map _chapterItemKeys = {}; final Map _queueItemKeys = {}; late Stream _chapterIndexStream; @@ -110,11 +108,10 @@ class ContentStripState extends State { void _normalizeActiveTab() { if (_activeTab == _StripTab.chapters && !_hasChapters && _hasQueue) { _activeTab = _StripTab.queue; - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; + _lastAutoScrolled.remove(_StripTab.queue); } else if (_activeTab == _StripTab.queue && !_hasQueue && _hasChapters) { _activeTab = _StripTab.chapters; - _lastAutoScrolledChapterIndex = null; + _lastAutoScrolled.remove(_StripTab.chapters); } } @@ -204,12 +201,7 @@ class ContentStripState extends State { void _selectTab(_StripTab tab) { setState(() { _activeTab = tab; - if (tab == _StripTab.chapters) { - _lastAutoScrolledChapterIndex = null; - } else { - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; - } + _lastAutoScrolled.remove(tab); }); } @@ -218,21 +210,12 @@ class ContentStripState extends State { final key = event.logicalKey; - if (key == LogicalKeyboardKey.arrowLeft) { + if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; - if (index > 0) { - nodes[index - 1].requestFocus(); - _scrollToFocusedNode(nodes[index - 1]); - widget.onFocusActivity?.call(); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowRight) { - final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; - if (index < totalItems - 1) { - nodes[index + 1].requestFocus(); - _scrollToFocusedNode(nodes[index + 1]); + final target = index + (key == LogicalKeyboardKey.arrowLeft ? -1 : 1); + if (target >= 0 && target < totalItems) { + nodes[target].requestFocus(); + _scrollToFocusedNode(nodes[target]); widget.onFocusActivity?.call(); } return KeyEventResult.handled; @@ -243,7 +226,7 @@ class ContentStripState extends State { // Switch to chapters page and focus current chapter setState(() { _activeTab = _StripTab.chapters; - _lastAutoScrolledChapterIndex = null; + _lastAutoScrolled.remove(_StripTab.chapters); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _chapterFocusNodes.isNotEmpty) { @@ -266,8 +249,7 @@ class ContentStripState extends State { // Switch to queue page and focus current queue item setState(() { _activeTab = _StripTab.queue; - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; + _lastAutoScrolled.remove(_StripTab.queue); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _queueFocusNodes.isNotEmpty) { @@ -407,41 +389,86 @@ class ContentStripState extends State { ); } - Widget _buildChapterStrip(bool isTablet) { - final thumbWidth = isTablet ? 200.0 : 120.0; - final thumbHeight = isTablet ? 112.0 : 68.0; + /// Horizontal list of strip items: auto-scrolls to [autoScrollIndex] whenever + /// [autoScrollToken] changes, and wraps items for focus navigation. + Widget _buildStrip({ + required _StripTab tab, + required ScrollController controller, + required Map keys, + required List nodes, + required String focusPrefix, + required int itemCount, + required bool isTablet, + required int? autoScrollIndex, + required Object? autoScrollToken, + required (Widget, VoidCallback?) Function(BuildContext context, int index, Key key) itemBuilder, + }) { + _trimItemKeys(keys, itemCount); + if (autoScrollIndex != null && _lastAutoScrolled[tab] != autoScrollToken) { + _lastAutoScrolled[tab] = autoScrollToken; + _autoScrollTo( + controller, + keys, + autoScrollIndex, + isTablet: isTablet, + isCurrent: () => _lastAutoScrolled[tab] == autoScrollToken, + ); + } + + if (widget.useFocusNavigation) { + _ensureFocusNodes(nodes, itemCount, focusPrefix); + } + + return ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + itemCount: itemCount, + padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), + itemBuilder: (context, index) { + final (item, onTap) = itemBuilder(context, index, _itemKeyFor(keys, index)); + + if (!widget.useFocusNavigation) return item; + + return Align( + alignment: .topCenter, + child: FocusableWrapper( + focusNode: nodes[index], + onSelect: onTap, + onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, itemCount, tab), + onFocusChange: (hasFocus) { + if (hasFocus) widget.onFocusActivity?.call(); + }, + borderRadius: 6, + autoScroll: false, + useBackgroundFocus: true, + child: item, + ), + ); + }, + ); + } + + Widget _buildChapterStrip(bool isTablet) { return StreamBuilder( stream: _chapterIndexStream, initialData: MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters), builder: (context, chapterSnapshot) { final currentChapterIndex = chapterSnapshot.data; - _trimItemKeys(_chapterItemKeys, widget.chapters.length); - if (currentChapterIndex != null && _lastAutoScrolledChapterIndex != currentChapterIndex) { - _lastAutoScrolledChapterIndex = currentChapterIndex; - _autoScrollTo( - _chapterScrollController, - _chapterItemKeys, - currentChapterIndex, - isTablet: isTablet, - isCurrent: () => _lastAutoScrolledChapterIndex == currentChapterIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_chapterFocusNodes, widget.chapters.length, 'ChapterFocus'); - } - - return ListView.builder( + return _buildStrip( + tab: _StripTab.chapters, controller: _chapterScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + keys: _chapterItemKeys, + nodes: _chapterFocusNodes, + focusPrefix: 'ChapterFocus', itemCount: widget.chapters.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { + isTablet: isTablet, + autoScrollIndex: currentChapterIndex, + autoScrollToken: currentChapterIndex, + itemBuilder: (context, index, itemKey) { final chapter = widget.chapters[index]; - final isCurrent = currentChapterIndex == index; final localThumbPath = widget.serverId != null && chapter.thumb != null ? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!) @@ -451,48 +478,25 @@ class ContentStripState extends State { ? () => unawaited(_handleChapterTap(chapter.startTime)) : null; - final itemKey = _itemKeyFor(_chapterItemKeys, index); - final item = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, - isTablet: isTablet, - thumbnail: chapter.thumb != null - ? OptimizedMediaImage.thumb( - client: _tryGetClient(context, serverIdOrNull(widget.serverId)), - imagePath: chapter.thumb, - localFilePath: localThumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - title: chapter.label, - subtitle: formatDurationTimestamp(chapter.startTime), - onTap: onTap, + return ( + _buildStripItem( + key: itemKey, + isCurrent: currentChapterIndex == index, + isTablet: isTablet, + thumbnail: chapter.thumb != null + ? _buildStripThumbnail( + client: _tryGetClient(context, serverIdOrNull(widget.serverId)), + imagePath: chapter.thumb, + localFilePath: localThumbPath, + isTablet: isTablet, + ) + : null, + title: chapter.label, + subtitle: formatDurationTimestamp(chapter.startTime), + onTap: onTap, + ), + onTap, ); - - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _chapterFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => - _handleFocusItemKeyEvent(event, index, widget.chapters.length, _StripTab.chapters), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: item, - ), - ); - } - - return item; }, ); }, @@ -500,9 +504,6 @@ class ContentStripState extends State { } Widget _buildQueueStrip(bool isTablet) { - final thumbWidth = isTablet ? 200.0 : 120.0; - final thumbHeight = isTablet ? 112.0 : 68.0; - return SettingValueBuilder( pref: SettingsService.hideSpoilers, builder: (context, hideSpoilers, _) => Consumer( @@ -513,35 +514,18 @@ class ContentStripState extends State { ? -1 : items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); - _trimItemKeys(_queueItemKeys, items.length); - - if (currentIndex >= 0 && - (_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) { - _lastAutoScrolledQueueItemID = currentItemID; - _lastAutoScrolledQueueIndex = currentIndex; - _autoScrollTo( - _queueScrollController, - _queueItemKeys, - currentIndex, - isTablet: isTablet, - isCurrent: () => - _lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus'); - } - - return ListView.builder( + return _buildStrip( + tab: _StripTab.queue, controller: _queueScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + keys: _queueItemKeys, + nodes: _queueFocusNodes, + focusPrefix: 'QueueFocus', itemCount: items.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { + isTablet: isTablet, + autoScrollIndex: currentIndex >= 0 ? currentIndex : null, + autoScrollToken: (currentItemID, currentIndex), + itemBuilder: (context, index, itemKey) { final item = items[index]; - final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; final client = item.serverId != null ? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)) @@ -549,47 +533,21 @@ class ContentStripState extends State { void onTap() => widget.onQueueItemSelected?.call(item); - final itemKey = _itemKeyFor(_queueItemKeys, index); - final stripItem = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, - isTablet: isTablet, - thumbnail: item.thumbPath != null - ? OptimizedMediaImage.thumb( - client: client, - imagePath: item.thumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - blurThumbnail: hideSpoilers && item.shouldHideSpoiler, - title: item.title ?? '', - subtitle: formatQueueItemSubtitle(item), - onTap: onTap, + return ( + _buildStripItem( + key: itemKey, + isCurrent: playbackState.playQueueItemIdFor(item) == currentItemID, + isTablet: isTablet, + thumbnail: item.thumbPath != null + ? _buildStripThumbnail(client: client, imagePath: item.thumbPath, isTablet: isTablet) + : null, + blurThumbnail: hideSpoilers && item.shouldHideSpoiler, + title: item.title ?? '', + subtitle: formatQueueItemSubtitle(item), + onTap: onTap, + ), + onTap, ); - - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _queueFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, items.length, _StripTab.queue), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: stripItem, - ), - ); - } - - return stripItem; }, ); }, @@ -597,6 +555,23 @@ class ContentStripState extends State { ); } + Widget _buildStripThumbnail({ + required MediaServerClient? client, + required String? imagePath, + required bool isTablet, + String? localFilePath, + }) { + return OptimizedMediaImage.thumb( + client: client, + imagePath: imagePath, + localFilePath: localFilePath, + width: isTablet ? 200.0 : 120.0, + height: isTablet ? 112.0 : 68.0, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ); + } + Widget _buildStripItem({ Key? key, required bool isCurrent, diff --git a/lib/widgets/video_controls/widgets/content_strip_panel.dart b/lib/widgets/video_controls/widgets/content_strip_panel.dart new file mode 100644 index 00000000..75ea0dae --- /dev/null +++ b/lib/widgets/video_controls/widgets/content_strip_panel.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../app_icon.dart'; + +/// Gradient scrim that hosts the content strip once it is on screen. +/// +/// [chevron] points back at the controls the strip replaced — down for the +/// mobile swipe, up for D-pad focus. [padding] compensates for the strip's +/// own horizontal padding, which differs between touch and focus navigation. +class ContentStripPanel extends StatelessWidget { + final EdgeInsetsGeometry padding; + final IconData chevron; + final Widget child; + + const ContentStripPanel({super.key, required this.padding, required this.chevron, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.65), Colors.black.withValues(alpha: 0.7)], + stops: const [0.0, 0.42, 1.0], + ), + ), + child: Column( + mainAxisSize: .min, + children: [ + AppIcon(chevron, color: Colors.white38, size: 20), + const SizedBox(height: 4), + child, + ], + ), + ); + } +} + +/// Chevron pinned to the bottom of the controls hinting that the content +/// strip can be pulled into view. Must be placed directly in a [Stack]. +class ContentStripHint extends StatelessWidget { + final IconData chevron; + + const ContentStripHint(this.chevron, {super.key}); + + @override + Widget build(BuildContext context) { + return Positioned(left: 0, right: 0, bottom: 12, child: AppIcon(chevron, color: Colors.white24, size: 24)); + } +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 72efae64..99bbc9f0 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -3,8 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../../focus/dpad_navigator.dart'; -import '../../../media/media_item.dart'; -import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../media/media_source_info.dart'; import '../../../services/sleep_timer_service.dart'; @@ -13,12 +11,10 @@ import '../../../utils/quality_preset_labels.dart'; import '../../../i18n/strings.g.dart'; import '../../../widgets/overlay_sheet.dart'; import '../models/track_controls_state.dart'; -import '../../../models/transcode_quality_preset.dart'; import '../sheets/chapter_sheet.dart'; import '../sheets/queue_sheet.dart'; import '../sheets/track_sheet.dart'; import '../sheets/video_settings_sheet.dart'; -import '../../../services/shader_service.dart'; import '../../../utils/track_label_builder.dart'; import '../video_control_button.dart'; @@ -65,43 +61,6 @@ class TrackChapterControls extends StatelessWidget { this.hideChaptersAndQueue = false, }); - List get availableVersions => trackControlsState.availableVersions; - int get selectedMediaIndex => trackControlsState.selectedMediaIndex; - TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset; - bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding; - ValueChanged? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset; - int get boxFitMode => trackControlsState.boxFitMode; - double get videoZoomScale => trackControlsState.videoZoomScale; - int get audioSyncOffset => trackControlsState.audioSyncOffset; - int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset; - bool get isRotationLocked => trackControlsState.isRotationLocked; - bool get isScreenLocked => trackControlsState.isScreenLocked; - bool get isFullscreen => trackControlsState.isFullscreen; - bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop; - VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode; - VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode; - ValueChanged? get onVideoZoomChanged => trackControlsState.onVideoZoomChanged; - VoidCallback? get onResetVideoZoom => trackControlsState.onResetVideoZoom; - VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock; - VoidCallback? get onToggleScreenLock => trackControlsState.onToggleScreenLock; - VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen; - VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop; - Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion; - VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes; - VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide; - VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide; - void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged; - String? get serverId => trackControlsState.serverId; - ShaderService? get shaderService => trackControlsState.shaderService; - VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged; - bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled; - VoidCallback? get onToggleAmbientLighting => trackControlsState.onToggleAmbientLighting; - bool get canControl => trackControlsState.canControl; - bool get isLive => trackControlsState.isLive; - bool get subtitlesVisible => trackControlsState.subtitlesVisible; - bool get showQueueButton => trackControlsState.showQueueButton; - Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; - /// Handle key event for button navigation KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) { if (!event.isActionable) { @@ -183,6 +142,7 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; + final state = trackControlsState; final isMobile = PlatformDetector.isMobile(context); final isDesktop = PlatformDetector.isDesktopOS(); @@ -196,13 +156,14 @@ class TrackChapterControls extends StatelessWidget { listenable: SleepTimerService(), builder: (context, _) { final sleepTimer = SleepTimerService(); + final shaderService = state.shaderService; final isShaderActive = - shaderService != null && shaderService!.isSupported && shaderService!.currentPreset.isEnabled; - final isZoomActive = (videoZoomScale - 1.0).abs() > 0.0001; + shaderService != null && shaderService.isSupported && shaderService.currentPreset.isEnabled; + final isZoomActive = (state.videoZoomScale - 1.0).abs() > 0.0001; final isActive = sleepTimer.isActive || - audioSyncOffset != 0 || - subtitleSyncOffset != 0 || + state.audioSyncOffset != 0 || + state.subtitleSyncOffset != 0 || isShaderActive || isZoomActive; return _buildTrackButton( @@ -216,37 +177,14 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => VideoSettingsSheet( - player: player, - audioSyncOffset: audioSyncOffset, - subtitleSyncOffset: subtitleSyncOffset, - videoZoomScale: videoZoomScale, - onVideoZoomChanged: onVideoZoomChanged, - onResetVideoZoom: onResetVideoZoom, - canControl: canControl, - isLive: isLive, - availableVersions: availableVersions, - selectedMediaIndex: selectedMediaIndex, - selectedQualityPreset: selectedQualityPreset, - serverSupportsTranscoding: serverSupportsTranscoding, - sourceDurationMs: trackControlsState.sourceDurationMs, - onVersionSelected: onSwitchVersion == null ? null : (i) => onSwitchVersion!(i), - onQualitySelected: onSwitchQualityPreset, - shaderService: shaderService, - onShaderChanged: onShaderChanged, - isAmbientLightingEnabled: isAmbientLightingEnabled, - onToggleAmbientLighting: onToggleAmbientLighting, - onCancelAutoHide: onCancelAutoHide, - onStartAutoHide: onStartAutoHide, - onSyncOffsetChanged: onSyncOffsetChanged, - ), + builder: (_) => VideoSettingsSheet(player: player, trackControlsState: state), ) .whenComplete(() { - onStartAutoHide?.call(); - onLoadSeekTimes?.call(); + state.onStartAutoHide?.call(); + state.onLoadSeekTimes?.call(); }); }, ); @@ -264,10 +202,10 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.track, builder: (context, selectionSnapshot) { final selection = selectionSnapshot.data ?? player.state.track; - final hasSubtitleControls = trackControlsState.hasSubtitleControls(tracks); + final hasSubtitleControls = state.hasSubtitleControls(tracks); final selectedSub = selection.subtitle; final hasActiveSubtitle = selectedSub != null && selectedSub.id != SubtitleTrack.off.id; - final isHidden = hasSubtitleControls && hasActiveSubtitle && !subtitlesVisible; + final isHidden = hasSubtitleControls && hasActiveSubtitle && !state.subtitlesVisible; final icon = hasSubtitleControls ? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded) : Symbols.audiotrack_rounded; @@ -280,12 +218,12 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => TrackSheet(player: player, trackControlsState: trackControlsState), + builder: (_) => TrackSheet(player: player, trackControlsState: state), ) - .whenComplete(() => onStartAutoHide?.call()); + .whenComplete(() => state.onStartAutoHide?.call()); }, ); }, @@ -306,20 +244,20 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( builder: (_) => ChapterSheet( player: player, chapters: chapters, chaptersLoaded: chaptersLoaded, - canControl: canControl, - serverId: serverId, + canControl: state.canControl, + serverId: state.serverId, onSeekRequested: onSeekRequested, onSeekCompleted: onSeekCompleted, ), ) - .whenComplete(() => onStartAutoHide?.call()); + .whenComplete(() => state.onStartAutoHide?.call()); }, ), ); @@ -327,7 +265,7 @@ class TrackChapterControls extends StatelessWidget { } // Queue button (hidden on mobile when content strip is available) - if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) { + if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -338,10 +276,10 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) - .show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!)) - .whenComplete(() => onStartAutoHide?.call()); + .show(builder: (_) => QueueSheet(onItemSelected: state.onQueueItemSelected!)) + .whenComplete(() => state.onStartAutoHide?.call()); }, ), ); @@ -349,7 +287,7 @@ class TrackChapterControls extends StatelessWidget { } // Picture-in-Picture mode - if (onTogglePIPMode != null) { + if (state.onTogglePIPMode != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -359,25 +297,25 @@ class TrackChapterControls extends StatelessWidget { semanticLabel: t.videoControls.pipButton, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onTogglePIPMode, + onPressed: state.onTogglePIPMode, ), ); buttonIndex++; } // BoxFit mode button - if (onCycleBoxFitMode != null) { + if (state.onCycleBoxFitMode != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: _getBoxFitIcon(boxFitMode), - tooltip: _getBoxFitTooltip(boxFitMode), + icon: _getBoxFitIcon(state.boxFitMode), + tooltip: _getBoxFitTooltip(state.boxFitMode), semanticLabel: t.videoControls.aspectRatioButton, - semanticValue: _getBoxFitTooltip(boxFitMode), + semanticValue: _getBoxFitTooltip(state.boxFitMode), isMobile: isMobile, isDesktop: isDesktop, - onPressed: onCycleBoxFitMode, + onPressed: state.onCycleBoxFitMode, ), ); buttonIndex++; @@ -389,13 +327,13 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, - tooltip: isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation, + icon: state.isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, + tooltip: state.isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation, semanticLabel: t.videoControls.rotationLockButton, - checked: isRotationLocked, + checked: state.isRotationLocked, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleRotationLock, + onPressed: state.onToggleRotationLock, ), ); buttonIndex++; @@ -412,14 +350,14 @@ class TrackChapterControls extends StatelessWidget { semanticLabel: t.videoControls.screenLockButton, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleScreenLock, + onPressed: state.onToggleScreenLock, ), ); buttonIndex++; } // Always on top button (desktop only, not TV) - if (isDesktop && onToggleAlwaysOnTop != null) { + if (isDesktop && state.onToggleAlwaysOnTop != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -427,11 +365,11 @@ class TrackChapterControls extends StatelessWidget { icon: Symbols.layers_rounded, tooltip: t.videoControls.alwaysOnTopButton, semanticLabel: t.videoControls.alwaysOnTopButton, - isActive: isAlwaysOnTop, - checked: isAlwaysOnTop, + isActive: state.isAlwaysOnTop, + checked: state.isAlwaysOnTop, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleAlwaysOnTop, + onPressed: state.onToggleAlwaysOnTop, ), ); buttonIndex++; @@ -443,13 +381,15 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, - tooltip: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - semanticLabel: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - checked: isFullscreen, + icon: state.isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, + tooltip: state.isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, + semanticLabel: state.isFullscreen + ? t.videoControls.exitFullscreenButton + : t.videoControls.fullscreenButton, + checked: state.isFullscreen, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleFullscreen, + onPressed: state.onToggleFullscreen, ), ); } @@ -462,15 +402,16 @@ class TrackChapterControls extends StatelessWidget { } String? _versionQualitySemanticValue() { + final state = trackControlsState; final values = []; - if (availableVersions.length > 1) { - final index = selectedMediaIndex; - if (index >= 0 && index < availableVersions.length) { - values.add(availableVersions[index].displayLabel); + if (state.availableVersions.length > 1) { + final index = state.selectedMediaIndex; + if (index >= 0 && index < state.availableVersions.length) { + values.add(state.availableVersions[index].displayLabel); } } - if (serverSupportsTranscoding) { - values.add(qualityPresetLabel(selectedQualityPreset)); + if (state.serverSupportsTranscoding) { + values.add(qualityPresetLabel(state.selectedQualityPreset)); } return values.isEmpty ? null : values.join(' / '); } @@ -519,14 +460,15 @@ class TrackChapterControls extends StatelessWidget { /// Calculate total button count for navigation int _getButtonCount(bool isMobile, bool isDesktop) { + final state = trackControlsState; int count = 1; // Settings button always shown count++; // Audio & subtitles button always shown if (chapters.isNotEmpty && !hideChaptersAndQueue) count++; - if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++; - if (onTogglePIPMode != null) count++; - if (onCycleBoxFitMode != null) count++; + if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) count++; + if (state.onTogglePIPMode != null) count++; + if (state.onCycleBoxFitMode != null) count++; if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV) - if (isDesktop && onToggleAlwaysOnTop != null) count++; // Always on top + if (isDesktop && state.onToggleAlwaysOnTop != null) count++; // Always on top if (isDesktop) count++; // Fullscreen return count; } diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 38879bf7..5b9aa395 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -617,20 +617,7 @@ void MpvPlayer::CommandAsync(const std::vector& args, CommandCallba return; } - std::vector c_args; - c_args.reserve(args.size() + 1); - for (const auto& arg : args) { - c_args.push_back(arg.c_str()); - } - c_args.push_back(nullptr); - - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - int result = mpv_command_async(mpv_, request_id, c_args.data()); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback)); } void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { @@ -658,14 +645,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val if (completion) completion(error); }; } - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - char* property_value = const_cast(value.c_str()); - int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback)); } void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) { @@ -674,13 +654,7 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca return; } - uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback)); - - int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING); - if (result < 0) { - auto cb = pending_requests_.TakeProperty(request_id); - if (cb) cb(result, ""); - } + plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback)); } void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { @@ -920,33 +894,12 @@ void MpvPlayer::EnsureAudioRecoveryTimer() { } void MpvPlayer::HandleMpvEvent(mpv_event* event) { + if (plezy::mpv_common::DispatchReplyEvent( + pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) { + return; + } + switch (event->event_id) { - case MPV_EVENT_COMMAND_REPLY: - case MPV_EVENT_SET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - StatusCallback callback = pending_requests_.TakeStatus(request_id); - if (callback) { - callback(event->error); - } - break; - } - case MPV_EVENT_GET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - GetPropertyCallback callback = pending_requests_.TakeProperty(request_id); - if (callback) { - int error = event->error; - std::string value; - if (error >= 0) { - auto* prop = static_cast(event->data); - if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { - auto c_value = *static_cast(prop->data); - if (c_value) value = SanitizeUtf8(c_value); - } - } - callback(error, value); - } - break; - } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); if (!msg) break; @@ -963,54 +916,12 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); if (!prop || !prop->name) break; - mpv_node node; - node.format = prop->format; + mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop); - switch (prop->format) { - case MPV_FORMAT_STRING: - node.u.string = prop->data ? *static_cast(prop->data) : nullptr; - break; - case MPV_FORMAT_FLAG: - node.u.flag = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_INT64: - node.u.int64 = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_DOUBLE: - node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; - break; - case MPV_FORMAT_NODE: - if (prop->data) { - node = *static_cast(prop->data); - } else { - node.format = MPV_FORMAT_NONE; - } - break; - default: - node.format = MPV_FORMAT_NONE; - break; - } - - if (strcmp(prop->name, "current-ao") == 0) { - const char* current_ao = nullptr; - if (prop->format == MPV_FORMAT_STRING && prop->data) { - current_ao = *static_cast(prop->data); - } - const bool is_null = current_ao && strcmp(current_ao, "null") == 0; - const auto transition = - audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now()); - if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) { - LogRecovery("current-ao fell back to null; starting recovery"); - EnsureAudioRecoveryTimer(); - } else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) { - LogRecovery("audio recovered (current-ao no longer null)"); - } - } - if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && - audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) { - LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); - EnsureAudioRecoveryTimer(); - } + const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop); + if (notice.message) LogRecovery(notice.message); + // Recovery runs off a GLib timer here, so newly queued work has to arm it. + if (notice.scheduled_work) EnsureAudioRecoveryTimer(); SendPropertyChange(prop->name, &node); break; @@ -1048,77 +959,41 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { break; } } -FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { - NodeConversionBudget budget{ - /*remaining_entries=*/16384, - /*remaining_bytes=*/16 * 1024 * 1024, - }; - return NodeToFlValue(node, 0, &budget); -} -bool MpvPlayer::ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result) { - if (!input || !budget || !result) return false; - const size_t length = strnlen(input, budget->remaining_bytes + 1); - if (length > budget->remaining_bytes) return false; - budget->remaining_bytes -= length; - *result = SanitizeUtf8(input, length); - return true; -} +namespace { -FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget) { - constexpr size_t kMaxNodeDepth = 32; - constexpr int kMaxNodeEntries = 16384; - if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) { - return fl_value_new_null(); +// Adapts the shared, bounded mpv_node walk onto GLib-owned FlValues. +struct FlValueNodeBuilder { + using Value = FlValue*; + using ListBuilder = FlValue*; + using MapBuilder = FlValue*; + + static Value Null() { return fl_value_new_null(); } + static Value Bool(bool value) { return fl_value_new_bool(value); } + static Value Int(int64_t value) { return fl_value_new_int(value); } + static Value Double(double value) { return fl_value_new_float(value); } + static Value String(const char* value, size_t length) { + return fl_value_new_string(SanitizeUtf8(value, length).c_str()); } - --budget->remaining_entries; - switch (node->format) { - case MPV_FORMAT_STRING: { - std::string value; - if (!ConvertNodeString(node->u.string, budget, &value)) return fl_value_new_null(); - return fl_value_new_string(value.c_str()); - } - case MPV_FORMAT_FLAG: - return fl_value_new_bool(node->u.flag != 0); - case MPV_FORMAT_INT64: - return fl_value_new_int(node->u.int64); - case MPV_FORMAT_DOUBLE: - return fl_value_new_float(node->u.double_); - case MPV_FORMAT_NODE_ARRAY: { - const mpv_node_list* list = node->u.list; - if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) { - return fl_value_new_null(); - } - FlValue* result = fl_value_new_list(); - for (int i = 0; i < list->num; i++) { - fl_value_append_take(result, NodeToFlValue(&list->values[i], depth + 1, budget)); - } - return result; - } - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list* map = node->u.list; - if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) { - return fl_value_new_null(); - } - FlValue* result = fl_value_new_map(); - for (int i = 0; i < map->num; i++) { - if (!map->keys[i]) { - fl_value_unref(result); - return fl_value_new_null(); - } - std::string key; - if (!ConvertNodeString(map->keys[i], budget, &key)) { - fl_value_unref(result); - return fl_value_new_null(); - } - fl_value_set_string_take(result, key.c_str(), NodeToFlValue(&map->values[i], depth + 1, budget)); - } - return result; - } - default: - return fl_value_new_null(); + static ListBuilder NewList() { return fl_value_new_list(); } + static void Append(ListBuilder& list, Value value) { fl_value_append_take(list, value); } + static Value FinishList(ListBuilder list) { return list; } + + static MapBuilder NewMap() { return fl_value_new_map(); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + fl_value_set_string_take(map, SanitizeUtf8(key, key_length).c_str(), value); } + static Value FinishMap(MapBuilder map) { return map; } + static void AbandonMap(MapBuilder& map) { fl_value_unref(map); } +}; + +} // namespace + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { return plezy::mpv_common::ConvertNode(node); } + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget) { + return plezy::mpv_common::ConvertNode(node, 0, budget); } void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 61b76fec..2654be8c 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -242,15 +242,9 @@ class MpvPlayer { void LogRecovery(const std::string& text); void SetHDREnabled(bool enabled, StatusCallback callback = nullptr); - struct NodeConversionBudget { - size_t remaining_entries; - size_t remaining_bytes; - }; - - /// Helper to convert mpv_node to FlValue. + /// Helper to convert mpv_node to FlValue, bounded by the shared node budget. ::_FlValue* NodeToFlValue(mpv_node* node); - ::_FlValue* NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget); - bool ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result); + ::_FlValue* NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget); const bool audio_only_; mpv_handle* mpv_ = nullptr; diff --git a/linux/runner/mpv/mpv_player_lifecycle_test.cc b/linux/runner/mpv/mpv_player_lifecycle_test.cc index f7f97e43..c481467a 100644 --- a/linux/runner/mpv/mpv_player_lifecycle_test.cc +++ b/linux/runner/mpv/mpv_player_lifecycle_test.cc @@ -102,8 +102,8 @@ class MpvPlayerLifecycleTestPeer { static FlValue* ConvertNode(MpvPlayer& player, mpv_node* node) { return player.NodeToFlValue(node); } static FlValue* ConvertNodeWithBudget( MpvPlayer& player, mpv_node* node, size_t remaining_entries, size_t remaining_bytes) { - MpvPlayer::NodeConversionBudget budget{remaining_entries, remaining_bytes}; - return player.NodeToFlValue(node, 0, &budget); + plezy::mpv_common::NodeConversionBudget budget{remaining_entries, remaining_bytes}; + return player.NodeToFlValue(node, &budget); } static void RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) { player.observed_properties_.Register(name, "node", id); diff --git a/scripts/check_build_workflow.py b/scripts/check_build_workflow.py index f894955f..89ab4d70 100644 --- a/scripts/check_build_workflow.py +++ b/scripts/check_build_workflow.py @@ -5,8 +5,15 @@ from pathlib import Path import re import sys +from workflow_yaml import iter_uses_references, job_block -DEFAULT_WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/build.yml" + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_WORKFLOW = ROOT / ".github/workflows/build.yml" +# The shared bootstrap both windows-arm jobs call, and the pins it must keep. +SETUP_FLUTTER_GIT = ROOT / ".github/actions/setup-flutter-git/action.yml" +FLUTTER_VERSION = "3.44.0" +FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" if len(sys.argv) > 2: raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]") WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW @@ -20,11 +27,9 @@ def require(condition: bool, message: str) -> None: def job(name: str) -> str: - match = re.search( - rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text - ) - require(match is not None, f"missing {name} job") - return match.group(0) if match else "" + block = job_block(text, name) + require(bool(block), f"missing {name} job") + return block def named_step(block: str, name: str) -> str: @@ -132,7 +137,7 @@ require( for expected in ( "if: matrix.flutter_setup == 'action'", "if: matrix.flutter_setup == 'git'", - "git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42", + "uses: ./.github/actions/setup-flutter-git", "flutter pub get --enforce-lockfile --no-example", "--dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }}", "--split-debug-info=debug-info/windows-${{ matrix.arch }}", @@ -154,6 +159,20 @@ require( ) require_explicit_shells("build-windows", windows, "pwsh") +setup_flutter_git = ( + SETUP_FLUTTER_GIT.read_text(encoding="utf-8") if SETUP_FLUTTER_GIT.is_file() else "" +) +require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/action.yml") +for expected in ( + f'$version = "{FLUTTER_VERSION}"', + f'$expectedCommit = "{FLUTTER_COMMIT}"', + "$actualCommit -ne $expectedCommit", +): + require( + expected in setup_flutter_git, + f"shared Flutter bootstrap must keep its immutable pin: {expected}", + ) + linux = job("build-linux") require("runs-on: ${{ matrix.runner }}" in linux, "Linux must use its matrix runner") require("fail-fast: false" in linux, "Linux matrix must not cancel its other architecture") @@ -181,7 +200,7 @@ require( ) for expected in ( "channel: ${{ matrix.flutter_channel }}", - 'flutter-version: "3.44.0"', + "flutter-version: ${{ env.FLUTTER_VERSION }}", "flutter pub get --enforce-lockfile --no-example", "lib/${{ matrix.pkg_config_arch }}/pkgconfig", "--dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }}", @@ -286,6 +305,10 @@ for protected_job in ( f"{protected_job} must depend on trusted-ref validation", ) +require( + text.count(FLUTTER_VERSION) == 1 and f'FLUTTER_VERSION: "{FLUTTER_VERSION}"' in text, + "the Flutter SDK version must be written once, as the workflow FLUTTER_VERSION env", +) require( "TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1" in text, "build caches must use a dedicated trusted namespace", @@ -303,17 +326,18 @@ require( "every Flutter SDK cache must define its trusted cache key", ) -action_refs = re.findall(r"(?m)^\s*(?:-\s+)?uses:\s+([^\s@]+)@([^\s#]+)", text) -require(bool(action_refs), "build workflow must use pinned actions") -for action, ref in action_refs: - require( - re.fullmatch(r"[0-9a-f]{40}", ref) is not None, - f"action {action} must be pinned to a full commit SHA", - ) - -checkout_count = sum(action == "actions/checkout" for action, _ in action_refs) +# check_workflow_action_pins.py owns the SHA-pin rule for every workflow, this +# one included; build.yml only adds the credential invariant on top, because it +# is workflow_dispatch-only and so escapes the pull-request rule in +# check_workflow_security.py. +remote_actions = [ + reference.rpartition("@")[0] + for _, reference in iter_uses_references(text) + if not reference.startswith("./") +] +require(bool(remote_actions), "build workflow must use pinned actions") require( - text.count("persist-credentials: false") == checkout_count, + text.count("persist-credentials: false") == remote_actions.count("actions/checkout"), "every build checkout must discard GitHub credentials", ) diff --git a/scripts/check_update_packages_workflow.py b/scripts/check_update_packages_workflow.py index 75f6a501..65172130 100644 --- a/scripts/check_update_packages_workflow.py +++ b/scripts/check_update_packages_workflow.py @@ -5,6 +5,8 @@ from pathlib import Path import re import sys +from workflow_yaml import job_block + WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/update-packages.yml" text = WORKFLOW.read_text(encoding="utf-8") @@ -17,11 +19,9 @@ def require(condition: bool, message: str) -> None: def job(name: str) -> str: - match = re.search( - rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text - ) - require(match is not None, f"missing {name} job") - return match.group(0) if match else "" + block = job_block(text, name) + require(bool(block), f"missing {name} job") + return block require( diff --git a/scripts/check_workflow_action_pins.py b/scripts/check_workflow_action_pins.py index 2f30fc69..8cf66929 100755 --- a/scripts/check_workflow_action_pins.py +++ b/scripts/check_workflow_action_pins.py @@ -7,336 +7,21 @@ import re import sys from pathlib import Path +import workflow_yaml + ROOT = Path(__file__).resolve().parent.parent WORKFLOWS = ROOT / ".github" / "workflows" -MAPPING_RE = re.compile( - r"""^\s*(?:-\s*)?(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P.*?)\s*$""" -) -EXPLICIT_KEY_RE = re.compile( - r"""^\s*(?:-\s*)?\?\s*(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$""" -) -EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P.*?)\s*$") +ACTIONS = ROOT / ".github" / "actions" REMOTE_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-fA-F]{40}$") -BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$") -BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$") -YAML_DOUBLE_ESCAPES = { - "0": "\0", - "a": "\a", - "b": "\b", - "t": "\t", - "\t": "\t", - "n": "\n", - "v": "\v", - "f": "\f", - "r": "\r", - "e": "\x1b", - " ": " ", - '"': '"', - "/": "/", - "\\": "\\", - "N": "\u0085", - "_": "\u00a0", - "L": "\u2028", - "P": "\u2029", -} -def iter_workflow_files(directory: Path = WORKFLOWS): - yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml"))) - - -def _strip_yaml_comment(value: str) -> str: - quote = None - escaped = False - for index, char in enumerate(value): - if escaped: - escaped = False - continue - if char == "\\" and quote == '"': - escaped = True - continue - if char in ("'", '"'): - if quote is None: - quote = char - elif quote == char: - quote = None - continue - if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()): - return value[:index].rstrip() - return value.rstrip() - - -def _decode_quoted_yaml_string(value: str) -> str | None: - if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'): - return None - if value[0] == "'": - return value[1:-1].replace("''", "'") - - decoded = [] - index = 1 - end = len(value) - 1 - while index < end: - char = value[index] - if char != "\\": - decoded.append(char) - index += 1 - continue - index += 1 - if index >= end: - return None - escape = value[index] - if escape in YAML_DOUBLE_ESCAPES: - decoded.append(YAML_DOUBLE_ESCAPES[escape]) - index += 1 - continue - width = {"x": 2, "u": 4, "U": 8}.get(escape) - if width is None or index + width >= end: - return None - digits = value[index + 1 : index + 1 + width] - if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits): - return None - try: - decoded.append(chr(int(digits, 16))) - except ValueError: - return None - index += width + 1 - return "".join(decoded) - - -def _unquote(value: str) -> str: - decoded = _decode_quoted_yaml_string(value) - return value if decoded is None else decoded - - -def _flow_value(line: str, start: int, mapping_depth: int) -> str: - index = start - quote = None - escaped = False - depth = mapping_depth - while index < len(line): - char = line[index] - if escaped: - escaped = False - elif char == "\\" and quote == '"': - escaped = True - elif quote is not None: - if char == quote: - quote = None - elif char in ("'", '"'): - quote = char - elif char in ("{", "["): - depth += 1 - elif char in ("}", "]"): - if depth == mapping_depth: - break - depth -= 1 - elif char == "," and depth == mapping_depth: - break - index += 1 - return _unquote(line[start:index].strip()) - - -def _has_unsupported_block_mapping_key(line: str) -> bool: - candidate = line.lstrip() - if candidate.startswith("-") and not candidate.startswith("---"): - candidate = candidate[1:].lstrip() - if not candidate: - return False - if candidate[0] in "!&*": - return True - if candidate[0] not in ("'", '"'): - return False - - quote = candidate[0] - escaped = False - index = 1 - while index < len(candidate): - char = candidate[index] - if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'": - index += 2 - continue - if escaped: - escaped = False - elif quote == '"' and char == "\\": - escaped = True - elif char == quote: - return False - index += 1 - return True - - -def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]: - references = [] - depth = initial_depth - index = 0 - while index < len(line): - char = line[index] - if char in ("'", '"'): - quote = char - escaped = False - end = index + 1 - while end < len(line): - quoted_char = line[end] - if escaped: - escaped = False - elif quoted_char == "\\" and quote == '"': - escaped = True - elif quoted_char == quote: - break - end += 1 - if end >= len(line): - if depth > 0: - references.append("") - return references, depth - key = _decode_quoted_yaml_string(line[index : end + 1]) - after_key = end + 1 - while after_key < len(line) and line[after_key].isspace(): - after_key += 1 - if depth > 0 and after_key < len(line) and line[after_key] == ":": - if key == "uses": - references.append(_flow_value(line, after_key + 1, depth)) - elif key is None: - references.append("") - index = end + 1 - continue - if line.startswith("${{", index): - expression_end = line.find("}}", index + 3) - if expression_end < 0: - references.append("") - return references, depth - index = expression_end + 2 - continue - if char in ("{", "["): - depth += 1 - index += 1 - continue - if char in ("}", "]"): - depth = max(0, depth - 1) - index += 1 - continue - if depth > 0 and char == "?": - references.append("") - index += 1 - continue - if depth > 0 and char in "!&*": - references.append("") - index += 1 - continue - if depth > 0 and (char.isalpha() or char == "_"): - end = index + 1 - while end < len(line) and (line[end].isalnum() or line[end] in "_-"): - end += 1 - after_key = end - while after_key < len(line) and line[after_key].isspace(): - after_key += 1 - if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":": - references.append(_flow_value(line, after_key + 1, depth)) - index = end - continue - index += 1 - return references, depth +def iter_action_files(directory: Path = ACTIONS): + """Local composite actions run in the same trust boundary as the workflows.""" + yield from sorted((*directory.glob("*/action.yml"), *directory.glob("*/action.yaml"))) def iter_uses_references(path: Path): - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content: list[str] = [] - explicit_uses_line = None - flow_start_line = None - flow_depth = 0 - for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - stripped = raw_line.lstrip() - indent = len(raw_line) - len(stripped) - if block_parent_indent is not None: - if not stripped: - if block_uses_line is not None: - block_uses_content.append("") - continue - if indent <= block_parent_indent: - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content = [] - elif block_content_indent is None: - block_content_indent = indent - if block_uses_line is not None: - block_uses_content.append(raw_line[block_content_indent:]) - continue - elif indent >= block_content_indent: - if block_uses_line is not None: - block_uses_content.append(raw_line[block_content_indent:]) - continue - else: - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content = [] - if stripped.startswith("#") or not stripped: - continue - active_line = _strip_yaml_comment(raw_line) - if explicit_uses_line is not None: - explicit_value = EXPLICIT_VALUE_RE.match(active_line) - if explicit_value is None: - yield explicit_uses_line, "" - else: - value = explicit_value.group("value").strip() - if BLOCK_SCALAR_VALUE_RE.fullmatch(value): - block_parent_indent = indent - block_content_indent = None - block_uses_line = explicit_uses_line - block_uses_content = [] - else: - yield explicit_uses_line, _unquote(value) - explicit_uses_line = None - continue - explicit_uses_line = None - explicit_key = EXPLICIT_KEY_RE.match(active_line) - if explicit_key: - if _unquote(explicit_key.group("key")) == "uses": - explicit_uses_line = line_number - continue - if re.match(r"^\s*(?:-\s*)?\?", active_line): - yield line_number, "" - continue - if _has_unsupported_block_mapping_key(active_line): - yield line_number, "" - continue - match = MAPPING_RE.match(active_line) if flow_depth == 0 else None - if match: - key = _unquote(match.group("key")) - value = match.group("value").strip() - if BLOCK_SCALAR_VALUE_RE.fullmatch(value): - block_parent_indent = indent - block_content_indent = None - if key == "uses": - block_uses_line = line_number - block_uses_content = [] - continue - if key == "uses": - yield line_number, _unquote(value) - if BLOCK_SCALAR_RE.search(raw_line): - block_parent_indent = indent - block_content_indent = None - continue - previous_flow_depth = flow_depth - flow_references, flow_depth = _flow_uses_references(active_line, flow_depth) - for reference in flow_references: - yield line_number, reference - if previous_flow_depth == 0 and flow_depth > 0: - flow_start_line = line_number - elif flow_depth == 0: - flow_start_line = None - if explicit_uses_line is not None: - yield explicit_uses_line, "" - if flow_depth > 0: - yield flow_start_line or 1, "" - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() + return workflow_yaml.iter_uses_references(path.read_text(encoding="utf-8")) def validate_reference(reference: str) -> str | None: @@ -349,7 +34,11 @@ def validate_reference(reference: str) -> str | None: def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) - paths = [Path(value) for value in args] if args else list(iter_workflow_files()) + paths = ( + [Path(value) for value in args] + if args + else [*workflow_yaml.iter_workflow_files(WORKFLOWS), *iter_action_files()] + ) violations = [] for path in paths: for line_number, reference in iter_uses_references(path): diff --git a/scripts/check_workflow_security.py b/scripts/check_workflow_security.py index 03811133..cf6f9703 100755 --- a/scripts/check_workflow_security.py +++ b/scripts/check_workflow_security.py @@ -5,12 +5,13 @@ from pathlib import Path import re import sys +from workflow_yaml import iter_uses_references, iter_workflow_files, scalar + ROOT = Path(__file__).resolve().parents[1] WORKFLOWS = ROOT / ".github" / "workflows" CI_WORKFLOW = Path(".github/workflows/ci.yml") FULL_SHA = re.compile(r"[0-9a-f]{40}") -USES_LINE = re.compile(r"^\s*(?:-\s+)?(?:uses|'uses'|\"uses\")\s*:\s*(.*?)\s*$") def _active_text(text: str) -> str: @@ -19,34 +20,6 @@ def _active_text(text: str) -> str: ) -def _scalar(value: str) -> str: - """Remove an inline YAML comment and matching scalar quotes.""" - quote: str | None = None - escaped = False - end = len(value) - for index, character in enumerate(value): - if escaped: - escaped = False - continue - if quote == '"' and character == "\\": - escaped = True - continue - if character in ("'", '"'): - if quote is None: - quote = character - elif quote == character: - quote = None - elif character == "#" and quote is None and ( - index == 0 or value[index - 1].isspace() - ): - end = index - break - result = value[:end].strip() - if len(result) >= 2 and result[0] == result[-1] and result[0] in ("'", '"'): - return result[1:-1] - return result - - def _has_trigger(text: str, event: str) -> bool: lines = text.splitlines() on_key = r"""(?:on|'on'|"on")""" @@ -61,7 +34,7 @@ def _has_trigger(text: str, event: str) -> bool: match = re.fullmatch(rf"{on_key}:\s*(.+?)\s*", line) if match is not None and re.search( rf"""(?:^|[\[{{,\s])['"]?{re.escape(event)}['"]?(?:$|[\]}},\s:])""", - _scalar(match.group(1)), + scalar(match.group(1)), ): return True return False @@ -104,7 +77,7 @@ def _check_fail_open(path: Path, text: str) -> list[str]: r"""^\s*(?:-\s+)?(?:continue-on-error|'continue-on-error'|"continue-on-error")\s*:\s*(.*?)\s*$""", line, ) - if match is not None and _scalar(match.group(1)).lower() != "false": + if match is not None and scalar(match.group(1)).lower() != "false": errors.append(f"{path}:{line_number}: continue-on-error must remain false") if re.search(r"\|\|\s*true(?:\s|$)", line): errors.append(f"{path}:{line_number}: command must not suppress failure with || true") @@ -121,26 +94,22 @@ def check_workflow(path: Path, text: str) -> list[str]: pull_request = _has_trigger(active, "pull_request") lines = active.splitlines() - for line_index, line in enumerate(lines): - match = USES_LINE.match(line) - if match is None: - continue - reference = _scalar(match.group(1)) + for line_number, reference in iter_uses_references(active): if reference.startswith("./"): continue action, separator, ref = reference.rpartition("@") if not separator or not action or FULL_SHA.fullmatch(ref) is None: errors.append( - f"{path}:{line_index + 1}: external action must use a full commit SHA: {reference}" + f"{path}:{line_number}: external action must use a full commit SHA: {reference}" ) if pull_request and action == "actions/checkout": - step = _step_block(lines, line_index) + step = _step_block(lines, line_number - 1) if re.search( r"""(?mi)^\s+(?:persist-credentials|'persist-credentials'|"persist-credentials")\s*:\s*['"]?false['"]?\s*(?:#.*)?$""", step, ) is None: errors.append( - f"{path}:{line_index + 1}: pull-request checkout must discard GitHub credentials" + f"{path}:{line_number}: pull-request checkout must discard GitHub credentials" ) if re.search( @@ -163,7 +132,7 @@ def check_workflow(path: Path, text: str) -> list[str]: def main() -> int: errors: list[str] = [] - for path in sorted((*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml"))): + for path in iter_workflow_files(WORKFLOWS): errors.extend(check_workflow(path.relative_to(ROOT), path.read_text(encoding="utf-8"))) if errors: diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 4ed46376..a4bf1622 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -82,29 +82,7 @@ fi # 4. Workflow and script regression guards section "workflow and script guards" -if python3 scripts/check_build_workflow.py && - python3 scripts/test_check_build_workflow.py && - python3 scripts/check_apple_spm_locks.py && - python3 scripts/test_check_apple_spm_locks.py && - python3 scripts/check_workflow_security.py && - python3 scripts/test_check_workflow_security.py && - python3 scripts/check_workflow_action_pins.py && - python3 scripts/test_check_workflow_action_pins.py && - python3 scripts/check_container_image_pins.py && - python3 scripts/test_check_container_image_pins.py && - python3 scripts/verify_runtime_inputs.py && - python3 scripts/test_verify_runtime_inputs.py && - python3 scripts/test_fetch_tvos_engine.py && - python3 scripts/test_check_codegen.py && - python3 scripts/test_generate_relay_protocol.py && - python3 scripts/test_format_native.py && - python3 scripts/check_update_packages_workflow.py && - python3 scripts/test_pubspec_version.py && - python3 scripts/test_clean_translations.py && - python3 scripts/test_run_maestro.py && - python3 scripts/test_maestro_flow_contracts.py && - python3 scripts/test_maestro_jellyfin_proxy.py && - python3 scripts/test_check_icon_consistency.py; then +if bash scripts/ci_guard_checks.sh; then ok "workflow and script guards passed" else fail "workflow or script guard failed" diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh new file mode 100644 index 00000000..be182dbb --- /dev/null +++ b/scripts/ci_guard_checks.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Workflow and script regression guards. +# +# Single source of truth for the guard roster, shared by the "Verify workflow +# and script guards" step in .github/workflows/ci.yml and section 4 of +# scripts/ci_checks.sh. The checkers are named explicitly because a few of them +# belong to other jobs (check_bun_audit.py needs Bun, check_codegen.py runs via +# codegen.sh), but their regression tests are discovered by glob so a newly +# added scripts/test_*.py is picked up automatically instead of having to be +# remembered in two places. +set -euo pipefail +shopt -s nullglob + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +for checker in \ + scripts/check_build_workflow.py \ + scripts/check_apple_spm_locks.py \ + scripts/verify_runtime_inputs.py \ + scripts/check_workflow_security.py \ + scripts/check_workflow_action_pins.py \ + scripts/check_container_image_pins.py \ + scripts/check_update_packages_workflow.py; do + python3 "$checker" +done + +for guard_test in scripts/test_*.py; do + python3 "$guard_test" +done diff --git a/scripts/workflow_yaml.py b/scripts/workflow_yaml.py new file mode 100644 index 00000000..91abc154 --- /dev/null +++ b/scripts/workflow_yaml.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Shared YAML scanning for the GitHub Actions guard scripts. + +The guards deliberately avoid a YAML dependency, so the scalar plumbing and the +`uses:` scanner live here once rather than being re-implemented, with differing +rigor, in every checker. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +MAPPING_RE = re.compile( + r"""^\s*(?:-\s*)?(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P.*?)\s*$""" +) +EXPLICIT_KEY_RE = re.compile( + r"""^\s*(?:-\s*)?\?\s*(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$""" +) +EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P.*?)\s*$") +BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$") +BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$") +YAML_DOUBLE_ESCAPES = { + "0": "\0", + "a": "\a", + "b": "\b", + "t": "\t", + "\t": "\t", + "n": "\n", + "v": "\v", + "f": "\f", + "r": "\r", + "e": "\x1b", + " ": " ", + '"': '"', + "/": "/", + "\\": "\\", + "N": "\u0085", + "_": "\u00a0", + "L": "\u2028", + "P": "\u2029", +} + + +def iter_workflow_files(directory: Path): + yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml"))) + + +def job_block(text: str, name: str) -> str: + """Return the YAML block of a top-level job, or "" when it is absent.""" + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text + ) + return match.group(0) if match else "" + + +def strip_comment(value: str) -> str: + quote = None + escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + continue + if char == "\\" and quote == '"': + escaped = True + continue + if char in ("'", '"'): + if quote is None: + quote = char + elif quote == char: + quote = None + continue + if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + +def _decode_quoted_yaml_string(value: str) -> str | None: + if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'): + return None + if value[0] == "'": + return value[1:-1].replace("''", "'") + + decoded = [] + index = 1 + end = len(value) - 1 + while index < end: + char = value[index] + if char != "\\": + decoded.append(char) + index += 1 + continue + index += 1 + if index >= end: + return None + escape = value[index] + if escape in YAML_DOUBLE_ESCAPES: + decoded.append(YAML_DOUBLE_ESCAPES[escape]) + index += 1 + continue + width = {"x": 2, "u": 4, "U": 8}.get(escape) + if width is None or index + width >= end: + return None + digits = value[index + 1 : index + 1 + width] + if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits): + return None + try: + decoded.append(chr(int(digits, 16))) + except ValueError: + return None + index += width + 1 + return "".join(decoded) + + +def unquote(value: str) -> str: + decoded = _decode_quoted_yaml_string(value) + return value if decoded is None else decoded + + +def scalar(value: str) -> str: + """Read a single-line YAML scalar: drop an inline comment and its quotes.""" + return unquote(strip_comment(value).strip()) + + +def _flow_value(line: str, start: int, mapping_depth: int) -> str: + index = start + quote = None + escaped = False + depth = mapping_depth + while index < len(line): + char = line[index] + if escaped: + escaped = False + elif char == "\\" and quote == '"': + escaped = True + elif quote is not None: + if char == quote: + quote = None + elif char in ("'", '"'): + quote = char + elif char in ("{", "["): + depth += 1 + elif char in ("}", "]"): + if depth == mapping_depth: + break + depth -= 1 + elif char == "," and depth == mapping_depth: + break + index += 1 + return unquote(line[start:index].strip()) + + +def _has_unsupported_block_mapping_key(line: str) -> bool: + candidate = line.lstrip() + if candidate.startswith("-") and not candidate.startswith("---"): + candidate = candidate[1:].lstrip() + if not candidate: + return False + if candidate[0] in "!&*": + return True + if candidate[0] not in ("'", '"'): + return False + + quote = candidate[0] + escaped = False + index = 1 + while index < len(candidate): + char = candidate[index] + if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'": + index += 2 + continue + if escaped: + escaped = False + elif quote == '"' and char == "\\": + escaped = True + elif char == quote: + return False + index += 1 + return True + + +def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]: + references = [] + depth = initial_depth + index = 0 + while index < len(line): + char = line[index] + if char in ("'", '"'): + quote = char + escaped = False + end = index + 1 + while end < len(line): + quoted_char = line[end] + if escaped: + escaped = False + elif quoted_char == "\\" and quote == '"': + escaped = True + elif quoted_char == quote: + break + end += 1 + if end >= len(line): + if depth > 0: + references.append("") + return references, depth + key = _decode_quoted_yaml_string(line[index : end + 1]) + after_key = end + 1 + while after_key < len(line) and line[after_key].isspace(): + after_key += 1 + if depth > 0 and after_key < len(line) and line[after_key] == ":": + if key == "uses": + references.append(_flow_value(line, after_key + 1, depth)) + elif key is None: + references.append("") + index = end + 1 + continue + if line.startswith("${{", index): + expression_end = line.find("}}", index + 3) + if expression_end < 0: + references.append("") + return references, depth + index = expression_end + 2 + continue + if char in ("{", "["): + depth += 1 + index += 1 + continue + if char in ("}", "]"): + depth = max(0, depth - 1) + index += 1 + continue + if depth > 0 and char == "?": + references.append("") + index += 1 + continue + if depth > 0 and char in "!&*": + references.append("") + index += 1 + continue + if depth > 0 and (char.isalpha() or char == "_"): + end = index + 1 + while end < len(line) and (line[end].isalnum() or line[end] in "_-"): + end += 1 + after_key = end + while after_key < len(line) and line[after_key].isspace(): + after_key += 1 + if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":": + references.append(_flow_value(line, after_key + 1, depth)) + index = end + continue + index += 1 + return references, depth + + +def iter_uses_references(text: str): + """Yield (line number, reference) for every `uses:` value in a workflow. + + Constructs the scanner cannot resolve are yielded as `<...>` placeholders so + that callers fail closed rather than silently skipping an unpinned action. + """ + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content: list[str] = [] + explicit_uses_line = None + flow_start_line = None + flow_depth = 0 + for line_number, raw_line in enumerate(text.splitlines(), 1): + stripped = raw_line.lstrip() + indent = len(raw_line) - len(stripped) + if block_parent_indent is not None: + if not stripped: + if block_uses_line is not None: + block_uses_content.append("") + continue + if indent <= block_parent_indent: + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content = [] + elif block_content_indent is None: + block_content_indent = indent + if block_uses_line is not None: + block_uses_content.append(raw_line[block_content_indent:]) + continue + elif indent >= block_content_indent: + if block_uses_line is not None: + block_uses_content.append(raw_line[block_content_indent:]) + continue + else: + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content = [] + if stripped.startswith("#") or not stripped: + continue + active_line = strip_comment(raw_line) + if explicit_uses_line is not None: + explicit_value = EXPLICIT_VALUE_RE.match(active_line) + if explicit_value is None: + yield explicit_uses_line, "" + else: + value = explicit_value.group("value").strip() + if BLOCK_SCALAR_VALUE_RE.fullmatch(value): + block_parent_indent = indent + block_content_indent = None + block_uses_line = explicit_uses_line + block_uses_content = [] + else: + yield explicit_uses_line, unquote(value) + explicit_uses_line = None + continue + explicit_uses_line = None + explicit_key = EXPLICIT_KEY_RE.match(active_line) + if explicit_key: + if unquote(explicit_key.group("key")) == "uses": + explicit_uses_line = line_number + continue + if re.match(r"^\s*(?:-\s*)?\?", active_line): + yield line_number, "" + continue + if _has_unsupported_block_mapping_key(active_line): + yield line_number, "" + continue + match = MAPPING_RE.match(active_line) if flow_depth == 0 else None + if match: + key = unquote(match.group("key")) + value = match.group("value").strip() + if BLOCK_SCALAR_VALUE_RE.fullmatch(value): + block_parent_indent = indent + block_content_indent = None + if key == "uses": + block_uses_line = line_number + block_uses_content = [] + continue + if key == "uses": + yield line_number, unquote(value) + if BLOCK_SCALAR_RE.search(raw_line): + block_parent_indent = indent + block_content_indent = None + continue + previous_flow_depth = flow_depth + flow_references, flow_depth = _flow_uses_references(active_line, flow_depth) + for reference in flow_references: + yield line_number, reference + if previous_flow_depth == 0 and flow_depth > 0: + flow_start_line = line_number + elif flow_depth == 0: + flow_start_line = None + if explicit_uses_line is not None: + yield explicit_uses_line, "" + if flow_depth > 0: + yield flow_start_line or 1, "" + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() diff --git a/server/artifact_store.go b/server/artifact_store.go new file mode 100644 index 00000000..1c11f33f --- /dev/null +++ b/server/artifact_store.go @@ -0,0 +1,434 @@ +package main + +import ( + "crypto/rand" + "errors" + "io/fs" + "log" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// --- Artifact store --- +// +// Uploaded logs and posters are the same on-disk artifact store: a flat +// directory of `` files whose mtime carries the creation time, written +// through a temp file, capped by a quota and swept for expiry. Files that +// cannot be deleted become pending debt so a failed removal never silently +// frees quota. artifactStore implements all of that once; the two flavours +// differ only in the policy fields below. + +type artifactRemovalError struct { + err error +} + +func (e *artifactRemovalError) Error() string { + return "artifact removal failed" +} + +func (e *artifactRemovalError) Unwrap() error { + return e.err +} + +var errArtifactOutsideStore = errors.New("artifact path outside store") + +func classifyRemovalError(err error) error { + if err == nil || errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func removeArtifact(removeFile func(string) error, root, path string) error { + err := classifyRemovalError(removeFile(path)) + if err == nil { + return nil + } + if !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) { + return &artifactRemovalError{err: err} + } + if err := removeConfinedDirectory(root, path); err != nil { + return &artifactRemovalError{err: err} + } + return nil +} + +func removeConfinedDirectory(root, path string) error { + info, err := os.Lstat(path) + if err != nil { + return classifyRemovalError(err) + } + if !info.IsDir() { + return syscall.ENOTDIR + } + + rootPath, err := filepath.Abs(root) + if err != nil { + return errArtifactOutsideStore + } + rootPath, err = filepath.EvalSymlinks(rootPath) + if err != nil { + return errArtifactOutsideStore + } + artifactPath, err := filepath.Abs(path) + if err != nil { + return errArtifactOutsideStore + } + artifactPath, err = filepath.EvalSymlinks(artifactPath) + if err != nil { + return errArtifactOutsideStore + } + relative, err := filepath.Rel(rootPath, artifactPath) + if err != nil || + relative == "." || + relative == ".." || + filepath.IsAbs(relative) || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errArtifactOutsideStore + } + return os.RemoveAll(artifactPath) +} + +const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" + +func generateID(length int) string { + b := make([]byte, length) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) + b[i] = idChars[n.Int64()] + } + return string(b) +} + +func validID(id string, length int) bool { + if len(id) != length { + return false + } + for _, ch := range id { + if !strings.ContainsRune(idChars, ch) { + return false + } + } + return true +} + +// pendingRemoval is an artifact file that could not be deleted yet. Its size is +// unknown when the file could not be stat'ed or is not a regular file. +type pendingRemoval struct { + size int64 + sizeKnown bool +} + +type artifactEntry struct { + Filename string + Size int64 + ContentType string + CreatedAt time.Time + ExpiresAt time.Time +} + +type artifactStore struct { + entries map[string]artifactEntry + pendingRemovals map[string]pendingRemoval + dir string + name string // log prefix, e.g. "logs" + maxAge time.Duration + removeFile func(string) error + + // Policy. generateID is a field so tests can force ID collisions. + generateID func() string + idFromFilename func(filename string) (string, bool) + // acceptLoaded reports whether a file found on disk is a usable artifact + // and returns the content type recorded for it. + acceptLoaded func(filename string, size int64) (string, bool) + // limit caps accountedLocked, measured in the units cost returns: + // one per artifact for logs, bytes for posters. + limit int64 + cost func(size int64) int64 + pendingCost func(pending pendingRemoval) int64 + // evictToFit admits a new artifact by evicting the oldest live ones; + // stores that leave it false reject the upload with errFull instead. + evictToFit bool + // retryKnownDebtOnPut retries only debt whose size is accounted, leaving + // unknown debt to periodic cleanup. + retryKnownDebtOnPut bool + errFull error + + used int64 // accounted cost of live entries + pendingDebt int64 // accounted cost of pending removals + unknownPending int // pending removals kept out of the quota + startupErr error + mu sync.RWMutex +} + +func (as *artifactStore) filePath(filename string) string { + return filepath.Join(as.dir, filename) +} + +func (as *artifactStore) accountedLocked() int64 { + return as.used + as.pendingDebt +} + +func (as *artifactStore) loadExisting(now time.Time) error { + as.mu.Lock() + defer as.mu.Unlock() + + files, err := os.ReadDir(as.dir) + if err != nil { + log.Printf("%s: failed to read dir %s: %v", as.name, as.dir, err) + return nil + } + var removalErr error + drop := func(file fs.DirEntry) { + size, sizeKnown := dirEntrySize(file) + removalErr = errors.Join(removalErr, as.removeUntrackedLocked(file.Name(), size, sizeKnown)) + } + for _, file := range files { + filename := file.Name() + if file.IsDir() || strings.HasSuffix(filename, ".tmp") { + drop(file) + continue + } + id, ok := as.idFromFilename(filename) + if !ok { + drop(file) + continue + } + info, infoErr := file.Info() + if infoErr != nil || !info.Mode().IsRegular() { + drop(file) + continue + } + contentType, ok := as.acceptLoaded(filename, info.Size()) + if !ok { + drop(file) + continue + } + // Several extensions can map to one id; keep the first and drop the rest. + if _, duplicate := as.entries[id]; duplicate { + drop(file) + continue + } + createdAt := info.ModTime() + as.entries[id] = artifactEntry{ + Filename: filename, + Size: info.Size(), + ContentType: contentType, + CreatedAt: createdAt, + ExpiresAt: createdAt.Add(as.maxAge), + } + as.used += as.cost(info.Size()) + } + removalErr = errors.Join(removalErr, as.cleanupExpiredLocked(now)) + removalErr = errors.Join(removalErr, as.evictOldestLocked(0)) + return removalErr +} + +// put writes data as `` once the quota allows it. +func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time) (string, artifactEntry, error) { + as.mu.Lock() + defer as.mu.Unlock() + + size := int64(len(data)) + cost := as.cost(size) + // Reclaim what the quota can get back without touching live entries. + // Removal failures stay accounted as debt instead of blocking the write. + _ = as.retryPendingLocked(as.retryKnownDebtOnPut) + _ = as.cleanupExpiredLocked(now) + var headroom int64 + if as.evictToFit { + headroom = cost + } + if err := as.evictOldestLocked(headroom); err != nil { + return "", artifactEntry{}, err + } + if as.accountedLocked()+cost > as.limit { + return "", artifactEntry{}, as.errFull + } + + id := as.generateID() + for { + if _, exists := as.entries[id]; !exists { + if _, err := os.Stat(as.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { + break + } + } + id = as.generateID() + } + + filename := id + ext + path := as.filePath(filename) + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + as.cleanupFailedTempLocked(tmpPath) + return "", artifactEntry{}, err + } + if err := os.Rename(tmpPath, path); err != nil { + as.cleanupFailedTempLocked(tmpPath) + return "", artifactEntry{}, err + } + _ = os.Chtimes(path, now, now) + + entry := artifactEntry{ + Filename: filename, + Size: size, + ContentType: contentType, + CreatedAt: now, + ExpiresAt: now.Add(as.maxAge), + } + as.entries[id] = entry + as.used += cost + return id, entry, nil +} + +// lookupEntry returns the live entry for id, dropping it when it has expired. +// match, when set, rejects entries the caller did not ask for before expiry is +// considered, so a mismatched request never triggers a removal. +func (as *artifactStore) lookupEntry( + id string, + now time.Time, + match func(artifactEntry) bool, +) (artifactEntry, bool, error) { + as.mu.Lock() + defer as.mu.Unlock() + entry, ok := as.entries[id] + if !ok || (match != nil && !match(entry)) { + return artifactEntry{}, false, nil + } + if !now.Before(entry.ExpiresAt) { + if err := as.deleteEntryLocked(id); err != nil { + return artifactEntry{}, false, err + } + return artifactEntry{}, false, nil + } + return entry, true, nil +} + +func (as *artifactStore) cleanup(now time.Time) error { + as.mu.Lock() + defer as.mu.Unlock() + return as.cleanupLocked(now) +} + +func (as *artifactStore) cleanupLocked(now time.Time) error { + removalErr := as.retryPendingLocked(false) + removalErr = errors.Join(removalErr, as.cleanupExpiredLocked(now)) + return errors.Join(removalErr, as.evictOldestLocked(0)) +} + +func (as *artifactStore) cleanupExpiredLocked(now time.Time) error { + var removalErr error + for id, entry := range as.entries { + if !now.Before(entry.ExpiresAt) { + removalErr = errors.Join(removalErr, as.deleteEntryLocked(id)) + } + } + return removalErr +} + +// evictOldestLocked deletes oldest-first until headroom more cost units fit. +func (as *artifactStore) evictOldestLocked(headroom int64) error { + for as.accountedLocked()+headroom > as.limit && len(as.entries) > 0 { + var oldestID string + var oldest artifactEntry + for id, entry := range as.entries { + if oldestID == "" || entry.CreatedAt.Before(oldest.CreatedAt) { + oldestID = id + oldest = entry + } + } + if err := as.deleteEntryLocked(oldestID); err != nil { + return err + } + } + return nil +} + +func (as *artifactStore) deleteEntryLocked(id string) error { + entry, ok := as.entries[id] + if !ok { + return nil + } + if err := removeArtifact(as.removeFile, as.dir, as.filePath(entry.Filename)); err != nil { + return err + } + delete(as.entries, id) + as.used -= as.cost(entry.Size) + return nil +} + +// removeUntrackedLocked deletes a file the index does not own, recording it as +// pending debt when the removal fails. +func (as *artifactStore) removeUntrackedLocked(filename string, size int64, sizeKnown bool) error { + if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil { + as.addPendingLocked(filename, size, sizeKnown) + return err + } + as.dropPendingLocked(filename) + return nil +} + +func (as *artifactStore) retryPendingLocked(knownDebtOnly bool) error { + var removalErr error + for filename, pending := range as.pendingRemovals { + if knownDebtOnly && !pending.sizeKnown { + continue + } + if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil { + removalErr = errors.Join(removalErr, err) + continue + } + as.dropPendingLocked(filename) + } + return removalErr +} + +func (as *artifactStore) cleanupFailedTempLocked(tmpPath string) { + size, sizeKnown := fileSize(tmpPath) + _ = as.removeUntrackedLocked(filepath.Base(tmpPath), size, sizeKnown) +} + +func (as *artifactStore) addPendingLocked(filename string, size int64, sizeKnown bool) { + if _, exists := as.pendingRemovals[filename]; exists { + return + } + pending := pendingRemoval{size: size, sizeKnown: sizeKnown} + as.pendingRemovals[filename] = pending + as.pendingDebt += as.pendingCost(pending) + if !sizeKnown { + as.unknownPending++ + } +} + +func (as *artifactStore) dropPendingLocked(filename string) { + pending, exists := as.pendingRemovals[filename] + if !exists { + return + } + delete(as.pendingRemovals, filename) + as.pendingDebt -= as.pendingCost(pending) + if !pending.sizeKnown { + as.unknownPending-- + } +} + +func dirEntrySize(file fs.DirEntry) (int64, bool) { + info, err := file.Info() + if err != nil || !info.Mode().IsRegular() { + return 0, false + } + return info.Size(), true +} + +func fileSize(path string) (int64, bool) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return 0, false + } + return info.Size(), true +} diff --git a/server/main.go b/server/main.go index 8ad3c545..c2a0acb7 100644 --- a/server/main.go +++ b/server/main.go @@ -13,7 +13,6 @@ import ( "io" "io/fs" "log" - "math/big" "net" "net/http" "os" @@ -383,100 +382,17 @@ func (r *Room) sendFrom(senderID string, sender *Client, targetID string, msg se } // --- Log store --- -type artifactRemovalError struct { - err error -} -func (e *artifactRemovalError) Error() string { - return "artifact removal failed" -} - -func (e *artifactRemovalError) Unwrap() error { - return e.err -} - -var errArtifactOutsideStore = errors.New("artifact path outside store") - -func classifyRemovalError(err error) error { - if err == nil || errors.Is(err, fs.ErrNotExist) { - return nil - } - return err -} - -func removeArtifact(removeFile func(string) error, root, path string) error { - err := classifyRemovalError(removeFile(path)) - if err == nil { - return nil - } - if !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) { - return &artifactRemovalError{err: err} - } - if err := removeConfinedDirectory(root, path); err != nil { - return &artifactRemovalError{err: err} - } - return nil -} - -func removeConfinedDirectory(root, path string) error { - info, err := os.Lstat(path) - if err != nil { - return classifyRemovalError(err) - } - if !info.IsDir() { - return syscall.ENOTDIR - } - - rootPath, err := filepath.Abs(root) - if err != nil { - return errArtifactOutsideStore - } - rootPath, err = filepath.EvalSymlinks(rootPath) - if err != nil { - return errArtifactOutsideStore - } - artifactPath, err := filepath.Abs(path) - if err != nil { - return errArtifactOutsideStore - } - artifactPath, err = filepath.EvalSymlinks(artifactPath) - if err != nil { - return errArtifactOutsideStore - } - relative, err := filepath.Rel(rootPath, artifactPath) - if err != nil || - relative == "." || - relative == ".." || - filepath.IsAbs(relative) || - strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return errArtifactOutsideStore - } - return os.RemoveAll(artifactPath) -} - -type pendingRemoval struct { - size int64 - sizeKnown bool -} - -type logEntry struct { - Size int - CreatedAt time.Time - ExpiresAt time.Time -} +const logFileExt = ".log" var errLogStoreFull = errors.New("log store full") +// logStore keeps diagnostic uploads capped by artifact count; a full store +// rejects new uploads rather than evicting logs someone may still be reading. type logStore struct { - entries map[string]logEntry - pendingRemovals map[string]pendingRemoval + artifactStore rateLimit map[string]time.Time // IP -> last upload time failedLookupRate map[string]*rateLimiter - dir string - generateID func() string - removeFile func(string) error - startupErr error - mu sync.RWMutex } func newLogStore(dir string) *logStore { @@ -488,31 +404,32 @@ func newLogStoreWithRemover(dir string, removeFile func(string) error) *logStore log.Fatalf("failed to create log dir %s: %v", dir, err) } ls := &logStore{ - entries: make(map[string]logEntry), - pendingRemovals: make(map[string]pendingRemoval), + artifactStore: artifactStore{ + entries: make(map[string]artifactEntry), + pendingRemovals: make(map[string]pendingRemoval), + dir: dir, + name: "logs", + maxAge: logMaxAge, + removeFile: removeFile, + generateID: generateLogID, + idFromFilename: logIDFromFilename, + acceptLoaded: func(_ string, size int64) (string, bool) { + return "", size > 0 && size <= maxLogSize + }, + limit: maxLogEntries, + cost: func(int64) int64 { return 1 }, + pendingCost: func(pendingRemoval) int64 { return 1 }, + errFull: errLogStoreFull, + }, rateLimit: make(map[string]time.Time), failedLookupRate: make(map[string]*rateLimiter), - dir: dir, - generateID: generateLogID, - removeFile: removeFile, } ls.startupErr = ls.loadExisting(time.Now()) return ls } func (ls *logStore) filePath(id string) string { - return filepath.Join(ls.dir, id+".log") -} - -const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" - -func generateID(length int) string { - b := make([]byte, length) - for i := range b { - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) - b[i] = idChars[n.Int64()] - } - return string(b) + return ls.artifactStore.filePath(id + logFileExt) } func generateLogID() string { @@ -520,149 +437,28 @@ func generateLogID() string { } func logIDFromFilename(filename string) (string, bool) { - if filepath.Ext(filename) != ".log" { + if filepath.Ext(filename) != logFileExt { return "", false } - id := strings.TrimSuffix(filename, ".log") + id := strings.TrimSuffix(filename, logFileExt) return id, validID(id, logIDLength) } -func (ls *logStore) loadExisting(now time.Time) error { - ls.mu.Lock() - defer ls.mu.Unlock() - - files, err := os.ReadDir(ls.dir) - if err != nil { - log.Printf("logs: failed to read dir %s: %v", ls.dir, err) - return nil - } - var removalErr error - for _, file := range files { - filename := file.Name() - if file.IsDir() || strings.HasSuffix(filename, ".tmp") { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - id, ok := logIDFromFilename(filename) - if !ok { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - info, infoErr := file.Info() - if infoErr != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxLogSize { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - createdAt := info.ModTime() - ls.entries[id] = logEntry{ - Size: int(info.Size()), - CreatedAt: createdAt, - ExpiresAt: createdAt.Add(logMaxAge), - } - } - removalErr = errors.Join(removalErr, ls.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ls.evictOldestLocked(maxLogEntries)) - return removalErr -} - -func (ls *logStore) removeUntrackedLocked(filename string) error { - if err := removeArtifact(ls.removeFile, ls.dir, filepath.Join(ls.dir, filename)); err != nil { - if _, exists := ls.pendingRemovals[filename]; !exists { - ls.pendingRemovals[filename] = pendingRemoval{} - } - return err - } - delete(ls.pendingRemovals, filename) - return nil -} - -func (ls *logStore) retryPendingLocked() error { - var removalErr error - for filename := range ls.pendingRemovals { - if err := removeArtifact(ls.removeFile, ls.dir, filepath.Join(ls.dir, filename)); err != nil { - removalErr = errors.Join(removalErr, err) - continue - } - delete(ls.pendingRemovals, filename) - } - return removalErr -} - -func (ls *logStore) cleanupFailedTempLocked(tmpPath string) { - _ = ls.removeUntrackedLocked(filepath.Base(tmpPath)) -} - -func (ls *logStore) artifactCountLocked() int { - return len(ls.entries) + len(ls.pendingRemovals) -} - -func (ls *logStore) store(data []byte, now time.Time) (string, logEntry, error) { +func (ls *logStore) store(data []byte, now time.Time) (string, artifactEntry, error) { if len(data) == 0 { - return "", logEntry{}, errors.New("empty log") + return "", artifactEntry{}, errors.New("empty log") } if len(data) > maxLogSize { - return "", logEntry{}, errors.New("log too large") + return "", artifactEntry{}, errors.New("log too large") } - - ls.mu.Lock() - defer ls.mu.Unlock() - _ = ls.retryPendingLocked() - _ = ls.cleanupExpiredLocked(now) - if err := ls.evictOldestLocked(maxLogEntries); err != nil { - return "", logEntry{}, err - } - if ls.artifactCountLocked() >= maxLogEntries { - return "", logEntry{}, errLogStoreFull - } - - id := ls.generateID() - for { - if _, exists := ls.entries[id]; !exists { - if _, err := os.Stat(ls.filePath(id)); errors.Is(err, fs.ErrNotExist) { - break - } - } - id = ls.generateID() - } - - path := ls.filePath(id) - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - ls.cleanupFailedTempLocked(tmpPath) - return "", logEntry{}, err - } - if err := os.Rename(tmpPath, path); err != nil { - ls.cleanupFailedTempLocked(tmpPath) - return "", logEntry{}, err - } - _ = os.Chtimes(path, now, now) - - entry := logEntry{ - Size: len(data), - CreatedAt: now, - ExpiresAt: now.Add(logMaxAge), - } - ls.entries[id] = entry - return id, entry, nil + return ls.put(data, logFileExt, "", now) } -func (ls *logStore) lookup(id string, now time.Time) (logEntry, bool, error) { +func (ls *logStore) lookup(id string, now time.Time) (artifactEntry, bool, error) { if !validID(id, logIDLength) { - return logEntry{}, false, nil + return artifactEntry{}, false, nil } - ls.mu.Lock() - defer ls.mu.Unlock() - entry, ok := ls.entries[id] - if !ok { - return logEntry{}, false, nil - } - if !now.Before(entry.ExpiresAt) { - if err := ls.deleteEntryLocked(id); err != nil { - return logEntry{}, false, err - } - return logEntry{}, false, nil - } - return entry, true, nil + return ls.lookupEntry(id, now, nil) } func (ls *logStore) allowFailedLookup(source string, now time.Time) bool { @@ -680,53 +476,10 @@ func (ls *logStore) allowFailedLookup(source string, now time.Time) bool { return limiter.allowAt(now) } -func (ls *logStore) cleanupExpiredLocked(now time.Time) error { - var removalErr error - for id, entry := range ls.entries { - if !now.Before(entry.ExpiresAt) { - removalErr = errors.Join(removalErr, ls.deleteEntryLocked(id)) - } - } - return removalErr -} - -func (ls *logStore) evictOldestLocked(limit int) error { - for ls.artifactCountLocked() > limit { - var oldestID string - var oldest logEntry - for id, entry := range ls.entries { - if oldestID == "" || entry.CreatedAt.Before(oldest.CreatedAt) { - oldestID = id - oldest = entry - } - } - if oldestID == "" { - return nil - } - if err := ls.deleteEntryLocked(oldestID); err != nil { - return err - } - } - return nil -} - -func (ls *logStore) deleteEntryLocked(id string) error { - if _, ok := ls.entries[id]; !ok { - return nil - } - if err := removeArtifact(ls.removeFile, ls.dir, ls.filePath(id)); err != nil { - return err - } - delete(ls.entries, id) - return nil -} - func (ls *logStore) cleanup(now time.Time) error { ls.mu.Lock() defer ls.mu.Unlock() - removalErr := ls.retryPendingLocked() - removalErr = errors.Join(removalErr, ls.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ls.evictOldestLocked(maxLogEntries)) + removalErr := ls.cleanupLocked(now) cleanupRateWindows(ls.rateLimit, now, logRateInterval) cleanupRateLimiters(ls.failedLookupRate, now, nil) return removalErr @@ -734,26 +487,12 @@ func (ls *logStore) cleanup(now time.Time) error { // --- Poster store --- -type posterEntry struct { - Filename string - Size int64 - ContentType string - CreatedAt time.Time - ExpiresAt time.Time -} +var errPosterStoreFull = errors.New("poster store full") +// posterStore caps shared posters by accounted bytes and evicts the oldest to +// admit a new upload. type posterStore struct { - entries map[string]posterEntry - pendingRemovals map[string]pendingRemoval - dir string - maxBytes int64 - maxAge time.Duration - totalBytes int64 - pendingBytes int64 - unknownPending int - removeFile func(string) error - startupErr error - mu sync.RWMutex + artifactStore } func newPosterStore(dir string, maxBytes int64, maxAge time.Duration) *posterStore { @@ -769,22 +508,37 @@ func newPosterStoreWithRemover( if err := os.MkdirAll(dir, 0755); err != nil { log.Fatalf("failed to create poster dir %s: %v", dir, err) } - ps := &posterStore{ - entries: make(map[string]posterEntry), + ps := &posterStore{artifactStore{ + entries: make(map[string]artifactEntry), pendingRemovals: make(map[string]pendingRemoval), dir: dir, - maxBytes: maxBytes, + name: "posters", maxAge: maxAge, removeFile: removeFile, - } + generateID: generatePosterID, + idFromFilename: posterIDFromFilename, + acceptLoaded: func(filename string, _ int64) (string, bool) { + return posterContentTypeForExt(filepath.Ext(filename)) + }, + limit: maxBytes, + cost: func(size int64) int64 { return size }, + pendingCost: func(pending pendingRemoval) int64 { + // Unknown debt cannot be sized safely, so it is kept out of the + // quota: a permanent directory or stat failure must not deny + // otherwise capacity-safe uploads. + if !pending.sizeKnown { + return 0 + } + return pending.size + }, + evictToFit: true, + retryKnownDebtOnPut: true, + errFull: errPosterStoreFull, + }} ps.startupErr = ps.loadExisting(time.Now()) return ps } -func (ps *posterStore) filePath(filename string) string { - return filepath.Join(ps.dir, filename) -} - func generatePosterID() string { return generateID(posterIDLength) } @@ -819,18 +573,6 @@ func posterContentTypeForExt(ext string) (string, bool) { } } -func validID(id string, length int) bool { - if len(id) != length { - return false - } - for _, ch := range id { - if !strings.ContainsRune(idChars, ch) { - return false - } - } - return true -} - func posterIDFromFilename(filename string) (string, bool) { if filename == "" || strings.ContainsAny(filename, `/\\`) { return "", false @@ -846,269 +588,29 @@ func posterIDFromFilename(filename string) (string, bool) { return id, true } -func (ps *posterStore) loadExisting(now time.Time) error { - ps.mu.Lock() - defer ps.mu.Unlock() - - files, err := os.ReadDir(ps.dir) - if err != nil { - log.Printf("posters: failed to read dir %s: %v", ps.dir, err) - return nil - } - var removalErr error - for _, file := range files { - filename := file.Name() - if file.IsDir() || strings.HasSuffix(filename, ".tmp") { - size, known := posterArtifactSize(file) - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, size, known), - ) - continue - } - id, ok := posterIDFromFilename(filename) - if !ok { - size, known := posterArtifactSize(file) - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, size, known), - ) - continue - } - info, infoErr := file.Info() - if infoErr != nil || !info.Mode().IsRegular() { - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, 0, false), - ) - continue - } - if _, duplicate := ps.entries[id]; duplicate { - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, info.Size(), true), - ) - continue - } - createdAt := info.ModTime() - contentType, _ := posterContentTypeForExt(filepath.Ext(filename)) - entry := posterEntry{ - Filename: filename, - Size: info.Size(), - ContentType: contentType, - CreatedAt: createdAt, - ExpiresAt: createdAt.Add(ps.maxAge), - } - ps.entries[id] = entry - ps.totalBytes += entry.Size - } - removalErr = errors.Join(removalErr, ps.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ps.evictOldestLocked(0)) - return removalErr -} - -func posterArtifactSize(file fs.DirEntry) (int64, bool) { - info, err := file.Info() - if err != nil || !info.Mode().IsRegular() { - return 0, false - } - return info.Size(), true -} - -func (ps *posterStore) addPendingLocked(filename string, size int64, known bool) { - if _, exists := ps.pendingRemovals[filename]; exists { - return - } - ps.pendingRemovals[filename] = pendingRemoval{size: size, sizeKnown: known} - if known { - ps.pendingBytes += size - } else { - ps.unknownPending++ - } -} - -func (ps *posterStore) removeUntrackedLocked(filename string, size int64, known bool) error { - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(filename)); err != nil { - ps.addPendingLocked(filename, size, known) - return err - } - return nil -} - -func (ps *posterStore) retryPendingLocked(knownOnly bool) error { - var removalErr error - for filename, pending := range ps.pendingRemovals { - if knownOnly && !pending.sizeKnown { - continue - } - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(filename)); err != nil { - removalErr = errors.Join(removalErr, err) - continue - } - delete(ps.pendingRemovals, filename) - if pending.sizeKnown { - ps.pendingBytes -= pending.size - } else { - ps.unknownPending-- - } - } - return removalErr -} - -func (ps *posterStore) cleanupFailedTempLocked(tmpPath string) { - if err := removeArtifact(ps.removeFile, ps.dir, tmpPath); err == nil { - return - } - info, statErr := os.Stat(tmpPath) - known := statErr == nil && info.Mode().IsRegular() - var size int64 - if known { - size = info.Size() - } - ps.addPendingLocked(filepath.Base(tmpPath), size, known) -} - -func (ps *posterStore) accountedBytesLocked() int64 { - return ps.totalBytes + ps.pendingBytes -} - -func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, posterEntry, error) { +func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, artifactEntry, error) { entrySize := int64(len(data)) if entrySize <= 0 { - return "", posterEntry{}, errors.New("empty poster") + return "", artifactEntry{}, errors.New("empty poster") } - if entrySize > ps.maxBytes { - return "", posterEntry{}, errors.New("poster exceeds store size") + if entrySize > ps.limit { + return "", artifactEntry{}, errors.New("poster exceeds store size") } ext, ok := posterExtForContentType(contentType) if !ok { - return "", posterEntry{}, errors.New("unsupported poster type") + return "", artifactEntry{}, errors.New("unsupported poster type") } - - ps.mu.Lock() - defer ps.mu.Unlock() - - // Known regular-file debt counts against quota and is retried on demand. - // Unknown artifacts are left to periodic cleanup: their size cannot be - // accounted safely, and a permanent directory or stat failure must not - // deny otherwise capacity-safe uploads. - _ = ps.retryPendingLocked(true) - _ = ps.cleanupExpiredLocked(now) - if err := ps.evictOldestLocked(entrySize); err != nil { - return "", posterEntry{}, err - } - if ps.accountedBytesLocked()+entrySize > ps.maxBytes { - return "", posterEntry{}, errors.New("poster store full") - } - - id := generatePosterID() - for { - if _, exists := ps.entries[id]; !exists { - if _, err := os.Stat(ps.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { - break - } - } - id = generatePosterID() - } - - filename := id + ext - path := ps.filePath(filename) - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - ps.cleanupFailedTempLocked(tmpPath) - return "", posterEntry{}, err - } - if err := os.Rename(tmpPath, path); err != nil { - ps.cleanupFailedTempLocked(tmpPath) - return "", posterEntry{}, err - } - _ = os.Chtimes(path, now, now) - - entry := posterEntry{ - Filename: filename, - Size: entrySize, - ContentType: strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), - CreatedAt: now, - ExpiresAt: now.Add(ps.maxAge), - } - ps.entries[id] = entry - ps.totalBytes += entry.Size - return id, entry, nil + return ps.put(data, ext, strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), now) } -func (ps *posterStore) lookup(filename string, now time.Time) (posterEntry, bool, error) { +func (ps *posterStore) lookup(filename string, now time.Time) (artifactEntry, bool, error) { id, ok := posterIDFromFilename(filename) if !ok { - return posterEntry{}, false, nil + return artifactEntry{}, false, nil } - - ps.mu.Lock() - defer ps.mu.Unlock() - entry, ok := ps.entries[id] - if !ok || entry.Filename != filename { - return posterEntry{}, false, nil - } - if !now.Before(entry.ExpiresAt) { - if err := ps.deleteEntryLocked(id); err != nil { - return posterEntry{}, false, err - } - return posterEntry{}, false, nil - } - return entry, true, nil -} - -func (ps *posterStore) cleanup(now time.Time) error { - ps.mu.Lock() - defer ps.mu.Unlock() - removalErr := ps.retryPendingLocked(false) - removalErr = errors.Join(removalErr, ps.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ps.evictOldestLocked(0)) - return removalErr -} - -func (ps *posterStore) cleanupExpiredLocked(now time.Time) error { - var removalErr error - for id, entry := range ps.entries { - if !now.Before(entry.ExpiresAt) { - removalErr = errors.Join(removalErr, ps.deleteEntryLocked(id)) - } - } - return removalErr -} - -func (ps *posterStore) evictOldestLocked(extraBytes int64) error { - for ps.accountedBytesLocked()+extraBytes > ps.maxBytes && len(ps.entries) > 0 { - var oldestID string - var oldest posterEntry - first := true - for id, entry := range ps.entries { - if first || entry.CreatedAt.Before(oldest.CreatedAt) { - oldestID = id - oldest = entry - first = false - } - } - if oldestID == "" { - return nil - } - if err := ps.deleteEntryLocked(oldestID); err != nil { - return err - } - } - return nil -} - -func (ps *posterStore) deleteEntryLocked(id string) error { - entry, ok := ps.entries[id] - if !ok { - return nil - } - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(entry.Filename)); err != nil { - return err - } - delete(ps.entries, id) - ps.totalBytes -= entry.Size - return nil + return ps.lookupEntry(id, now, func(entry artifactEntry) bool { + return entry.Filename == filename + }) } // --- Snapshotter (single-writer, debounced, atomic disk persistence) --- @@ -1626,7 +1128,7 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { } type lookupResult struct { - entry logEntry + entry artifactEntry data []byte status int message string @@ -1688,7 +1190,7 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Content-Length", strconv.Itoa(lookup.entry.Size)) + w.Header().Set("Content-Length", strconv.FormatInt(lookup.entry.Size, 10)) if written, err := w.Write(lookup.data); err != nil || written != len(lookup.data) { log.Printf("logs: response write failed") } diff --git a/server/main_test.go b/server/main_test.go index af7d5e42..657a6bd3 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -4316,7 +4316,7 @@ func snapshotPosterStore(t *testing.T, store *posterStore) (int, int64, []string t.Helper() store.mu.RLock() entryCount := len(store.entries) - totalBytes := store.totalBytes + totalBytes := store.used store.mu.RUnlock() files, err := os.ReadDir(store.dir) if err != nil { @@ -4701,7 +4701,7 @@ func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { ps.mu.RLock() _, hasFirst := ps.entries[id1] _, hasSecond := ps.entries[id2] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if hasFirst { @@ -4711,7 +4711,7 @@ func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { t.Fatal("newest poster should remain") } if total != int64(len(payload)) { - t.Fatalf("totalBytes=%d want %d", total, len(payload)) + t.Fatalf("stored bytes=%d want %d", total, len(payload)) } if _, err := os.Stat(ps.filePath(entry1.Filename)); !os.IsNotExist(err) { t.Fatalf("oldest file still exists or stat failed unexpectedly: %v", err) @@ -4735,13 +4735,13 @@ func TestPosterStoreCleanupExpiresOldPosters(t *testing.T) { ps.mu.RLock() _, exists := ps.entries[id] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if exists { t.Fatal("expired poster should have been removed") } if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } if _, err := os.Stat(ps.filePath(entry.Filename)); !os.IsNotExist(err) { t.Fatalf("expired file still exists or stat failed unexpectedly: %v", err) @@ -4790,7 +4790,7 @@ func TestLogStoreRemovalFailureRetainsEntryUntilRetry(t *testing.T) { } ls.mu.RLock() _, indexed := ls.entries[id] - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !indexed || artifacts != 1 { t.Fatalf("failed removal changed metadata: indexed=%v artifacts=%d", indexed, artifacts) @@ -4927,7 +4927,7 @@ func TestLogStoreTracksFailedTempCleanup(t *testing.T) { } ls.mu.RLock() _, pending := ls.pendingRemovals[filepath.Base(tmpPath)] - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !pending || artifacts != 1 { t.Fatalf("temp cleanup not tracked: pending=%v artifacts=%d", pending, artifacts) @@ -4974,7 +4974,7 @@ func TestLogStoreStartupReconcilesLiveAndPendingRemovals(t *testing.T) { ls.mu.RLock() _, live := ls.entries[expiredID] pending := len(ls.pendingRemovals) - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !live || pending != 2 || artifacts != 3 { t.Fatalf("startup accounting: live=%v pending=%d artifacts=%d", live, pending, artifacts) @@ -4992,7 +4992,7 @@ func TestLogStoreStartupReconcilesLiveAndPendingRemovals(t *testing.T) { } restarted := newLogStore(dir) restarted.mu.RLock() - restartedArtifacts := restarted.artifactCountLocked() + restartedArtifacts := restarted.accountedLocked() _, newLogRestored := restarted.entries[newID] restarted.mu.RUnlock() if restartedArtifacts != 1 || !newLogRestored { @@ -5084,14 +5084,14 @@ func TestPosterQuotaRemovalFailureDoesNotReclaimAccounting(t *testing.T) { if !errors.Is(err, fs.ErrPermission) { t.Fatalf("quota store error=%v want permission error", err) } - if newID != "" || newEntry != (posterEntry{}) { + if newID != "" || newEntry != (artifactEntry{}) { t.Fatalf("failed store returned success values: id=%q entry=%+v", newID, newEntry) } ps.mu.RLock() _, retained := ps.entries[oldID] - total := ps.totalBytes - pending := ps.pendingBytes - accounted := ps.accountedBytesLocked() + total := ps.used + pending := ps.pendingDebt + accounted := ps.accountedLocked() ps.mu.RUnlock() if !retained || total != int64(len(payload)) || pending != 0 { t.Fatalf("failed eviction accounting: retained=%v total=%d pending=%d", retained, total, pending) @@ -5112,8 +5112,8 @@ func TestPosterQuotaRemovalFailureDoesNotReclaimAccounting(t *testing.T) { t.Fatalf("old poster remove calls=%d want 2", remover.callCount(oldPath)) } ps.mu.RLock() - accounted = ps.accountedBytesLocked() - total = ps.totalBytes + accounted = ps.accountedLocked() + total = ps.used ps.mu.RUnlock() if total != int64(len(payload)) || regularFileBytes(t, dir) != accounted { t.Fatalf("retry accounting: total=%d accounted=%d physical=%d", total, accounted, regularFileBytes(t, dir)) @@ -5142,7 +5142,7 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { } ps.mu.RLock() _, retained := ps.entries[id] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if !retained || total != entry.Size { t.Fatalf("failed expiry accounting: retained=%v total=%d", retained, total) @@ -5159,10 +5159,10 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { t.Fatalf("remove calls=%d want 2", remover.callCount(path)) } ps.mu.RLock() - total = ps.totalBytes + total = ps.used ps.mu.RUnlock() if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } }) @@ -5194,10 +5194,10 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { t.Fatalf("remove calls=%d want 1", remover.callCount(path)) } ps.mu.RLock() - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } }) } @@ -5219,8 +5219,8 @@ func TestPosterStoreKnownCleanupDebtConsumesCapacityAndRetries(t *testing.T) { t.Fatal("upload exceeded capacity after known stale bytes were accounted") } ps.mu.RLock() - pendingBytes := ps.pendingBytes - accountedBytes := ps.accountedBytesLocked() + pendingBytes := ps.pendingDebt + accountedBytes := ps.accountedLocked() ps.mu.RUnlock() if pendingBytes != 4 || accountedBytes != 4 { t.Fatalf("known debt accounting: pending=%d accounted=%d, want 4", pendingBytes, accountedBytes) @@ -5236,7 +5236,7 @@ func TestPosterStoreKnownCleanupDebtConsumesCapacityAndRetries(t *testing.T) { t.Fatalf("stored entry size=%d, want 2", entry.Size) } ps.mu.RLock() - pendingBytes = ps.pendingBytes + pendingBytes = ps.pendingDebt ps.mu.RUnlock() if pendingBytes != 0 { t.Fatalf("known debt remained after successful retry: %d bytes", pendingBytes) @@ -5389,7 +5389,7 @@ func TestPosterHandlerRejectsUploadWhenQuotaRemovalFails(t *testing.T) { } posters.mu.RLock() _, retained := posters.entries[oldID] - total := posters.totalBytes + total := posters.used posters.mu.RUnlock() if !retained || total != int64(len(payload)) { t.Fatalf("failed upload changed old poster: retained=%v total=%d", retained, total) diff --git a/shared/mpv/mpv_player_common.h b/shared/mpv/mpv_player_common.h index 257cd345..9dd6125c 100644 --- a/shared/mpv/mpv_player_common.h +++ b/shared/mpv/mpv_player_common.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -105,6 +106,210 @@ class AsyncRequestRegistry { std::mutex mutex_; }; +// Every libmpv async submission follows the same shape: register the callback, +// hand the request to mpv, and roll back when the submission fails. A negative +// result means the request never reached mpv, so nothing will ever complete it +// and the callback has to be taken back and failed inline. The uninitialized +// handle guard stays with the callers: they own the lifecycle flags and the +// error code they report when the handle is gone. +inline void SubmitCommandAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::vector& args, StatusCallback callback) { + std::vector c_args; + c_args.reserve(args.size() + 1); + for (const auto& arg : args) { + c_args.push_back(arg.c_str()); + } + c_args.push_back(nullptr); + + const uint64_t request_id = callback ? requests.RegisterStatus(std::move(callback)) : 0; + // mpv_command_async returns immediately. + const int result = mpv_command_async(mpv, request_id, c_args.data()); + if (result < 0) { + auto pending = requests.TakeStatus(request_id); + if (pending) pending(result); + } +} + +inline void SubmitSetPropertyAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::string& name, const std::string& value, + StatusCallback callback) { + const uint64_t request_id = callback ? requests.RegisterStatus(std::move(callback)) : 0; + char* property_value = const_cast(value.c_str()); + const int result = mpv_set_property_async(mpv, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); + if (result < 0) { + auto pending = requests.TakeStatus(request_id); + if (pending) pending(result); + } +} + +inline void SubmitGetPropertyAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::string& name, GetPropertyCallback callback) { + const uint64_t request_id = requests.RegisterProperty(std::move(callback)); + const int result = mpv_get_property_async(mpv, request_id, name.c_str(), MPV_FORMAT_STRING); + if (result < 0) { + auto pending = requests.TakeProperty(request_id); + if (pending) pending(result, ""); + } +} + +// Completes a COMMAND_REPLY / SET_PROPERTY_REPLY / GET_PROPERTY_REPLY against +// the pending-request registry. `sanitize` converts mpv's payload (not +// guaranteed to be valid UTF-8) into the string handed to the callback. +// Returns true when the event was a reply event and has been fully handled. +template +inline bool DispatchReplyEvent(AsyncRequestRegistry& requests, const mpv_event* event, const Sanitizer& sanitize) { + switch (event->event_id) { + case MPV_EVENT_COMMAND_REPLY: + case MPV_EVENT_SET_PROPERTY_REPLY: { + StatusCallback callback = requests.TakeStatus(event->reply_userdata); + if (callback) { + callback(event->error); + } + return true; + } + case MPV_EVENT_GET_PROPERTY_REPLY: { + GetPropertyCallback callback = requests.TakeProperty(event->reply_userdata); + if (callback) { + std::string value; + if (event->error >= 0) { + auto* prop = static_cast(event->data); + if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { + auto c_value = *static_cast(prop->data); + if (c_value) value = sanitize(c_value); + } + } + callback(event->error, value); + } + return true; + } + default: + return false; + } +} + +// Copies a PROPERTY_CHANGE payload into a node the platform marshallers can +// convert. mpv owns the storage, so the node only borrows it for the lifetime +// of the event. +inline mpv_node ExtractPropertyNode(const mpv_event_property* prop) { + mpv_node node{}; + node.format = prop ? prop->format : MPV_FORMAT_NONE; + if (!prop) return node; + + switch (prop->format) { + case MPV_FORMAT_STRING: + node.u.string = prop->data ? *static_cast(prop->data) : nullptr; + break; + case MPV_FORMAT_FLAG: + node.u.flag = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_INT64: + node.u.int64 = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_DOUBLE: + node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; + break; + case MPV_FORMAT_NODE: + if (prop->data) { + node = *static_cast(prop->data); + } else { + node.format = MPV_FORMAT_NONE; + } + break; + default: + node.format = MPV_FORMAT_NONE; + break; + } + return node; +} + +// mpv node payloads are untrusted input: a property can nest arbitrarily +// deeply, carry a list as long as mpv claims, and hold strings that are +// neither length-bounded nor valid UTF-8. Every runner walks the same trees +// into its own Flutter value type, so the walk and its bounds live here once. +static constexpr size_t kMaxNodeDepth = 32; +static constexpr int kMaxNodeEntries = 16384; + +struct NodeConversionBudget { + size_t remaining_entries = static_cast(kMaxNodeEntries); + size_t remaining_bytes = 16 * 1024 * 1024; +}; + +// Measures an mpv string without ever reading past the remaining byte budget, +// and charges it to that budget. Returns false for a missing string or one +// that would exceed what is left, which is how the walk rejects a payload. +inline bool ClaimNodeString(const char* input, NodeConversionBudget* budget, size_t* length) { + if (!input || !budget || !length) return false; + const size_t measured = strnlen(input, budget->remaining_bytes + 1); + if (measured > budget->remaining_bytes) return false; + budget->remaining_bytes -= measured; + *length = measured; + return true; +} + +// `Builder` adapts the walk to a platform value type and keeps that platform's +// UTF-8 sanitizer out of this header. It supplies the types `Value`, +// `ListBuilder` and `MapBuilder`, the leaves `Null`, `Bool`, `Int`, `Double` +// and `String(data, length)`, and the containers `NewList`/`Append`/ +// `FinishList` plus `NewMap`/`Insert`/`FinishMap`. A value passed to +// `Append`/`Insert` belongs to the builder from then on, and `AbandonMap` +// releases a partially built map whose key was rejected. +template +typename Builder::Value ConvertNode(const mpv_node* node, size_t depth, NodeConversionBudget* budget) { + if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) { + return Builder::Null(); + } + --budget->remaining_entries; + + switch (node->format) { + case MPV_FORMAT_STRING: { + size_t length = 0; + if (!ClaimNodeString(node->u.string, budget, &length)) return Builder::Null(); + return Builder::String(node->u.string, length); + } + case MPV_FORMAT_FLAG: + return Builder::Bool(node->u.flag != 0); + case MPV_FORMAT_INT64: + return Builder::Int(node->u.int64); + case MPV_FORMAT_DOUBLE: + return Builder::Double(node->u.double_); + case MPV_FORMAT_NODE_ARRAY: { + const mpv_node_list* list = node->u.list; + if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) { + return Builder::Null(); + } + typename Builder::ListBuilder result = Builder::NewList(); + for (int i = 0; i < list->num; i++) { + Builder::Append(result, ConvertNode(&list->values[i], depth + 1, budget)); + } + return Builder::FinishList(std::move(result)); + } + case MPV_FORMAT_NODE_MAP: { + const mpv_node_list* map = node->u.list; + if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) { + return Builder::Null(); + } + typename Builder::MapBuilder result = Builder::NewMap(); + for (int i = 0; i < map->num; i++) { + size_t key_length = 0; + if (!ClaimNodeString(map->keys[i], budget, &key_length)) { + Builder::AbandonMap(result); + return Builder::Null(); + } + Builder::Insert(result, map->keys[i], key_length, ConvertNode(&map->values[i], depth + 1, budget)); + } + return Builder::FinishMap(std::move(result)); + } + default: + return Builder::Null(); + } +} + +template +typename Builder::Value ConvertNode(const mpv_node* node) { + NodeConversionBudget budget; + return ConvertNode(node, 0, &budget); +} + inline mpv_format ParsePropertyFormat(const std::string& format) { if (format == "string") return MPV_FORMAT_STRING; if (format == "flag" || format == "bool") return MPV_FORMAT_FLAG; @@ -305,6 +510,42 @@ class AudioRecoveryState { mutable std::mutex mutex_; }; +struct AudioRecoveryNotice { + // What to log, or nullptr when the property changed nothing of interest. + const char* message = nullptr; + // True when the state machine now has a reload queued, which platforms that + // drive recovery from a timer rather than an event-loop tick must wake for. + bool scheduled_work = false; +}; + +// Feeds the audio-related properties of a PROPERTY_CHANGE event into the +// recovery state machine, leaving the caller only the platform reporting. +inline AudioRecoveryNotice ObserveAudioRecoveryProperty( + AudioRecoveryState& state, const mpv_event* event, const mpv_event_property* prop) { + if (!event || !prop || !prop->name) return {}; + + if (std::strcmp(prop->name, "current-ao") == 0) { + const char* current_ao = nullptr; + if (prop->format == MPV_FORMAT_STRING && prop->data) { + current_ao = *static_cast(prop->data); + } + const bool is_null = current_ao && std::strcmp(current_ao, "null") == 0; + const auto transition = state.SetCurrentAudioOutputNull(is_null, AudioRecoveryState::Clock::now()); + if (transition == AudioOutputTransition::kFellBackToNull) { + return {"current-ao fell back to null; starting recovery", true}; + } + if (transition == AudioOutputTransition::kRecovered) { + return {"audio recovered (current-ao no longer null)", false}; + } + return {}; + } + if (std::strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && + state.OnAudioDeviceListChanged(AudioRecoveryState::Clock::now())) { + return {"audio-device-list changed while ao=null; rescheduling ao-reload", true}; + } + return {}; +} + } // namespace mpv_common } // namespace plezy diff --git a/shared/mpv/mpv_player_common_test.cpp b/shared/mpv/mpv_player_common_test.cpp index ac0100b7..baf3c03d 100644 --- a/shared/mpv/mpv_player_common_test.cpp +++ b/shared/mpv/mpv_player_common_test.cpp @@ -332,6 +332,96 @@ void TestStaleReloadCompletionCannotClearCurrentRequest() { assert(state.CompleteReload(current_request.request_generation)); } +// Renders the shared node walk into text so its bounds can be asserted on +// every platform, without a platform value type in the way. +struct TextNodeBuilder { + using Value = std::string; + using ListBuilder = std::string; + using MapBuilder = std::string; + + static Value Null() { return "null"; } + static Value Bool(bool value) { return value ? "true" : "false"; } + static Value Int(int64_t value) { return std::to_string(value); } + static Value Double(double value) { return std::to_string(value); } + static Value String(const char* value, size_t length) { return "'" + std::string(value, length) + "'"; } + + static ListBuilder NewList() { return std::string("["); } + static void Append(ListBuilder& list, Value value) { list += value + ","; } + static Value FinishList(ListBuilder list) { return list + "]"; } + + static MapBuilder NewMap() { return std::string("{"); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + map += std::string(key, key_length) + ":" + value + ","; + } + static Value FinishMap(MapBuilder map) { return map + "}"; } + static void AbandonMap(MapBuilder& map) { map += ""; } +}; + +void TestNodeConversionBounds() { + using plezy::mpv_common::ConvertNode; + using plezy::mpv_common::NodeConversionBudget; + + char value[] = "hello"; + mpv_node text{}; + text.format = MPV_FORMAT_STRING; + text.u.string = value; + assert(ConvertNode(&text) == "'hello'"); + + // Missing storage is never trusted: no node, no string, no list. + assert(ConvertNode(nullptr) == "null"); + text.u.string = nullptr; + assert(ConvertNode(&text) == "null"); + + mpv_node array{}; + array.format = MPV_FORMAT_NODE_ARRAY; + array.u.list = nullptr; + assert(ConvertNode(&array) == "null"); + + // Neither a negative nor an implausible length reaches the builder. + mpv_node entry{}; + entry.format = MPV_FORMAT_INT64; + entry.u.int64 = 7; + mpv_node_list negative{-1, &entry, nullptr}; + array.u.list = &negative; + assert(ConvertNode(&array) == "null"); + mpv_node_list oversized{plezy::mpv_common::kMaxNodeEntries + 1, &entry, nullptr}; + array.u.list = &oversized; + assert(ConvertNode(&array) == "null"); + + mpv_node_list single{1, &entry, nullptr}; + array.u.list = &single; + assert(ConvertNode(&array) == "[7,]"); + + // A map with a null key is voided rather than half-converted. + char* missing_key[] = {nullptr}; + mpv_node_list keyless{1, &entry, missing_key}; + mpv_node map{}; + map.format = MPV_FORMAT_NODE_MAP; + map.u.list = &keyless; + assert(ConvertNode(&map) == "null"); + + // Depth, entry, and byte budgets each stop the walk. + std::vector chain(plezy::mpv_common::kMaxNodeDepth + 1); + std::vector links(chain.size()); + chain.back() = entry; + for (size_t i = chain.size() - 1; i > 0; --i) { + links[i - 1] = mpv_node_list{1, &chain[i], nullptr}; + chain[i - 1].format = MPV_FORMAT_NODE_ARRAY; + chain[i - 1].u.list = &links[i - 1]; + } + assert(ConvertNode(&chain[0]).find('7') == std::string::npos); + + NodeConversionBudget entries{2, 1024}; + assert(ConvertNode(&array, 0, &entries) == "[7,]"); + assert(entries.remaining_entries == 0); + assert(ConvertNode(&array, 0, &entries) == "null"); + + NodeConversionBudget bytes{8, 4}; + text.u.string = value; + assert(ConvertNode(&text, 0, &bytes) == "null"); + assert(bytes.remaining_bytes == 4); +} + void TestHdrHelpers() { assert(plezy::mpv_common::ParseEnabledFlag("yes")); assert(plezy::mpv_common::ParseEnabledFlag("true")); @@ -355,6 +445,7 @@ int main() { TestFileBoundaryRestartsNullRecoveryOnlyAfterLoad(); TestUnloadedResumeIsConsumed(); TestStaleReloadCompletionCannotClearCurrentRequest(); + TestNodeConversionBounds(); TestHdrHelpers(); return 0; } diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 7bd93b58..e5594875 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -68,8 +69,8 @@ class _AppDatabaseTestSuite { // v20 dropped the profile_id FK so virtual Plex Home profiles can // persist join rows without a parent `profiles` row. Profile deletion // instead cleans up join rows explicitly (via the teardown flow's - // removeAllProfileConnectionsAndCleanup) before deleting the profile, - // so the cascade isn't needed. + // ProfileConnectionCleanup.removeAllProfileConnections) before deleting + // the profile, so the cascade isn't needed. final now = DateTime.now().millisecondsSinceEpoch; await db .into(db.connections) diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 3e3e9a3a..df52b558 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -8,6 +8,8 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; import 'package:plezy/models/download_models.dart'; +import '../test_helpers/download_fixtures.dart'; + void main() { late AppDatabase db; @@ -19,115 +21,6 @@ void main() { await db.close(); }); - // ============================================================ - // insertDownload - // ============================================================ - - group('insertDownload', () { - test('inserts a movie row with defaults', () async { - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: '100', - globalKey: 'srv:100', - type: 'movie', - status: DownloadStatus.queued.index, - ); - - final rows = await db.select(db.downloadedMedia).get(); - expect(rows, hasLength(1)); - final r = rows.first; - expect(r.serverId, 'srv'); - expect(r.ratingKey, '100'); - expect(r.globalKey, 'srv:100'); - expect(r.type, 'movie'); - expect(r.status, DownloadStatus.queued.index); - expect(r.parentRatingKey, isNull); - expect(r.grandparentRatingKey, isNull); - expect(r.mediaIndex, 0); - }); - - test('inserts an episode with parent and grandparent keys', () async { - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: 'ep1', - globalKey: 'srv:ep1', - type: 'episode', - parentRatingKey: 'season1', - grandparentRatingKey: 'show1', - status: DownloadStatus.queued.index, - mediaIndex: 7, - ); - - final row = (await db.select(db.downloadedMedia).get()).single; - expect(row.parentRatingKey, 'season1'); - expect(row.grandparentRatingKey, 'show1'); - expect(row.mediaIndex, 7); - }); - - test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { - await db.insertDownload( - serverId: ServerId('srv'), - clientScopeId: 'scope-old', - ratingKey: '100', - globalKey: 'srv:100', - type: 'movie', - status: DownloadStatus.queued.index, - mediaIndex: 1, - mediaSourceId: 'source-old', - ); - final original = (await db.getDownloadedMedia('srv:100'))!; - await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( - const DownloadedMediaCompanion( - progress: Value(50), - downloadedBytes: Value(500), - totalBytes: Value(1000), - videoFilePath: Value('downloads/video.mkv'), - safRootUri: Value('content://downloads'), - thumbPath: Value('downloads/thumb.jpg'), - downloadedAt: Value(1234), - errorMessage: Value('old error'), - retryCount: Value(2), - bgTaskId: Value('current-task'), - ), - ); - - await db.insertDownload( - serverId: ServerId('srv-new'), - clientScopeId: 'scope-new', - ratingKey: '100-new', - globalKey: 'srv:100', - type: 'episode', - parentRatingKey: 'season-new', - grandparentRatingKey: 'show-new', - status: DownloadStatus.failed.index, - mediaIndex: 3, - mediaSourceId: 'source-new', - ); - - final row = (await db.select(db.downloadedMedia).get()).single; - expect(row.id, original.id); - expect(row.serverId, 'srv-new'); - expect(row.clientScopeId, 'scope-new'); - expect(row.ratingKey, '100-new'); - expect(row.type, 'episode'); - expect(row.parentRatingKey, 'season-new'); - expect(row.grandparentRatingKey, 'show-new'); - expect(row.status, DownloadStatus.failed.index); - expect(row.mediaIndex, 3); - expect(row.mediaSourceId, 'source-new'); - expect(row.progress, 0); - expect(row.downloadedBytes, 0); - expect(row.totalBytes, isNull); - expect(row.errorMessage, isNull); - expect(row.retryCount, 0); - expect(row.videoFilePath, 'downloads/video.mkv'); - expect(row.safRootUri, 'content://downloads'); - expect(row.thumbPath, 'downloads/thumb.jpg'); - expect(row.downloadedAt, 1234); - expect(row.bgTaskId, 'current-task'); - }); - }); - group('insertQueuedDownload', () { test('atomically persists media identity, scope, policy, and queue state', () async { final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_'); diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index 01cf7af1..a4fb4358 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/mixins/paginated_item_loader.dart'; +import 'package:plezy/mixins/standard_paginated_view.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import '../test_helpers/media_items.dart'; @@ -27,10 +28,20 @@ class _PaginatedProbe extends StatefulWidget { State<_PaginatedProbe> createState() => _PaginatedProbeState(); } -class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader { +class _PaginatedProbeState extends State<_PaginatedProbe> + with PaginatedItemLoader, StandardPaginatedView { int fetchCalls = 0; final List<({int start, int size})> fetchArgs = []; + /// View fields required by [StandardPaginatedView], mirroring the + /// `items`/`isLoading`/`errorMessage` trio the real screens expose. + @override + List items = []; + @override + bool isLoading = false; + @override + String? errorMessage; + @override Future> fetchPage(int start, int size, AbortController? abort) { fetchCalls++; @@ -109,10 +120,8 @@ void main() { expect(hooked, [(0, 5)]); }); - testWidgets('loadInitialPaginatedItems applies reset, data, and success callback', (tester) async { + testWidgets('loadStandardPaginatedItems resets view state, publishes items, and fires onLoaded', (tester) async { late _PaginatedProbeState state; - var reset = false; - List? applied; (int, int)? counts; await tester.pumpWidget( _PaginatedProbe( @@ -121,25 +130,26 @@ void main() { ), ); - final succeeded = await state.loadInitialPaginatedItems( + // Stale view state from a previous load must be cleared by the reset. + state.items = [_meta(99)]; + state.errorMessage = 'stale error'; + + await state.loadStandardPaginatedItems( pageSize: 3, - resetViewState: () => reset = true, - applyLoadedItems: (items) => applied = items, - applyError: (error, stackTrace) => fail('unexpected error: $error'), + errorMessageFor: (error, stackTrace) => fail('unexpected error: $error'), onLoaded: (loaded, total) => counts = (loaded, total), ); await tester.pump(); - expect(succeeded, isTrue); - expect(reset, isTrue); - expect(applied?.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(state.items.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(state.isLoading, isFalse); + expect(state.errorMessage, isNull); expect(counts, (3, 7)); }); - testWidgets('loadInitialPaginatedItems applies one error transaction', (tester) async { + testWidgets('loadStandardPaginatedItems applies one error transaction', (tester) async { late _PaginatedProbeState state; - Object? appliedError; - Object? loggedError; + Object? reportedError; await tester.pumpWidget( _PaginatedProbe( onState: (s) => state = s, @@ -147,18 +157,20 @@ void main() { ), ); - final succeeded = await state.loadInitialPaginatedItems( + await state.loadStandardPaginatedItems( pageSize: 3, - resetViewState: () {}, - applyLoadedItems: (_) => fail('items must not be applied'), - applyError: (error, stackTrace) => appliedError = error, - onError: (error, stackTrace) => loggedError = error, + errorMessageFor: (error, stackTrace) { + reportedError = error; + return 'could not load'; + }, + onLoaded: (_, _) => fail('items must not be applied'), ); await tester.pump(); - expect(succeeded, isFalse); - expect(appliedError, isA()); - expect(loggedError, same(appliedError)); + expect(reportedError, isA()); + expect(state.errorMessage, 'could not load'); + expect(state.isLoading, isFalse); + expect(state.items, isEmpty); }); testWidgets('totalSize == 0 means no more pages — ensureRangeLoaded is a no-op', (tester) async { diff --git a/test/profiles/profile_connection_cleanup_test.dart b/test/profiles/profile_connection_cleanup_test.dart index 56559f1c..5164747e 100644 --- a/test/profiles/profile_connection_cleanup_test.dart +++ b/test/profiles/profile_connection_cleanup_test.dart @@ -83,6 +83,7 @@ void main() { late ConnectionRegistry connections; late ProfileConnectionRegistry profileConnections; late StorageService storage; + late ProfileConnectionCleanup cleanup; setUp(() async { resetSharedPreferencesForTest(); @@ -90,6 +91,11 @@ void main() { connections = ConnectionRegistry(db); profileConnections = ProfileConnectionRegistry(db); storage = await StorageService.getInstance(); + cleanup = ProfileConnectionCleanup( + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); }); tearDown(() async { @@ -112,13 +118,7 @@ void main() { await storage.saveHiddenLibraries({'jf-machine:movies'}); await storage.saveLibraryOrder(['jf-machine:movies']); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await profileConnections.listForConnection(conn.id), isEmpty); expect(await connections.get(conn.id), isNull); @@ -151,13 +151,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'jf-machine:movies'}); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await connections.get(conn.id), isNotNull); final remaining = await profileConnections.listForConnection(conn.id); @@ -177,11 +171,7 @@ void main() { await storage.saveHiddenLibraries({'jf-machine:movies'}); await storage.saveLibrarySort('jf-machine:movies', 'titleSort'); - final removed = await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removed = await cleanup.pruneUnreferencedJellyfinConnections(); expect(removed, 1); expect(await connections.get(conn.id), isNull); @@ -205,11 +195,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'jf-machine:movies'}); - final removed = await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removed = await cleanup.pruneUnreferencedJellyfinConnections(); expect(removed, 1); expect(await connections.get(orphan.id), isNull); @@ -241,13 +227,7 @@ void main() { expect(await connections.get(acct.id), isNotNull); expect(await profileConnections.listAll(), hasLength(2)); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - plannedRemoval: plannedRemoval, - ); + final removal = await cleanup.removePlexAccountConnection(acct, plannedRemoval: plannedRemoval); expect(removal.removedVirtualProfileIds, {vProfile}); expect(removal.borrowerProfileIds, isEmpty); @@ -270,12 +250,7 @@ void main() { await profileConnections.upsert(_row(vProfile, jf)); await profileConnections.upsert(_row('local-1', jf)); - await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removePlexAccountConnection(acct); expect(await connections.get(jf.id), isNotNull); final remaining = await profileConnections.listAll(); @@ -293,12 +268,7 @@ void main() { await profileConnections.upsert(_row('local-1', acct)); await profileConnections.upsert(_row('local-1', jf)); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removal = await cleanup.removePlexAccountConnection(acct); expect(removal.removedVirtualProfileIds, isEmpty); expect(removal.borrowerProfileIds, {'local-1'}); @@ -323,12 +293,7 @@ void main() { await profileConnections.upsert(_row(v2, acct2, userIdentifier: uuid2)); await storage.savePlexHomeUsersCache(acct2.id, [_homeUser(uuid2).toJson()]); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct1, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removal = await cleanup.removePlexAccountConnection(acct1); expect(removal.removedVirtualProfileIds, {v1}); expect(await connections.get(acct2.id), isNotNull); @@ -348,12 +313,7 @@ void main() { await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid)); await profileConnections.upsert(_row(vProfile, jf)); - Future run() => removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + Future run() => cleanup.removePlexAccountConnection(acct); await run(); final second = await run(); @@ -375,13 +335,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'plex-machine:movies'}); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await connections.get(conn.id), isNotNull); expect(await profileConnections.listForConnection(conn.id), isEmpty); @@ -402,13 +356,7 @@ void main() { Future<({PostRemovalRoute route, List profiles})> resolve({ Map> plexHomeUsers = const {}, }) { - return resolvePostRemovalState( - profileRegistry: profileRegistry, - profileConnections: profileConnections, - connections: connections, - plexHomeUsers: plexHomeUsers, - storage: storage, - ); + return cleanup.resolvePostRemovalState(profileRegistry: profileRegistry, plexHomeUsers: plexHomeUsers); } Profile local(String id) => diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 2a1a4629..326aa2e9 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -21,6 +21,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/active_client_scope.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/media_items.dart'; /// Implements only [fetchPlayableDescendants], the surface [collectEpisodes] diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart index 960b1c91..612a3f5a 100644 --- a/test/providers/watch_state_store_test.dart +++ b/test/providers/watch_state_store_test.dart @@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/watch_state_store.dart'; +import 'package:plezy/services/watch_state_resolver.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; @@ -190,13 +191,13 @@ void main() { store.setHydratedPatches(const [ HydratedWatchStatePatch( globalKey: 'jf-machine:show-1', - patch: WatchStatePatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), + patch: WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), updatedAt: 100, order: 1, ), HydratedWatchStatePatch( globalKey: 'jf-machine:episode-1', - patch: WatchStatePatch(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), + patch: WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), updatedAt: 200, order: 2, ), @@ -213,13 +214,13 @@ void main() { store.setHydratedPatches(const [ HydratedWatchStatePatch( globalKey: 'jf-machine/user-a:show-1', - patch: WatchStatePatch(isWatched: true), + patch: WatchStateSnapshot(isWatched: true), updatedAt: 100, order: 1, ), HydratedWatchStatePatch( globalKey: 'jf-machine/user-b:show-1', - patch: WatchStatePatch(isWatched: false), + patch: WatchStateSnapshot(isWatched: false), updatedAt: 100, order: 2, ), diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index f19af6d0..2b592554 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -33,6 +33,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/active_client_scope.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; diff --git a/test/screens/video_player/media_control_router_test.dart b/test/services/media_control_router_test.dart similarity index 94% rename from test/screens/video_player/media_control_router_test.dart rename to test/services/media_control_router_test.dart index 8d2f20fd..dd5bcffd 100644 --- a/test/screens/video_player/media_control_router_test.dart +++ b/test/services/media_control_router_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:os_media_controls/os_media_controls.dart'; -import 'package:plezy/screens/video_player/media_control_router.dart'; +import 'package:plezy/services/media_control_router.dart'; void main() { test('denied playback and media-item commands are consumed without mutation', () { @@ -47,12 +47,12 @@ void main() { }); } -VideoPlayerMediaControlRouter _router({ +MediaControlRouter _router({ required bool Function() canControl, required bool Function() canNavigate, required List calls, }) { - return VideoPlayerMediaControlRouter( + return MediaControlRouter( canControlPlayback: canControl, canNavigateMediaItems: canNavigate, onPlay: () => calls.add('play'), diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 8c65fad9..ee649b1b 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -22,6 +22,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/playback_report_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index 64ba2a40..f19addce 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -11,6 +11,8 @@ import 'package:plezy/main.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/models/download_models.dart'; +import 'test_helpers/download_fixtures.dart'; + void main() { testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async { final completion = Completer(); diff --git a/test/test_helpers/download_fixtures.dart b/test/test_helpers/download_fixtures.dart new file mode 100644 index 00000000..f880ee19 --- /dev/null +++ b/test/test_helpers/download_fixtures.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.dart'; + +/// Seeds `downloaded_media` rows at an arbitrary [status] so tests can start +/// from completed, downloading, or paused state. +/// +/// Production never writes rows this way — it goes through +/// `insertQueuedDownload`, which only ever admits `queued` rows and guards on +/// the existing status. This fixture deliberately keeps neither restriction, +/// which is why it lives in `test/` instead of `lib/`. +extension DownloadFixtures on AppDatabase { + Future insertDownload({ + required ServerId serverId, + String? clientScopeId, + required String ratingKey, + required String globalKey, + required String type, + String? parentRatingKey, + String? grandparentRatingKey, + required int status, + int mediaIndex = 0, + String? mediaSourceId, + }) async { + await customUpdate( + ''' + INSERT INTO downloaded_media ( + server_id, + client_scope_id, + rating_key, + global_key, + type, + parent_rating_key, + grandparent_rating_key, + status, + media_index, + media_source_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(global_key) DO UPDATE SET + server_id = excluded.server_id, + client_scope_id = excluded.client_scope_id, + rating_key = excluded.rating_key, + type = excluded.type, + parent_rating_key = excluded.parent_rating_key, + grandparent_rating_key = excluded.grandparent_rating_key, + status = excluded.status, + progress = 0, + total_bytes = NULL, + downloaded_bytes = 0, + error_message = NULL, + retry_count = 0, + media_index = excluded.media_index, + media_source_id = excluded.media_source_id + ''', + variables: [ + Variable(serverId), + Variable(clientScopeId), + Variable(ratingKey), + Variable(globalKey), + Variable(type), + Variable(parentRatingKey), + Variable(grandparentRatingKey), + Variable(status), + Variable(mediaIndex), + Variable(mediaSourceId), + ], + updates: {downloadedMedia}, + ); + } +} diff --git a/test/test_helpers/download_fixtures_test.dart b/test/test_helpers/download_fixtures_test.dart new file mode 100644 index 00000000..cc6bc344 --- /dev/null +++ b/test/test_helpers/download_fixtures_test.dart @@ -0,0 +1,126 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/download_models.dart'; + +import 'download_fixtures.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + group('insertDownload', () { + test('inserts a movie row with defaults', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + ); + + final rows = await db.select(db.downloadedMedia).get(); + expect(rows, hasLength(1)); + final r = rows.first; + expect(r.serverId, 'srv'); + expect(r.ratingKey, '100'); + expect(r.globalKey, 'srv:100'); + expect(r.type, 'movie'); + expect(r.status, DownloadStatus.queued.index); + expect(r.parentRatingKey, isNull); + expect(r.grandparentRatingKey, isNull); + expect(r.mediaIndex, 0); + }); + + test('inserts an episode with parent and grandparent keys', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'ep1', + globalKey: 'srv:ep1', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.queued.index, + mediaIndex: 7, + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.parentRatingKey, 'season1'); + expect(row.grandparentRatingKey, 'show1'); + expect(row.mediaIndex, 7); + }); + + test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { + await db.insertDownload( + serverId: ServerId('srv'), + clientScopeId: 'scope-old', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + mediaIndex: 1, + mediaSourceId: 'source-old', + ); + final original = (await db.getDownloadedMedia('srv:100'))!; + await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( + const DownloadedMediaCompanion( + progress: Value(50), + downloadedBytes: Value(500), + totalBytes: Value(1000), + videoFilePath: Value('downloads/video.mkv'), + safRootUri: Value('content://downloads'), + thumbPath: Value('downloads/thumb.jpg'), + downloadedAt: Value(1234), + errorMessage: Value('old error'), + retryCount: Value(2), + bgTaskId: Value('current-task'), + ), + ); + + await db.insertDownload( + serverId: ServerId('srv-new'), + clientScopeId: 'scope-new', + ratingKey: '100-new', + globalKey: 'srv:100', + type: 'episode', + parentRatingKey: 'season-new', + grandparentRatingKey: 'show-new', + status: DownloadStatus.failed.index, + mediaIndex: 3, + mediaSourceId: 'source-new', + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.id, original.id); + expect(row.serverId, 'srv-new'); + expect(row.clientScopeId, 'scope-new'); + expect(row.ratingKey, '100-new'); + expect(row.type, 'episode'); + expect(row.parentRatingKey, 'season-new'); + expect(row.grandparentRatingKey, 'show-new'); + expect(row.status, DownloadStatus.failed.index); + expect(row.mediaIndex, 3); + expect(row.mediaSourceId, 'source-new'); + expect(row.progress, 0); + expect(row.downloadedBytes, 0); + expect(row.totalBytes, isNull); + expect(row.errorMessage, isNull); + expect(row.retryCount, 0); + expect(row.videoFilePath, 'downloads/video.mkv'); + expect(row.safRootUri, 'content://downloads'); + expect(row.thumbPath, 'downloads/thumb.jpg'); + expect(row.downloadedAt, 1234); + expect(row.bgTaskId, 'current-task'); + }); + }); +} diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index babdd7e1..3fa92daf 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -10,6 +10,7 @@ import 'package:plezy/mpv/player/player_streams.dart'; import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; import '../test_helpers/prefs.dart'; @@ -147,10 +148,8 @@ Future _pumpSheet( height: 700, child: VideoSettingsSheet( player: player ?? _FakeSettingsPlayer(), - audioSyncOffset: 0, - subtitleSyncOffset: 0, - canControl: canControl, supportsHdrControl: supportsHdrControl, + trackControlsState: TrackControlsState(canControl: canControl), ), ), ), diff --git a/website/src/lib/components/DownloadButtons.svelte b/website/src/lib/components/DownloadButtons.svelte index 460d3741..0f77b76d 100644 --- a/website/src/lib/components/DownloadButtons.svelte +++ b/website/src/lib/components/DownloadButtons.svelte @@ -5,7 +5,7 @@ import AmazonIcon from "~icons/cib/amazon"; import ChevronDownIcon from "~icons/heroicons/chevron-down-solid"; import WindowsIcon from "./WindowsIcon.svelte"; - import { linuxArchitectures } from "$lib/content/downloads"; + import { AMAZON_URL, linuxArchitectures, releaseAsset, storeOptions } from "$lib/content/downloads"; const componentId = $props.id(); const linuxPanelId = `${componentId}-linux-downloads`; @@ -34,7 +34,7 @@
@@ -75,7 +75,7 @@ diff --git a/website/src/lib/components/FAQ.svelte b/website/src/lib/components/FAQ.svelte index e880fcd0..f1b30d73 100644 --- a/website/src/lib/components/FAQ.svelte +++ b/website/src/lib/components/FAQ.svelte @@ -4,6 +4,7 @@ import MinusIcon from '~icons/heroicons/minus'; import PlusIcon from '~icons/heroicons/plus'; import ScrollReveal from "./ScrollReveal.svelte"; + import SectionHeader from "./SectionHeader.svelte"; const hash = $derived(page.url.hash.slice(1)); const hashIndex = $derived(faqs.findIndex((f) => f.id === hash)); @@ -24,12 +25,12 @@ } -
- - -

Common questions

-

Everything you need to know about Plezy.

-
+
+
{#each faqs as faq, i} @@ -71,43 +72,6 @@
diff --git a/website/src/lib/content/downloads.ts b/website/src/lib/content/downloads.ts index 6a79e63b..f7f4e901 100644 --- a/website/src/lib/content/downloads.ts +++ b/website/src/lib/content/downloads.ts @@ -12,16 +12,23 @@ export type StoreOption = { url: string; }; +export const APP_STORE_ID = '6754315964'; +export const ANDROID_PACKAGE = 'com.edde746.plezy'; +export const AMAZON_URL = 'https://www.amazon.com/gp/product/B0GK65CVS1'; + +export const releaseAsset = (name: string) => + `https://github.com/edde746/plezy/releases/latest/download/${name}`; + export const storeOptions = { ios: { id: 'app-store', label: 'App Store', - url: 'https://apps.apple.com/us/app/id6754315964', + url: `https://apps.apple.com/us/app/id${APP_STORE_ID}`, }, android: { id: 'play-store', label: 'Google Play', - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + url: `https://play.google.com/store/apps/details?id=${ANDROID_PACKAGE}`, }, } as const satisfies Record<'ios' | 'android', StoreOption>; @@ -45,23 +52,21 @@ export function storeOptionsForPlatform(platform: MobileStorePlatform): readonly return [storeOptions.ios, storeOptions.android]; } +// Every architecture ships the same formats, so the menu is their cross product. +const LINUX_FORMATS = [ + { label: '.deb (Debian/Ubuntu)', extension: 'deb' }, + { label: '.rpm (Fedora/RHEL)', extension: 'rpm' }, + { label: '.pkg.tar.zst (Arch)', extension: 'pkg.tar.zst' }, + { label: '.tar.gz (Portable)', extension: 'tar.gz' }, +]; + export const linuxArchitectures = [ - { - label: 'x64 (Intel/AMD)', - formats: [ - { label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb' }, - { label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm' }, - { label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst' }, - { label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz' }, - ], - }, - { - label: 'ARM64', - formats: [ - { label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb' }, - { label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm' }, - { label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst' }, - { label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz' }, - ], - }, -] as const; + { slug: 'x64', label: 'x64 (Intel/AMD)' }, + { slug: 'arm64', label: 'ARM64' }, +].map(({ slug, label }) => ({ + label, + formats: LINUX_FORMATS.map((format) => ({ + label: format.label, + url: releaseAsset(`plezy-linux-${slug}.${format.extension}`), + })), +})); diff --git a/website/src/lib/content/software_app_offers.ts b/website/src/lib/content/software_app_offers.ts index 24415648..b2234bc7 100644 --- a/website/src/lib/content/software_app_offers.ts +++ b/website/src/lib/content/software_app_offers.ts @@ -1,3 +1,5 @@ +import { AMAZON_URL, storeOptions } from './downloads'; + export type StorePrices = { appStorePrice: string | null; playStorePrice: string | null; @@ -23,7 +25,7 @@ export function buildSoftwareApplicationOffers({ return [ { '@type': 'Offer', - url: 'https://apps.apple.com/us/app/id6754315964', + url: storeOptions.ios.url, category: 'App Store', ...(appStorePrice === null ? {} @@ -34,7 +36,7 @@ export function buildSoftwareApplicationOffers({ }, { '@type': 'Offer', - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + url: storeOptions.android.url, category: 'Google Play', ...(playStorePrice === null ? {} @@ -45,7 +47,7 @@ export function buildSoftwareApplicationOffers({ }, { '@type': 'Offer', - url: 'https://www.amazon.com/gp/product/B0GK65CVS1', + url: AMAZON_URL, category: 'Amazon Appstore', }, { diff --git a/website/src/routes/+error.svelte b/website/src/routes/+error.svelte index 1853cb81..c5e690c1 100644 --- a/website/src/routes/+error.svelte +++ b/website/src/routes/+error.svelte @@ -8,16 +8,16 @@ -
-
- +
+ diff --git a/website/src/routes/+page.server.ts b/website/src/routes/+page.server.ts index 16c820b6..3d165092 100644 --- a/website/src/routes/+page.server.ts +++ b/website/src/routes/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad } from './$types'; +import { ANDROID_PACKAGE, APP_STORE_ID } from '$lib/content/downloads'; import { normalizeUsdStorePrice } from '$lib/content/software_app_offers'; export const load: PageServerLoad = async ({ fetch }) => { @@ -8,7 +9,7 @@ export const load: PageServerLoad = async ({ fetch }) => { let playStorePrice: string | null = null; try { - const res = await fetch('https://itunes.apple.com/lookup?id=6754315964'); + const res = await fetch(`https://itunes.apple.com/lookup?id=${APP_STORE_ID}`); if (!res.ok) throw new Error(`App Store lookup failed: HTTP ${res.status}`); const data = await res.json(); const app = data.results?.[0]; @@ -27,7 +28,7 @@ export const load: PageServerLoad = async ({ fetch }) => { // Module initialization is optional external data and must stay inside this failure boundary. const { default: gplay } = await import('google-play-scraper'); const app = await gplay.app({ - appId: 'com.edde746.plezy', + appId: ANDROID_PACKAGE, country: 'us', lang: 'en' }); diff --git a/website/src/routes/layout.css b/website/src/routes/layout.css index 0065db6d..840c124b 100644 --- a/website/src/routes/layout.css +++ b/website/src/routes/layout.css @@ -138,6 +138,87 @@ ul { background: var(--color-surface); } +/* Standalone page holding a single centered card (/scan, +error). */ +.centered-page { + display: flex; + min-height: 100dvh; + align-items: center; + justify-content: center; + padding: var(--page-gutter); +} + +.centered-card { + display: flex; + width: min(100%, 32rem); + flex-direction: column; + align-items: center; + border-radius: var(--radius-xl); + padding: clamp(2rem, 8vw, 4rem); + background: var(--color-surface); + text-align: center; +} + +.card-logo { + display: flex; + width: 5rem; + height: 5rem; + align-items: center; + justify-content: center; + margin-bottom: 2rem; + border-radius: var(--radius-lg); + background: var(--color-surface-highest); +} + +.card-logo img { + width: 2.5rem; + height: 2.5rem; +} + +/* Filled pill action; add .btn-pill--icon when it leads with an icon. */ +.btn-pill { + display: inline-flex; + min-height: 3rem; + align-items: center; + border-radius: var(--radius-pill); + padding-inline: 1.25rem; + color: var(--color-on-primary); + background: var(--color-text); + font-size: 0.8125rem; + font-weight: 700; + transition: + border-radius var(--motion-expressive) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.btn-pill:hover, +.btn-pill:focus-visible { + border-radius: var(--radius-md); + background: #fff; + outline: none; +} + +.btn-pill--icon { + gap: 0.625rem; +} + +.btn-pill--icon svg { + width: 1.125rem; + height: 1.125rem; +} + +/* Landing section that stays inside the page grid. */ +.page-section { + width: min(100%, var(--page-width)); + margin-inline: auto; + padding: clamp(4rem, 9vw, 8rem) var(--page-gutter); +} + +/* Landing section whose content bleeds past the page grid. */ +.bleed-section { + overflow: hidden; + padding-block: clamp(4rem, 9vw, 8rem); +} + @keyframes fade-in-up { from { opacity: 0; diff --git a/website/src/routes/scan/+page.svelte b/website/src/routes/scan/+page.svelte index 76646559..c4737a5e 100644 --- a/website/src/routes/scan/+page.svelte +++ b/website/src/routes/scan/+page.svelte @@ -40,9 +40,9 @@ -
-
- +
+
+

Scan in Plezy

To use this feature, scan this QR code with the Plezy app.

@@ -53,7 +53,7 @@ href={store.url} target="_blank" rel="noopener noreferrer" - class="store-button" + class="btn-pill btn-pill--icon" > {#if store.id === "app-store"} @@ -68,41 +68,6 @@
diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 07cf262c..bb129833 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -23,44 +23,32 @@ struct InnerWindowSubclassState { namespace { -flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { - if (!node) return flutter::EncodableValue(); +// Adapts the shared, bounded mpv_node walk onto Flutter's encodable values. +struct EncodableNodeBuilder { + using Value = flutter::EncodableValue; + using ListBuilder = flutter::EncodableList; + using MapBuilder = flutter::EncodableMap; - switch (node->format) { - case MPV_FORMAT_STRING: - return flutter::EncodableValue(SanitizeUtf8(node->u.string)); - case MPV_FORMAT_FLAG: - return flutter::EncodableValue(node->u.flag != 0); - case MPV_FORMAT_INT64: - return flutter::EncodableValue(node->u.int64); - case MPV_FORMAT_DOUBLE: - return flutter::EncodableValue(node->u.double_); - case MPV_FORMAT_NODE_ARRAY: { - const mpv_node_list* node_list = node->u.list; - if (!node_list || node_list->num < 0 || (node_list->num > 0 && !node_list->values)) { - return flutter::EncodableValue(); - } - flutter::EncodableList list; - list.reserve(static_cast(node_list->num)); - for (int i = 0; i < node_list->num; ++i) { - list.push_back(NodeToEncodableValue(&node_list->values[i])); - } - return flutter::EncodableValue(list); - } - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list* node_list = node->u.list; - if (!node_list || node_list->num < 0 || (node_list->num > 0 && (!node_list->values || !node_list->keys))) { - return flutter::EncodableValue(); - } - flutter::EncodableMap map; - for (int i = 0; i < node_list->num; ++i) { - map[flutter::EncodableValue(SanitizeUtf8(node_list->keys[i]))] = NodeToEncodableValue(&node_list->values[i]); - } - return flutter::EncodableValue(map); - } - default: - return flutter::EncodableValue(); + static Value Null() { return flutter::EncodableValue(); } + static Value Bool(bool value) { return flutter::EncodableValue(value); } + static Value Int(int64_t value) { return flutter::EncodableValue(value); } + static Value Double(double value) { return flutter::EncodableValue(value); } + static Value String(const char* value, size_t length) { return flutter::EncodableValue(SanitizeUtf8(value, length)); } + + static ListBuilder NewList() { return flutter::EncodableList(); } + static void Append(ListBuilder& list, Value value) { list.push_back(std::move(value)); } + static Value FinishList(ListBuilder list) { return flutter::EncodableValue(std::move(list)); } + + static MapBuilder NewMap() { return flutter::EncodableMap(); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + map[flutter::EncodableValue(SanitizeUtf8(key, key_length))] = std::move(value); } + static Value FinishMap(MapBuilder map) { return flutter::EncodableValue(std::move(map)); } + static void AbandonMap(MapBuilder&) {} +}; + +flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { + return plezy::mpv_common::ConvertNode(node); } // DComp-mode input forwarding. mpv's inner window lives on mpv's own thread @@ -585,21 +573,7 @@ void MpvPlayer::CommandAsync(const std::vector& args, CommandCallba return; } - std::vector c_args; - c_args.reserve(args.size() + 1); - for (const auto& arg : args) { - c_args.push_back(arg.c_str()); - } - c_args.push_back(nullptr); - - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - // mpv_command_async returns immediately - int result = mpv_command_async(mpv_, request_id, c_args.data()); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback)); } void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { @@ -618,14 +592,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val return; } - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - char* property_value = const_cast(value.c_str()); - int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback)); } void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) { @@ -634,13 +601,7 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca return; } - uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback)); - - int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING); - if (result < 0) { - auto cb = pending_requests_.TakeProperty(request_id); - if (cb) cb(result, ""); - } + plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback)); } void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { @@ -756,32 +717,12 @@ void MpvPlayer::EventLoop() { } void MpvPlayer::HandleMpvEvent(mpv_event* event) { + if (plezy::mpv_common::DispatchReplyEvent( + pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) { + return; + } + switch (event->event_id) { - case MPV_EVENT_COMMAND_REPLY: - case MPV_EVENT_SET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - StatusCallback callback = pending_requests_.TakeStatus(request_id); - if (callback) { - callback(event->error); - } - break; - } - case MPV_EVENT_GET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - GetPropertyCallback callback = pending_requests_.TakeProperty(request_id); - if (callback) { - std::string value; - if (event->error >= 0) { - auto* prop = static_cast(event->data); - if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { - auto c_value = *static_cast(prop->data); - if (c_value) value = SanitizeUtf8(c_value); - } - } - callback(event->error, value); - } - break; - } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); char log_msg[512]; @@ -797,50 +738,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { } case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); - mpv_node node{}; - node.format = prop->format; + mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop); - switch (prop->format) { - case MPV_FORMAT_STRING: - node.u.string = prop->data ? *static_cast(prop->data) : nullptr; - break; - case MPV_FORMAT_FLAG: - node.u.flag = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_INT64: - node.u.int64 = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_DOUBLE: - node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; - break; - case MPV_FORMAT_NODE: - if (prop->data) { - node = *static_cast(prop->data); - } - break; - default: - node.format = MPV_FORMAT_NONE; - break; - } - - if (strcmp(prop->name, "current-ao") == 0) { - const char* current_ao = nullptr; - if (prop->format == MPV_FORMAT_STRING && prop->data) { - current_ao = *static_cast(prop->data); - } - const bool is_null = current_ao && strcmp(current_ao, "null") == 0; - const auto transition = - audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now()); - if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) { - LogRecovery("current-ao fell back to null; starting recovery"); - } else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) { - LogRecovery("audio recovered (current-ao no longer null)"); - } - } - if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && - audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) { - LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); - } + // The 100ms mpv_wait_event tick already polls for scheduled reloads. + const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop); + if (notice.message) LogRecovery(notice.message); SendPropertyChange(prop->name, &node); break; From 316a69a1de5318b819bdd32d0eaa890fa46cf9cb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:11:44 +0200 Subject: [PATCH 05/12] refactor: share the paginated grid tab, cached remote store, and tile focus - PaginatedCardGridTabState: the collections and playlists tabs were 95% identical; they now supply only pageSize/fetchPage/idOf instead of each duplicating the grid, memo, inflation budget and focus wiring. - EtagCachedRemoteStore: the anime-lists and fribb mapping stores now share one download/cache/isolate-parse/conditional-GET lifecycle. - FocusableTileStateMixin manages its own initState/didUpdateWidget/dispose instead of requiring every caller to forward three lifecycle hooks. Also drops unused ServerCapabilities entries and dead code in focusable_list_tile and music/track_row. --- lib/focus/focusable_tile_mixin.dart | 24 +- lib/media/media_server_client.dart | 2 +- lib/media/server_capabilities.dart | 108 --------- lib/models/livetv_lineup.dart | 92 -------- lib/models/livetv_lineup.g.dart | 42 ---- lib/models/livetv_server_status.dart | 26 --- lib/models/livetv_server_status.g.dart | 14 -- lib/models/livetv_session.dart | 66 ------ lib/models/livetv_session.g.dart | 25 -- lib/models/media_grabber_device.dart | 103 --------- lib/models/media_grabber_device.g.dart | 47 ---- .../tabs/library_collections_tab.dart | 162 +------------ .../libraries/tabs/library_playlists_tab.dart | 163 +------------ .../tabs/paginated_card_grid_tab.dart | 177 ++++++++++++++ .../trackers/anime_lists_mapping_store.dart | 186 ++------------- .../trackers/etag_cached_remote_store.dart | 216 ++++++++++++++++++ .../trackers/fribb_mapping_store.dart | 209 +++-------------- lib/widgets/app_menu.dart | 8 +- lib/widgets/focusable_list_tile.dart | 72 ------ lib/widgets/music/track_row.dart | 18 -- test/mixins/library_tab_state_test.dart | 6 +- .../profile_session_screen_test.dart | 4 +- test/providers/libraries_provider_test.dart | 4 +- .../providers/offline_mode_provider_test.dart | 11 +- .../catalog_item_detail_screen_test.dart | 5 +- test/screens/catalog_search_screen_test.dart | 5 +- .../collection_detail_screen_test.dart | 4 +- test/screens/discover_screen_test.dart | 6 +- .../downloads_screen_focus_test.dart | 4 +- .../downloads/sync_rules_screen_test.dart | 14 +- test/screens/hub_detail_screen_test.dart | 4 +- .../libraries/folder_tree_view_test.dart | 10 +- .../libraries/libraries_screen_test.dart | 4 +- .../libraries/library_browse_music_test.dart | 4 +- .../libraries/library_browse_tab_test.dart | 4 +- .../libraries/library_playlists_tab_test.dart | 4 +- test/screens/livetv/guide_tab_test.dart | 5 +- test/screens/livetv/live_tv_screen_test.dart | 6 +- .../live_tv_show_schedule_screen_test.dart | 5 +- test/screens/livetv/recordings_tab_test.dart | 5 +- test/screens/media_detail_screen_test.dart | 14 +- test/screens/metadata_edit_screen_test.dart | 4 +- .../music/album_detail_screen_test.dart | 4 +- .../music/now_playing_screen_test.dart | 10 +- test/screens/music/queue_sheet_test.dart | 10 +- test/screens/playlist_detail_screen_test.dart | 4 +- .../profile/profile_teardown_test.dart | 4 +- .../settings/add_jellyfin_screen_test.dart | 4 +- .../episode_navigation_service_test.dart | 17 +- .../live_tv_capability_contract_test.dart | 5 +- .../utils/live_tv_player_navigation_test.dart | 5 +- .../library_management_sheet_test.dart | 7 +- test/widgets/media_context_menu_test.dart | 12 +- test/widgets/music/mini_player_test.dart | 10 +- test/widgets/side_navigation_rail_test.dart | 20 +- test/widgets/tv_browse_rail_test.dart | 66 +++--- 56 files changed, 615 insertions(+), 1455 deletions(-) delete mode 100644 lib/models/livetv_lineup.dart delete mode 100644 lib/models/livetv_lineup.g.dart delete mode 100644 lib/models/livetv_server_status.dart delete mode 100644 lib/models/livetv_server_status.g.dart delete mode 100644 lib/models/livetv_session.dart delete mode 100644 lib/models/livetv_session.g.dart delete mode 100644 lib/models/media_grabber_device.dart delete mode 100644 lib/models/media_grabber_device.g.dart create mode 100644 lib/screens/libraries/tabs/paginated_card_grid_tab.dart create mode 100644 lib/services/trackers/etag_cached_remote_store.dart diff --git a/lib/focus/focusable_tile_mixin.dart b/lib/focus/focusable_tile_mixin.dart index 6ed39821..9b11c40d 100644 --- a/lib/focus/focusable_tile_mixin.dart +++ b/lib/focus/focusable_tile_mixin.dart @@ -7,23 +7,35 @@ import 'owned_focus_node_binding.dart'; /// auto-scrolls the tile into view when it gains focus. mixin FocusableTileStateMixin on State { final _focusNodeBinding = OwnedFocusNodeBinding(); + FocusNode? _boundExternalNode; FocusNode? get widgetFocusNode; FocusNode get effectiveFocusNode => _focusNodeBinding.node; - void initFocusNode() { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); + @override + void initState() { + super.initState(); + _bindFocusNode(); } - void updateFocusNode(FocusNode? oldFocusNode) { - if (oldFocusNode != widgetFocusNode) { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (_boundExternalNode != widgetFocusNode) { + _bindFocusNode(); } } - void disposeFocusNode() { + @override + void dispose() { _focusNodeBinding.dispose(); + super.dispose(); + } + + void _bindFocusNode() { + _boundExternalNode = widgetFocusNode; + _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); } void _onFocusChange() { diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 161978b1..0af35131 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -260,7 +260,7 @@ abstract class MediaServerClient { /// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a /// sidecar-lyrics track stream (`streamType 4`) fetched from /// `/library/streams/{id}` and parsed from LRC. Synced-ness is per - /// [Lyrics.synced]; gated by [ServerCapabilities.lyrics]. + /// [Lyrics.synced]; per-track absence is the runtime gate. Future fetchLyrics(MediaItem track); /// Free-text search across the user's libraries. diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 993251a4..8dcafb82 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -1,17 +1,3 @@ -/// How the alpha-jump bar behaves for libraries on this backend. -enum AlphaBarMode { - /// No alpha bar — hide entirely. - none, - - /// Plex: server reports per-letter cumulative offsets via `/firstCharacter`, - /// taps scroll the grid to the offset. - scrollSnap, - - /// Jellyfin: bar acts as a filter button — taps set `NameStartsWith` query - /// param, results re-fetch. - nameStartsWithFilter, -} - /// Static capability flags advertised by a [MediaServerClient]. UI consults /// these to gate feature affordances per server (e.g. hide Live TV when no /// connected server supports it). @@ -21,14 +7,6 @@ enum AlphaBarMode { /// Jellyfin features are wired in over time, the corresponding flags flip /// without changing call sites. class ServerCapabilities { - /// Server-side `PlayQueue` resource (Plex `/playQueues`) — enables shared - /// queue state across devices and Watch Together coordination. - final bool serverSidePlayQueue; - - /// Server-side editable playlists (Plex `/playlists`, Jellyfin - /// `/Playlists`). - final bool serverSidePlaylists; - /// This backend kind has a Live TV / DVR API the app can talk to. Whether /// a *specific* server has Live TV configured is a runtime concern — /// [MultiServerProvider.checkLiveTvAvailability] probes each server and @@ -41,17 +19,9 @@ class ServerCapabilities { /// when [liveTv] is true. final bool liveTvDvr; - /// Server proxies subtitle search (e.g. OpenSubtitles). - final bool subtitleSearch; - /// Server can transcode video. final bool videoTranscoding; - /// Server supports server-side downloads / "sync" (the queued-from-server - /// model). Both Plex and Jellyfin support client-driven downloads, which - /// is a separate concept. - final bool serverSideSync; - /// Server provides curated recommendation hubs (Plex Discover). Jellyfin /// returns synthesized hubs but with sparser categorisation. final bool richHubs; @@ -72,32 +42,9 @@ class ServerCapabilities { /// Hides the "Search subtitles" affordance when false. final bool externalSubtitleSearch; - /// Persisting per-track audio/subtitle preferences server-side. Plex uses - /// `/library/metadata/{id}/prefs` + `selectStream`; Jellyfin saves selected - /// stream indexes from `/Sessions/Playing/Progress` when the user's Jellyfin - /// remember-selection settings are enabled. When false, in-player switching - /// still works but choices don't follow the user across devices. - final bool trackPreferencePersistence; - - /// Multi-endpoint connection model with endpoint racing/failover. Plex gets - /// local/remote/relay candidates from plex.tv; Jellyfin uses user-entered - /// URLs for the same server. - final bool endpointFailover; - - /// Watch progress can be queued offline and replayed when reconnected - /// ([OfflineWatchSyncService]). Jellyfin reports inline only today. - final bool offlineWatchQueue; - - /// Discord rich-presence integration. Plex-only because the RPC payload - /// uses Plex-shaped session/metadata. - final bool discordRpc; - /// Server exposes metadata edit endpoints. Hides edit affordances when false. final bool richMetadataEdit; - /// How the alpha-jump bar should behave for this backend's libraries. - final AlphaBarMode alphaBar; - /// Server can supply thumbnails for the player's seek-bar scrub preview. /// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite /// sheets. Both backends are wired through [ScrubPreviewSource]; the flag @@ -109,73 +56,40 @@ class ServerCapabilities { /// `/Items?ParentId=...&Recursive=false` queries. final bool folderGrouping; - /// Server can supply track lyrics. Jellyfin exposes `/Audio/{id}/Lyrics`; - /// Plex surfaces sidecar `.lrc`/`.txt` files as track streams - /// (`streamType 4`) fetched via `/library/streams/{id}`. Gates the lyrics - /// affordance in the music player; per-track absence is the runtime gate. - final bool lyrics; - /// Server can build an "instant mix" / radio track list from a seed item. /// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues /// (`POST /playQueues?type=audio&uri=...station...`). final bool instantMix; - /// Server can transcode audio to a capped bitrate. Plex: - /// `/music/:/transcode/universal`; Jellyfin: `PlaybackInfo` with an audio - /// `TranscodingProfile`. Gates the music quality picker (vs original-only). - final bool audioTranscoding; - const ServerCapabilities({ - this.serverSidePlayQueue = false, - this.serverSidePlaylists = false, this.liveTv = false, this.liveTvDvr = false, - this.subtitleSearch = false, this.videoTranscoding = true, - this.serverSideSync = false, this.richHubs = false, this.numericUserRating = false, this.userFavorites = false, this.continueWatchingRemoval = false, this.externalSubtitleSearch = false, - this.trackPreferencePersistence = false, - this.endpointFailover = false, - this.offlineWatchQueue = false, - this.discordRpc = false, this.richMetadataEdit = false, - this.alphaBar = AlphaBarMode.none, this.scrubThumbnails = false, this.folderGrouping = false, - this.lyrics = false, this.instantMix = false, - this.audioTranscoding = false, }); /// Defaults for a fully-featured Plex server. static const ServerCapabilities plex = ServerCapabilities( - serverSidePlayQueue: true, - serverSidePlaylists: true, liveTv: true, liveTvDvr: true, - subtitleSearch: true, videoTranscoding: true, - serverSideSync: true, richHubs: true, numericUserRating: true, userFavorites: false, continueWatchingRemoval: true, externalSubtitleSearch: true, - trackPreferencePersistence: true, - endpointFailover: true, - offlineWatchQueue: true, - discordRpc: true, richMetadataEdit: true, - alphaBar: AlphaBarMode.scrollSnap, scrubThumbnails: true, folderGrouping: true, - lyrics: true, instantMix: true, - audioTranscoding: true, ); /// Defaults for a Jellyfin server. @@ -188,28 +102,17 @@ class ServerCapabilities { /// `/LiveTv/Programs`. Detection + channel listing are wired today; /// EPG and tuning are follow-ups. static const ServerCapabilities jellyfin = ServerCapabilities( - serverSidePlayQueue: false, - serverSidePlaylists: true, liveTv: true, liveTvDvr: false, - subtitleSearch: false, videoTranscoding: true, - serverSideSync: false, richHubs: false, numericUserRating: false, userFavorites: true, externalSubtitleSearch: false, - trackPreferencePersistence: true, - endpointFailover: true, - offlineWatchQueue: false, - discordRpc: false, richMetadataEdit: true, - alphaBar: AlphaBarMode.nameStartsWithFilter, scrubThumbnails: true, folderGrouping: true, - lyrics: true, instantMix: true, - audioTranscoding: true, ); /// Every flag here is fixed per backend *kind* except [videoTranscoding], @@ -218,29 +121,18 @@ class ServerCapabilities { /// ever becomes a runtime probe. ServerCapabilities copyWith({bool? videoTranscoding}) { return ServerCapabilities( - serverSidePlayQueue: serverSidePlayQueue, - serverSidePlaylists: serverSidePlaylists, liveTv: liveTv, liveTvDvr: liveTvDvr, - subtitleSearch: subtitleSearch, videoTranscoding: videoTranscoding ?? this.videoTranscoding, - serverSideSync: serverSideSync, richHubs: richHubs, numericUserRating: numericUserRating, userFavorites: userFavorites, continueWatchingRemoval: continueWatchingRemoval, externalSubtitleSearch: externalSubtitleSearch, - trackPreferencePersistence: trackPreferencePersistence, - endpointFailover: endpointFailover, - offlineWatchQueue: offlineWatchQueue, - discordRpc: discordRpc, richMetadataEdit: richMetadataEdit, - alphaBar: alphaBar, scrubThumbnails: scrubThumbnails, folderGrouping: folderGrouping, - lyrics: lyrics, instantMix: instantMix, - audioTranscoding: audioTranscoding, ); } } diff --git a/lib/models/livetv_lineup.dart b/lib/models/livetv_lineup.dart deleted file mode 100644 index 524bf694..00000000 --- a/lib/models/livetv_lineup.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; -import 'livetv_channel.dart'; - -part 'livetv_lineup.g.dart'; - -List _parseChannels(Object? raw) => parseFlexibleJsonList(raw, LiveTvChannel.fromJson); - -@JsonSerializable(createToJson: false) -class LiveTvCountry { - final String? key; - final String? type; - @JsonKey(defaultValue: '') - final String title; - @JsonKey(defaultValue: '') - final String code; - final String? language; - final String? languageTitle; - final String? example; - @JsonKey(fromJson: flexibleInt) - final int? flavor; - - const LiveTvCountry({ - this.key, - this.type, - required this.title, - required this.code, - this.language, - this.languageTitle, - this.example, - this.flavor, - }); - - factory LiveTvCountry.fromJson(Map json) => _$LiveTvCountryFromJson(json); -} - -@JsonSerializable(createToJson: false) -class LiveTvLanguage { - @JsonKey(defaultValue: '') - final String code; - @JsonKey(defaultValue: '') - final String title; - - const LiveTvLanguage({required this.code, required this.title}); - - factory LiveTvLanguage.fromJson(Map json) => _$LiveTvLanguageFromJson(json); -} - -@JsonSerializable(createToJson: false) -class LiveTvRegion { - @JsonKey(defaultValue: '') - final String key; - final String? type; - @JsonKey(defaultValue: '') - final String title; - - const LiveTvRegion({required this.key, this.type, required this.title}); - - factory LiveTvRegion.fromJson(Map json) => _$LiveTvRegionFromJson(json); -} - -@JsonSerializable(createToJson: false) -class LiveTvLineup { - @JsonKey(defaultValue: '') - final String uuid; - final String? type; - final String? title; - @JsonKey(fromJson: flexibleInt) - final int? lineupType; - final String? location; - @JsonKey(name: 'Channel', fromJson: _parseChannels) - final List channels; - - const LiveTvLineup({ - required this.uuid, - this.type, - this.title, - this.lineupType, - this.location, - this.channels = const [], - }); - - factory LiveTvLineup.fromJson(Map json) => _$LiveTvLineupFromJson(json); -} - -class LiveTvLineupResult { - final String? lineupGroupUuid; - final List lineups; - - const LiveTvLineupResult({this.lineupGroupUuid, required this.lineups}); -} diff --git a/lib/models/livetv_lineup.g.dart b/lib/models/livetv_lineup.g.dart deleted file mode 100644 index f2c3920b..00000000 --- a/lib/models/livetv_lineup.g.dart +++ /dev/null @@ -1,42 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'livetv_lineup.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LiveTvCountry _$LiveTvCountryFromJson(Map json) => - LiveTvCountry( - key: json['key'] as String?, - type: json['type'] as String?, - title: json['title'] as String? ?? '', - code: json['code'] as String? ?? '', - language: json['language'] as String?, - languageTitle: json['languageTitle'] as String?, - example: json['example'] as String?, - flavor: flexibleInt(json['flavor']), - ); - -LiveTvLanguage _$LiveTvLanguageFromJson(Map json) => - LiveTvLanguage( - code: json['code'] as String? ?? '', - title: json['title'] as String? ?? '', - ); - -LiveTvRegion _$LiveTvRegionFromJson(Map json) => LiveTvRegion( - key: json['key'] as String? ?? '', - type: json['type'] as String?, - title: json['title'] as String? ?? '', -); - -LiveTvLineup _$LiveTvLineupFromJson(Map json) => LiveTvLineup( - uuid: json['uuid'] as String? ?? '', - type: json['type'] as String?, - title: json['title'] as String?, - lineupType: flexibleInt(json['lineupType']), - location: json['location'] as String?, - channels: json['Channel'] == null - ? const [] - : _parseChannels(json['Channel']), -); diff --git a/lib/models/livetv_server_status.dart b/lib/models/livetv_server_status.dart deleted file mode 100644 index eea63382..00000000 --- a/lib/models/livetv_server_status.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; - -part 'livetv_server_status.g.dart'; - -@JsonSerializable(createToJson: false) -class LiveTvServerStatus { - @JsonKey(name: 'livetv', fromJson: flexibleInt) - final int? liveTvCount; - @JsonKey(fromJson: flexibleBoolNullable) - final bool? allowTuners; - final String? ownerFeatures; - - const LiveTvServerStatus({this.liveTvCount, this.allowTuners, this.ownerFeatures}); - - factory LiveTvServerStatus.fromJson(Map json) => _$LiveTvServerStatusFromJson(json); - - Set get ownerFeatureSet => - (ownerFeatures ?? '').split(',').map((feature) => feature.trim()).where((feature) => feature.isNotEmpty).toSet(); - - bool get hasConfiguredDvr => (liveTvCount ?? 0) > 0; - bool get supportsTuners => allowTuners != false; - bool get hasDvrFeature => ownerFeatureSet.contains('dvr'); - bool get hasLiveTvFeature => ownerFeatureSet.contains('livetv'); -} diff --git a/lib/models/livetv_server_status.g.dart b/lib/models/livetv_server_status.g.dart deleted file mode 100644 index bcdd4431..00000000 --- a/lib/models/livetv_server_status.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'livetv_server_status.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LiveTvServerStatus _$LiveTvServerStatusFromJson(Map json) => - LiveTvServerStatus( - liveTvCount: flexibleInt(json['livetv']), - allowTuners: flexibleBoolNullable(json['allowTuners']), - ownerFeatures: json['ownerFeatures'] as String?, - ); diff --git a/lib/models/livetv_session.dart b/lib/models/livetv_session.dart deleted file mode 100644 index 05fe69d2..00000000 --- a/lib/models/livetv_session.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; -import 'livetv_capture_buffer.dart'; -import 'livetv_program.dart'; -import 'media_grab_operation.dart'; - -part 'livetv_session.g.dart'; - -LiveTvProgram? _programFromRaw(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson); - -MediaGrabOperation? _grabOperationFromRaw(Object? raw) => parseFlexibleJsonObject(raw, MediaGrabOperation.fromJson); - -CaptureBuffer? _captureBufferFromRaw(Object? raw) { - final map = firstFlexibleMap(raw); - if (map == null) return null; - final session = firstFlexibleMap(map['TranscodeSession']) ?? map; - return CaptureBuffer.fromTranscodeSession(session); -} - -@JsonSerializable(createToJson: false) -class LiveTvSession { - @JsonKey(readValue: readStringField, defaultValue: '') - final String sessionID; - @JsonKey(readValue: readStringField) - final String? dvrID; - final String? channelIdentifier; - final String? channelCallSign; - final String? channelTitle; - final String? activityUUID; - @JsonKey(fromJson: flexibleInt) - final int? currentPosition; - @JsonKey(fromJson: flexibleInt) - final int? nextPosition; - @JsonKey(fromJson: flexibleInt) - final int? startedAt; - @JsonKey(name: 'CaptureBuffer', fromJson: _captureBufferFromRaw) - final CaptureBuffer? captureBuffer; - @JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw) - final MediaGrabOperation? grabOperation; - @JsonKey(name: 'Timeline', fromJson: firstFlexibleMap) - final Map? timeline; - @JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw) - final LiveTvProgram? airingMetadataItem; - @JsonKey(name: 'UpNextMetadataItem', fromJson: _programFromRaw) - final LiveTvProgram? upNextMetadataItem; - - const LiveTvSession({ - required this.sessionID, - this.dvrID, - this.channelIdentifier, - this.channelCallSign, - this.channelTitle, - this.activityUUID, - this.currentPosition, - this.nextPosition, - this.startedAt, - this.captureBuffer, - this.grabOperation, - this.timeline, - this.airingMetadataItem, - this.upNextMetadataItem, - }); - - factory LiveTvSession.fromJson(Map json) => _$LiveTvSessionFromJson(json); -} diff --git a/lib/models/livetv_session.g.dart b/lib/models/livetv_session.g.dart deleted file mode 100644 index 0e1f1571..00000000 --- a/lib/models/livetv_session.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'livetv_session.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LiveTvSession _$LiveTvSessionFromJson(Map json) => - LiveTvSession( - sessionID: readStringField(json, 'sessionID') as String? ?? '', - dvrID: readStringField(json, 'dvrID') as String?, - channelIdentifier: json['channelIdentifier'] as String?, - channelCallSign: json['channelCallSign'] as String?, - channelTitle: json['channelTitle'] as String?, - activityUUID: json['activityUUID'] as String?, - currentPosition: flexibleInt(json['currentPosition']), - nextPosition: flexibleInt(json['nextPosition']), - startedAt: flexibleInt(json['startedAt']), - captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']), - grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']), - timeline: firstFlexibleMap(json['Timeline']), - airingMetadataItem: _programFromRaw(json['AiringMetadataItem']), - upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']), - ); diff --git a/lib/models/media_grabber_device.dart b/lib/models/media_grabber_device.dart deleted file mode 100644 index 5ab834e6..00000000 --- a/lib/models/media_grabber_device.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; -import 'livetv_dvr.dart'; -import 'media_subscription.dart'; - -part 'media_grabber_device.g.dart'; - -List _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson); - -List _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson); - -@JsonSerializable(createToJson: false) -class MediaGrabber { - @JsonKey(defaultValue: '') - final String identifier; - final String? protocol; - final String? title; - - const MediaGrabber({required this.identifier, this.protocol, this.title}); - - factory MediaGrabber.fromJson(Map json) => _$MediaGrabberFromJson(json); -} - -/// Tuner/grabber device known to Plex Media Server. -@JsonSerializable(createToJson: false) -class MediaGrabberDevice { - @JsonKey(defaultValue: '') - final String key; - @JsonKey(defaultValue: '') - final String uuid; - final String? uri; - final String? protocol; - final String? title; - final String? make; - final String? model; - final String? modelNumber; - final String? firmware; - @JsonKey(fromJson: flexibleInt) - final int? tuners; - final String? sources; - @JsonKey(fromJson: flexibleInt) - final int? status; - @JsonKey(fromJson: flexibleInt) - final int? state; - @JsonKey(fromJson: flexibleInt) - final int? lastSeenAt; - @JsonKey(name: 'ChannelMapping', fromJson: _parseChannelMappings) - final List channelMappings; - @JsonKey(name: 'Setting', fromJson: _parseSettings) - final List settings; - - const MediaGrabberDevice({ - required this.key, - required this.uuid, - this.uri, - this.protocol, - this.title, - this.make, - this.model, - this.modelNumber, - this.firmware, - this.tuners, - this.sources, - this.status, - this.state, - this.lastSeenAt, - this.channelMappings = const [], - this.settings = const [], - }); - - factory MediaGrabberDevice.fromJson(Map json) => _$MediaGrabberDeviceFromJson(json); -} - -@JsonSerializable(createToJson: false) -class MediaGrabberDeviceChannel { - @JsonKey(readValue: readStringField, defaultValue: '') - final String identifier; - @JsonKey(readValue: readStringField) - final String? key; - @JsonKey(readValue: readStringField) - final String? name; - @JsonKey(fromJson: flexibleBool) - final bool drm; - @JsonKey(fromJson: flexibleBool) - final bool hd; - - const MediaGrabberDeviceChannel({required this.identifier, this.key, this.name, this.drm = false, this.hd = false}); - - factory MediaGrabberDeviceChannel.fromJson(Map json) => _$MediaGrabberDeviceChannelFromJson(json); -} - -class MediaGrabberChannelMapRequest { - final List channelsEnabled; - final Map channelMapping; - final Map channelMappingByKey; - - const MediaGrabberChannelMapRequest({ - this.channelsEnabled = const [], - this.channelMapping = const {}, - this.channelMappingByKey = const {}, - }); -} diff --git a/lib/models/media_grabber_device.g.dart b/lib/models/media_grabber_device.g.dart deleted file mode 100644 index fff4efb0..00000000 --- a/lib/models/media_grabber_device.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'media_grabber_device.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MediaGrabber _$MediaGrabberFromJson(Map json) => MediaGrabber( - identifier: json['identifier'] as String? ?? '', - protocol: json['protocol'] as String?, - title: json['title'] as String?, -); - -MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map json) => - MediaGrabberDevice( - key: json['key'] as String? ?? '', - uuid: json['uuid'] as String? ?? '', - uri: json['uri'] as String?, - protocol: json['protocol'] as String?, - title: json['title'] as String?, - make: json['make'] as String?, - model: json['model'] as String?, - modelNumber: json['modelNumber'] as String?, - firmware: json['firmware'] as String?, - tuners: flexibleInt(json['tuners']), - sources: json['sources'] as String?, - status: flexibleInt(json['status']), - state: flexibleInt(json['state']), - lastSeenAt: flexibleInt(json['lastSeenAt']), - channelMappings: json['ChannelMapping'] == null - ? const [] - : _parseChannelMappings(json['ChannelMapping']), - settings: json['Setting'] == null - ? const [] - : _parseSettings(json['Setting']), - ); - -MediaGrabberDeviceChannel _$MediaGrabberDeviceChannelFromJson( - Map json, -) => MediaGrabberDeviceChannel( - identifier: readStringField(json, 'identifier') as String? ?? '', - key: readStringField(json, 'key') as String?, - name: readStringField(json, 'name') as String?, - drm: json['drm'] == null ? false : flexibleBool(json['drm']), - hd: json['hd'] == null ? false : flexibleBool(json['hd']), -); diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 4dbd3cbc..6df6ed59 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -1,26 +1,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../focus/input_mode_tracker.dart'; import '../../../media/library_query.dart'; import '../../../media/media_item.dart'; -import '../../../mixins/library_tab_focus_mixin.dart'; -import '../../../mixins/paginated_item_loader.dart'; -import '../../../mixins/standard_paginated_view.dart'; -import '../../../services/settings_service.dart'; -import '../../../utils/error_message_utils.dart'; -import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; -import '../../../utils/platform_detector.dart'; -import '../../../widgets/card_inflation_budget.dart'; -import '../../../widgets/focusable_media_card.dart'; -import '../../../widgets/media_card_sliver_layout.dart'; -import '../../../widgets/settings_builder.dart'; -import '../../../widgets/skeleton_media_card.dart'; -import '../../../widgets/sliver_child_memo.dart'; import '../../../i18n/strings.g.dart'; -import '../../main_screen.dart'; import 'base_library_tab.dart'; +import 'paginated_card_grid_tab.dart'; /// Collections tab for library screen. /// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root. @@ -40,24 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab { State createState() => _LibraryCollectionsTabState(); } -class _LibraryCollectionsTabState extends BaseLibraryTabState - with - LibraryTabFocusMixin, - PaginatedItemLoader, - StandardPaginatedView, - SkeletonUpgradeScheduler { - static const int _pageSize = 36; - - /// Reuses card widgets across delegate swaps so tab-level setStates - /// (pagination, refreshes) don't rebuild every realized card inside layout. - final SliverChildMemo _cardMemo = SliverChildMemo(); +class _LibraryCollectionsTabState extends PaginatedCardGridTabState { + @override + int get pageSize => 36; @override String get focusNodeDebugLabel => 'collections_first_item'; - @override - int get itemCount => totalSize; - @override IconData get emptyIcon => Symbols.collections_rounded; @@ -71,7 +46,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState? getRefreshStream() => LibraryRefreshNotifier().collectionsStream; @override - Future> loadData() async => const []; + String idOf(MediaItem item) => item.id; @override Future> fetchPage(int start, int size, AbortController? abort) { @@ -80,42 +55,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() { - return loadStandardPaginatedItems( - pageSize: _pageSize, - errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), - onLoaded: (_, _) => markItemsLoaded(), - ); - } - - @override - Widget buildContent(List items) { - return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], - builder: (context) { - final settings = SettingsService.instance; - final viewMode = settings.read(SettingsService.viewMode); - final density = settings.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); - return CustomScrollView( - clipBehavior: Clip.none, - slivers: [ - SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - _buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout), - ], - ); - }, - ); - } - - static const double _focusDecorationPadding = 3.0; - - EdgeInsets get _effectivePadding { - final base = GridLayoutConstants.gridPadding; - return base.copyWith(top: base.top + _focusDecorationPadding); - } - - bool get _usesSquareCards { + bool get usesSquareCards { final loaded = loadedItems.values; return loaded.isNotEmpty && loaded.every(_isMusicCollection); } @@ -124,94 +64,4 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState item.kind.isMusic || (item is PlexMediaItem && widget.library.kind.isMusic); - - Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { - final shape = _usesSquareCards ? CardShape.square : null; - final useFullCardLayout = fullCardLayout && shape != CardShape.square; - return MediaCardSliverLayout( - viewMode: viewMode, - itemCount: totalSize, - density: density, - padding: _effectivePadding, - fullBleedImage: useFullCardLayout, - shape: shape, - listEpoch: (ViewMode.list, totalSize, density, shape), - gridEpochBuilder: (geometry) => - (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), - itemBuilder: (context, position) { - final index = position.index; - final item = loadedItems[index]; - if (item == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - if (!position.isGrid) { - return _cardMemo.widgetFor( - index, - item, - epoch: position.layoutEpoch!, - build: () => - _buildMediaCardItem(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true), - ); - } - - final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!); - if (cached != null) return cached; - if (CardInflationBudget.isScrollingContext(context) && - !InputModeTracker.isKeyboardMode(context) && - !CardInflationBudget.tryTake()) { - scheduleSkeletonUpgrade(); - return const SkeletonMediaCard(); - } - return _cardMemo.widgetFor( - index, - item, - epoch: position.layoutEpoch!, - build: () => _buildMediaCardItem( - index, - isFirstRow: position.isFirstRow, - isFirstColumn: position.isFirstColumn, - fullBleedImage: useFullCardLayout, - ), - ); - }, - ); - } - - Widget _buildMediaCardItem( - int index, { - required bool isFirstRow, - required bool isFirstColumn, - bool disableScale = false, - bool fullBleedImage = false, - }) { - final item = loadedItems[index]; - if (item == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - - return FocusableMediaCard( - key: Key(item.id), - item: item, - focusNode: index == 0 ? firstItemFocusNode : null, - disableScale: disableScale, - fullBleedImage: fullBleedImage, - cardShapeOverride: _usesSquareCards ? CardShape.square : null, - onListRefresh: loadItems, - onNavigateUp: isFirstRow ? widget.onBack : null, - onBack: widget.onBack, - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, - ); - } - - void _navigateToSidebar() { - MainScreenFocusScope.focusSidebarOf(context); - } - - @override - void dispose() { - disposePagination(); - super.dispose(); - } } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 12127c31..93b82cac 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -1,28 +1,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../focus/input_mode_tracker.dart'; import '../../../media/library_query.dart'; -import '../../../media/media_item.dart'; import '../../../media/media_kind.dart'; import '../../../media/media_playlist.dart'; -import '../../../mixins/library_tab_focus_mixin.dart'; -import '../../../mixins/paginated_item_loader.dart'; -import '../../../mixins/standard_paginated_view.dart'; -import '../../../services/settings_service.dart'; -import '../../../utils/error_message_utils.dart'; -import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; -import '../../../utils/platform_detector.dart'; -import '../../../widgets/card_inflation_budget.dart'; -import '../../../widgets/focusable_media_card.dart'; -import '../../../widgets/media_card_sliver_layout.dart'; -import '../../../widgets/settings_builder.dart'; -import '../../../widgets/skeleton_media_card.dart'; -import '../../../widgets/sliver_child_memo.dart'; import '../../../i18n/strings.g.dart'; -import '../../main_screen.dart'; import 'base_library_tab.dart'; +import 'paginated_card_grid_tab.dart'; /// Playlists tab for library screen /// Shows playlists that contain items from the current library @@ -42,24 +27,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab { State createState() => _LibraryPlaylistsTabState(); } -class _LibraryPlaylistsTabState extends BaseLibraryTabState - with - LibraryTabFocusMixin, - PaginatedItemLoader, - StandardPaginatedView, - SkeletonUpgradeScheduler { - static const int _pageSize = 200; - - /// Reuses card widgets across delegate swaps so tab-level setStates - /// (pagination, refreshes) don't rebuild every realized card inside layout. - final SliverChildMemo _cardMemo = SliverChildMemo(); +class _LibraryPlaylistsTabState extends PaginatedCardGridTabState { + @override + int get pageSize => 200; @override String get focusNodeDebugLabel => 'playlists_first_item'; - @override - int get itemCount => totalSize; - @override IconData get emptyIcon => Symbols.playlist_play_rounded; @@ -73,7 +47,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState? getRefreshStream() => LibraryRefreshNotifier().playlistsStream; @override - Future> loadData() async => const []; + String idOf(MediaPlaylist playlist) => playlist.id; @override Future> fetchPage(int start, int size, AbortController? abort) { @@ -86,130 +60,5 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState loadItems() { - return loadStandardPaginatedItems( - pageSize: _pageSize, - errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), - onLoaded: (_, _) => markItemsLoaded(), - ); - } - - @override - Widget buildContent(List items) { - return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], - builder: (context) { - final settings = SettingsService.instance; - final viewMode = settings.read(SettingsService.viewMode); - final density = settings.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); - return CustomScrollView( - clipBehavior: Clip.none, - slivers: [ - SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - _buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout), - ], - ); - }, - ); - } - - static const double _focusDecorationPadding = 3.0; - - EdgeInsets get _effectivePadding { - final base = GridLayoutConstants.gridPadding; - return base.copyWith(top: base.top + _focusDecorationPadding); - } - - bool get _usesSquareCards => widget.library.kind.isMusic; - - Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { - final shape = _usesSquareCards ? CardShape.square : null; - final useFullCardLayout = fullCardLayout && shape != CardShape.square; - return MediaCardSliverLayout( - viewMode: viewMode, - itemCount: totalSize, - density: density, - padding: _effectivePadding, - fullBleedImage: useFullCardLayout, - shape: shape, - listEpoch: (ViewMode.list, totalSize, density, shape), - gridEpochBuilder: (geometry) => - (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), - itemBuilder: (context, position) { - final index = position.index; - final playlist = loadedItems[index]; - if (playlist == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - if (!position.isGrid) { - return _cardMemo.widgetFor( - index, - playlist, - epoch: position.layoutEpoch!, - build: () => - _buildPlaylistCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true), - ); - } - - final cached = _cardMemo.tryGet(index, playlist, epoch: position.layoutEpoch!); - if (cached != null) return cached; - if (CardInflationBudget.isScrollingContext(context) && - !InputModeTracker.isKeyboardMode(context) && - !CardInflationBudget.tryTake()) { - scheduleSkeletonUpgrade(); - return const SkeletonMediaCard(); - } - return _cardMemo.widgetFor( - index, - playlist, - epoch: position.layoutEpoch!, - build: () => _buildPlaylistCard( - index, - isFirstRow: position.isFirstRow, - isFirstColumn: position.isFirstColumn, - fullBleedImage: useFullCardLayout, - ), - ); - }, - ); - } - - Widget _buildPlaylistCard( - int index, { - required bool isFirstRow, - required bool isFirstColumn, - bool disableScale = false, - bool fullBleedImage = false, - }) { - final playlist = loadedItems[index]; - if (playlist == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - - return FocusableMediaCard( - key: Key(playlist.id), - item: playlist, - focusNode: index == 0 ? firstItemFocusNode : null, - disableScale: disableScale, - fullBleedImage: fullBleedImage, - cardShapeOverride: _usesSquareCards ? CardShape.square : null, - onListRefresh: loadItems, - onNavigateUp: isFirstRow ? widget.onBack : null, - onBack: widget.onBack, - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, - ); - } - - void _navigateToSidebar() { - MainScreenFocusScope.focusSidebarOf(context); - } - - @override - void dispose() { - disposePagination(); - super.dispose(); - } + bool get usesSquareCards => widget.library.kind.isMusic; } diff --git a/lib/screens/libraries/tabs/paginated_card_grid_tab.dart b/lib/screens/libraries/tabs/paginated_card_grid_tab.dart new file mode 100644 index 00000000..4686d32e --- /dev/null +++ b/lib/screens/libraries/tabs/paginated_card_grid_tab.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import '../../../focus/input_mode_tracker.dart'; +import '../../../media/media_item.dart'; +import '../../../mixins/library_tab_focus_mixin.dart'; +import '../../../mixins/paginated_item_loader.dart'; +import '../../../mixins/standard_paginated_view.dart'; +import '../../../services/settings_service.dart'; +import '../../../utils/error_message_utils.dart'; +import '../../../utils/layout_constants.dart'; +import '../../../utils/platform_detector.dart'; +import '../../../widgets/card_inflation_budget.dart'; +import '../../../widgets/focusable_media_card.dart'; +import '../../../widgets/media_card_sliver_layout.dart'; +import '../../../widgets/settings_builder.dart'; +import '../../../widgets/skeleton_media_card.dart'; +import '../../../widgets/sliver_child_memo.dart'; +import '../../main_screen.dart'; +import 'base_library_tab.dart'; + +/// Library tabs whose whole body is one paginated grid of media cards. +/// +/// Owns the grid: sparse page loading, the card widget memo, the inflation +/// budget and skeleton-upgrade handshake, and first-item/sidebar focus wiring. +/// Subclasses supply only what differs per tab — [pageSize], [fetchPage], +/// [usesSquareCards], [idOf], and the empty/error chrome from +/// [BaseLibraryTabState]. +abstract class PaginatedCardGridTabState> + extends BaseLibraryTabState + with + LibraryTabFocusMixin, + PaginatedItemLoader, + StandardPaginatedView, + SkeletonUpgradeScheduler { + static const double _focusDecorationPadding = 3.0; + + /// Reuses card widgets across delegate swaps so tab-level setStates + /// (pagination, refreshes) don't rebuild every realized card inside layout. + final SliverChildMemo _cardMemo = SliverChildMemo(); + + /// Items fetched per page. + int get pageSize; + + /// Whether cards render with the square container silhouette. + bool get usesSquareCards; + + /// Card key for [item]. The tabs' item types share no common supertype. + String idOf(T item); + + @override + int get itemCount => totalSize; + + @override + Future> loadData() async => []; + + @override + Future loadItems() { + return loadStandardPaginatedItems( + pageSize: pageSize, + errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), + onLoaded: (_, _) => markItemsLoaded(), + ); + } + + @override + Widget buildContent(List items) { + return SettingsBuilder( + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], + builder: (context) { + final settings = SettingsService.instance; + final viewMode = settings.read(SettingsService.viewMode); + final density = settings.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); + return CustomScrollView( + clipBehavior: Clip.none, + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + _buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout), + ], + ); + }, + ); + } + + EdgeInsets get _effectivePadding { + final base = GridLayoutConstants.gridPadding; + return base.copyWith(top: base.top + _focusDecorationPadding); + } + + Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { + final shape = usesSquareCards ? CardShape.square : null; + final useFullCardLayout = fullCardLayout && shape != CardShape.square; + return MediaCardSliverLayout( + viewMode: viewMode, + itemCount: totalSize, + density: density, + padding: _effectivePadding, + fullBleedImage: useFullCardLayout, + shape: shape, + listEpoch: (ViewMode.list, totalSize, density, shape), + gridEpochBuilder: (geometry) => + (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), + itemBuilder: (context, position) { + final index = position.index; + final item = loadedItems[index]; + if (item == null) { + ensureIndexLoaded(index, pageSize: pageSize); + return const SkeletonMediaCard(); + } + if (!position.isGrid) { + return _cardMemo.widgetFor( + index, + item, + epoch: position.layoutEpoch!, + build: () => _buildCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true), + ); + } + + final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!); + if (cached != null) return cached; + if (CardInflationBudget.isScrollingContext(context) && + !InputModeTracker.isKeyboardMode(context) && + !CardInflationBudget.tryTake()) { + scheduleSkeletonUpgrade(); + return const SkeletonMediaCard(); + } + return _cardMemo.widgetFor( + index, + item, + epoch: position.layoutEpoch!, + build: () => _buildCard( + index, + isFirstRow: position.isFirstRow, + isFirstColumn: position.isFirstColumn, + fullBleedImage: useFullCardLayout, + ), + ); + }, + ); + } + + Widget _buildCard( + int index, { + required bool isFirstRow, + required bool isFirstColumn, + bool disableScale = false, + bool fullBleedImage = false, + }) { + final item = loadedItems[index]; + if (item == null) { + ensureIndexLoaded(index, pageSize: pageSize); + return const SkeletonMediaCard(); + } + + return FocusableMediaCard( + key: Key(idOf(item)), + item: item, + focusNode: index == 0 ? firstItemFocusNode : null, + disableScale: disableScale, + fullBleedImage: fullBleedImage, + cardShapeOverride: usesSquareCards ? CardShape.square : null, + onListRefresh: loadItems, + onNavigateUp: isFirstRow ? widget.onBack : null, + onBack: widget.onBack, + onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + ); + } + + void _navigateToSidebar() { + MainScreenFocusScope.focusSidebarOf(context); + } + + @override + void dispose() { + disposePagination(); + super.dispose(); + } +} diff --git a/lib/services/trackers/anime_lists_mapping_store.dart b/lib/services/trackers/anime_lists_mapping_store.dart index cc8f216f..6b412a60 100644 --- a/lib/services/trackers/anime_lists_mapping_store.dart +++ b/lib/services/trackers/anime_lists_mapping_store.dart @@ -1,27 +1,23 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:xml/xml.dart'; import '../../models/trackers/anime_lists_mapping.dart'; -import '../../utils/abortable_http_request.dart'; -import '../../utils/app_logger.dart'; import '../../utils/json_utils.dart'; -import '../../utils/platform_http_client_stub.dart' - if (dart.library.io) '../../utils/platform_http_client_io.dart' - as platform; -import '../base_shared_preferences_service.dart'; +import 'etag_cached_remote_store.dart'; -class AnimeListsIndex { +class AnimeListsIndex implements RemoteIndex { final Map> byTvdb; final Map> byTmdbTv; const AnimeListsIndex({required this.byTvdb, required this.byTmdbTv}); + @override bool get isEmpty => byTvdb.isEmpty && byTmdbTv.isEmpty; + + @override + String get logSummary => '${byTvdb.length} tvdb entries'; } abstract interface class AnimeListsMappingLookup { @@ -32,88 +28,25 @@ abstract interface class AnimeListsMappingLookup { Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}); } -class AnimeListsMappingStore implements AnimeListsMappingLookup { - static const String _diskFileName = 'anime-list.xml'; - static const String _prefsEtagKey = 'anime_lists_etag'; - static const String _prefsLastCheckKey = 'anime_lists_last_check'; - static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml'; - - static const Duration _refreshInterval = Duration(days: 7); - static const Duration _requestTimeout = Duration(seconds: 60); - - AnimeListsMappingStore._(); - static final AnimeListsMappingStore instance = AnimeListsMappingStore._(); - - AnimeListsIndex? _index; - Future? _loading; - bool _refreshRunning = false; - - Future _ensureLoaded() async { - final existing = _index; - if (existing != null) return existing; - final loading = _loading; - if (loading != null) return loading; - - final fresh = _loadOrFetch(); - _loading = fresh; - try { - final idx = await fresh; - if (!idx.isEmpty) { - _index = idx; - unawaited(maybeRefresh()); - } - return idx; - } finally { - _loading = null; - } - } - - Future _loadOrFetch() async { - final path = await _diskPath(); - try { - return await compute(_readAndParseAnimeLists, path); - } on FileSystemException { - appLogger.d('Anime-Lists: no disk cache, downloading from jsDelivr'); - final raw = await _download(); - if (raw == null) return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}); - return await compute(parseAnimeListsIndex, raw); - } catch (e) { - appLogger.w('Anime-Lists: parse failed - deleting disk copy so next lookup re-downloads', error: e); - await _deleteDiskCopy(); - return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}); - } - } - - Future _download() async { - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: const {'Accept': 'application/xml,text/xml'}, - timeout: _requestTimeout, - operation: 'Anime-Lists mapping download', +class AnimeListsMappingStore extends EtagCachedRemoteStore implements AnimeListsMappingLookup { + AnimeListsMappingStore._() + : super( + diskFileName: 'anime-list.xml', + prefsEtagKey: 'anime_lists_etag', + prefsLastCheckKey: 'anime_lists_last_check', + sourceUrl: 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml', + acceptHeader: 'application/xml,text/xml', + logLabel: 'Anime-Lists', + emptyIndex: const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}), + parse: parseAnimeListsIndex, + readAndParse: _readAndParseAnimeLists, ); - if (res.statusCode != 200) { - appLogger.d('Anime-Lists: download returned HTTP ${res.statusCode}'); - return null; - } - await _writeDiskCopy(res.body, etag: res.headers['etag']); - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); - return res.body; - } catch (e) { - appLogger.w('Anime-Lists: download failed', error: e); - return null; - } finally { - client.close(); - } - } + + static final AnimeListsMappingStore instance = AnimeListsMappingStore._(); @override Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); return lookupAnimeListEpisodeInIndex( idx, tvdbId: tvdbId, @@ -125,7 +58,7 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup { @override Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); if (tvdbId != null) { final ids = _seasonAnimeIds(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season); if (ids.isNotEmpty) return ids; @@ -138,7 +71,7 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup { @override Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); if (tvdbId != null) { final entries = idx.byTvdb[tvdbId]; if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId}; @@ -149,79 +82,6 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup { } return const {}; } - - Future maybeRefresh() async { - if (_refreshRunning) return; - if (_index == null) return; - _refreshRunning = true; - try { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0; - final now = DateTime.now().millisecondsSinceEpoch; - if (now - lastCheck < _refreshInterval.inMilliseconds) return; - - final etag = prefs.getString(_prefsEtagKey); - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: {'If-None-Match': ?etag, 'Accept': 'application/xml,text/xml'}, - timeout: _requestTimeout, - operation: 'Anime-Lists mapping refresh', - ); - await prefs.setInt(_prefsLastCheckKey, now); - - if (res.statusCode == 304) { - appLogger.d('Anime-Lists: mapping unchanged (304)'); - return; - } - if (res.statusCode != 200) { - appLogger.d('Anime-Lists: refresh returned HTTP ${res.statusCode}'); - return; - } - - await _writeDiskCopy(res.body, etag: res.headers['etag']); - final fresh = await compute(parseAnimeListsIndex, res.body); - _index = fresh; - appLogger.d('Anime-Lists: mapping refreshed (${fresh.byTvdb.length} tvdb entries)'); - } finally { - client.close(); - } - } catch (e) { - appLogger.d('Anime-Lists: refresh failed (non-fatal)', error: e); - } finally { - _refreshRunning = false; - } - } - - Future _writeDiskCopy(String body, {String? etag}) async { - await File(await _diskPath()).writeAsString(body, flush: true); - if (etag != null) { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setString(_prefsEtagKey, etag); - } - } - - Future _deleteDiskCopy() async { - try { - await File(await _diskPath()).delete(); - } on FileSystemException { - // Already gone. - } - } - - Future _diskPath() async { - final dir = await getApplicationSupportDirectory(); - return p.join(dir.path, _diskFileName); - } - - @visibleForTesting - void resetForTesting() { - _index = null; - _loading = null; - } } @visibleForTesting diff --git a/lib/services/trackers/etag_cached_remote_store.dart b/lib/services/trackers/etag_cached_remote_store.dart new file mode 100644 index 00000000..10db84e2 --- /dev/null +++ b/lib/services/trackers/etag_cached_remote_store.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../utils/abortable_http_request.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/platform_http_client_stub.dart' + if (dart.library.io) '../../utils/platform_http_client_io.dart' + as platform; +import '../base_shared_preferences_service.dart'; + +/// An index parsed out of a cached remote file. +abstract interface class RemoteIndex { + bool get isEmpty; + + /// Reported after a successful refresh, e.g. `'1234 tvdb entries'`. + String get logSummary; +} + +/// Base for the mapping stores backed by a static file on jsDelivr. +/// +/// Owns the whole lifecycle: lazy download on first use, a disk copy in the +/// app-support directory, parsing in a background isolate, and a weekly +/// conditional-GET ([maybeRefresh], If-None-Match) to pick up upstream changes. +/// Subclasses supply the source, the cache keys and the two isolate entry +/// points, and build their lookups on top of [ensureLoaded]. +abstract class EtagCachedRemoteStore { + static const Duration _refreshInterval = Duration(days: 7); + static const Duration _requestTimeout = Duration(seconds: 60); + + final String diskFileName; + final String prefsEtagKey; + final String prefsLastCheckKey; + final String sourceUrl; + final String acceptHeader; + + /// Prefixes log lines and the abortable-request operation names. + final String logLabel; + + /// Returned when nothing could be loaded; never cached. + final T emptyIndex; + + /// Parses a raw body. Top-level so it can run in a `compute` isolate. + final T Function(String raw) parse; + + /// Reads the disk copy and parses it inside the isolate. Halves peak memory + /// vs. reading the string on the main isolate and shipping it across. + final T Function(String path) readAndParse; + + EtagCachedRemoteStore({ + required this.diskFileName, + required this.prefsEtagKey, + required this.prefsLastCheckKey, + required this.sourceUrl, + required this.acceptHeader, + required this.logLabel, + required this.emptyIndex, + required this.parse, + required this.readAndParse, + }); + + T? _index; + Future? _loading; + bool _refreshRunning = false; + + /// Lazily load, downloading on first use. Subsequent calls return the + /// cached index in O(1). Concurrent callers share the same Future. + /// Schedules a background refresh after the first successful load. + @protected + Future ensureLoaded() async { + final existing = _index; + if (existing != null) return existing; + final loading = _loading; + if (loading != null) return loading; + + final fresh = _loadOrFetch(); + _loading = fresh; + try { + final idx = await fresh; + // Don't cache an empty index (network failure, no disk copy) — let the + // next lookup retry so transient offline periods self-heal. + if (!idx.isEmpty) { + _index = idx; + unawaited(maybeRefresh()); + } + return idx; + } finally { + _loading = null; + } + } + + Future _loadOrFetch() async { + final path = await _diskPath(); + try { + return await compute(readAndParse, path); + } on FileSystemException { + appLogger.d('$logLabel: no disk cache, downloading from jsDelivr'); + final raw = await _download(); + if (raw == null) return emptyIndex; + return await compute(parse, raw); + } catch (e) { + appLogger.w('$logLabel: parse failed — deleting disk copy so next lookup re-downloads', error: e); + await _deleteDiskCopy(); + return emptyIndex; + } + } + + /// GET the mapping, save it to disk, and return the body. Returns `null` + /// on any failure (offline, 4xx/5xx, timeout). + Future _download() async { + final client = platform.createPlatformClient(); + try { + final res = await sendAbortableHttpRequest( + client, + 'GET', + Uri.parse(sourceUrl), + headers: {'Accept': acceptHeader}, + timeout: _requestTimeout, + operation: '$logLabel mapping download', + ); + if (res.statusCode != 200) { + appLogger.d('$logLabel: download returned HTTP ${res.statusCode}'); + return null; + } + await _writeDiskCopy(res.body, etag: res.headers['etag']); + // Seed the weekly throttle so a same-week relaunch skips the refresh. + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setInt(prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); + return res.body; + } catch (e) { + appLogger.w('$logLabel: download failed', error: e); + return null; + } finally { + client.close(); + } + } + + /// Conditional-GET the mapping if the last check was >[_refreshInterval] ago + /// and we already have an index loaded. No-op when nothing is loaded — the + /// first lookup handles the initial download. + Future maybeRefresh() async { + if (_refreshRunning) return; + if (_index == null) return; + _refreshRunning = true; + try { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final lastCheck = prefs.getInt(prefsLastCheckKey) ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastCheck < _refreshInterval.inMilliseconds) return; + + final etag = prefs.getString(prefsEtagKey); + final client = platform.createPlatformClient(); + try { + final res = await sendAbortableHttpRequest( + client, + 'GET', + Uri.parse(sourceUrl), + headers: {'If-None-Match': ?etag, 'Accept': acceptHeader}, + timeout: _requestTimeout, + operation: '$logLabel mapping refresh', + ); + await prefs.setInt(prefsLastCheckKey, now); + + if (res.statusCode == 304) { + appLogger.d('$logLabel: mapping unchanged (304)'); + return; + } + if (res.statusCode != 200) { + appLogger.d('$logLabel: refresh returned HTTP ${res.statusCode}'); + return; + } + + await _writeDiskCopy(res.body, etag: res.headers['etag']); + final fresh = await compute(parse, res.body); + _index = fresh; + appLogger.d('$logLabel: mapping refreshed (${fresh.logSummary})'); + } finally { + client.close(); + } + } catch (e) { + appLogger.d('$logLabel: refresh failed (non-fatal)', error: e); + } finally { + _refreshRunning = false; + } + } + + Future _writeDiskCopy(String body, {String? etag}) async { + await File(await _diskPath()).writeAsString(body, flush: true); + if (etag != null) { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString(prefsEtagKey, etag); + } + } + + Future _deleteDiskCopy() async { + try { + await File(await _diskPath()).delete(); + } on FileSystemException { + // Already gone. + } + } + + Future _diskPath() async { + final dir = await getApplicationSupportDirectory(); + return p.join(dir.path, diskFileName); + } + + @visibleForTesting + void resetForTesting() { + _index = null; + _loading = null; + } +} diff --git a/lib/services/trackers/fribb_mapping_store.dart b/lib/services/trackers/fribb_mapping_store.dart index 2e4a5917..5653e088 100644 --- a/lib/services/trackers/fribb_mapping_store.dart +++ b/lib/services/trackers/fribb_mapping_store.dart @@ -1,17 +1,10 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import '../../models/trackers/fribb_mapping_row.dart'; -import '../base_shared_preferences_service.dart'; -import '../../utils/abortable_http_request.dart'; import '../../utils/app_logger.dart'; -import '../../utils/platform_http_client_stub.dart' - if (dart.library.io) '../../utils/platform_http_client_io.dart' - as platform; +import 'etag_cached_remote_store.dart'; /// Indexed view of the Fribb mapping database, queried by external ID. /// @@ -19,7 +12,7 @@ import '../../utils/platform_http_client_stub.dart' /// A single tvdb_id may map to multiple rows (split-cour anime → one per /// season); callers that have a Plex season number should filter by /// [FribbMappingRow.tvdbSeason] or [FribbMappingRow.tmdbSeason]. -class FribbIndex { +class FribbIndex implements RemoteIndex { final Map> byTvdb; final Map> byTmdb; final Map> byImdb; @@ -30,7 +23,11 @@ class FribbIndex { const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb, this.byMal = const {}}); + @override bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty && byMal.isEmpty; + + @override + String get logSummary => '${byTvdb.length} tvdb entries'; } abstract interface class FribbMappingLookup { @@ -39,107 +36,31 @@ abstract interface class FribbMappingLookup { Future lookupByMal(int malId); } -/// Loads and refreshes the Fribb anime-lists mapping on demand. -/// -/// On first lookup the ~5 MB JSON is downloaded from jsDelivr and cached to -/// the app-support directory. Subsequent lookups read from the cache. Parsing -/// runs in a background isolate. [maybeRefresh] does a weekly conditional-GET -/// (If-None-Match) to pick up upstream changes. -class FribbMappingStore implements FribbMappingLookup { - static const String _diskFileName = 'anime-list-mini.json'; - static const String _prefsEtagKey = 'fribb_anime_list_etag'; - static const String _prefsLastCheckKey = 'fribb_anime_list_last_check'; - - /// jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits - /// aggressively on shared IPs and returns 429 mid-refresh. - static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json'; - - static const Duration _refreshInterval = Duration(days: 7); - static const Duration _requestTimeout = Duration(seconds: 60); - - FribbMappingStore._(); - static final FribbMappingStore instance = FribbMappingStore._(); - - FribbIndex? _index; - Future? _loading; - bool _refreshRunning = false; - - /// Lazily load, downloading on first use. Subsequent calls return the - /// cached index in O(1). Concurrent callers share the same Future. - /// Schedules a background refresh after the first successful load. - Future _ensureLoaded() async { - final existing = _index; - if (existing != null) return existing; - final loading = _loading; - if (loading != null) return loading; - - final fresh = _loadOrFetch(); - _loading = fresh; - try { - final idx = await fresh; - // Don't cache an empty index (network failure, no disk copy) — let the - // next lookup retry so transient offline periods self-heal. - if (!idx.isEmpty) { - _index = idx; - unawaited(maybeRefresh()); - } - return idx; - } finally { - _loading = null; - } - } - - Future _loadOrFetch() async { - final path = await _diskPath(); - try { - return await compute(_readAndParse, path); - } on FileSystemException { - appLogger.d('Fribb: no disk cache, downloading from jsDelivr'); - final raw = await _download(); - if (raw == null) return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); - return await compute(parseFribbIndex, raw); - } catch (e) { - appLogger.w('Fribb: parse failed — deleting disk copy so next lookup re-downloads', error: e); - await _deleteDiskCopy(); - return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); - } - } - - /// GET the mapping, save it to disk, and return the body. Returns `null` - /// on any failure (offline, 4xx/5xx, timeout). - Future _download() async { - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: const {'Accept': 'application/json'}, - timeout: _requestTimeout, - operation: 'Fribb mapping download', +/// Loads and refreshes the Fribb anime-lists mapping on demand — the ~5 MB +/// JSON, indexed by external ID. +class FribbMappingStore extends EtagCachedRemoteStore implements FribbMappingLookup { + FribbMappingStore._() + : super( + diskFileName: 'anime-list-mini.json', + prefsEtagKey: 'fribb_anime_list_etag', + prefsLastCheckKey: 'fribb_anime_list_last_check', + // jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits + // aggressively on shared IPs and returns 429 mid-refresh. + sourceUrl: 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json', + acceptHeader: 'application/json', + logLabel: 'Fribb', + emptyIndex: const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}), + parse: parseFribbIndex, + readAndParse: _readAndParse, ); - if (res.statusCode != 200) { - appLogger.d('Fribb: download returned HTTP ${res.statusCode}'); - return null; - } - await _writeDiskCopy(res.body, etag: res.headers['etag']); - // Seed the weekly throttle so a same-week relaunch skips the refresh. - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); - return res.body; - } catch (e) { - appLogger.w('Fribb: download failed', error: e); - return null; - } finally { - client.close(); - } - } + + static final FribbMappingStore instance = FribbMappingStore._(); /// Look up rows by Plex external IDs. Returns the first non-empty candidate /// list in preference order: tvdb → tmdb → imdb. @override Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); if (tvdbId != null) { final hits = idx.byTvdb[tvdbId]; if (hits != null && hits.isNotEmpty) return hits; @@ -156,87 +77,9 @@ class FribbMappingStore implements FribbMappingLookup { } @override - Future lookupByMal(int malId) async => (await _ensureLoaded()).byMal[malId]; - - /// Conditional-GET the mapping if the last check was >[_refreshInterval] ago - /// and we already have an index loaded. No-op when nothing is loaded — the - /// first lookup handles the initial download. - Future maybeRefresh() async { - if (_refreshRunning) return; - if (_index == null) return; - _refreshRunning = true; - try { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0; - final now = DateTime.now().millisecondsSinceEpoch; - if (now - lastCheck < _refreshInterval.inMilliseconds) return; - - final etag = prefs.getString(_prefsEtagKey); - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: {'If-None-Match': ?etag, 'Accept': 'application/json'}, - timeout: _requestTimeout, - operation: 'Fribb mapping refresh', - ); - await prefs.setInt(_prefsLastCheckKey, now); - - if (res.statusCode == 304) { - appLogger.d('Fribb: mapping unchanged (304)'); - return; - } - if (res.statusCode != 200) { - appLogger.d('Fribb: refresh returned HTTP ${res.statusCode}'); - return; - } - - await _writeDiskCopy(res.body, etag: res.headers['etag']); - final fresh = await compute(parseFribbIndex, res.body); - _index = fresh; - appLogger.d('Fribb: mapping refreshed (${fresh.byTvdb.length} tvdb entries)'); - } finally { - client.close(); - } - } catch (e) { - appLogger.d('Fribb: refresh failed (non-fatal)', error: e); - } finally { - _refreshRunning = false; - } - } - - Future _writeDiskCopy(String body, {String? etag}) async { - await File(await _diskPath()).writeAsString(body, flush: true); - if (etag != null) { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setString(_prefsEtagKey, etag); - } - } - - Future _deleteDiskCopy() async { - try { - await File(await _diskPath()).delete(); - } on FileSystemException { - // Already gone. - } - } - - Future _diskPath() async { - final dir = await getApplicationSupportDirectory(); - return p.join(dir.path, _diskFileName); - } - - @visibleForTesting - void resetForTesting() { - _index = null; - _loading = null; - } + Future lookupByMal(int malId) async => (await ensureLoaded()).byMal[malId]; } -/// Read the JSON from disk and parse it inside the isolate. Halves peak -/// memory vs. reading the string on the main isolate and shipping it across. FribbIndex _readAndParse(String path) { final raw = File(path).readAsStringSync(); return parseFribbIndex(raw); diff --git a/lib/widgets/app_menu.dart b/lib/widgets/app_menu.dart index 05f00b45..4e93c7d9 100644 --- a/lib/widgets/app_menu.dart +++ b/lib/widgets/app_menu.dart @@ -390,16 +390,15 @@ class _AppMenuItemTileState extends State> with FocusableT @override void initState() { super.initState(); - initFocusNode(); effectiveFocusNode.addListener(_updateFocusedState); } @override void didUpdateWidget(AppMenuItemTile oldWidget) { + final rebinds = oldWidget.focusNode != widget.focusNode; + if (rebinds) effectiveFocusNode.removeListener(_updateFocusedState); super.didUpdateWidget(oldWidget); - if (oldWidget.focusNode != widget.focusNode) { - effectiveFocusNode.removeListener(_updateFocusedState); - updateFocusNode(oldWidget.focusNode); + if (rebinds) { effectiveFocusNode.addListener(_updateFocusedState); _isFocused = effectiveFocusNode.hasFocus; } @@ -408,7 +407,6 @@ class _AppMenuItemTileState extends State> with FocusableT @override void dispose() { effectiveFocusNode.removeListener(_updateFocusedState); - disposeFocusNode(); super.dispose(); } diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 04419d3f..709234b4 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -82,24 +82,6 @@ class _FocusableListTileState extends State with FocusableTil @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { // When hovered/focused with a custom hoverColor, use onError-style foreground @@ -212,24 +194,6 @@ class _FocusableRadioListTileState extends State> @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableRadioListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { return ClickableCursor( @@ -316,24 +280,6 @@ class _FocusableSwitchListTileState extends State @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableSwitchListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { return ClickableCursor( @@ -398,24 +344,6 @@ class _FocusableCheckboxListTileState extends State @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableCheckboxListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { return ClickableCursor( diff --git a/lib/widgets/music/track_row.dart b/lib/widgets/music/track_row.dart index b0e2c72b..aba4bcf5 100644 --- a/lib/widgets/music/track_row.dart +++ b/lib/widgets/music/track_row.dart @@ -123,24 +123,6 @@ class _TrackRowState extends State with ContextMenuTapMixin, @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(TrackRow oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - void _handleFocusChange(bool hasFocus) { setState(() { _hasFocus = hasFocus; diff --git a/test/mixins/library_tab_state_test.dart b/test/mixins/library_tab_state_test.dart index 43a87408..1c7fdec4 100644 --- a/test/mixins/library_tab_state_test.dart +++ b/test/mixins/library_tab_state_test.dart @@ -7,9 +7,10 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/mixins/library_tab_state.dart'; import 'package:provider/provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import '../test_helpers/multi_server_fixtures.dart'; + class _Probe extends StatefulWidget { const _Probe({required this.library, required this.onState}); @@ -45,8 +46,7 @@ void main() { late _ProbeState state; final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final provider = MultiServerProvider(manager, aggregation); + final provider = testMultiServerProvider(manager); // provider.dispose() cascades to manager.dispose() — only register // the outer teardown to avoid a double-close on the manager's stream. addTearDown(provider.dispose); diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index 559edc7e..aadc19c6 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -16,13 +16,13 @@ import 'package:plezy/providers/discover_provider.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/trackers_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/system_shelf_service.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -51,7 +51,7 @@ void main() { storage: storage, ); final serverManager = MultiServerManager(); - final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServer = testMultiServerProvider(serverManager); // The session tree instantiates MusicPlaybackServiceImpl (the mini-player // overlay watches it), which needs the database + offline watch service. final offlineWatch = OfflineWatchSyncService(database: db, serverManager: serverManager); diff --git a/test/providers/libraries_provider_test.dart b/test/providers/libraries_provider_test.dart index de4793d0..d430a7af 100644 --- a/test/providers/libraries_provider_test.dart +++ b/test/providers/libraries_provider_test.dart @@ -8,11 +8,11 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/providers/libraries_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary( @@ -563,7 +563,7 @@ void main() { test('online-servers listener is removed on dispose', () { final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); final before = multiServer.onlineServersListenerCount; final scoped = LibrariesProvider(multiServer: multiServer); diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart index 46c7985e..12f80141 100644 --- a/test/providers/offline_mode_provider_test.dart +++ b/test/providers/offline_mode_provider_test.dart @@ -4,12 +4,11 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/providers/offline_mode_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -105,7 +104,7 @@ void main() { httpClient: MockClient((_) async => http.Response('', 401)), ); manager.debugRegisterJellyfinClientForTesting(client, online: false); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); @@ -122,7 +121,7 @@ void main() { test('expected but unreachable visible servers enter offline without live clients', () async { final manager = MultiServerManager(); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); manager.updateServerStatus(ServerId('plex-server'), false); @@ -146,7 +145,7 @@ void main() { test('expected but unreachable profile servers enter offline once visibility settles', () async { final manager = MultiServerManager(); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); expect(p.isOffline, isFalse); @@ -173,7 +172,7 @@ void main() { test('Plex auth errors without live clients stay out of generic offline', () async { final manager = MultiServerManager(); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 4e43dad2..04678b1e 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -10,11 +10,9 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/models/catalog/catalog_cast_member.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/providers/catalog_sources_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/catalog_library_matcher.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -24,6 +22,7 @@ import 'package:plezy/widgets/media_card.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; class _FakeCatalogSource implements CatalogSource { @@ -127,7 +126,7 @@ Future _pumpDetail( }) async { final sources = _FakeCatalogSourcesProvider(source); final serverManager = MultiServerManager(); - final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServer = testMultiServerProvider(serverManager); final matcher = _FakeCatalogLibraryMatcher(multiServer, matches); addTearDown(sources.dispose); addTearDown(source.dispose); diff --git a/test/screens/catalog_search_screen_test.dart b/test/screens/catalog_search_screen_test.dart index 043390e3..cad6b380 100644 --- a/test/screens/catalog_search_screen_test.dart +++ b/test/screens/catalog_search_screen_test.dart @@ -6,12 +6,10 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/models/catalog/catalog_cast_member.dart'; import 'package:plezy/providers/catalog_sources_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/screens/catalog_search_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/catalog_library_matcher.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -19,6 +17,7 @@ import 'package:plezy/widgets/app_menu.dart'; import 'package:plezy/widgets/media_card.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; /// Only the members the search screen touches; everything else throws. @@ -197,7 +196,7 @@ Future _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {req final sources = _FakeCatalogSourcesProvider(source); final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); final matcher = _FakeCatalogLibraryMatcher(multiServer); addTearDown(manager.dispose); addTearDown(multiServer.dispose); diff --git a/test/screens/collection_detail_screen_test.dart b/test/screens/collection_detail_screen_test.dart index 4da96f39..79e2ff0a 100644 --- a/test/screens/collection_detail_screen_test.dart +++ b/test/screens/collection_detail_screen_test.dart @@ -13,7 +13,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/collection_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -29,6 +28,7 @@ import 'package:plezy/widgets/media_card_sliver_layout.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; @@ -133,7 +133,7 @@ Future<_CollectionHarness> _createHarness(List items) async { final client = _CollectionClient(items); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(() async { downloadProvider.dispose(); diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index d7c7e49e..769509f0 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -31,7 +31,6 @@ import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/discover_screen.dart'; import 'package:plezy/screens/main_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; @@ -46,6 +45,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -82,7 +82,7 @@ void main() { final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1); final client = _FakeMediaServerClient(hubs: [hub]); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); @@ -262,7 +262,7 @@ void main() { ]; final client = _FakeMediaServerClient(hubs: const [], continueWatching: onDeck); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); diff --git a/test/screens/downloads/downloads_screen_focus_test.dart b/test/screens/downloads/downloads_screen_focus_test.dart index c9912611..4d9189be 100644 --- a/test/screens/downloads/downloads_screen_focus_test.dart +++ b/test/screens/downloads/downloads_screen_focus_test.dart @@ -18,7 +18,6 @@ import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/downloads/downloads_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -33,6 +32,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; import '../../test_helpers/io_fakes.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; class _FakeConnectionRegistry extends ConnectionRegistry { _FakeConnectionRegistry(super.db); @@ -77,7 +77,7 @@ void main() { await downloadProvider.ensureInitialized(); serverManager = MultiServerManager(); - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); }); tearDown(() async { diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index 2660cc8b..e7ef8025 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -15,7 +15,6 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/downloads/sync_rules_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -27,6 +26,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; PlexConnection _plexConnection() { return PlexConnection( @@ -213,7 +213,7 @@ void main() { addTearDown(authClient.close); serverManager.debugRegisterJellyfinClientForTesting(authClient, online: false); serverManager.debugMarkAuthErrorForTesting(ServerId('auth-jf')); - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('plex-srv'), 'show-1'); await insertRule(ServerId('jf-machine'), 'show-2'); @@ -233,7 +233,7 @@ void main() { }); testWidgets('removes orphaned sync rules from the sync rules screen', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester); @@ -256,7 +256,7 @@ void main() { }); testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester); @@ -268,7 +268,7 @@ void main() { }); testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); FocusManager.instance.primaryFocus?.unfocus(); @@ -279,7 +279,7 @@ void main() { }); testWidgets('keyboard navigation reaches and toggles the sync rule switch', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester, keyboardMode: true); @@ -297,7 +297,7 @@ void main() { }); testWidgets('setting sync rule count to zero removes the rule', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester, keyboardMode: true); diff --git a/test/screens/hub_detail_screen_test.dart b/test/screens/hub_detail_screen_test.dart index a78966ce..a78b18ab 100644 --- a/test/screens/hub_detail_screen_test.dart +++ b/test/screens/hub_detail_screen_test.dart @@ -11,7 +11,6 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/hub_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -21,6 +20,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -144,7 +144,7 @@ Future<_HubHarness> _createHarness(List items, {required MediaBackend await SettingsService.getInstance(); final client = _PagedHubClient(items, backend: backend); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); return _HubHarness(client: client, provider: provider); } diff --git a/test/screens/libraries/folder_tree_view_test.dart b/test/screens/libraries/folder_tree_view_test.dart index 491bbae7..a2a09374 100644 --- a/test/screens/libraries/folder_tree_view_test.dart +++ b/test/screens/libraries/folder_tree_view_test.dart @@ -8,14 +8,13 @@ import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/folder_tree_view.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; void main() { @@ -62,12 +61,7 @@ void main() { ); }), ); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); + final provider = testMultiServer(clients: [client]).provider; await tester.pumpWidget( ChangeNotifierProvider.value( diff --git a/test/screens/libraries/libraries_screen_test.dart b/test/screens/libraries/libraries_screen_test.dart index 2805d434..8c1da0ac 100644 --- a/test/screens/libraries/libraries_screen_test.dart +++ b/test/screens/libraries/libraries_screen_test.dart @@ -11,7 +11,6 @@ import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/libraries_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; @@ -22,6 +21,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shared_preferences_platform_interface/types.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; const _libraryA = MediaLibrary( @@ -135,7 +135,7 @@ final class _Harness { final hiddenLibraries = HiddenLibrariesProvider(); await hiddenLibraries.ensureInitialized(); final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer); } diff --git a/test/screens/libraries/library_browse_music_test.dart b/test/screens/libraries/library_browse_music_test.dart index 8ce74156..63a15d71 100644 --- a/test/screens/libraries/library_browse_music_test.dart +++ b/test/screens/libraries/library_browse_music_test.dart @@ -13,7 +13,6 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; @@ -24,6 +23,7 @@ import 'package:plezy/widgets/media_card.dart'; import '../../test_helpers/backend_client_fixtures.dart'; import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _musicLibrary = MediaLibrary( @@ -165,7 +165,7 @@ class _MusicBrowseHarness { }), ); manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } void dispose() { diff --git a/test/screens/libraries/library_browse_tab_test.dart b/test/screens/libraries/library_browse_tab_test.dart index 909a1dd2..58ec55b8 100644 --- a/test/screens/libraries/library_browse_tab_test.dart +++ b/test/screens/libraries/library_browse_tab_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; @@ -27,6 +26,7 @@ import 'package:plezy/widgets/focusable_filter_chip.dart'; import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; void main() { @@ -191,7 +191,7 @@ class _BrowseHarness { manager = MultiServerManager() ..debugRegisterClientForTesting(clientA) ..debugRegisterClientForTesting(clientB); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } MediaLibrary _libraryFor(_BrowseClient client) { diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 6b15ef4c..2ecc6c5a 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -16,7 +16,6 @@ import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_playlists_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -28,6 +27,7 @@ import 'package:plezy/widgets/media_card_sliver_layout.dart'; import '../../test_helpers/backend_client_fixtures.dart'; import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('playlist-server'); @@ -246,7 +246,7 @@ class _PlaylistHarness { }), ); manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } Future dispose() async { diff --git a/test/screens/livetv/guide_tab_test.dart b/test/screens/livetv/guide_tab_test.dart index 8789ee26..ee7cb394 100644 --- a/test/screens/livetv/guide_tab_test.dart +++ b/test/screens/livetv/guide_tab_test.dart @@ -19,13 +19,14 @@ import 'package:plezy/models/livetv_channel.dart'; import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/screens/livetv/tabs/guide_tab.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; + const _selectDown = KeyDownEvent( physicalKey: PhysicalKeyboardKey.enter, logicalKey: LogicalKeyboardKey.enter, @@ -202,7 +203,7 @@ final class _GuideHarness { final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null; final manager = MultiServerManager()..debugRegisterClientForTesting(serverA); if (serverB != null) manager.debugRegisterClientForTesting(serverB); - final provider = MultiServerProvider(manager, DataAggregationService(manager)) + final provider = testMultiServerProvider(manager) ..debugSetLiveTvServersForTesting([ LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'), if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'), diff --git a/test/screens/livetv/live_tv_screen_test.dart b/test/screens/livetv/live_tv_screen_test.dart index 9d649c39..2b7841a9 100644 --- a/test/screens/livetv/live_tv_screen_test.dart +++ b/test/screens/livetv/live_tv_screen_test.dart @@ -16,12 +16,12 @@ import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/livetv/live_tv_screen.dart'; import 'package:plezy/screens/livetv/tabs/guide_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; void main() { @@ -75,7 +75,7 @@ void main() { final manager = MultiServerManager() ..debugRegisterClientForTesting(failedClient) ..debugRegisterClientForTesting(healthyClient); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); provider.debugSetLiveTvServersForTesting([ LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a', lineup: 'provider-a'), LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b', lineup: 'provider-b'), @@ -148,7 +148,7 @@ Future<_LiveTvHarness> _pumpLiveTvScreen(WidgetTester tester) async { final liveTv = _FakeLiveTvSupport(); final client = _FakeMediaServerClient(liveTv); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); provider.debugSetLiveTvServersForTesting([ LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr-a', lineup: 'provider-a'), ]); diff --git a/test/screens/livetv/live_tv_show_schedule_screen_test.dart b/test/screens/livetv/live_tv_show_schedule_screen_test.dart index b75a8618..06151f58 100644 --- a/test/screens/livetv/live_tv_show_schedule_screen_test.dart +++ b/test/screens/livetv/live_tv_show_schedule_screen_test.dart @@ -14,12 +14,13 @@ import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/models/media_subscription.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/livetv/live_tv_show_schedule_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() => initializeDateFormatting('en')); @@ -113,7 +114,7 @@ LiveTvProgram _program({String? guid}) => LiveTvProgram( MultiServerProvider _providerFor(MediaServerClient client) { final manager = MultiServerManager()..debugRegisterClientForTesting(client); - return MultiServerProvider(manager, DataAggregationService(manager)); + return testMultiServerProvider(manager); } Future _pumpScreen(WidgetTester tester, MultiServerProvider provider) async { diff --git a/test/screens/livetv/recordings_tab_test.dart b/test/screens/livetv/recordings_tab_test.dart index ed38a978..bf27bd99 100644 --- a/test/screens/livetv/recordings_tab_test.dart +++ b/test/screens/livetv/recordings_tab_test.dart @@ -13,11 +13,12 @@ import 'package:plezy/models/media_grab_operation.dart'; import 'package:plezy/models/media_subscription.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/livetv/tabs/recordings_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -27,7 +28,7 @@ void main() { final dvr = _ControllableDvr(); final client = _FakeMediaServerClient(dvr); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)) + final provider = testMultiServerProvider(manager) ..debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr')]); addTearDown(provider.dispose); final tabKey = GlobalKey(); diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index e11dc14e..3cd95470 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -22,7 +22,6 @@ import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/watch_state_store.dart'; import 'package:plezy/screens/media_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import '../test_helpers/paged_fakes.dart'; import 'package:plezy/services/download_manager_service.dart'; @@ -46,6 +45,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/profile_navigation.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -308,7 +308,7 @@ void main() { pendingPlayableDescendants: descendantsCompleter.future, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -434,7 +434,7 @@ void main() { }, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -531,7 +531,7 @@ void main() { childrenPageErrors: {season1.id: Exception('season cache failed')}, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -622,7 +622,7 @@ void main() { childrenPageFutures: {season2.id: season2Completer.future}, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -708,7 +708,7 @@ void main() { PlexApiCache.initialize(database); JellyfinApiCache.initialize(database); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final offlineWatch = OfflineWatchSyncService(database: database, serverManager: manager); final downloadManager = DownloadManagerService( database: database, @@ -832,7 +832,7 @@ void main() { await downloadProvider.ensureInitialized(); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final watchStateOverlay = WatchStateStore(); addTearDown(() async { diff --git a/test/screens/metadata_edit_screen_test.dart b/test/screens/metadata_edit_screen_test.dart index 3f12cb3e..82742ae4 100644 --- a/test/screens/metadata_edit_screen_test.dart +++ b/test/screens/metadata_edit_screen_test.dart @@ -19,7 +19,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_models.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/metadata_edit_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/file_picker_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -32,6 +31,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/http_fixtures.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { setUp(() { @@ -327,7 +327,7 @@ Future<_EditorHarness> _pumpEditor(WidgetTester tester, _PlexMetadataRequests re PlexApiCache.initialize(database); final client = testPlexClient(serverId: ServerId('server-1'), handler: requests.handle); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); final metadata = ValueNotifier(_show()); await tester.pumpWidget( diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart index 83e4480b..0d64c747 100644 --- a/test/screens/music/album_detail_screen_test.dart +++ b/test/screens/music/album_detail_screen_test.dart @@ -9,7 +9,6 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/music/album_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; @@ -19,6 +18,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -109,7 +109,7 @@ Future<_AlbumHarness> _createHarness(List tracks) async { final client = _FakeMusicClient(tracks); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); diff --git a/test/screens/music/now_playing_screen_test.dart b/test/screens/music/now_playing_screen_test.dart index c3fd012a..b3b1371b 100644 --- a/test/screens/music/now_playing_screen_test.dart +++ b/test/screens/music/now_playing_screen_test.dart @@ -10,14 +10,13 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/music/now_playing_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; MediaItem _track({required String id, required String title, required String album, required int year}) { return testMediaItem( @@ -118,13 +117,8 @@ void main() { TvDetectionService.debugSetAppleTVOverride(isTv); PlatformDetector.debugSetIsDesktopOSOverride(!isTv); - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(service.dispose); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); + final multiServerProvider = testMultiServer().provider; await tester.pumpWidget( InputModeTracker( diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index ac548bfe..0195479b 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -7,8 +7,6 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/music/queue_sheet.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -17,6 +15,7 @@ import 'package:plezy/widgets/music/track_row.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; MediaItem _track(String id, String title) => testMediaItem( @@ -73,13 +72,8 @@ void main() { }); Widget wrap(MusicPlaybackService service) { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(service.dispose); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); + final multiServerProvider = testMultiServer().provider; return TranslationProvider( child: MultiProvider( diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 69151ff1..052fc93e 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -20,7 +20,6 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/playlist/playlist_detail_screen.dart'; import 'package:plezy/screens/playlist/playlist_item_card.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -41,6 +40,7 @@ import 'package:plezy/utils/media_image_helper.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; @@ -671,7 +671,7 @@ Future<_PlaylistHarness> _createHarness( final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult, backend: backend); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final playbackState = PlaybackStateProvider(); addTearDown(() async { diff --git a/test/screens/profile/profile_teardown_test.dart b/test/screens/profile/profile_teardown_test.dart index 0fa02b79..8895bb1a 100644 --- a/test/screens/profile/profile_teardown_test.dart +++ b/test/screens/profile/profile_teardown_test.dart @@ -20,12 +20,12 @@ import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/providers/user_profile_provider.dart'; import 'package:plezy/screens/profile/profile_teardown.dart'; import 'package:plezy/services/plex_auth_service.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/system_shelf_service.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; class _PlexHome extends PlexHomeService { @@ -275,7 +275,7 @@ Future<_Harness> _pumpHarness( await storage.setActiveProfileId(profile.id); await active.initialize(); final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); shelf.beginProfileSession(profile.id); SystemShelfService.debugOverrideInstance(shelf); diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 08ac3338..8da907d5 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -22,7 +22,6 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; import 'package:plezy/services/jellyfin_auth_service.dart'; import 'package:plezy/services/credential_vault.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_lan_discovery_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; @@ -30,6 +29,7 @@ import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; Profile _profile(String id) => @@ -244,7 +244,7 @@ class _RouteHarness { storage: storage, ); final manager = _CountingJellyfinManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final binder = _CountingActiveProfileBinder( activeProfile: activeProfiles, connections: connections, diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 23dfc67b..22b9a568 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -8,11 +8,11 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/play_queue.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/episode_navigation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; MediaItem _meta(String id, {String? title}) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); @@ -106,7 +106,7 @@ void main() { final playback = PlaybackStateProvider(); addTearDown(playback.dispose); final manager = _StubManager(null); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -176,8 +176,7 @@ void main() { ], ); final manager = _StubManager(client); - final aggregation = DataAggregationService(manager); - final serverProvider = MultiServerProvider(manager, aggregation); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -211,7 +210,7 @@ void main() { addTearDown(playback.dispose); final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3], clientBackend: MediaBackend.plex); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -244,7 +243,7 @@ void main() { fetchError: StateError('network unavailable'), ); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -272,7 +271,7 @@ void main() { addTearDown(playback.dispose); final client = _RecordingClient(seriesEpisodes: [ep1, ep2], clientBackend: MediaBackend.plex); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -330,7 +329,7 @@ void main() { ); final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); return (playback, client, serverProvider); } @@ -403,7 +402,7 @@ void main() { addTearDown(playback.dispose); final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); final result = await probe(tester, playback, serverProvider, ep3); diff --git a/test/services/live_tv_capability_contract_test.dart b/test/services/live_tv_capability_contract_test.dart index 6c97b18f..7ff303ea 100644 --- a/test/services/live_tv_capability_contract_test.dart +++ b/test/services/live_tv_capability_contract_test.dart @@ -8,14 +8,13 @@ import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { late AppDatabase db; @@ -107,7 +106,7 @@ void main() { final manager = MultiServerManager(); manager.debugRegisterClientForTesting(plex); manager.debugRegisterJellyfinClientForTesting(jellyfin); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(() { provider.dispose(); manager.dispose(); diff --git a/test/utils/live_tv_player_navigation_test.dart b/test/utils/live_tv_player_navigation_test.dart index 130d6233..5ccd4f11 100644 --- a/test/utils/live_tv_player_navigation_test.dart +++ b/test/utils/live_tv_player_navigation_test.dart @@ -8,10 +8,11 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/video_player_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/utils/live_tv_player_navigation.dart'; +import '../test_helpers/multi_server_fixtures.dart'; + void main() { late MultiServerManager manager; late MultiServerProvider multiServer; @@ -19,7 +20,7 @@ void main() { setUp(() async { await LocaleSettings.setLocale(AppLocale.bg); manager = MultiServerManager(); - multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + multiServer = testMultiServerProvider(manager); }); tearDown(() { diff --git a/test/widgets/library_management_sheet_test.dart b/test/widgets/library_management_sheet_test.dart index 2bcba7da..532ca2e2 100644 --- a/test/widgets/library_management_sheet_test.dart +++ b/test/widgets/library_management_sheet_test.dart @@ -15,7 +15,6 @@ import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/theme/mono_theme.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -24,6 +23,7 @@ import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; @@ -49,8 +49,7 @@ Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementL addTearDown(hiddenLibrariesProvider.dispose); final fallbackManager = multiServerProvider == null ? MultiServerManager() : null; - final effectiveMultiServerProvider = - multiServerProvider ?? MultiServerProvider(fallbackManager!, DataAggregationService(fallbackManager)); + final effectiveMultiServerProvider = multiServerProvider ?? testMultiServerProvider(fallbackManager!); if (fallbackManager != null) { addTearDown(() { effectiveMultiServerProvider.dispose(); @@ -268,7 +267,7 @@ class _LibraryActionHarness { ); manager.debugRegisterClientForTesting(owner); } - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } void dispose() { diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 63c38449..73f40f6e 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -30,7 +30,6 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/music/album_detail_screen.dart'; import 'package:plezy/screens/music/artist_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; @@ -46,6 +45,7 @@ import 'package:plezy/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/profile_stack.dart'; @@ -124,7 +124,7 @@ void main() { final client = _AudioPlaylistClient(tracks); final music = _RecordingMusicPlaybackService(); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { await stack.dispose(); @@ -228,7 +228,7 @@ void main() { ])..blockWithAbort = true; final playback = PlaybackStateProvider(); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { playback.dispose(); @@ -293,7 +293,7 @@ void main() { addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { await stack.dispose(); @@ -569,7 +569,7 @@ Future> _pumpPlexMovieMenu( }), ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final stack = await ProfileStack.create(db: db, withStorage: false); addTearDown(() async { await stack.dispose(); @@ -771,7 +771,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( await downloadProvider.ensureInitialized(); final client = _RelatedMusicClient(relatedItems); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final stack = await ProfileStack.create(db: db, withStorage: false); final music = _RecordingMusicPlaybackService(); final rootNavigatorKey = GlobalKey(); diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index d114bccc..df3b1c20 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -14,8 +14,6 @@ import 'package:plezy/models/download_models.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -26,6 +24,7 @@ import 'package:plezy/widgets/music/mini_player.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/profile_stack.dart'; @@ -159,14 +158,9 @@ void main() { ActiveProfileProvider? activeProfileProvider, DownloadProvider? downloadProvider, }) { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(service.dispose); addTearDown(observer.suppress.dispose); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); + final multiServerProvider = testMultiServer().provider; return TranslationProvider( child: MultiProvider( diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 69a93be1..158f3429 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -14,7 +14,6 @@ import 'package:plezy/navigation/navigation_tabs.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -22,6 +21,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/theme.dart'; @@ -82,8 +82,7 @@ Future _pumpBasicRail( addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final rail = SideNavigationRail( @@ -140,8 +139,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); await tester.pumpWidget( @@ -205,8 +203,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); await tester.pumpWidget( @@ -309,8 +306,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final reports = []; @@ -373,8 +369,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final sideNavKey = GlobalKey(); @@ -450,8 +445,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final sideNavKey = GlobalKey(); diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 00901b5d..b46dd3e6 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -10,7 +10,6 @@ import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/device_performance.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; @@ -25,6 +24,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -346,7 +346,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -418,7 +418,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -473,7 +473,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: theme, home: Scaffold( @@ -513,7 +513,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -557,7 +557,7 @@ void main() { Widget rail(MediaHub hub) { return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -599,7 +599,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -689,7 +689,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -762,7 +762,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -822,7 +822,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -884,7 +884,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -939,7 +939,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1001,7 +1001,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1039,7 +1039,7 @@ void main() { Widget buildRail(List hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) { final serverManager = MultiServerManager(); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1081,7 +1081,7 @@ void main() { Widget buildRail(List hubs, {String? initialHubId}) { final serverManager = MultiServerManager(); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1124,7 +1124,7 @@ void main() { Widget buildRail(List hubs, {String? initialItemId}) { final serverManager = MultiServerManager(); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1204,7 +1204,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1318,7 +1318,7 @@ void main() { Widget buildRail(List hubs) { return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1401,7 +1401,7 @@ void main() { Widget buildRail({required bool backgroundLoaded}) { return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1477,7 +1477,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1579,7 +1579,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1655,7 +1655,7 @@ void main() { final serverManager = MultiServerManager(); await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1744,7 +1744,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1835,7 +1835,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1907,7 +1907,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1972,7 +1972,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2023,7 +2023,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2071,7 +2071,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2127,7 +2127,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2178,7 +2178,7 @@ void main() { final item = testMediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2213,7 +2213,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2258,7 +2258,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2289,7 +2289,7 @@ void main() { testWidgets('background bleed updates do not renotify rail focus', (tester) async { final serverManager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServerProvider = testMultiServerProvider(serverManager); addTearDown(multiServerProvider.dispose); final focusedItemIds = []; From 7416327d4ba81fcf3d6f84913e95f96279893fc2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:23:08 +0200 Subject: [PATCH 06/12] refactor: unify tracker slots, Seerr detail models, and queue launches - Merge SeerrMovieDetails/SeerrTvDetails into one SeerrDetails model and route both detail endpoints through a single request helper. - Replace the three parallel tracker session/store/rebind-generation triples in TrackersProvider with a _TrackerSlot record plus one _rebind path. - Fold the three JellyfinSequentialLauncher entry points onto a shared _launchLocalQueue helper that owns loading, abort, shuffle and publish; each caller now supplies only its fetch. --- lib/models/seerr/seerr_details.dart | 22 +- lib/models/seerr/seerr_details.g.dart | 33 +-- lib/providers/trackers_provider.dart | 270 +++++++----------- .../jellyfin_sequential_launcher.dart | 177 +++++------- lib/services/seerr/seerr_client.dart | 13 +- 5 files changed, 197 insertions(+), 318 deletions(-) diff --git a/lib/models/seerr/seerr_details.dart b/lib/models/seerr/seerr_details.dart index f6ff6f26..aa25ef1c 100644 --- a/lib/models/seerr/seerr_details.dart +++ b/lib/models/seerr/seerr_details.dart @@ -4,28 +4,18 @@ import 'seerr_media.dart'; part 'seerr_details.g.dart'; -/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog -/// surfaces need (credits, availability). +/// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}` — the subset +/// the catalog surfaces need (credits, availability, seasons). `seasons` is +/// absent on movies. @JsonSerializable(createToJson: false) -class SeerrMovieDetails { - final SeerrCredits? credits; - final SeerrMediaInfo? mediaInfo; - - const SeerrMovieDetails({this.credits, this.mediaInfo}); - - factory SeerrMovieDetails.fromJson(Map json) => _$SeerrMovieDetailsFromJson(json); -} - -/// Full TV detail from `GET /tv/{tmdbId}`. -@JsonSerializable(createToJson: false) -class SeerrTvDetails { +class SeerrDetails { final List? seasons; final SeerrCredits? credits; final SeerrMediaInfo? mediaInfo; - const SeerrTvDetails({this.seasons, this.credits, this.mediaInfo}); + const SeerrDetails({this.seasons, this.credits, this.mediaInfo}); - factory SeerrTvDetails.fromJson(Map json) => _$SeerrTvDetailsFromJson(json); + factory SeerrDetails.fromJson(Map json) => _$SeerrDetailsFromJson(json); } /// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. diff --git a/lib/models/seerr/seerr_details.g.dart b/lib/models/seerr/seerr_details.g.dart index d0f9afe4..487de020 100644 --- a/lib/models/seerr/seerr_details.g.dart +++ b/lib/models/seerr/seerr_details.g.dart @@ -6,28 +6,17 @@ part of 'seerr_details.dart'; // JsonSerializableGenerator // ************************************************************************** -SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => - SeerrMovieDetails( - credits: json['credits'] == null - ? null - : SeerrCredits.fromJson(json['credits'] as Map), - mediaInfo: json['mediaInfo'] == null - ? null - : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), - ); - -SeerrTvDetails _$SeerrTvDetailsFromJson(Map json) => - SeerrTvDetails( - seasons: (json['seasons'] as List?) - ?.map((e) => SeerrSeason.fromJson(e as Map)) - .toList(), - credits: json['credits'] == null - ? null - : SeerrCredits.fromJson(json['credits'] as Map), - mediaInfo: json['mediaInfo'] == null - ? null - : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), - ); +SeerrDetails _$SeerrDetailsFromJson(Map json) => SeerrDetails( + seasons: (json['seasons'] as List?) + ?.map((e) => SeerrSeason.fromJson(e as Map)) + .toList(), + credits: json['credits'] == null + ? null + : SeerrCredits.fromJson(json['credits'] as Map), + mediaInfo: json['mediaInfo'] == null + ? null + : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), +); SeerrSeason _$SeerrSeasonFromJson(Map json) => SeerrSeason( seasonNumber: (json['seasonNumber'] as num).toInt(), diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index 8384aae8..466511e5 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -45,13 +45,23 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin final MalAuthService _malAuth = MalAuthService(); final AnilistAuthService _anilistAuth = AnilistAuthService(); final SimklAuthService _simklAuth = SimklAuthService(); - final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal); - final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist); - final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl); - TrackerSession? _mal; - TrackerSession? _anilist; - TrackerSession? _simkl; + final _TrackerSlot _mal = _TrackerSlot( + TrackerService.mal, + (session, {required onInvalidated, onUpdated}) => + MalTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated), + ); + final _TrackerSlot _anilist = _TrackerSlot( + TrackerService.anilist, + (session, {required onInvalidated, onUpdated}) => + AnilistTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated), + ); + final _TrackerSlot _simkl = _TrackerSlot( + TrackerService.simkl, + (session, {required onInvalidated, onUpdated}) => + SimklTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated), + ); + late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl]; String _activeUserUuid = ''; int _profileBindingGeneration = 0; @@ -59,22 +69,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin Completer? _cancelCompleter; int _connectGeneration = 0; - // Bumped on every rebind so a late callback from a disposed client (e.g. an - // in-flight MAL token refresh that resolves after a profile switch) can't - // persist or clear a session under the wrong profile, and so a disconnect - // racing an in-flight profile load only suppresses its own service. Mirrors - // TraktAccountProvider's binding-generation guard, but per service. - final _RebindGeneration _malRebind = _RebindGeneration(); - final _RebindGeneration _anilistRebind = _RebindGeneration(); - final _RebindGeneration _simklRebind = _RebindGeneration(); + TrackerSession? get mal => _mal.session; + TrackerSession? get anilist => _anilist.session; + TrackerSession? get simkl => _simkl.session; - TrackerSession? get mal => _mal; - TrackerSession? get anilist => _anilist; - TrackerSession? get simkl => _simkl; - - bool get isMalConnected => _mal != null; - bool get isAnilistConnected => _anilist != null; - bool get isSimklConnected => _simkl != null; + bool get isMalConnected => _mal.session != null; + bool get isAnilistConnected => _anilist.session != null; + bool get isSimklConnected => _simkl.session != null; /// The live MAL client for the Explore catalog, shared with the scrobble /// tracker so both ride one session (MAL rotates refresh tokens — a second @@ -82,17 +83,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// provider's own session so a freshly-mounted profile subtree never sees /// the previous profile's client while its sessions are still loading; /// every rebind is followed by a notify, so proxy consumers track identity. - MalClient? get malCatalogClient => _mal == null ? null : MalTracker.instance.client; + MalClient? get malCatalogClient => _mal.session == null ? null : MalTracker.instance.client; /// Live AniList and Simkl clients for Explore. Like [malCatalogClient], /// these are gated on this provider's profile-bound sessions so a fresh /// profile subtree cannot observe clients still bound to the prior profile. - AnilistClient? get anilistCatalogClient => _anilist == null ? null : AnilistTracker.instance.client; - SimklClient? get simklCatalogClient => _simkl == null ? null : SimklTracker.instance.client; + AnilistClient? get anilistCatalogClient => _anilist.session == null ? null : AnilistTracker.instance.client; + SimklClient? get simklCatalogClient => _simkl.session == null ? null : SimklTracker.instance.client; - String? get malUsername => _mal?.username; - String? get anilistUsername => _anilist?.username; - String? get simklUsername => _simkl?.username; + String? get malUsername => _mal.session?.username; + String? get anilistUsername => _anilist.session?.username; + String? get simklUsername => _simkl.session?.username; bool isConnecting(TrackerService service) => _connecting == service; @@ -114,26 +115,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Snapshot each service's rebind generation before the await so a disconnect // that races this load only suppresses its own service (whose generation // moves) rather than dropping the freshly-loaded sessions for the others. - final malRebind = _malRebind.value; - final anilistRebind = _anilistRebind.value; - final simklRebind = _simklRebind.value; - final results = await Future.wait([ - _malStore.load(userUuid), - _anilistStore.load(userUuid), - _simklStore.load(userUuid), - ]); + final rebinds = [for (final slot in _slots) slot.rebindGeneration]; + final results = await Future.wait([for (final slot in _slots) slot.store.load(userUuid)]); if (!_isCurrentProfileBinding(userUuid, generation)) return; - if (_malRebind.value == malRebind) { - _mal = results.first; - _rebindMal(); - } - if (_anilistRebind.value == anilistRebind) { - _anilist = results[1]; - _rebindAnilist(); - } - if (_simklRebind.value == simklRebind) { - _simkl = results[2]; - _rebindSimkl(); + for (var i = 0; i < _slots.length; i++) { + final slot = _slots[i]; + if (slot.rebindGeneration != rebinds[i]) continue; + slot.session = results[i]; + _rebind(slot); } // Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so // the next lookup re-evaluates whether to consult Fribb. @@ -142,78 +131,51 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } Future connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( - service: TrackerService.mal, - alreadyConnected: isMalConnected, + _mal, authorize: () => _malAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichMal, - store: _malStore, - assign: (s) { - _mal = s; - _rebindMal(); - }, ); - Future disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () { - _mal = null; - _rebindMal(); - }); + Future disconnectMal() => _clearAndRebind(_mal); Future connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( - service: TrackerService.anilist, - alreadyConnected: isAnilistConnected, + _anilist, authorize: () => _anilistAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichAnilist, - store: _anilistStore, - assign: (s) { - _anilist = s; - _rebindAnilist(); - }, ); - Future disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () { - _anilist = null; - _rebindAnilist(); - }); + Future disconnectAnilist() => _clearAndRebind(_anilist); Future connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect( - service: TrackerService.simkl, - alreadyConnected: isSimklConnected, + _simkl, authorize: () => _simklAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichSimkl, - store: _simklStore, - assign: (s) { - _simkl = s; - _rebindSimkl(); - }, ); - Future disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () { - _simkl = null; - _rebindSimkl(); - }); + Future disconnectSimkl() => _clearAndRebind(_simkl); - Future _runConnect({ - required TrackerService service, - required bool alreadyConnected, + bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false; + + Future _runConnect( + _TrackerSlot slot, { required Future Function() authorize, required Future Function(TrackerSession raw) enrich, - required TrackerAccountStore store, - required void Function(TrackerSession session) assign, }) async { - if (isDisposed || _connecting != null || alreadyConnected) return false; + if (isDisposed || _connecting != null || slot.session != null) return false; + final service = slot.service; final userUuid = _activeUserUuid; final generation = ++_connectGeneration; _connecting = service; @@ -231,11 +193,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin enrich: enrich, save: (session) async { if (!_isCurrentConnect(service, userUuid, generation)) return; - await store.save(userUuid, session); + await slot.store.save(userUuid, session); }, assign: (session) { if (!_isCurrentConnect(service, userUuid, generation)) return; - assign(session); + slot.session = session; + _rebind(slot); TrackerCoordinator.instance.invalidateResolverCache(); assigned = true; }, @@ -250,20 +213,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - Future _clearAndRebind( - TrackerService service, - TrackerAccountStore store, - void Function() clearAndRebind, - ) async { - _invalidateConnect(service); + Future _clearAndRebind(_TrackerSlot slot) async { + _invalidateConnect(slot.service); final userUuid = _activeUserUuid; - // `clearAndRebind` bumps the affected service's rebind generation, which is - // what stops an in-flight profile load from resurrecting the cleared - // session — so we no longer touch the shared profile-binding generation - // (which would also abort that load for the other two services). - clearAndRebind(); + // The rebind bumps the affected service's generation, which is what stops + // an in-flight profile load from resurrecting the cleared session — so we + // no longer touch the shared profile-binding generation (which would also + // abort that load for the other two services). + slot.session = null; + _rebind(slot); safeNotifyListeners(); - await store.clear(userUuid); + await slot.store.clear(userUuid); } void _invalidateConnect([TrackerService? service]) { @@ -305,68 +265,33 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin }, ); - /// Snapshot the active profile + bump this service's rebind generation, - /// returning the bound uuid and an `isCurrent` predicate. Bumping here is what - /// lets a stale client callback — or a racing profile load — detect that it - /// has been superseded for this service. - (String, bool Function()) _beginRebind(_RebindGeneration gen) { - final boundUuid = _activeUserUuid; - final generation = gen.bump(); - bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == gen.value; - return (boundUuid, isCurrent); - } - - void _rebindMal() { + /// Push a slot's session to its tracker, snapshotting the active profile and + /// bumping the slot's rebind generation first. Bumping here is what lets a + /// stale client callback — or a racing profile load — detect that it has been + /// superseded for this service. + void _rebind(_TrackerSlot slot) { if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_malRebind); - MalTracker.instance.rebindSession( - _mal, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_malStore, boundUuid, () => _mal = null, _rebindMal); - }, - onSessionUpdated: (next) { + final boundUuid = _activeUserUuid; + final generation = ++slot.rebindGeneration; + bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == slot.rebindGeneration; + slot.bind( + slot.session, + onInvalidated: () { if (!isCurrent()) return; - _mal = next; - _malStore.save(boundUuid, next); + slot.store.clear(boundUuid); + slot.session = null; + _rebind(slot); + safeNotifyListeners(); + }, + onUpdated: (next) { + if (!isCurrent()) return; + slot.session = next; + slot.store.save(boundUuid, next); safeNotifyListeners(); }, ); } - void _rebindAnilist() { - if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_anilistRebind); - AnilistTracker.instance.rebindSession( - _anilist, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_anilistStore, boundUuid, () => _anilist = null, _rebindAnilist); - }, - ); - } - - void _rebindSimkl() { - if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_simklRebind); - SimklTracker.instance.rebindSession( - _simkl, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_simklStore, boundUuid, () => _simkl = null, _rebindSimkl); - }, - ); - } - - void _handleInvalidated( - TrackerAccountStore store, - String userUuid, - void Function() clearSession, - void Function() rebind, - ) { - store.clear(userUuid); - clearSession(); - rebind(); - safeNotifyListeners(); - } - @override void dispose() { _invalidateConnect(); @@ -377,12 +302,29 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } -/// A monotonic per-service rebind counter. Each rebind bumps it so a stale -/// client callback — or a profile load that started earlier — can tell it has -/// been superseded for that service. -class _RebindGeneration { - int _value = 0; +/// Pushes a session to one service's tracker singleton. `onUpdated` is only +/// wired for MAL, the one service that rotates its refresh token. +typedef _TrackerBind = + void Function( + TrackerSession? session, { + required void Function() onInvalidated, + void Function(TrackerSession session)? onUpdated, + }); - int bump() => ++_value; - int get value => _value; +/// Owns one service's session, the generation guarding its rebinds, and the +/// adapter that pushes that session to the service's tracker singleton. +class _TrackerSlot { + _TrackerSlot(this.service, this.bind) : store = trackerAccountStore(service); + + final TrackerService service; + final TrackerAccountStore store; + final _TrackerBind bind; + TrackerSession? session; + + /// Bumped on every rebind so a late callback from a disposed client (e.g. an + /// in-flight MAL token refresh that resolves after a profile switch) can't + /// persist or clear a session under the wrong profile, and so a disconnect + /// racing an in-flight profile load only suppresses its own service. Mirrors + /// TraktAccountProvider's binding-generation guard, but per service. + int rebindGeneration = 0; } diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index b6a4c4d7..a15e62cf 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -64,24 +64,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } - final abort = AbortController(); - - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: shuffle ? t.common.shuffle : t.common.play, - abort: abort, - execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(ServerId(serverId)); - if (client == null) { - return _missingClientError(serverId, dismissLoading); - } - + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:${facts.id}', + contextKey: facts.id, + shuffle: shuffle, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { // Playlists go through the dedicated `/Playlists/{id}/Items` endpoint // so playlist-defined order is preserved; collections fall back to // recursive descendant expansion (which skips unplayable Series // containers and surfaces Movies + Episodes flat). - List items; + final List items; if (facts.isPlaylist) { items = await fetchAllPlaylistItems(client, facts.id, abort: abort); } else if (client is JellyfinClient) { @@ -92,44 +86,15 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { items = await client.fetchPlayableDescendants(facts.id); } abort.throwIfAborted(); - - if (items.isEmpty) return const PlayQueueEmpty(); - - abort.throwIfAborted(); - if (shuffle) { - items = List.of(items)..shuffle(Random()); - } - - abort.throwIfAborted(); - // When a startItem is given (and we're not shuffling), keep the full - // original order and move the local queue cursor to that item. - var startIndex = 0; - if (!shuffle && startItem != null) { - startIndex = items.indexWhere((it) => it.id == startItem.id); - if (startIndex < 0) startIndex = 0; - } - - await dismissLoading(); - abort.throwIfAborted(); - if (!context.mounted && navigateForTesting == null) { - return const PlayQueueError('Context not mounted'); - } - - abort.throwIfAborted(); - final playbackState = playbackStateForTesting ?? context.read(); - return launchLocalQueuePlayback( - context: context, - playbackState: playbackState, - queue: LocalPlayQueue( - id: 'jellyfin:${facts.id}', - items: items, - currentIndex: startIndex, - shuffled: shuffle, - backendId: client.backend.id, - ), - contextKey: facts.id, - navigateForTesting: navigateForTesting, - ); + return items; + }, + // When a startItem is given (and we're not shuffling), keep the full + // original order and move the local queue cursor to that item. + resolveStartIndex: (items) { + final start = startItem; + if (shuffle || start == null) return 0; + final index = items.indexWhere((it) => it.id == start.id); + return index < 0 ? 0 : index; }, ); } @@ -148,24 +113,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } - final abort = AbortController(); - - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: shuffle ? t.common.shuffle : t.common.play, - abort: abort, - execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(ServerId(serverId)); - if (client == null) { - return _missingClientError(serverId, dismissLoading); - } - + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:folder:${folder.id}', + contextKey: folder.id, + shuffle: shuffle, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { final fetched = client is JellyfinClient ? await client.fetchPlayableFolderDescendants(folder.id, abort: abort) : await client.fetchPlayableDescendants(folder.id); abort.throwIfAborted(); - var items = fetched.where((item) => item.kind.isVideo).map((item) { + return fetched.where((item) => item.kind.isVideo).map((item) { return item.copyWith( serverId: item.serverId ?? serverId, serverName: item.serverName ?? folder.serverName, @@ -173,35 +132,6 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { libraryTitle: item.libraryTitle ?? folder.libraryTitle, ); }).toList(); - - if (items.isEmpty) return const PlayQueueEmpty(); - - abort.throwIfAborted(); - if (shuffle) { - items = List.of(items)..shuffle(Random()); - } - - await dismissLoading(); - abort.throwIfAborted(); - if (!context.mounted && navigateForTesting == null) { - return const PlayQueueError('Context not mounted'); - } - - abort.throwIfAborted(); - final playbackState = playbackStateForTesting ?? context.read(); - return launchLocalQueuePlayback( - context: context, - playbackState: playbackState, - queue: LocalPlayQueue( - id: 'jellyfin:folder:${folder.id}', - items: items, - currentIndex: 0, - shuffled: shuffle, - backendId: client.backend.id, - ), - contextKey: folder.id, - navigateForTesting: navigateForTesting, - ); }, ); } @@ -227,12 +157,43 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { seriesId = parent; } + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:$seriesId', + contextKey: seriesId, + shuffle: true, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { + final raw = client is JellyfinClient + ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) + : await client.fetchClientSideEpisodeQueue(seriesId); + abort.throwIfAborted(); + if (raw == null) return const []; + return raw + .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) + .toList(); + }, + ); + } + + /// Fetch, shuffle, and publish a local queue behind the cancellable loading + /// dialog. [fetchItems] carries the only per-entry-point difference: which + /// client call produces the items and how they're normalized. + Future _launchLocalQueue({ + required String serverId, + required String queueId, + required String contextKey, + required bool shuffle, + required bool showLoadingIndicator, + required Future> Function(MediaServerClient client, AbortController abort) fetchItems, + int Function(List items)? resolveStartIndex, + }) async { final abort = AbortController(); return executeWithLoading( context: context, showLoading: showLoadingIndicator, - actionLabel: t.common.shuffle, + actionLabel: shuffle ? t.common.shuffle : t.common.play, abort: abort, execute: (dismissLoading) async { final client = clientForTesting ?? _resolveClient(ServerId(serverId)); @@ -240,18 +201,16 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return _missingClientError(serverId, dismissLoading); } - final raw = client is JellyfinClient - ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) - : await client.fetchClientSideEpisodeQueue(seriesId); - abort.throwIfAborted(); - if (raw == null || raw.isEmpty) return const PlayQueueEmpty(); + var items = await fetchItems(client, abort); + if (items.isEmpty) return const PlayQueueEmpty(); abort.throwIfAborted(); - final shuffled = List.of(raw)..shuffle(Random()); + if (shuffle) { + items = List.of(items)..shuffle(Random()); + } + abort.throwIfAborted(); - final items = shuffled - .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) - .toList(); + final startIndex = resolveStartIndex?.call(items) ?? 0; await dismissLoading(); abort.throwIfAborted(); @@ -265,13 +224,13 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { context: context, playbackState: playbackState, queue: LocalPlayQueue( - id: 'jellyfin:$seriesId', + id: queueId, items: items, - currentIndex: 0, - shuffled: true, + currentIndex: startIndex, + shuffled: shuffle, backendId: client.backend.id, ), - contextKey: seriesId, + contextKey: contextKey, navigateForTesting: navigateForTesting, ); }, diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index 144475c5..39080475 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -128,14 +128,13 @@ class SeerrClient { // ---------- Details ---------- - Future getMovie(int tmdbId) async { - final data = await _request('GET', '/movie/$tmdbId'); - return SeerrMovieDetails.fromJson(data as Map); - } + Future getMovie(int tmdbId) => _details('/movie/$tmdbId'); - Future getTv(int tmdbId) async { - final data = await _request('GET', '/tv/$tmdbId'); - return SeerrTvDetails.fromJson(data as Map); + Future getTv(int tmdbId) => _details('/tv/$tmdbId'); + + Future _details(String path) async { + final data = await _request('GET', path); + return SeerrDetails.fromJson(data as Map); } // ---------- Requests ---------- From 83f4e2a2639bb6cb3aa788de40d2abe8648ffddd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:36:56 +0200 Subject: [PATCH 07/12] refactor: fold single-use helpers into their call sites Collapses indirection layers and one-caller abstractions across the video player, shortcut dispatch, shader loading and context-menu code, including the VideoPIPManager pass-through over PipService. --- lib/screens/livetv/record_options_sheet.dart | 158 +++++--------- .../parts/episode_navigation.dart | 2 +- lib/screens/video_player/parts/pip.dart | 95 +++++++- .../video_player/parts/playback_prompts.dart | 2 +- .../video_player/parts/playback_services.dart | 11 +- .../video_player/parts/playback_start.dart | 5 +- lib/screens/video_player_screen.dart | 8 +- lib/services/data_aggregation_service.dart | 139 ++++++------ lib/services/keyboard_shortcuts_service.dart | 200 ++++++----------- lib/services/shader_asset_loader.dart | 103 +++------ lib/services/shortcut_action.dart | 5 +- lib/services/video_pip_manager.dart | 91 -------- lib/widgets/media_context_menu.dart | 205 ++++++++++-------- .../video_controls/parts/key_events.dart | 2 - test/services/shader_asset_loader_test.dart | 15 ++ 15 files changed, 443 insertions(+), 598 deletions(-) delete mode 100644 lib/services/video_pip_manager.dart diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 4251ce14..f69e0be0 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -419,9 +419,23 @@ class _SettingRow extends StatelessWidget { return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); } if (type == 'int') { - return _IntSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); + return _TextFieldSettingRow( + setting: setting, + currentValue: currentValue, + autofocus: autofocus, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + parseValue: int.tryParse, + onChanged: onChanged, + ); } - return _TextSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); + return _TextFieldSettingRow( + setting: setting, + currentValue: currentValue, + autofocus: autofocus, + parseValue: (text) => text, + onChanged: onChanged, + ); } } @@ -435,6 +449,28 @@ bool _coerceBool(Object? value) { return false; } +/// Setting label with its optional secondary summary line. +class _SettingLabel extends StatelessWidget { + final String label; + final String? summary; + + const _SettingLabel({required this.label, this.summary}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final summary = this.summary; + return Column( + crossAxisAlignment: .start, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + if (summary != null && summary.isNotEmpty) + Text(summary, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ], + ); + } +} + class _BoolSettingRow extends StatelessWidget { final SubscriptionSetting setting; final Object? currentValue; @@ -450,7 +486,6 @@ class _BoolSettingRow extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final value = _coerceBool(currentValue); void toggle() => onChanged(!value); return FocusableWrapper( @@ -466,17 +501,7 @@ class _BoolSettingRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium), - if (setting.summary != null && setting.summary!.isNotEmpty) - Text( - setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], - ), + child: _SettingLabel(label: setting.label ?? setting.id, summary: setting.summary), ), IgnorePointer( child: Switch(value: value, onChanged: (v) => onChanged(v)), @@ -549,14 +574,7 @@ class _PickerRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(label, style: theme.textTheme.bodyMedium), - if (summary != null && summary!.isNotEmpty) - Text(summary!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), - ], - ), + child: _SettingLabel(label: label, summary: summary), ), const SizedBox(width: 12), Text(value, style: theme.textTheme.bodyMedium), @@ -575,24 +593,32 @@ class _PickerRow extends StatelessWidget { } } -class _IntSettingRow extends StatefulWidget { +/// Free-text setting row. [parseValue] maps the field text to the value handed +/// back to [onChanged] — identity for text settings, `int.tryParse` for ints. +class _TextFieldSettingRow extends StatefulWidget { final SubscriptionSetting setting; final Object? currentValue; final bool autofocus; + final TextInputType? keyboardType; + final List? inputFormatters; + final Object? Function(String) parseValue; final void Function(Object?) onChanged; - const _IntSettingRow({ + const _TextFieldSettingRow({ required this.setting, required this.currentValue, required this.autofocus, + required this.parseValue, required this.onChanged, + this.keyboardType, + this.inputFormatters, }); @override - State<_IntSettingRow> createState() => _IntSettingRowState(); + State<_TextFieldSettingRow> createState() => _TextFieldSettingRowState(); } -class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerMixin { +class _TextFieldSettingRowState extends State<_TextFieldSettingRow> with ControllerDisposerMixin { late final TextEditingController _controller; @override @@ -602,7 +628,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM } @override - void didUpdateWidget(covariant _IntSettingRow oldWidget) { + void didUpdateWidget(covariant _TextFieldSettingRow oldWidget) { super.didUpdateWidget(oldWidget); final next = widget.currentValue?.toString() ?? ''; if (next != _controller.text) _controller.text = next; @@ -610,91 +636,21 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Column( crossAxisAlignment: .start, children: [ - Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), - if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) - Text( - widget.setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), + _SettingLabel(label: widget.setting.label ?? widget.setting.id, summary: widget.setting.summary), const SizedBox(height: 4), FocusableTextField( controller: _controller, autofocus: widget.autofocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, onNavigateUp: () => FocusScope.of(context).previousFocus(), onNavigateDown: () => FocusScope.of(context).nextFocus(), - onChanged: (text) { - final parsed = int.tryParse(text); - widget.onChanged(parsed); - }, - ), - ], - ), - ); - } -} - -class _TextSettingRow extends StatefulWidget { - final SubscriptionSetting setting; - final Object? currentValue; - final bool autofocus; - final void Function(Object?) onChanged; - - const _TextSettingRow({ - required this.setting, - required this.currentValue, - required this.autofocus, - required this.onChanged, - }); - - @override - State<_TextSettingRow> createState() => _TextSettingRowState(); -} - -class _TextSettingRowState extends State<_TextSettingRow> with ControllerDisposerMixin { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = createTextEditingController(text: widget.currentValue?.toString() ?? ''); - } - - @override - void didUpdateWidget(covariant _TextSettingRow oldWidget) { - super.didUpdateWidget(oldWidget); - final next = widget.currentValue?.toString() ?? ''; - if (next != _controller.text) _controller.text = next; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Column( - crossAxisAlignment: .start, - children: [ - Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), - if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) - Text( - widget.setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 4), - FocusableTextField( - controller: _controller, - autofocus: widget.autofocus, - onNavigateUp: () => FocusScope.of(context).previousFocus(), - onNavigateDown: () => FocusScope.of(context).nextFocus(), - onChanged: (text) => widget.onChanged(text), + onChanged: (text) => widget.onChanged(widget.parseValue(text)), ), ], ), diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index a4e88b73..76999c99 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -817,7 +817,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (!isCurrentReload()) return _MediaReloadOutcome.superseded; if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); + unawaited(_updateAutoPipState(isPlaying: currentPlayer.state.playing)); } return _MediaReloadOutcome.opened; } catch (e) { diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index aa05c811..f52a1dfc 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -19,12 +19,12 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { _autoPipEnteringCallback = null; } - /// Initialize VideoFilterManager and VideoPIPManager if not already set up. + /// Initialize VideoFilterManager and the PiP methods if not already set up. /// Called from both live TV and VOD playback paths. Future _initVideoFilterAndPip() async { final currentPlayer = player; if (!mounted || currentPlayer == null) return; - if (_videoFilterManager != null && _videoPIPManager != null) { + if (_videoFilterManager != null && _pipInitialized) { _attachPipStateListener(); return; } @@ -44,20 +44,91 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { unawaited(_videoFilterManager!.updateVideoFilter()); } - _videoPIPManager ??= VideoPIPManager( - player: currentPlayer, - playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null, - ); - _videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry; + _pipInitialized = true; _attachPipStateListener(); } Future _togglePIPMode() async { - final result = await _videoPIPManager?.togglePIP(); - if (result != null && !result.$1 && mounted) { - _restorePipFiltersAfterExit(); - showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed); + if (!_pipInitialized) return; + + final supported = await PipService.isSupported(); + if (!supported) { + _onPipRequestFailed('PiP not supported on this device'); + return; } + + // If PiP is already active, exit it + if (PipService().isPipActive.value) { + await PipService.exit(); + return; + } + + // Reset video filter to contain mode before entering PiP. Android, iOS, + // and macOS all reuse the inline video surface/layer for PiP. + if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { + if (_pipInitialized) _preparePipFiltersForEntry(); + // Wait a frame for the filter change to take effect + await Future.delayed(const Duration(milliseconds: 50)); + } + + final dims = await _getVideoDimensions(); + final result = await PipService.enter(width: dims.$1, height: dims.$2); + if (!result.$1) _onPipRequestFailed(result.$2); + } + + void _onPipRequestFailed(String? error) { + if (!mounted) return; + _restorePipFiltersAfterExit(); + showErrorSnackBar(context, error ?? t.videoControls.pipFailed); + } + + Future _updateAutoPipState({required bool isPlaying}) async { + if (!_pipInitialized) return; + + if (!isPlaying) { + await PipService.setAutoPipReady(ready: false); + return; + } + + final dims = await _getVideoDimensions(); + await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); + } + + /// Get current video dimensions (display or storage or fallback to viewport) + Future<(int? width, int? height)> _getVideoDimensions() async { + final currentPlayer = player; + int? width; + int? height; + + try { + final dwidth = await currentPlayer?.getProperty('dwidth'); + final dheight = await currentPlayer?.getProperty('dheight'); + if (dwidth != null && dheight != null) { + width = int.tryParse(dwidth); + height = int.tryParse(dheight); + } + } catch (e) { + appLogger.d('PiP: dwidth/dheight unavailable', error: e); + } + + if (width == null || height == null) { + try { + final videoWidth = await currentPlayer?.getProperty('width'); + final videoHeight = await currentPlayer?.getProperty('height'); + if (videoWidth != null && videoHeight != null) { + width = int.tryParse(videoWidth); + height = int.tryParse(videoHeight); + } + } catch (e) { + appLogger.d('PiP: width/height unavailable', error: e); + } + } + + final viewport = _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null; + width ??= viewport?.width.toInt(); + height ??= viewport?.height.toInt(); + + return (width, height); } void _preparePipFiltersForEntry() { @@ -99,7 +170,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); - if (_videoPIPManager == null || _videoFilterManager == null) return; + if (!_pipInitialized || _videoFilterManager == null) return; if (isInPip) { _preparePipFiltersForEntry(); diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index f25b8a50..4b07fe75 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -24,7 +24,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { unawaited(DiscordRPCService.instance.pausePlayback()); unawaited(TraktScrobbleService.instance.pausePlayback()); if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false)); + unawaited(_updateAutoPipState(isPlaying: false)); } // End-of-video sleep timer takes precedence over autoplay / next-episode diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index dcafffe7..967b17ea 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -267,12 +267,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _stopLiveTimelineUpdates(); _detachPipStateListener(); _clearAutoPipEnteringCallback(); - final videoPipManager = _videoPIPManager; - _videoPIPManager = null; - if (videoPipManager != null) { - videoPipManager.onBeforeEnterPip = null; + final pipInitialized = _pipInitialized; + _pipInitialized = false; + if (pipInitialized) { try { - await videoPipManager.disableAutoPip(); + await PipService.setAutoPipReady(ready: false); } catch (e, st) { appLogger.w('Failed to disable auto-PiP during initialization rollback', error: e, stackTrace: st); } @@ -632,7 +631,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Update auto-PiP readiness if (_autoPipEnabled) { - _videoPIPManager?.updateAutoPipState(isPlaying: isPlaying); + unawaited(_updateAutoPipState(isPlaying: isPlaying)); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 4ece6685..27a5738d 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -305,9 +305,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _autoPipEnteringCallback = autoPipEnteringCallback; PipService.onAutoPipEntering = autoPipEnteringCallback; - final pipManager = _videoPIPManager; - if (currentPlayer.state.playing && pipManager != null) { - unawaited(pipManager.updateAutoPipState(isPlaying: true)); + if (currentPlayer.state.playing) { + unawaited(_updateAutoPipState(isPlaying: true)); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 03924649..030cecb1 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -65,7 +65,6 @@ import '../services/track_manager.dart'; import '../services/track_selection_service.dart'; import '../services/ambient_lighting_service.dart'; import '../services/video_filter_manager.dart'; -import '../services/video_pip_manager.dart'; import '../services/video_volume_controller.dart'; import '../services/pip_service.dart'; import '../models/shader_preset.dart'; @@ -525,7 +524,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin ({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority; PlaybackProgressTracker? _progressTracker; VideoFilterManager? _videoFilterManager; - VideoPIPManager? _videoPIPManager; + bool _pipInitialized = false; ShaderService? _shaderService; AmbientLightingService? _ambientLightingService; bool _fullscreenListenerAttached = false; @@ -1517,11 +1516,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin _stopLiveTimelineUpdates(); _detachPipStateListener(); - _videoPIPManager?.onBeforeEnterPip = null; - unawaited(_videoPIPManager?.disableAutoPip()); + if (_pipInitialized) unawaited(PipService.setAutoPipReady(ready: false)); _clearAutoPipEnteringCallback(); _videoFilterManager?.dispose(); - _videoPIPManager = null; + _pipInitialized = false; _videoFilterManager = null; _scrubPreviewSource?.dispose(); diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 99456a41..2410681a 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -25,6 +25,7 @@ typedef LibraryAggregationResult = ({ Set succeededServerIds, Set cancelledServerIds, }); +typedef _FanOutResult = ({List items, Set succeededServerIds, Set cancelledServerIds}); /// Whether [error] is a client-side abort (client teardown mid-request) /// rather than a genuine server failure. Aggregation reports these servers @@ -54,6 +55,37 @@ class DataAggregationService { }; } + /// Run [fetch] against every client in [clients] and concatenate the results + /// in client order. A per-server failure is swallowed — logged with + /// [failureMessage] and contributing nothing — so one bad server cannot sink + /// the pass; that server is simply absent from `succeededServerIds`, and also + /// lands in `cancelledServerIds` when the failure was a client-side abort. + Future<_FanOutResult> _fanOut( + Map clients, { + required String Function(String serverId) failureMessage, + required Future> Function(String serverId, MediaServerClient client) fetch, + }) async { + final cancelledServerIds = {}; + final futures = clients.entries.map((entry) async { + try { + return (serverId: entry.key, items: await fetch(entry.key, entry.value)); + } catch (e, stackTrace) { + if (_isCancellation(e)) cancelledServerIds.add(entry.key); + appLogger.e(failureMessage(entry.key), error: e, stackTrace: stackTrace); + return (serverId: null, items: []); + } + }); + final results = await Future.wait(futures); + return ( + items: [for (final result in results) ...result.items], + succeededServerIds: { + for (final result in results) + if (result.serverId != null) result.serverId!, + }, + cancelledServerIds: cancelledServerIds, + ); + } + /// Fetch libraries from all online clients regardless of backend, returning /// the merged neutral [MediaLibrary]s alongside the ids of the servers whose /// fetch actually succeeded. [serverIds] restricts the fan-out to those @@ -77,24 +109,15 @@ class DataAggregationService { cancelledServerIds: const {}, ); } - final succeededServerIds = {}; - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - try { - final libraries = await entry.value.fetchLibraries(); - succeededServerIds.add(entry.key); - return libraries; - } catch (e, stackTrace) { - if (_isCancellation(e)) cancelledServerIds.add(entry.key); - appLogger.e('Failed neutral library fetch from ${entry.key}', error: e, stackTrace: stackTrace); - return []; - } - }); - final results = await Future.wait(futures); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed neutral library fetch from $serverId', + fetch: (_, client) => client.fetchLibraries(), + ); return ( - libraries: [for (final list in results) ...list], - succeededServerIds: succeededServerIds, - cancelledServerIds: cancelledServerIds, + libraries: fetched.items, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, ); } @@ -113,24 +136,12 @@ class DataAggregationService { return (items: const [], succeededServerIds: const {}, cancelledServerIds: const {}); } - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - final client = entry.value; - try { - final items = await client.fetchContinueWatching(count: limit); - return (serverId: entry.key, items: items); - } catch (e, st) { - if (_isCancellation(e)) cancelledServerIds.add(entry.key); - appLogger.e('Failed on-deck fetch from ${entry.key}', error: e, stackTrace: st); - return (serverId: null, items: []); - } - }); - final results = await Future.wait(futures); - final succeededServerIds = { - for (final result in results) - if (result.serverId != null) result.serverId!, - }; - final allOnDeck = results.expand((result) => result.items).toList(); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed on-deck fetch from $serverId', + fetch: (_, client) => client.fetchContinueWatching(count: limit), + ); + final allOnDeck = fetched.items; // Filter out items from hidden libraries List filteredOnDeck = allOnDeck; @@ -154,7 +165,11 @@ class DataAggregationService { appLogger.i('Fetched ${items.length} on deck items from all servers'); - return (items: items, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds); + return ( + items: items, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, + ); } /// Merge an [existing] Continue Watching list with [fresh] rows from @@ -382,11 +397,10 @@ class DataAggregationService { ? _groupLibrariesByServer((await getMediaLibrariesFromAllServers(serverIds: serverIds)).libraries) : null; - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - final serverId = entry.key; - final client = entry.value; - try { + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed to fetch hubs from server $serverId', + fetch: (serverId, client) async { final serverLibraries = libraries?[serverId]; final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs; final hubItemLimit = limit ?? defaultHubPreviewLimit; @@ -413,28 +427,13 @@ class DataAggregationService { includePlaybackHubs: includePlaybackHubs, libraries: useGlobalHubs ? serverLibraries : null, ); - return ( - serverId: serverId, - hubs: _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys), - ); - } catch (e, stackTrace) { - if (_isCancellation(e)) cancelledServerIds.add(serverId); - appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); - return (serverId: null, hubs: []); - } - }); + return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys); + }, + ); - final results = await Future.wait(futures); - final succeededServerIds = { - for (final result in results) - if (result.serverId != null) result.serverId!, - }; - final all = []; - for (final result in results) { - all.addAll(result.hubs); - } + final all = fetched.items; final hubs = limit != null && limit < all.length ? all.sublist(0, limit) : all; - return (hubs: hubs, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds); + return (hubs: hubs, succeededServerIds: fetched.succeededServerIds, cancelledServerIds: fetched.cancelledServerIds); } /// Per-library hub fetch for a single client. Filters to visible libraries @@ -519,18 +518,12 @@ class DataAggregationService { final resultLimit = limit ?? defaultMediaSearchLimit; final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit; - final futures = clients.entries.map((entry) async { - final client = entry.value; - try { - return await client.searchItems(query, limit: fetchLimit); - } catch (e, st) { - appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st); - return []; - } - }); - - final allResults = (await Future.wait(futures)).expand((l) => l).toList(); - final result = rankMediaSearchResults(allResults, query, limit: resultLimit); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Search failed on $serverId', + fetch: (_, client) => client.searchItems(query, limit: fetchLimit), + ); + final result = rankMediaSearchResults(fetched.items, query, limit: resultLimit); appLogger.i('Found ${result.length} search results across all servers'); diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 52d79410..448794c8 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -202,8 +202,6 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onVolumeUp, VoidCallback? onVolumeDown, VoidCallback? onToggleMute, - int? currentPositionEpoch, - ValueChanged? onLiveSeek, ValueChanged? onLiveSeekBy, Future Function(Duration position)? onSeekRequested, }) { @@ -277,32 +275,78 @@ class KeyboardShortcutsService extends ChangeNotifier { return KeyEventResult.handled; } - _executeAction( - action, - player, - onToggleFullscreen, - onToggleSubtitles, - onNextAudioTrack, - onNextSubtitleTrack, - onNextChapter, - onPreviousChapter, - onPlayPause: onPlayPause, - onToggleShader: onToggleShader, - onSkipMarker: onSkipMarker, - onNextEpisode: onNextEpisode, - onPreviousEpisode: onPreviousEpisode, - onScreenshot: onScreenshot, - onZoomIn: onZoomIn, - onZoomOut: onZoomOut, - onZoomReset: onZoomReset, - onVolumeUp: onVolumeUp, - onVolumeDown: onVolumeDown, - onToggleMute: onToggleMute, - currentPositionEpoch: currentPositionEpoch, - onLiveSeek: onLiveSeek, - onLiveSeekBy: onLiveSeekBy, - onSeekRequested: onSeekRequested, - ); + void performSeek(int offsetSeconds) { + // Relative live-TV skip: route through the parent accumulator, which + // coalesces a rapid burst into one transcode re-open (#1253). + if (onLiveSeekBy != null) { + onLiveSeekBy(offsetSeconds); + } else { + final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); + unawaited((onSeekRequested ?? player.seek)(target)); + } + } + + switch (action) { + case ShortcutAction.playPause: + (onPlayPause ?? player.playOrPause).call(); + case ShortcutAction.volumeUp: + onVolumeUp?.call(); + case ShortcutAction.volumeDown: + onVolumeDown?.call(); + case ShortcutAction.seekForward: + performSeek(_seekTimeSmall); + case ShortcutAction.seekBackward: + performSeek(-_seekTimeSmall); + case ShortcutAction.seekForwardLarge: + performSeek(_seekTimeLarge); + case ShortcutAction.seekBackwardLarge: + performSeek(-_seekTimeLarge); + case ShortcutAction.fullscreenToggle: + onToggleFullscreen?.call(); + case ShortcutAction.muteToggle: + onToggleMute?.call(); + case ShortcutAction.subtitleToggle: + onToggleSubtitles?.call(); + case ShortcutAction.audioTrackNext: + onNextAudioTrack?.call(); + case ShortcutAction.subtitleTrackNext: + onNextSubtitleTrack?.call(); + case ShortcutAction.chapterNext: + onNextChapter?.call(); + case ShortcutAction.chapterPrevious: + onPreviousChapter?.call(); + case ShortcutAction.episodeNext: + onNextEpisode?.call(); + case ShortcutAction.episodePrevious: + onPreviousEpisode?.call(); + case ShortcutAction.speedIncrease: + final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); + player.setRate(newRateUp); + _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); + case ShortcutAction.speedDecrease: + final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); + player.setRate(newRateDown); + _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); + case ShortcutAction.speedReset: + player.setRate(1.0); + _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); + case ShortcutAction.subSeekNext: + player.command(['sub-seek', '1']); + case ShortcutAction.subSeekPrev: + player.command(['sub-seek', '-1']); + case ShortcutAction.shaderToggle: + onToggleShader?.call(); + case ShortcutAction.skipMarker: + onSkipMarker?.call(); + case ShortcutAction.screenshot: + unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); + case ShortcutAction.zoomIn: + onZoomIn?.call(); + case ShortcutAction.zoomOut: + onZoomOut?.call(); + case ShortcutAction.zoomReset: + onZoomReset?.call(); + } return KeyEventResult.handled; } } @@ -310,106 +354,6 @@ class KeyboardShortcutsService extends ChangeNotifier { return KeyEventResult.ignored; } - void _executeAction( - ShortcutAction action, - Player player, - VoidCallback? onToggleFullscreen, - VoidCallback? onToggleSubtitles, - VoidCallback? onNextAudioTrack, - VoidCallback? onNextSubtitleTrack, - VoidCallback? onNextChapter, - VoidCallback? onPreviousChapter, { - VoidCallback? onPlayPause, - VoidCallback? onToggleShader, - VoidCallback? onSkipMarker, - VoidCallback? onNextEpisode, - VoidCallback? onPreviousEpisode, - VoidCallback? onScreenshot, - VoidCallback? onZoomIn, - VoidCallback? onZoomOut, - VoidCallback? onZoomReset, - VoidCallback? onVolumeUp, - VoidCallback? onVolumeDown, - VoidCallback? onToggleMute, - int? currentPositionEpoch, - ValueChanged? onLiveSeek, - ValueChanged? onLiveSeekBy, - Future Function(Duration position)? onSeekRequested, - }) { - void performSeek(int offsetSeconds) { - // Relative live-TV skip: route through the parent accumulator, which - // coalesces a rapid burst into one transcode re-open (#1253). - if (onLiveSeekBy != null) { - onLiveSeekBy(offsetSeconds); - } else { - final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); - unawaited((onSeekRequested ?? player.seek)(target)); - } - } - - switch (action) { - case ShortcutAction.playPause: - (onPlayPause ?? player.playOrPause).call(); - case ShortcutAction.volumeUp: - onVolumeUp?.call(); - case ShortcutAction.volumeDown: - onVolumeDown?.call(); - case ShortcutAction.seekForward: - performSeek(_seekTimeSmall); - case ShortcutAction.seekBackward: - performSeek(-_seekTimeSmall); - case ShortcutAction.seekForwardLarge: - performSeek(_seekTimeLarge); - case ShortcutAction.seekBackwardLarge: - performSeek(-_seekTimeLarge); - case ShortcutAction.fullscreenToggle: - onToggleFullscreen?.call(); - case ShortcutAction.muteToggle: - onToggleMute?.call(); - case ShortcutAction.subtitleToggle: - onToggleSubtitles?.call(); - case ShortcutAction.audioTrackNext: - onNextAudioTrack?.call(); - case ShortcutAction.subtitleTrackNext: - onNextSubtitleTrack?.call(); - case ShortcutAction.chapterNext: - onNextChapter?.call(); - case ShortcutAction.chapterPrevious: - onPreviousChapter?.call(); - case ShortcutAction.episodeNext: - onNextEpisode?.call(); - case ShortcutAction.episodePrevious: - onPreviousEpisode?.call(); - case ShortcutAction.speedIncrease: - final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); - player.setRate(newRateUp); - _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); - case ShortcutAction.speedDecrease: - final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); - player.setRate(newRateDown); - _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); - case ShortcutAction.speedReset: - player.setRate(1.0); - _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); - case ShortcutAction.subSeekNext: - player.command(['sub-seek', '1']); - case ShortcutAction.subSeekPrev: - player.command(['sub-seek', '-1']); - case ShortcutAction.shaderToggle: - onToggleShader?.call(); - case ShortcutAction.skipMarker: - onSkipMarker?.call(); - case ShortcutAction.screenshot: - unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); - case ShortcutAction.zoomIn: - onZoomIn?.call(); - case ShortcutAction.zoomOut: - onZoomOut?.call(); - case ShortcutAction.zoomReset: - onZoomReset?.call(); - } - } - String getActionDisplayName(String action) { final shortcut = ShortcutAction.fromId(action); if (shortcut == null) return action; diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index b9ce944a..dcab1efe 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -182,85 +182,32 @@ class ShaderAssetLoader { /// Get the shader file paths for an Anime4K preset. /// Returns a list of shader paths in the correct order for MPV. static Future> getAnime4KShaders(Anime4KConfig config) async { + final (restoreVariant, upscaleVariant) = switch (config.quality) { + Anime4KQuality.fast => ('restore_m', 'upscale_m'), + Anime4KQuality.hq => ('restore_vl', 'upscale_vl'), + }; + + // All modes start with Clamp, then apply their own ordered chain. + final chain = [ + 'clamp', + ...switch (config.mode) { + Anime4KMode.modeA => [restoreVariant], + Anime4KMode.modeB => [restoreVariant, upscaleVariant, 'downscale'], + Anime4KMode.modeC => [upscaleVariant, 'downscale'], + Anime4KMode.modeAA => [restoreVariant, restoreVariant], + Anime4KMode.modeBB => [restoreVariant, restoreVariant, upscaleVariant, 'downscale'], + Anime4KMode.modeCA => [upscaleVariant, restoreVariant, 'downscale'], + }, + ]; + final shaders = []; - final quality = config.quality; - final mode = config.mode; - - String restoreVariant; - String upscaleVariant; - - switch (quality) { - case Anime4KQuality.fast: - restoreVariant = 'restore_m'; - upscaleVariant = 'upscale_m'; - break; - case Anime4KQuality.hq: - restoreVariant = 'restore_vl'; - upscaleVariant = 'upscale_vl'; - break; - } - - // Build shader chain based on mode - // All modes start with Clamp - final clampPath = await _extractShader(_anime4kShaders['clamp']!); - if (clampPath != null) shaders.add(clampPath); - - switch (mode) { - case Anime4KMode.modeA: - // A: Clamp + Restore - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - break; - - case Anime4KMode.modeB: - // B: Clamp + Restore + Upscale + Downscale - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeC: - // C: Clamp + Upscale + Downscale - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeAA: - // A+A: Clamp + Restore + Restore - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) { - shaders.add(restorePath); - shaders.add(restorePath); // Second restore pass - } - break; - - case Anime4KMode.modeBB: - // B+B: Clamp + Restore + Restore + Upscale + Downscale - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) { - shaders.add(restorePath); - shaders.add(restorePath); // Second restore pass - } - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeCA: - // C+A: Clamp + Upscale + Restore + Downscale - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; + final extracted = {}; + for (final key in chain) { + if (!extracted.containsKey(key)) { + extracted[key] = await _extractShader(_anime4kShaders[key]!); + } + final shaderPath = extracted[key]; + if (shaderPath != null) shaders.add(shaderPath); } return shaders; diff --git a/lib/services/shortcut_action.dart b/lib/services/shortcut_action.dart index 34f9364e..60ca5fdc 100644 --- a/lib/services/shortcut_action.dart +++ b/lib/services/shortcut_action.dart @@ -9,8 +9,9 @@ import 'shader_service.dart'; /// One row per action carries everything about it except the behaviour: the /// persisted [id], the [defaultHotKey] shipped with the app, the localized /// [label], and the capability flags that gate dispatch. Adding a shortcut is -/// one entry here plus a case in `KeyboardShortcutsService._executeAction`, -/// which the analyzer demands because that switch is exhaustive over this enum. +/// one entry here plus a case in +/// `KeyboardShortcutsService.handleVideoPlayerKeyEvent`, which the analyzer +/// demands because that switch is exhaustive over this enum. /// /// Declaration order is the order shortcuts are listed in settings, and [id] is /// persisted in preferences — do not reorder or rename existing entries. diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart deleted file mode 100644 index 7d0a554b..00000000 --- a/lib/services/video_pip_manager.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import '../mpv/mpv.dart'; -import '../services/pip_service.dart'; -import '../utils/app_logger.dart'; - -class VideoPIPManager { - final Player player; - - /// Current viewport size, used as the PiP aspect ratio fallback. - final Size? Function() playerSize; - - VideoPIPManager({required this.player, required this.playerSize}); - - /// Callback to prepare video filter before entering PiP - VoidCallback? onBeforeEnterPip; - - /// Get current video dimensions (display or storage or fallback to viewport) - Future<(int? width, int? height)> _getVideoDimensions() async { - int? width; - int? height; - - try { - final dwidth = await player.getProperty('dwidth'); - final dheight = await player.getProperty('dheight'); - if (dwidth != null && dheight != null) { - width = int.tryParse(dwidth); - height = int.tryParse(dheight); - } - } catch (e) { - appLogger.d('VideoPipManager: dwidth/dheight unavailable', error: e); - } - - if (width == null || height == null) { - try { - final videoWidth = await player.getProperty('width'); - final videoHeight = await player.getProperty('height'); - if (videoWidth != null && videoHeight != null) { - width = int.tryParse(videoWidth); - height = int.tryParse(videoHeight); - } - } catch (e) { - appLogger.d('VideoPipManager: width/height unavailable', error: e); - } - } - - final viewport = playerSize(); - width ??= viewport?.width.toInt(); - height ??= viewport?.height.toInt(); - - return (width, height); - } - - Future<(bool success, String? error)> togglePIP() async { - final supported = await PipService.isSupported(); - if (!supported) return (false, 'PiP not supported on this device'); - - // If PiP is already active, exit it - if (PipService().isPipActive.value) { - await PipService.exit(); - return (true, null); - } - - // Reset video filter to contain mode before entering PiP. Android, iOS, - // and macOS all reuse the inline video surface/layer for PiP. - if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { - onBeforeEnterPip?.call(); - // Wait a frame for the filter change to take effect - await Future.delayed(const Duration(milliseconds: 50)); - } - - final dims = await _getVideoDimensions(); - return await PipService.enter(width: dims.$1, height: dims.$2); - } - - Future updateAutoPipState({required bool isPlaying}) async { - if (!isPlaying) { - await PipService.setAutoPipReady(ready: false); - return; - } - - final dims = await _getVideoDimensions(); - await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); - } - - /// Disable auto-PiP (called on dispose or when leaving player) - Future disableAutoPip() async { - await PipService.setAutoPipReady(ready: false); - } -} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index cd2f8059..6ce43f00 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1135,53 +1135,29 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - if (result == '_create_new') { - final playlistName = await showTextInputDialog( - context, + await _addItemToContainer( + context, + kind: 'playlist', + item: item, + client: client, + result: result, + createPrompt: ( title: t.playlists.create, - labelText: t.playlists.playlistName, - hintText: t.playlists.enterPlaylistName, - ); - - if (playlistName == null || playlistName.isEmpty || !context.mounted) { - return; - } - - appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}'); - final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (newPlaylist != null) { - appLogger.d('Successfully created playlist: ${newPlaylist.title}'); - showSuccessSnackBar(context, t.playlists.created); - // Trigger refresh of playlists tab - LibraryRefreshNotifier().notifyPlaylistsChanged(); - } else { - appLogger.e('Failed to create playlist - API returned null'); - showErrorSnackBar(context, t.playlists.errorCreating); - } - } - } else { - appLogger.d('Adding item ${item.id} to playlist $result'); - final success = await client.addToPlaylist(playlistId: result, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (success) { - appLogger.d('Successfully added item(s) to playlist $result'); - showSuccessSnackBar(context, t.playlists.itemAdded); - // Trigger refresh of playlists tab - LibraryRefreshNotifier().notifyPlaylistsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, result); - } else { - appLogger.e('Failed to add item(s) to playlist $result - API returned false'); - showErrorSnackBar(context, t.playlists.errorAdding); - } - } - } + label: t.playlists.playlistName, + hint: t.playlists.enterPlaylistName, + ), + create: (name) => client.createPlaylist(title: name, items: [item]), + createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}', + eagerSyncId: (_) => null, + add: () => client.addToPlaylist(playlistId: result, items: [item]), + messages: ( + created: t.playlists.created, + createError: t.playlists.errorCreating, + added: t.playlists.itemAdded, + addError: t.playlists.errorAdding, + ), + notifyChanged: () => LibraryRefreshNotifier().notifyPlaylistsChanged(), + ); } catch (e, stackTrace) { appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); if (context.mounted) { @@ -1239,59 +1215,34 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - if (result == '_create_new') { - final collectionName = await showTextInputDialog( - context, + await _addItemToContainer( + context, + kind: 'collection', + item: item, + client: client, + result: result, + createPrompt: ( title: t.common.createNew, - labelText: t.collections.collectionName, - hintText: t.collections.enterCollectionName, - ); - - if (collectionName == null || collectionName.isEmpty || !context.mounted) { - return; - } - - appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}'); - final newCollectionId = await client.createCollection( + label: t.collections.collectionName, + hint: t.collections.enterCollectionName, + ), + create: (name) => client.createCollection( libraryId: resolvedLibraryId, - title: collectionName, + title: name, items: [item], itemKind: itemKind, - ); - - if (!context.mounted) return; - - if (context.mounted) { - if (newCollectionId != null) { - appLogger.d('Successfully created collection with ID: $newCollectionId'); - showSuccessSnackBar(context, t.collections.created); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); - } else { - appLogger.e('Failed to create collection - API returned null'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } - } - } else { - appLogger.d('Adding item ${item.id} to collection $result'); - final success = await client.addToCollection(collectionId: result, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (success) { - appLogger.d('Successfully added item(s) to collection $result'); - showSuccessSnackBar(context, t.collections.addedToCollection); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, result); - } else { - appLogger.e('Failed to add item(s) to collection $result - API returned false'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } - } - } + ), + createdLog: (id) => 'Successfully created collection with ID: $id', + eagerSyncId: (id) => id, + add: () => client.addToCollection(collectionId: result, items: [item]), + messages: ( + created: t.collections.created, + createError: t.collections.errorAddingToCollection, + added: t.collections.addedToCollection, + addError: t.collections.errorAddingToCollection, + ), + notifyChanged: () => LibraryRefreshNotifier().notifyCollectionsChanged(), + ); } catch (e, stackTrace) { appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace); if (context.mounted) { @@ -1300,6 +1251,70 @@ class MediaContextMenuState extends State { } } + /// Create-or-add tail shared by the "Add to playlist" and "Add to collection" + /// flows. [result] is the picker selection: an existing container id, or the + /// `_create_new` sentinel to prompt for a name and create one via [create]. + /// [eagerSyncId] maps a freshly created container to the id to eager-sync, or + /// `null` to skip it. + Future _addItemToContainer( + BuildContext context, { + required String kind, + required MediaItem item, + required MediaServerClient client, + required String result, + required ({String title, String label, String hint}) createPrompt, + required Future Function(String name) create, + required String Function(T created) createdLog, + required String? Function(T created) eagerSyncId, + required Future Function() add, + required ({String created, String createError, String added, String addError}) messages, + required VoidCallback notifyChanged, + }) async { + if (result == '_create_new') { + final name = await showTextInputDialog( + context, + title: createPrompt.title, + labelText: createPrompt.label, + hintText: createPrompt.hint, + ); + + if (name == null || name.isEmpty || !context.mounted) return; + + appLogger.d('Creating $kind "$name" seeded with item ${item.id}'); + final created = await create(name); + + if (!context.mounted) return; + + if (created != null) { + appLogger.d(createdLog(created)); + showSuccessSnackBar(context, messages.created); + notifyChanged(); + final syncId = eagerSyncId(created); + if (syncId != null) { + _triggerEagerSyncIfRuleExists(context, client.serverId, syncId); + } + } else { + appLogger.e('Failed to create $kind - API returned null'); + showErrorSnackBar(context, messages.createError); + } + } else { + appLogger.d('Adding item ${item.id} to $kind $result'); + final success = await add(); + + if (!context.mounted) return; + + if (success) { + appLogger.d('Successfully added item(s) to $kind $result'); + showSuccessSnackBar(context, messages.added); + notifyChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, result); + } else { + appLogger.e('Failed to add item(s) to $kind $result - API returned false'); + showErrorSnackBar(context, messages.addError); + } + } + } + Future _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async { if (!mounted) return; // Presented from the menu's own context so a screen-level diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index f366dde8..9b3851b3 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -124,8 +124,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { onVolumeUp: () => widget.volumeController.adjust(10), onVolumeDown: () => widget.volumeController.adjust(-10), onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, onLiveSeekBy: widget.onLiveSeekBy, onSeekRequested: widget.onSeekRequested, ); diff --git a/test/services/shader_asset_loader_test.dart b/test/services/shader_asset_loader_test.dart index 1dfa1ffa..0bde6cc6 100644 --- a/test/services/shader_asset_loader_test.dart +++ b/test/services/shader_asset_loader_test.dart @@ -136,6 +136,21 @@ void main() { } }); + test('repeats the restore pass in place for doubled Anime4K modes', () async { + final shaders = await ShaderAssetLoader.getAnime4KShaders( + const Anime4KConfig(quality: Anime4KQuality.fast, mode: Anime4KMode.modeBB), + ); + + expect(shaders.map(path.basename).toList(), [ + 'Anime4K_Clamp_Highlights.glsl', + 'Anime4K_Restore_CNN_M.glsl', + 'Anime4K_Restore_CNN_M.glsl', + 'Anime4K_Upscale_CNN_x2_M.glsl', + 'Anime4K_AutoDownscalePre_x2.glsl', + ]); + expect(shaders[1], shaders[2]); + }); + test('nested and non-GLSL names are rejected without touching matching files', () async { final customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'))..createSync(recursive: true); final nested = File(path.join(customDirectory.path, 'subdir', 'name.glsl')) From c68ffe9ed03db08bf4f4d94b78b4bcdf73789e20 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:02:03 +0200 Subject: [PATCH 08/12] refactor: share the toolbar scrim and dedupe playback and download paths Extracts the repeated toolbar fade into a single ToolbarScrim widget, folds duplicated request/retry handling in the media server HTTP client, and collapses the parallel playback-source, download-manager and live TV helper paths into shared implementations. --- lib/providers/playback_state_provider.dart | 51 +--- lib/screens/discover_screen.dart | 262 +++++++--------- lib/screens/explore_screen.dart | 113 +++---- lib/screens/livetv/live_tv_screen.dart | 95 +++--- .../parts/episode_navigation.dart | 20 +- .../video_player/parts/playback_start.dart | 18 +- lib/screens/video_player_screen.dart | 20 +- lib/services/download_manager_service.dart | 167 +++++----- lib/services/download_storage_service.dart | 23 ++ lib/services/music/music_source_resolver.dart | 22 +- .../playback_initialization_service.dart | 35 +-- lib/services/playback_source_resolver.dart | 42 +-- lib/services/plex_client/parts/live_tv.dart | 56 +--- lib/utils/desktop_window_padding.dart | 44 +-- lib/utils/media_navigation_helper.dart | 14 +- lib/utils/media_server_http_client.dart | 287 +++++++++--------- lib/utils/plex_library_section_helpers.dart | 31 -- lib/widgets/toolbar_scrim.dart | 39 +++ lib/widgets/tv_spotlight_scaffold.dart | 24 ++ .../helpers/track_selection_helper.dart | 4 +- .../video_controls/sheets/track_sheet.dart | 14 +- ...ack_initialization_offline_cache_test.dart | 130 ++++---- .../playback_source_resolver_test.dart | 28 +- 23 files changed, 699 insertions(+), 840 deletions(-) delete mode 100644 lib/utils/plex_library_section_helpers.dart create mode 100644 lib/widgets/toolbar_scrim.dart diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 377f99cf..b54f93d1 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -306,7 +306,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { var anchor = current; // Bounded so a pathological all-same-file queue cannot spin. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemAfter(anchor); + final result = await _itemAtOffset(anchor, 1); final candidate = result.item; if (result.status != QueueNavigationStatus.found || candidate == null) { return result; @@ -337,7 +337,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { final current = _loadedItems[indexResult.index!]; MediaItem candidate = current; for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemBefore(candidate); + final result = await _itemAtOffset(candidate, -1); final before = result.item; if (result.status != QueueNavigationStatus.found || before == null) { return result; @@ -348,7 +348,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { // Collapse to the first episode of the candidate's same-file group. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemBefore(candidate); + final result = await _itemAtOffset(candidate, -1); final before = result.item; if (result.status == QueueNavigationStatus.failed) return result; if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) { @@ -359,18 +359,19 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return QueueNavigationResult.found(candidate); } - /// The queue item immediately after [anchor], extending a server-backed + /// The queue item [delta] steps from [anchor], extending a server-backed /// window when needed. The centered response proves whether [anchor] is at /// the global boundary; a window-local index is never compared with the /// queue's global item count. - Future _itemAfter(MediaItem anchor) async { + Future _itemAtOffset(MediaItem anchor, int delta) async { final anchorId = playQueueItemIdFor(anchor); if (anchorId == null) return const QueueNavigationResult.unavailable(); var anchorIndex = _findLoadedIndex(anchorId); if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - if (anchorIndex + 1 < _loadedItems.length) { - return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]); + var target = anchorIndex + delta; + if (target >= 0 && target < _loadedItems.length) { + return QueueNavigationResult.found(_loadedItems[target]); } // Local queues are fully resident, so their window edge is the queue edge. @@ -382,43 +383,15 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } // Refresh around the actual anchor. Queue ids are opaque and need not be - // consecutive, so never guess `anchorId + 1`. + // consecutive, so never guess the neighbour's id. if (!await _loadServerWindow(anchorId)) { return const QueueNavigationResult.failed(); } anchorIndex = _findLoadedIndex(anchorId); if (anchorIndex == -1) return const QueueNavigationResult.failed(); - return anchorIndex + 1 < _loadedItems.length - ? QueueNavigationResult.found(_loadedItems[anchorIndex + 1]) - : const QueueNavigationResult.boundary(); - } - - /// The queue item immediately before [anchor], extending a server-backed - /// window when needed. - Future _itemBefore(MediaItem anchor) async { - final anchorId = playQueueItemIdFor(anchor); - if (anchorId == null) return const QueueNavigationResult.unavailable(); - var anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - - if (anchorIndex > 0) { - return QueueNavigationResult.found(_loadedItems[anchorIndex - 1]); - } - - if (_windowFetcher == null || _playQueueId == null) { - return const QueueNavigationResult.boundary(); - } - if (_playQueueTotalCount > 0 && _loadedItems.length >= _playQueueTotalCount) { - return const QueueNavigationResult.boundary(); - } - - if (!await _loadServerWindow(anchorId)) { - return const QueueNavigationResult.failed(); - } - anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return const QueueNavigationResult.failed(); - return anchorIndex > 0 - ? QueueNavigationResult.found(_loadedItems[anchorIndex - 1]) + target = anchorIndex + delta; + return target >= 0 && target < _loadedItems.length + ? QueueNavigationResult.found(_loadedItems[target]) : const QueueNavigationResult.boundary(); } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 43c6414f..ad54cc7a 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -25,7 +25,7 @@ import '../utils/media_image_helper.dart'; import '../utils/content_utils.dart'; import '../widgets/cycling_media_backdrop.dart'; import '../widgets/optimized_media_image.dart' show blurArtwork; -import '../widgets/rasterized_gradient.dart'; +import '../widgets/toolbar_scrim.dart'; import '../providers/discover_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/watch_state_store.dart'; @@ -741,153 +741,125 @@ class _DiscoverScreenState extends State } Widget _buildOverlaidAppBar() { - final statusBarHeight = MediaQuery.paddingOf(context).top; final colorScheme = Theme.of(context).colorScheme; - final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; final foregroundColor = colorScheme.onSurface; - return RasterizedGradient( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), - child: Padding( - padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - if (!PlatformDetector.isTV()) - Text( - t.discover.title, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), - ), - const Spacer(), - Consumer2( - builder: (context, watchTogether, companionRemote, _) { - final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); + return ToolbarScrim( + child: Row( + children: [ + if (!PlatformDetector.isTV()) + Text( + t.discover.title, + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), + ), + const Spacer(), + Consumer2( + builder: (context, watchTogether, companionRemote, _) { + final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); - return FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: _focusContentFromAppBar, - actions: [ - FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: foregroundColor, - onPressed: _discover.load, - ), - // Watch Together - FocusableAction( - onPressed: () => - Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.group_rounded, - fill: watchTogether.isInSession ? 1 : 0, - color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, + return FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _focusContentFromAppBar, + actions: [ + FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load), + // Watch Together + FocusableAction( + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.group_rounded, + fill: watchTogether.isInSession ? 1 : 0, + color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, + ), + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + tooltip: t.watchTogether.title, + ), + if (watchTogether.isInSession && watchTogether.participantCount > 1) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - onPressed: () => Navigator.push( + child: Text( + '${watchTogether.participantCount}', + style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), + ), + ), + ), + ], + ), + ), + // Companion Remote + FocusableAction( + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen())); + } + }, + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.phone_android_rounded, + fill: companionRemote.isConnected ? 1 : 0, + color: companionRemote.isConnected ? colorScheme.primary : foregroundColor, + ), + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push( context, - MaterialPageRoute(builder: (_) => const WatchTogetherScreen()), + MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), + ); + } + }, + tooltip: t.companionRemote.title, + ), + if (companionRemote.isConnected) + Positioned( + top: 6, + right: 6, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.fromBorderSide(BorderSide(color: foregroundColor, width: 1)), ), - tooltip: t.watchTogether.title, ), - if (watchTogether.isInSession && watchTogether.participantCount > 1) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - '${watchTogether.participantCount}', - style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), - ), - ), - ), - ], - ), - ), - // Companion Remote - FocusableAction( - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), - ); - } - }, - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.phone_android_rounded, - fill: companionRemote.isConnected ? 1 : 0, - color: companionRemote.isConnected ? colorScheme.primary : foregroundColor, - ), - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), - ); - } - }, - tooltip: t.companionRemote.title, - ), - if (companionRemote.isConnected) - Positioned( - top: 6, - right: 6, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.fromBorderSide(BorderSide(color: foregroundColor, width: 1)), - ), - ), - ), - ], - ), - ), - // Server Tasks — Plex-only (`/activities` API has no - // Jellyfin equivalent), hide the button entirely on - // Jellyfin-only profiles so the chrome doesn't show - // a permanently empty popover. - if (PlatformDetector.isDesktop(context) && - context.select((p) => p.hasOnlinePlexServers)) - FocusableAction( - onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(), - child: ServerActivitiesButton(key: _serverActivitiesButtonKey), - ), - // User menu — profiles + sign out - _buildUserMenuAction(context), - ], - ); - }, - ), - ], + ), + ], + ), + ), + // Server Tasks — Plex-only (`/activities` API has no + // Jellyfin equivalent), hide the button entirely on + // Jellyfin-only profiles so the chrome doesn't show + // a permanently empty popover. + if (PlatformDetector.isDesktop(context) && + context.select((p) => p.hasOnlinePlexServers)) + FocusableAction( + onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(), + child: ServerActivitiesButton(key: _serverActivitiesButtonKey), + ), + // User menu — profiles + sign out + _buildUserMenuAction(context), + ], + ); + }, ), - ), + ], ), ); } @@ -1076,7 +1048,6 @@ class _DiscoverScreenState extends State final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final hubsSpanMultipleServers = _hubsSpanMultipleServers(); final browseHubs = _tvBrowseHubs; - final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: browseHubs, @@ -1110,14 +1081,7 @@ class _DiscoverScreenState extends State bottom: 0, child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers), ), - Builder( - builder: (context) => SideNavigationBleedBuilder( - targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), - child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), - builder: (context, animatedBleed, child) => - Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), - ), - ), + TvToolbarOverlay(child: _buildOverlaidAppBar()), if (_switchingProfile) const ProfileSwitchingOverlay(), ], ), diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 0d3550f7..5fd0d2a1 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/hub_section.dart'; import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/settings_builder.dart'; -import '../widgets/rasterized_gradient.dart'; +import '../widgets/toolbar_scrim.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_scaffold.dart'; import 'catalog_search_screen.dart'; @@ -331,75 +331,57 @@ class ExploreScreenState extends State Widget _buildTvToolbar(CatalogSourcesProvider sources) { final active = sources.activeSource; - final statusBarHeight = MediaQuery.paddingOf(context).top; - final colorScheme = Theme.of(context).colorScheme; - final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; - final foregroundColor = colorScheme.onSurface; + final foregroundColor = Theme.of(context).colorScheme.onSurface; - return RasterizedGradient( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), - child: Padding( - padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), - child: Row( - children: [ - const Spacer(), - FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, - onBack: _navigateToSidebar, - spacing: 4, - actions: [ - if (active != null && sources.connectedSources.length > 1) - FocusableAction( - debugLabel: 'ExploreSourceSwitcher', - onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), - child: _buildSourceSwitcher( - sources, - active, - textStyle: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), - anchorAlignment: AppMenuAnchorAlignment.end, - parentOwnsFocus: true, - ), - ), - if (active != null) - FocusableAction( - icon: Symbols.search_rounded, - iconColor: foregroundColor, - tooltip: t.common.search, - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), - ), + return ToolbarScrim( + child: Row( + children: [ + const Spacer(), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, + onBack: _navigateToSidebar, + spacing: 4, + actions: [ + if (active != null && sources.connectedSources.length > 1) FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: foregroundColor, - tooltip: t.common.refresh, - onPressed: () => unawaited(_explore.load()), + debugLabel: 'ExploreSourceSwitcher', + onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), + child: _buildSourceSwitcher( + sources, + active, + textStyle: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), + anchorAlignment: AppMenuAnchorAlignment.end, + parentOwnsFocus: true, + ), ), - ], - ), - ], - ), + if (active != null) + FocusableAction( + icon: Symbols.search_rounded, + iconColor: foregroundColor, + tooltip: t.common.search, + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), + ), + FocusableAction( + icon: Symbols.refresh_rounded, + iconColor: foregroundColor, + tooltip: t.common.refresh, + onPressed: () => unawaited(_explore.load()), + ), + ], + ), + ], ), ); } Widget _buildTvContent(List rowHubs, CatalogSourcesProvider sources) { final tvHubs = [for (final rowHub in rowHubs) rowHub.hub]; - final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: tvHubs, spotlightListenable: _spotlight, @@ -447,14 +429,7 @@ class ExploreScreenState extends State tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ), ), - Builder( - builder: (context) => SideNavigationBleedBuilder( - targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), - child: ExcludeFocusTraversal(child: _buildTvToolbar(sources)), - builder: (context, animatedBleed, child) => - Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), - ), - ), + TvToolbarOverlay(child: _buildTvToolbar(sources)), ], ), ); diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index dd98e5c1..7502f9bc 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -31,6 +31,8 @@ import 'tabs/guide_tab.dart'; import 'tabs/recordings_tab.dart'; import 'tabs/whats_on_tab.dart'; +typedef _FavoriteScope = ({String source, String storeKey, FavoriteChannelPersistenceMode mode}); + enum LiveTvTab { guide, whatsOn, recordings } class LiveTvScreen extends StatefulWidget { @@ -66,13 +68,15 @@ class _LiveTvScreenState extends State Set _favoriteKeys = {}; List _favoriteChannels = []; - /// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier. - final Map _favoriteSourceByLiveServer = {}; - final Map _favoriteSourceByChannel = {}; - final Map _favoriteStoreByLiveServer = {}; - final Map _favoriteStoreByChannel = {}; + /// Favorite source URI, store key and persistence mode per Live TV server/DVR. + /// The source is built from machineIdentifier + EPG provider identifier. + final Map _favoriteScopeByLiveServer = {}; + final Map _liveServerKeyByChannel = {}; + + /// Store key per favorite source. A superset of the scope sources: it also + /// collects sources of fetched and toggled favorites that belong to other + /// servers sharing an account-scoped store. final Map _favoriteStoreBySource = {}; - final Map _favoriteModeByStore = {}; Future? _channelsLoadFuture; int _favoritesLoadGeneration = 0; Future? _favoritesLoadFuture; @@ -90,8 +94,13 @@ class _LiveTvScreenState extends State String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}'; + _FavoriteScope? _favoriteScopeForChannel(LiveTvChannel channel) { + final liveServerKey = _liveServerKeyByChannel[liveTvChannelScopeKey(channel)]; + return liveServerKey == null ? null : _favoriteScopeByLiveServer[liveServerKey]; + } + String _sourceForChannel(LiveTvChannel channel) { - return channel.favoriteSource ?? _favoriteSourceByChannel[liveTvChannelScopeKey(channel)] ?? ''; + return channel.favoriteSource ?? _favoriteScopeForChannel(channel)?.source ?? ''; } String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key); @@ -303,12 +312,9 @@ class _LiveTvScreenState extends State final allChannels = []; final seenChannels = {}; - final favoriteSourceByLiveServer = {}; - final favoriteSourceByChannel = {}; - final favoriteStoreByLiveServer = {}; - final favoriteStoreByChannel = {}; + final favoriteScopeByLiveServer = {}; + final liveServerKeyByChannel = {}; final favoriteStoreBySource = {}; - final favoriteModeByStore = {}; appLogger.d( 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', @@ -333,10 +339,12 @@ class _LiveTvScreenState extends State final sourceTitle = _sourceTitleForServerInfo(serverInfo); final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - favoriteSourceByLiveServer[liveServerKey] = source; - favoriteStoreByLiveServer[liveServerKey] = storeKey; + favoriteScopeByLiveServer[liveServerKey] = ( + source: source, + storeKey: storeKey, + mode: liveTv.favoritePersistenceMode, + ); favoriteStoreBySource[source] = storeKey; - favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin @@ -355,9 +363,7 @@ class _LiveTvScreenState extends State ); final dedupKey = liveTvChannelScopeKey(scopedChannel); if (seenChannels.add(dedupKey)) { - final scopeKey = liveTvChannelScopeKey(scopedChannel); - favoriteSourceByChannel[scopeKey] = source; - favoriteStoreByChannel[scopeKey] = storeKey; + liveServerKeyByChannel[dedupKey] = liveServerKey; allChannels.add(scopedChannel); } } @@ -378,24 +384,15 @@ class _LiveTvScreenState extends State setState(() { _channels = allChannels; - _favoriteSourceByLiveServer + _favoriteScopeByLiveServer ..clear() - ..addAll(favoriteSourceByLiveServer); - _favoriteSourceByChannel + ..addAll(favoriteScopeByLiveServer); + _liveServerKeyByChannel ..clear() - ..addAll(favoriteSourceByChannel); - _favoriteStoreByLiveServer - ..clear() - ..addAll(favoriteStoreByLiveServer); - _favoriteStoreByChannel - ..clear() - ..addAll(favoriteStoreByChannel); + ..addAll(liveServerKeyByChannel); _favoriteStoreBySource ..clear() ..addAll(favoriteStoreBySource); - _favoriteModeByStore - ..clear() - ..addAll(favoriteModeByStore); _isLoading = false; }); @@ -433,10 +430,8 @@ class _LiveTvScreenState extends State _favoritesLoaded = false; _favoritesWritable = false; final previousStoreBySource = Map.of(_favoriteStoreBySource); - final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); - final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); + final scopeByLiveServer = Map.of(_favoriteScopeByLiveServer); final storeBySource = Map.of(_favoriteStoreBySource); - final modeByStore = Map.of(_favoriteModeByStore); final merged = []; final successfulStores = {}; final failedStores = {}; @@ -448,12 +443,10 @@ class _LiveTvScreenState extends State final liveTv = client.liveTv; final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - storeByLiveServer[liveServerKey] = storeKey; - modeByStore[storeKey] = liveTv.favoritePersistenceMode; try { final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); - sourceByLiveServer[liveServerKey] = source; + scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode); storeBySource[source] = storeKey; if (successfulStores.contains(storeKey)) continue; @@ -482,18 +475,12 @@ class _LiveTvScreenState extends State if (!mounted || loadGeneration != _favoritesLoadGeneration) return; setState(() { - _favoriteSourceByLiveServer + _favoriteScopeByLiveServer ..clear() - ..addAll(sourceByLiveServer); - _favoriteStoreByLiveServer - ..clear() - ..addAll(storeByLiveServer); + ..addAll(scopeByLiveServer); _favoriteStoreBySource ..clear() ..addAll(storeBySource); - _favoriteModeByStore - ..clear() - ..addAll(modeByStore); _favoriteChannels = merged; _refreshFavoriteKeys(); _favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty; @@ -515,8 +502,7 @@ class _LiveTvScreenState extends State _enqueueFavoriteMutation(() { final source = _sourceForChannel(channel); final favoriteKey = favoriteChannelKey(source, channel.key); - final scopeKey = liveTvChannelScopeKey(channel); - final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey]; + final storeKey = channel.favoriteStoreKey ?? _favoriteScopeForChannel(channel)?.storeKey; if (storeKey != null) _favoriteStoreBySource[source] = storeKey; setState(() { @@ -596,16 +582,13 @@ class _LiveTvScreenState extends State for (final serverInfo in multiServer.liveTvServers) { final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; - final liveServerKey = _liveServerScopeKey(serverInfo); - final storeKey = _favoriteStoreByLiveServer[liveServerKey]; - if (storeKey == null || !writtenStores.add(storeKey)) continue; - final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode; - final source = _favoriteSourceByLiveServer[liveServerKey]; - if (source == null) continue; - final channels = switch (mode) { - FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const [], + final scope = _favoriteScopeByLiveServer[_liveServerScopeKey(serverInfo)]; + if (scope == null || !writtenStores.add(scope.storeKey)) continue; + final storeChannels = byStore[scope.storeKey] ?? const []; + final channels = switch (scope.mode) { + FavoriteChannelPersistenceMode.sharedFullList => storeChannels, FavoriteChannelPersistenceMode.serverSlice => - (byStore[storeKey] ?? const []).where((favorite) => favorite.source == source).toList(), + storeChannels.where((favorite) => favorite.source == scope.source).toList(), }; writes.add(client.liveTv.setFavoriteChannels(channels)); } diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 76999c99..abc3be90 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -568,16 +568,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); final playbackContext = await playbackResolver.resolve( - metadata: metadata, - selectedMediaIndex: targetMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, + PlaybackInitializationOptions( + metadata: metadata, + selectedMediaIndex: targetMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, + qualityPreset: targetQualityPreset, + selectedAudioStreamId: targetAudioStreamId, + preferredSubtitleTrack: initializationSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: _offlineLibraryMode, - qualityPreset: targetQualityPreset, - selectedAudioStreamId: targetAudioStreamId, - preferredSubtitleTrack: initializationSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; final result = playbackContext.result; diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 27a5738d..e0c24563 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -110,15 +110,17 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { database: context.read(), ); playbackContext = await playbackResolver.resolve( - metadata: _currentMetadata, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: _requestedMediaSourceId, + PlaybackInitializationOptions( + metadata: _currentMetadata, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: _requestedMediaSourceId, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: true, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - preferredSubtitleTrack: _preferredSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); if (playbackContext.result.videoUrl == null) { throw PlaybackException(t.messages.fileInfoNotAvailable); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 030cecb1..282d2310 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1060,16 +1060,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin database: context.read(), ); _playbackDataFuture = playbackResolver.resolve( - metadata: _currentMetadata, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: _requestedMediaSourceId, - preferredVersionSignature: widget.preferredVersionSignature, + PlaybackInitializationOptions( + metadata: _currentMetadata, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: _requestedMediaSourceId, + preferredVersionSignature: widget.preferredVersionSignature, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: false, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - preferredSubtitleTrack: _preferredSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); // If MPV setup below throws before `_startPlayback` awaits this, // tell Dart we've "handled" the future so it's not reported as an diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index f3103e66..6db017af 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1646,6 +1646,20 @@ class DownloadManagerService { return true; } + /// Hand a prepared task to the native downloader, recording its id first so a + /// concurrent cancel can find it. Returns true if the download went inactive + /// while enqueueing and the task was dropped again. + Future _enqueuePreparedTask(String globalKey, Task task, String kind) async { + await _database.updateBgTaskId(globalKey, task.taskId); + final success = await FileDownloader().enqueue(task); + if (!success) throw Exception('Failed to enqueue $kind task'); + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { + return true; + } + appLogger.i('Enqueued $kind task ${task.taskId} for $globalKey'); + return false; + } + /// Resolve metadata, video URL, and file path, then enqueue a background download task. /// Returns true if successfully enqueued, false if it failed immediately. Future _prepareAndEnqueueDownload( @@ -1753,38 +1767,30 @@ class DownloadManagerService { if (_queueBlockedByStorageFailure) return true; final metadata = resolvedMetadata; final safBaseUri = _storageService.safBaseUri; + final DownloadTask task; + final String filePath; + final String? safRootUri; if (_storageService.isUsingSaf && safBaseUri != null) { - final safRootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); - if (safRootUri == null) { + final rootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); + if (rootUri == null) { throw StateError('Selected SAF root has no persisted permission'); } - await _replaceDownloadSafRootClaim(globalKey, safRootUri); + await _replaceDownloadSafRootClaim(globalKey, rootUri); // SAF mode: use UriDownloadTask (writes directly to content:// URI, // with no pause/resume support). - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [serverId, metadata.id]; - safFileName = 'video.$ext'; - } + final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: serverId); - final safDirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + final safDirUri = await _safStorage.createNestedDirectories(rootUri, target.components); if (safDirUri == null) { throw Exception('Failed to create SAF directory'); } - await _cleanupSafTargetFile(safDirUri, safFileName); + await _cleanupSafTargetFile(safDirUri, target.fileName); - final task = UriDownloadTask( + task = UriDownloadTask( url: resolution.videoUrl!, - filename: safFileName, + filename: target.fileName, directoryUri: Uri.parse(safDirUri), group: _downloadGroup, updates: Updates.statusAndProgress, @@ -1794,82 +1800,60 @@ class DownloadManagerService { metaData: globalKey, displayName: displayName, ); - - _pendingDownloadContext[globalKey] = _DownloadContext( - metadata: metadata, - queueItem: queueItem, - filePath: safDirUri, - extension: ext, - client: client, - showYear: showYear, - isSafMode: true, - safRootUri: safRootUri, - subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, - ); - - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue SAF download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { - return true; - } - appLogger.i('Enqueued SAF download task ${task.taskId} for $globalKey'); - return false; - } - - await _replaceDownloadSafRootClaim(globalKey, null); - - // Normal mode: use DownloadTask with pause/resume support. - String downloadFilePath; - if (metadata.isMovie) { - downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); - } else if (metadata.isEpisode) { - downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); + filePath = safDirUri; + safRootUri = rootUri; } else { - downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); + await _replaceDownloadSafRootClaim(globalKey, null); + + // Normal mode: use DownloadTask with pause/resume support. + final String downloadFilePath; + if (metadata.isMovie) { + downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); + } else if (metadata.isEpisode) { + downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); + } else { + downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); + } + + // Clean up partial files from previous attempts to prevent + // background_downloader from creating numbered copies (File (1).mp4). + await Future.wait([ + _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), + _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), + ]); + + await File(downloadFilePath).parent.create(recursive: true); + + task = DownloadTask( + url: resolution.videoUrl!, + filename: path.basename(downloadFilePath), + directory: path.dirname(downloadFilePath), + baseDirectory: BaseDirectory.root, + group: _downloadGroup, + updates: Updates.statusAndProgress, + requiresWiFi: requiresWiFi, + retries: _nativeRetries, + allowPause: true, + metaData: globalKey, + displayName: displayName, + ); + filePath = downloadFilePath; + safRootUri = null; } - // Clean up partial files from previous attempts to prevent - // background_downloader from creating numbered copies (File (1).mp4). - await Future.wait([ - _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), - _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), - ]); - - await File(downloadFilePath).parent.create(recursive: true); - - final task = DownloadTask( - url: resolution.videoUrl!, - filename: path.basename(downloadFilePath), - directory: path.dirname(downloadFilePath), - baseDirectory: BaseDirectory.root, - group: _downloadGroup, - updates: Updates.statusAndProgress, - requiresWiFi: requiresWiFi, - retries: _nativeRetries, - allowPause: true, - metaData: globalKey, - displayName: displayName, - ); - _pendingDownloadContext[globalKey] = _DownloadContext( metadata: metadata, queueItem: queueItem, - filePath: downloadFilePath, + filePath: filePath, extension: ext, client: client, showYear: showYear, + isSafMode: safRootUri != null, + safRootUri: safRootUri, subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, ); - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { - return true; - } - appLogger.i('Enqueued download task ${task.taskId} for $globalKey'); - return false; + return _enqueuePreparedTask(globalKey, task, safRootUri != null ? 'SAF download' : 'download'); }); if (becameInactive) return true; return true; @@ -2417,23 +2401,12 @@ class DownloadManagerService { } Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async { - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [metadata.serverId!, metadata.id]; - safFileName = 'video.$ext'; - } + final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: metadata.serverId); - final dirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + final dirUri = await _safStorage.createNestedDirectories(safRootUri, target.components); if (dirUri == null) return null; - final child = await _safStorage.getChild(dirUri, [safFileName]); + final child = await _safStorage.getChild(dirUri, [target.fileName]); return child?.uri; } diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index a2deca0a..e7adb362 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import 'settings_service.dart'; @@ -477,6 +478,28 @@ class DownloadStorageService { /// Get the extension-less episode filename used for SAF lookups. String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode); + /// Directory components and file name of a SAF download target. Used by both + /// the enqueue path that writes the file and the completion path that looks + /// it back up, so the two stay on the same layout. [serverId] is only read + /// for kinds without a dedicated folder scheme. + ({List components, String fileName}) safTarget( + MediaItem metadata, + String extension, { + int? showYear, + required String? serverId, + }) { + if (metadata.isMovie) { + return (components: getMovieSafPathComponents(metadata), fileName: getMovieSafFileName(metadata, extension)); + } + if (metadata.isEpisode) { + return ( + components: getEpisodeSafPathComponents(metadata, showYear: showYear), + fileName: getEpisodeSafFileName(metadata, extension), + ); + } + return (components: [serverId!, metadata.id], fileName: 'video.$extension'); + } + bool isSafUri(String storedPath) { return storedPath.startsWith('content://'); } diff --git a/lib/services/music/music_source_resolver.dart b/lib/services/music/music_source_resolver.dart index 764a1b48..6d566e0f 100644 --- a/lib/services/music/music_source_resolver.dart +++ b/lib/services/music/music_source_resolver.dart @@ -64,17 +64,19 @@ class ServerMusicSourceResolver implements MusicSourceResolver { Future resolve(MediaItem track) async { final settings = await SettingsService.getInstance(); final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve( - metadata: track, - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: track, + selectedMediaIndex: 0, + // Video-shaped preset is ignored for tracks; `original` also keeps the + // resolver's downloaded-copy preference on. + qualityPreset: TranscodeQualityPreset.original, + audioQualityPreset: settings.read(SettingsService.musicQualityPreset), + // Plex music transcode requires both session ids; fresh per track so + // concurrent gapless arming never reuses a live transcode session. + sessionIdentifier: generateSessionIdentifier(), + transcodeSessionId: generateSessionIdentifier(), + ), offlineLibraryMode: false, - // Video-shaped preset is ignored for tracks; `original` also keeps the - // resolver's downloaded-copy preference on. - qualityPreset: TranscodeQualityPreset.original, - audioQualityPreset: settings.read(SettingsService.musicQualityPreset), - // Plex music transcode requires both session ids; fresh per track so - // concurrent gapless arming never reuses a live transcode session. - sessionIdentifier: generateSessionIdentifier(), - transcodeSessionId: generateSessionIdentifier(), ); final result = context.result; diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 9a600db9..f4125a3c 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -8,8 +8,6 @@ import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; -import '../models/audio_quality_preset.dart'; -import '../models/transcode_quality_preset.dart'; import '../mpv/models.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; @@ -110,19 +108,11 @@ class PlaybackInitializationService { /// /// Downloaded/offline path: when [preferOffline] finds a downloaded copy, /// builds from cached [MediaSourceInfo] and local sidecars immediately. - Future getPlaybackData({ - required MediaItem metadata, - required int selectedMediaIndex, - String? selectedMediaSourceId, - String? preferredVersionSignature, + Future getPlaybackData( + PlaybackInitializationOptions options, { bool preferOffline = false, - TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original, - AudioQualityPreset? audioQualityPreset, - int? selectedAudioStreamId, - SubtitleTrack? preferredSubtitleTrack, - String? sessionIdentifier, - String? transcodeSessionId, }) async { + final metadata = options.metadata; final serverId = metadata.serverId ?? client?.serverId; DownloadedVideoSource? offlineSource; @@ -130,8 +120,8 @@ class PlaybackInitializationService { offlineSource = await _resolveOfflineVideoSource( ServerId(serverId), metadata.id, - mediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, + mediaIndex: options.selectedMediaIndex, + selectedMediaSourceId: options.selectedMediaSourceId, // With no client there is nothing to stream from, so any downloaded // version beats failing. With a client the strict match must stand: // an explicitly requested non-downloaded version streams from the @@ -156,20 +146,7 @@ class PlaybackInitializationService { PlaybackInitializationResult result; try { - result = await client!.getPlaybackInitialization( - PlaybackInitializationOptions( - metadata: metadata, - selectedMediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - qualityPreset: qualityPreset, - audioQualityPreset: audioQualityPreset, - selectedAudioStreamId: selectedAudioStreamId, - preferredSubtitleTrack: preferredSubtitleTrack, - sessionIdentifier: sessionIdentifier, - transcodeSessionId: transcodeSessionId, - ), - ); + result = await client!.getPlaybackInitialization(options); } catch (e) { rethrow; } diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index dabd4b84..52253f0b 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -1,11 +1,7 @@ import '../database/app_database.dart'; import '../media/ids.dart'; import '../media/media_backend.dart'; -import '../media/media_item.dart'; import '../media/media_server_client.dart'; -import '../models/audio_quality_preset.dart'; -import '../models/transcode_quality_preset.dart'; -import '../mpv/mpv.dart'; import 'multi_server_manager.dart'; import 'playback_context.dart'; import 'playback_initialization_service.dart'; @@ -17,40 +13,20 @@ class PlaybackSourceResolver { const PlaybackSourceResolver({required this.serverManager, required this.database}); /// [preferOffline] overrides the default downloaded-copy preference - /// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for - /// flows that must stay on the server stream, e.g. a transcode restart. - /// - /// [audioQualityPreset] is the music transcode preset, consulted by the - /// backends only for [MediaKind.track] items ([qualityPreset] is - /// video-shaped and ignored for tracks). - Future resolve({ - required MediaItem metadata, - required int selectedMediaIndex, - String? selectedMediaSourceId, - String? preferredVersionSignature, + /// (`offlineLibraryMode || options.qualityPreset.isOriginal`, so an omitted + /// preset keeps it on). Pass false for flows that must stay on the server + /// stream, e.g. a transcode restart. + Future resolve( + PlaybackInitializationOptions options, { required bool offlineLibraryMode, - required TranscodeQualityPreset qualityPreset, - AudioQualityPreset? audioQualityPreset, - int? selectedAudioStreamId, - SubtitleTrack? preferredSubtitleTrack, - String? sessionIdentifier, - String? transcodeSessionId, bool? preferOffline, }) async { + final metadata = options.metadata; final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode); final service = PlaybackInitializationService(client: reportingClient, database: database); final result = await service.getPlaybackData( - metadata: metadata, - selectedMediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal), - qualityPreset: qualityPreset, - audioQualityPreset: audioQualityPreset, - selectedAudioStreamId: selectedAudioStreamId, - preferredSubtitleTrack: preferredSubtitleTrack, - sessionIdentifier: sessionIdentifier, - transcodeSessionId: transcodeSessionId, + options, + preferOffline: preferOffline ?? (offlineLibraryMode || options.qualityPreset.isOriginal), ); final sourceKind = result.usesLocalMedia @@ -75,7 +51,7 @@ class PlaybackSourceResolver { streamHeaders: _streamHeaders( client: reportingClient, sourceKind: sourceKind, - sessionIdentifier: sessionIdentifier, + sessionIdentifier: options.sessionIdentifier, ), ); } diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 33eadb1e..f36c7039 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -183,31 +183,19 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, Future> getEpgChannels({String? lineup}) async { List parseChannels(MediaServerResponse response) { final container = _getMediaContainer(response); - if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { - appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); + if (container == null || (container['Channel'] == null && container['Metadata'] == null)) { + appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); + return []; } - if (container != null && container['Channel'] != null) { - return (container['Channel'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); + final rawChannels = container['Channel']; + if (rawChannels is List && rawChannels.isNotEmpty) { + appLogger.d('EPG channel sample: ${rawChannels.first}'); } - if (container != null && container['Metadata'] != null) { - return (container['Metadata'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); - } - appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); - return []; + return _extractContainerList( + response, + const ['Channel', 'Metadata'], + (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName), + ).where((ch) => ch.key.isNotEmpty).toList(); } final allChannels = []; @@ -545,11 +533,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, if (container == null) return null; final containerStatus = container['status']; - final statusInt = containerStatus is num - ? containerStatus.toInt() - : containerStatus is String - ? int.tryParse(containerStatus) - : null; + final statusInt = flexibleInt(containerStatus); if (statusInt != null && statusInt != 0 && statusInt != 200) { final msg = container['message'] ?? t.liveTv.unknownError; appLogger.w('Tune channel error: $msg (status: $containerStatus)'); @@ -579,14 +563,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, if (op is Map) { if (op['Metadata'] case [final Map firstMetadata, ...]) { if (firstMetadata['Media'] case [final Map firstMedia, ...]) { - final rawBeginsAt = firstMedia['beginsAt']; - - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; - + beginsAt = flexibleInt(firstMedia['beginsAt']); appLogger.d('beginsAt=$beginsAt'); } } @@ -652,12 +629,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, if (media is List && media.isNotEmpty) { final firstMedia = media.first; if (firstMedia is Map) { - final rawBeginsAt = firstMedia['beginsAt']; - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; + beginsAt = flexibleInt(firstMedia['beginsAt']); } } } diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index 7c0f8792..a3596715 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -29,6 +29,9 @@ class DesktopWindowPadding { /// Right padding for mobile devices to prevent actions from being too close to edge static const double mobileRight = 6.0; + + /// Left padding for macOS reflecting the current fullscreen state + static double get macOSLeftCurrent => FullscreenStateManager().isFullscreen ? macOSLeftFullscreen : macOSLeft; } /// Helper class for adjusting app bar widgets to account for desktop window controls @@ -60,38 +63,18 @@ class DesktopAppBarHelper { } if (context != null && SideNavigationScope.isPresent(context)) { - if (includeGestureDetector) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: leading, - ); - } - return leading; + return includeGestureDetector ? wrapWithGestureDetector(leading, opaque: true) : leading; } return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; - final paddedWidget = Padding( - padding: .only(left: leftPadding), + padding: .only(left: DesktopWindowPadding.macOSLeftCurrent), child: leading, ); - if (includeGestureDetector) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: paddedWidget, - ); - } - - return paddedWidget; + return includeGestureDetector ? wrapWithGestureDetector(paddedWidget, opaque: true) : paddedWidget; }, ); } @@ -102,12 +85,7 @@ class DesktopAppBarHelper { return flexibleSpace; } - return GestureDetector( - behavior: HitTestBehavior.translucent, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: flexibleSpace, - ); + return wrapWithGestureDetector(flexibleSpace); } /// Calculates the leading width for SliverAppBar to account for macOS traffic lights @@ -121,9 +99,7 @@ class DesktopAppBarHelper { return null; } - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; - return leftPadding + kToolbarHeight; + return DesktopWindowPadding.macOSLeftCurrent + kToolbarHeight; } /// Wraps a widget with GestureDetector on macOS to prevent window dragging @@ -175,10 +151,8 @@ class DesktopTitleBarPadding extends StatelessWidget { return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; // In fullscreen, use minimal padding since traffic lights auto-hide - final left = - leftPadding ?? (isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft); + final left = leftPadding ?? DesktopWindowPadding.macOSLeftCurrent; final right = rightPadding ?? 0.0; if (left == 0.0 && right == 0.0) { diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index fc6cfb13..9e0a0a4e 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -14,7 +14,7 @@ import '../services/settings_service.dart'; import '../utils/global_key_utils.dart'; import 'catalog_navigation_helper.dart'; import 'music_navigation.dart'; -import 'plex_library_section_helpers.dart'; +import 'plex_library_section_utils.dart'; import 'video_player_navigation.dart'; /// Result of media navigation indicating what action was taken @@ -192,11 +192,13 @@ Future navigateToMediaItem( ); // Handle library section items (shared whole-library entries) — Plex-only; - // [PlexLibrarySection.isLibrarySection] reads the stashed `key` from `raw`. - if (mi.isLibrarySection) { - final sectionKey = mi.librarySectionKey; - if (sectionKey != null && mi.serverId != null) { - final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), sectionKey); + // `PlexMappers` stashes the section path in `raw['key']`. Jellyfin "views" + // never appear inside a [MediaItem], so the gate never fires for them. + final rawKey = mi.raw?['key']; + if (rawKey is String && rawKey.startsWith('/library/sections/')) { + final sectionId = plexLibrarySectionIdFromString(rawKey); + if (sectionId != null && mi.serverId != null) { + final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), '$sectionId'); MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey); return MediaNavigationResult.librarySelected; } diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 0162f011..5daf4001 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -162,48 +162,19 @@ class MediaServerHttpClient { }) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); /// Fetch raw bytes (e.g. images, BIF files, subtitles). - Future getBytes( - String url, { - Map? headers, - Duration? timeout, - AbortController? abort, - }) async { - if (_closing) { - throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); - } - - final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); - final requestAbort = AbortController(); - _activeAborts.add(requestAbort); - final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll({...defaultHeaders, ...?headers}); - - final sw = Stopwatch()..start(); - try { - final streamed = await _withAbortOnTimeout( - _client.send(request), - timeout ?? connectTimeout, - operation: 'GET ${uri.path} connect', - abort: requestAbort, - ); - - final bytes = await _withAbortOnTimeout( - streamed.stream.toBytes(), - timeout ?? receiveTimeout, - operation: 'GET ${uri.path} receive', - abort: requestAbort, - ); - - sw.stop(); - _logResponse('GET', uri, streamed.statusCode, sw.elapsedMilliseconds); - return bytes; - } catch (e) { - requestAbort.abort(); - sw.stop(); - throw MediaServerHttpException.from(e, uri: uri); - } finally { - _activeAborts.remove(requestAbort); - } + Future getBytes(String url, {Map? headers, Duration? timeout, AbortController? abort}) { + return _perform( + 'GET', + url, + headers: headers, + timeout: timeout, + abort: abort, + consume: (streamed, scope) async { + final bytes = await scope.receive(streamed.stream.toBytes()); + scope.logResponse(streamed.statusCode); + return bytes; + }, + ); } /// Stream-download a URL directly into a file. @@ -213,64 +184,48 @@ class MediaServerHttpClient { Map? headers, Duration? timeout, AbortController? abort, - }) async { - if (_closing) { - throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); - } + }) { + final tempFile = File('$filePath.download'); + return _perform( + 'GET', + url, + label: 'download', + headers: headers, + timeout: timeout, + abort: abort, + // Also clears a temp file left by an earlier attempt when this one never + // got past connect. + onError: () async { + if (await tempFile.exists()) { + try { + await tempFile.delete(); + } catch (_) {} + } + }, + consume: (streamed, scope) async { + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await streamed.stream.drain(); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + requestUri: scope.uri, + message: 'HTTP ${streamed.statusCode}', + ); + } - final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); - final requestAbort = AbortController(); - _activeAborts.add(requestAbort); - final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll({...defaultHeaders, ...?headers}); - - try { - final streamed = await _withAbortOnTimeout( - _client.send(request), - timeout ?? connectTimeout, - operation: 'download ${uri.path} connect', - abort: requestAbort, - ); - - if (streamed.statusCode < 200 || streamed.statusCode >= 300) { - await streamed.stream.drain(); - throw MediaServerHttpException( - type: MediaServerHttpErrorType.unknown, - statusCode: streamed.statusCode, - requestUri: uri, - message: 'HTTP ${streamed.statusCode}', - ); - } - - final file = File(filePath); - await file.parent.create(recursive: true); - final tempFile = File('$filePath.download'); - if (await tempFile.exists()) await tempFile.delete(); - final sink = tempFile.openWrite(); - try { - await _withAbortOnTimeout( - streamed.stream.pipe(sink), - timeout ?? receiveTimeout, - operation: 'download ${uri.path} receive', - abort: requestAbort, - ); - } finally { - await sink.close(); - } - if (await file.exists()) await file.delete(); - await tempFile.rename(filePath); - } catch (e) { - requestAbort.abort(); - final tempFile = File('$filePath.download'); - if (await tempFile.exists()) { + final file = File(filePath); + await file.parent.create(recursive: true); + if (await tempFile.exists()) await tempFile.delete(); + final sink = tempFile.openWrite(); try { - await tempFile.delete(); - } catch (_) {} - } - throw MediaServerHttpException.from(e, uri: uri); - } finally { - _activeAborts.remove(requestAbort); - } + await scope.receive(streamed.stream.pipe(sink)); + } finally { + await sink.close(); + } + if (await file.exists()) await file.delete(); + await tempFile.rename(filePath); + }, + ); } void close() { @@ -297,69 +252,90 @@ class MediaServerHttpClient { Object? body, Duration? timeout, AbortController? abort, + }) { + return _perform( + method, + path, + queryParameters: queryParameters, + headers: headers, + body: body, + timeout: timeout, + abort: abort, + consume: (streamed, scope) async { + final effectiveUri = switch (streamed) { + http.BaseResponseWithUrl(:final url) => url, + _ => scope.uri, + }; + + final bytes = await scope.receive(streamed.stream.toBytes()); + scope.logResponse(streamed.statusCode); + + dynamic data; + try { + data = await _decodeBody(bytes, streamed.headers); + } catch (e) { + final body = await _decodeTextBody(bytes); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + responseData: body, + requestUri: scope.uri, + message: 'Failed to decode response body: $e', + ); + } + return MediaServerResponse( + statusCode: streamed.statusCode, + data: data, + headers: streamed.headers, + requestUri: scope.uri, + effectiveUri: effectiveUri, + ); + }, + ); + } + + /// Run one request: closing guard, abort registration, connect phase and + /// failure wrapping. [consume] reads the body through its scope, which + /// carries the same timeout and abort wiring into the receive phase; + /// [onError] runs after the abort and before the failure is wrapped. Every + /// exit path deregisters the request from [_activeAborts]. + Future _perform( + String method, + String url, { + String? label, + Map? queryParameters, + Map? headers, + Object? body, + Duration? timeout, + AbortController? abort, + Future Function()? onError, + required Future Function(http.StreamedResponse streamed, _RequestScope scope) consume, }) async { if (_closing) { throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); } - final uri = _isAbsoluteUrl(path) - ? _appendQuery(Uri.parse(path), queryParameters) - : _buildUri(path, queryParameters); - - final mergedHeaders = {...defaultHeaders, ...?headers}; + final uri = _resolveUri(url, queryParameters); + final operation = label ?? method; final requestAbort = AbortController(); _activeAborts.add(requestAbort); final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll(mergedHeaders); + request.headers.addAll({...defaultHeaders, ...?headers}); _setBody(request, body); - final sw = Stopwatch()..start(); + final scope = _RequestScope(this, uri, operation, requestAbort, timeout ?? receiveTimeout); try { final streamed = await _withAbortOnTimeout( _client.send(request), timeout ?? connectTimeout, - operation: '$method ${uri.path} connect', + operation: '$operation ${uri.path} connect', abort: requestAbort, ); - final effectiveUri = switch (streamed) { - http.BaseResponseWithUrl(:final url) => url, - _ => uri, - }; - - final bytes = await _withAbortOnTimeout( - streamed.stream.toBytes(), - timeout ?? receiveTimeout, - operation: '$method ${uri.path} receive', - abort: requestAbort, - ); - - sw.stop(); - _logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds); - - dynamic data; - try { - data = await _decodeBody(bytes, streamed.headers); - } catch (e) { - final body = await _decodeTextBody(bytes); - throw MediaServerHttpException( - type: MediaServerHttpErrorType.unknown, - statusCode: streamed.statusCode, - responseData: body, - requestUri: uri, - message: 'Failed to decode response body: $e', - ); - } - return MediaServerResponse( - statusCode: streamed.statusCode, - data: data, - headers: streamed.headers, - requestUri: uri, - effectiveUri: effectiveUri, - ); + return await consume(streamed, scope); } catch (e) { requestAbort.abort(); - sw.stop(); + await onError?.call(); throw MediaServerHttpException.from(e, uri: uri); } finally { _activeAborts.remove(requestAbort); @@ -410,6 +386,11 @@ class MediaServerHttpClient { return _appendQuery(Uri.parse('$base$cleanPath'), queryParameters); } + /// Resolve a request target: absolute URLs keep their own host and query, + /// relative paths go through [baseUrl]. + Uri _resolveUri(String url, Map? queryParameters) => + _isAbsoluteUrl(url) ? _appendQuery(Uri.parse(url), queryParameters) : _buildUri(url, queryParameters); + /// Append query parameters to an already-parsed URI. Uri _appendQuery(Uri uri, Map? queryParameters) { if (queryParameters == null || queryParameters.isEmpty) return uri; @@ -481,6 +462,28 @@ class MediaServerHttpClient { } } +/// The live request handed to a [MediaServerHttpClient._perform] body handler. +/// Its stopwatch starts with the connect phase, so [logResponse] reports the +/// full round trip regardless of how the body was read. +class _RequestScope { + _RequestScope(this._owner, this.uri, this._operation, this._abort, this._receiveTimeout); + + final MediaServerHttpClient _owner; + final Uri uri; + final String _operation; + final AbortController _abort; + final Duration _receiveTimeout; + final Stopwatch _sw = Stopwatch()..start(); + + Future receive(Future future) => + _owner._withAbortOnTimeout(future, _receiveTimeout, operation: '$_operation ${uri.path} receive', abort: _abort); + + void logResponse(int statusCode) { + _sw.stop(); + _owner._logResponse(_operation, uri, statusCode, _sw.elapsedMilliseconds); + } +} + /// Shared [MediaServerHttpClient] instance for ad-hoc requests (update checks, /// log uploads, image fetches, etc). No base URL or default Plex headers. final httpClient = MediaServerHttpClient(); diff --git a/lib/utils/plex_library_section_helpers.dart b/lib/utils/plex_library_section_helpers.dart deleted file mode 100644 index fec97cce..00000000 --- a/lib/utils/plex_library_section_helpers.dart +++ /dev/null @@ -1,31 +0,0 @@ -import '../media/media_item.dart'; - -/// Plex-only helpers for navigating to a "library section" hub entry. -/// -/// Plex's home/discover hubs occasionally surface library-section rows -/// (`/library/sections/{id}/all`) alongside individual items; the -/// `PlexMappers` adapter stashes the section key in [MediaItem.raw] under -/// `'key'` so navigation code can detect and route to the library screen -/// instead of the media-detail screen. -/// -/// Jellyfin's analogue is the dedicated `MediaLibrary` shape — Jellyfin -/// "views" never appear inside a [MediaItem], so these helpers correctly -/// return `false`/`null` for any Jellyfin item. -extension PlexLibrarySection on MediaItem { - /// Whether this item represents a Plex library section (shared - /// whole-library entry, not a media item). - bool get isLibrarySection { - final key = raw?['key']; - return key is String && key.startsWith('/library/sections/'); - } - - /// Extract the library section id from the stashed Plex `raw['key']`. - /// Returns `null` for non-section items or items without a parsable id. - String? get librarySectionKey { - if (!isLibrarySection) return null; - final key = raw?['key'] as String?; - if (key == null) return null; - final match = RegExp(r'/library/sections/(\d+)').firstMatch(key); - return match?.group(1); - } -} diff --git a/lib/widgets/toolbar_scrim.dart b/lib/widgets/toolbar_scrim.dart new file mode 100644 index 00000000..50214105 --- /dev/null +++ b/lib/widgets/toolbar_scrim.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'rasterized_gradient.dart'; + +/// Top-edge fade behind a toolbar that floats over content, keeping its +/// glyphs legible against artwork without a solid chrome bar. +/// +/// The fade is pure black on dark schemes — a tinted surface reads as haze +/// over backdrop artwork — and the scheme surface otherwise. [child] is laid +/// out below the status bar with the standard chrome insets. +class ToolbarScrim extends StatelessWidget { + const ToolbarScrim({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final statusBarHeight = MediaQuery.paddingOf(context).top; + final colorScheme = Theme.of(context).colorScheme; + final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; + return RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + overlayColor.withValues(alpha: 0.7), + overlayColor.withValues(alpha: 0.5), + overlayColor.withValues(alpha: 0.3), + Colors.transparent, + ], + stops: const [0.0, 0.3, 0.6, 1.0], + ), + child: Padding( + padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), + child: child, + ), + ); + } +} diff --git a/lib/widgets/tv_spotlight_scaffold.dart b/lib/widgets/tv_spotlight_scaffold.dart index 80b775ad..b6da0798 100644 --- a/lib/widgets/tv_spotlight_scaffold.dart +++ b/lib/widgets/tv_spotlight_scaffold.dart @@ -142,3 +142,27 @@ class TvSpotlightScaffold extends StatelessWidget { ); } } + +/// Pins a toolbar to the top of the viewport across the full bleed width, +/// sliding with the sidebar so it stays put while the content box translates. +/// +/// Excluded from default focus traversal so that initial/tab-switch focus +/// lands on content (hero/rails) rather than the toolbar; its buttons stay +/// reachable via explicit UP from the content. Reads the offset aspect from +/// its own element, so a sidebar flip rebuilds only this overlay. +class TvToolbarOverlay extends StatelessWidget { + const TvToolbarOverlay({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); + return SideNavigationBleedBuilder( + targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), + child: ExcludeFocusTraversal(child: child), + builder: (context, animatedBleed, child) => + Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), + ); + } +} diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index afe63949..67ad2312 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -7,7 +7,7 @@ import '../../../utils/track_label_builder.dart'; import '../../../widgets/focusable_list_tile.dart'; class TrackSelectionHelper { - static Widget buildOffTile({ + static Widget buildOffTile({ required BuildContext context, required bool isSelected, required VoidCallback onTap, @@ -30,7 +30,7 @@ class TrackSelectionHelper { ); } - static Widget buildTrackTile({ + static Widget buildTrackTile({ required BuildContext context, required TrackLabel label, required bool isSelected, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 56af4738..80748da2 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -155,7 +155,7 @@ class _SourceAudioColumn extends StatelessWidget { initialIndex: selectedIndex, itemBuilder: (context, index, scope) { final track = tracks[index]; - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, key: scope.keyFor(index), label: track.label, @@ -196,7 +196,7 @@ class _SourceSubtitleColumn extends StatelessWidget { footer: _buildSubtitleSearchFooter(context, trackControlsState), itemBuilder: (context, index, scope) { if (index == 0) { - return TrackSelectionHelper.buildOffTile( + return TrackSelectionHelper.buildOffTile( context: context, key: scope.keyFor(index), isSelected: selectedChoice.isOff, @@ -207,7 +207,7 @@ class _SourceSubtitleColumn extends StatelessWidget { } final track = tracks[index - 1]; - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, label: track.labelForIndex(index - 1), isSelected: track.id == selectedId, @@ -264,7 +264,7 @@ class _AudioColumn extends StatelessWidget { channels: track.channelsCount, index: index, ); - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, key: scope.keyFor(index), label: label, @@ -324,7 +324,7 @@ class _SubtitleColumn extends StatelessWidget { footer: _buildSubtitleSearchFooter(context, trackControlsState), itemBuilder: (context, index, scope) { if (index == 0) { - return TrackSelectionHelper.buildOffTile( + return TrackSelectionHelper.buildOffTile( context: context, key: scope.keyFor(index), isSelected: isOffSelected, @@ -357,7 +357,7 @@ class _SubtitleColumn extends StatelessWidget { if (trackIndex >= tracks.length) { final sourceIndex = trackIndex - tracks.length; final sourceTrack = unloadedSourceSidecars[sourceIndex]; - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, label: sourceTrack.labelForIndex(trackIndex), isSelected: false, @@ -387,7 +387,7 @@ class _SubtitleColumn extends StatelessWidget { } } - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, label: label, isSelected: isPrimary, diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 0af80d82..ba655aec 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -65,13 +65,15 @@ void main() { await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -94,13 +96,15 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'track-1', - backend: MediaBackend.plex, - kind: MediaKind.track, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'track-1', + backend: MediaBackend.plex, + kind: MediaKind.track, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -121,13 +125,15 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -153,13 +159,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 1, ), - selectedMediaIndex: 1, preferOffline: true, ); @@ -186,13 +194,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, ); expect(result.isOffline, isTrue); @@ -213,14 +223,16 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', ), - selectedMediaIndex: 0, - selectedMediaSourceId: 'source-a', ); expect(result.isOffline, isTrue); @@ -243,14 +255,16 @@ void main() { final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', ), - selectedMediaIndex: 0, - selectedMediaSourceId: 'source-a', preferOffline: true, ); @@ -297,13 +311,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'item-1', - backend: MediaBackend.jellyfin, - kind: MediaKind.movie, - serverId: ServerId('jf-machine'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: ServerId('jf-machine'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -325,13 +341,15 @@ void main() { await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); diff --git a/test/services/playback_source_resolver_test.dart b/test/services/playback_source_resolver_test.dart index c316ae00..5fa9fd99 100644 --- a/test/services/playback_source_resolver_test.dart +++ b/test/services/playback_source_resolver_test.dart @@ -57,10 +57,12 @@ void main() { manager.debugRegisterClientForTesting(client, online: false); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, ); expect(context.result.videoUrl, 'https://example.com/video.mp4'); @@ -80,11 +82,13 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'playback-session-id', + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, - sessionIdentifier: 'playback-session-id', ); expect(context.sourceKind, PlaybackSourceKind.remoteDirect); @@ -104,11 +108,13 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'playback-session-id', + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, - sessionIdentifier: 'playback-session-id', ); expect(context.sourceKind, PlaybackSourceKind.remoteDirect); From 4eaf4423a13675ec9a86cf3c80f6805243c71b31 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:30:32 +0200 Subject: [PATCH 09/12] refactor: share focus chrome and simplify the TV picker and browse paths Focus chrome was implemented twice, once in the focusable wrapper and once in the focus builders; both now go through FocusChrome. TvColorPicker's channel row was a copy of TvNumberSpinner and is now that widget in compact density. Also trims unused helpers and fields and simplifies the Jellyfin browse paths. --- lib/focus/card_focus_scope.dart | 4 +- lib/focus/focus_chrome.dart | 59 ++++ lib/focus/focusable_wrapper.dart | 48 +--- lib/media/media_item.dart | 7 - lib/media/media_playlist.dart | 5 - lib/models/plex/plex_user_profile.dart | 16 +- lib/models/user_switch_response.dart | 129 +-------- lib/mpv/player/platform/player_android.dart | 73 +++-- lib/profiles/plex_home_switch.dart | 4 +- lib/providers/download_provider.dart | 1 + lib/providers/hidden_libraries_provider.dart | 1 + lib/providers/libraries_provider.dart | 2 + lib/providers/offline_mode_provider.dart | 2 + lib/providers/offline_watch_provider.dart | 1 + lib/providers/theme_provider.dart | 1 + lib/providers/watch_state_store.dart | 1 + .../libraries/content_state_builder.dart | 12 - lib/screens/libraries/state_messages.dart | 18 -- lib/screens/media_detail_screen.dart | 124 +++++---- .../jellyfin_client/parts/browse.dart | 185 +++++-------- lib/services/keyboard_shortcuts_service.dart | 1 + lib/services/multi_server_manager.dart | 12 - lib/services/playback_subtitle_resolver.dart | 7 - lib/services/plex_auth_service.dart | 7 +- lib/services/plex_client.dart | 1 + lib/services/seerr/seerr_client.dart | 2 + lib/services/sleep_timer_service.dart | 1 + lib/services/track_manager.dart | 3 + lib/services/video_filter_manager.dart | 4 - lib/utils/content_utils.dart | 11 - lib/utils/layout_constants.dart | 4 - lib/utils/plex_cache_parser.dart | 6 - lib/widgets/focus_builders.dart | 145 +++------- lib/widgets/side_navigation_rail.dart | 184 +++++-------- lib/widgets/tv_color_picker.dart | 255 ++---------------- lib/widgets/tv_number_spinner.dart | 157 +++++++---- test/media/media_item_test.dart | 4 - test/media/media_playlist_test.dart | 11 - test/models/plex_user_profile_test.dart | 18 -- test/models/user_switch_response_test.dart | 44 +-- test/services/multi_server_manager_test.dart | 13 +- .../playback_subtitle_resolver_test.dart | 6 +- test/services/plex_auth_service_test.dart | 6 +- test/services/video_filter_manager_test.dart | 4 +- test/utils/content_utils_test.dart | 4 - test/utils/layout_constants_test.dart | 13 - test/utils/plex_cache_parser_test.dart | 43 --- 47 files changed, 526 insertions(+), 1133 deletions(-) create mode 100644 lib/focus/focus_chrome.dart diff --git a/lib/focus/card_focus_scope.dart b/lib/focus/card_focus_scope.dart index ffb247c2..ebcbf76b 100644 --- a/lib/focus/card_focus_scope.dart +++ b/lib/focus/card_focus_scope.dart @@ -5,8 +5,8 @@ import 'focus_theme.dart'; /// Exposes the focus state of an enclosing focus wrapper to a descendant /// [CardFocusBorder] that draws the focus border itself. /// -/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this -/// instead of painting a border when `delegateFocusBorder` is set, so cards can +/// The shared focus chrome ([buildFocusChrome]) inserts this instead of painting +/// a border when `delegateFocusBorder` is set, so cards can /// put the border on the exact rect the design highlights (the poster image, /// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder] /// element registers a dependency, so a focus flip rebuilds just that border diff --git a/lib/focus/focus_chrome.dart b/lib/focus/focus_chrome.dart new file mode 100644 index 00000000..841339d4 --- /dev/null +++ b/lib/focus/focus_chrome.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'card_focus_scope.dart'; +import 'focus_glow_overlay.dart'; +import 'focus_theme.dart'; + +/// Builds the focus border/glow chrome shared by [FocusableWrapper] and +/// [FocusBuilders.buildLockedFocusWrapper]. +/// +/// A function rather than a widget so it costs no element per card in dense TV +/// grids. Scale and input handling stay with the callers: the wrapper drives a +/// paint-only scale from its own controller and owns the [Focus] node, while +/// the locked builder scales implicitly and wraps gestures itself. +/// +/// Callers pass the [duration] they already resolved via +/// [FocusTheme.getAnimationDuration] so a build resolves it once. +Widget buildFocusChrome( + BuildContext context, { + required bool showFocus, + required Duration duration, + double borderRadius = FocusTheme.defaultBorderRadius, + BorderRadius? borderRadii, + Color? focusColor, + bool useBackgroundFocus = false, + bool useFocusGlow = false, + bool delegateFocusBorder = false, + Size? glowSize, + required Widget child, +}) { + Widget card; + if (delegateFocusBorder) { + card = CardFocusScope(showFocus: showFocus, child: child); + } else { + final decoration = useBackgroundFocus + ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: borderRadius, radii: borderRadii) + : FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: borderRadius, + radii: borderRadii, + color: focusColor, + ); + card = AnimatedContainer(duration: duration, curve: Curves.easeOutCubic, decoration: decoration, child: child); + } + + // Glow (full-bleed cards) renders in an overlay above siblings so it stays + // symmetric; the in-card decoration only carries the border. + if (useFocusGlow) { + card = FocusGlowOverlay( + isFocused: showFocus, + borderRadius: borderRadius, + color: focusColor ?? FocusTheme.getFocusBorderColor(context), + glowSize: glowSize, + child: card, + ); + } + + return card; +} diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index fe2b30e8..71100e6a 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -4,10 +4,9 @@ import 'package:flutter/rendering.dart'; import '../widgets/clickable_cursor.dart'; import '../utils/text_input_diagnostics.dart'; -import 'card_focus_scope.dart'; import 'dpad_navigator.dart'; import 'dpad_select_long_press_controller.dart'; -import 'focus_glow_overlay.dart'; +import 'focus_chrome.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; import 'owned_focus_node_binding.dart'; @@ -541,41 +540,20 @@ class _FocusableWrapperState extends State with SingleTickerPr // Keep the card subtree outside the scale builder. Rebuilding media-card // semantics on every animation tick is substantially more expensive than // changing the paint transform alone on dense TV grids. - Widget card; - if (widget.delegateFocusBorder) { - card = CardFocusScope(showFocus: showFocus, child: widget.child); - } else { - final focusDecoration = widget.useBackgroundFocus - ? FocusTheme.focusBackgroundDecoration( - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - ) - : FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - color: widget.focusColor, - ); - card = AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: focusDecoration, - child: widget.child, - ); - } - if (widget.useFocusGlow) { - card = FocusGlowOverlay( - isFocused: showFocus, - borderRadius: widget.borderRadius, - color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context), - child: card, - ); - } inner = AnimatedBuilder( animation: _scaleAnimation!, - child: card, + child: buildFocusChrome( + context, + showFocus: showFocus, + duration: duration, + borderRadius: widget.borderRadius, + borderRadii: widget.borderRadii, + focusColor: widget.focusColor, + useBackgroundFocus: widget.useBackgroundFocus, + useFocusGlow: widget.useFocusGlow, + delegateFocusBorder: widget.delegateFocusBorder, + child: widget.child, + ), builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!), ); } diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 42b05a5c..0ec10bb6 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -642,13 +642,6 @@ sealed class MediaItem with _$MediaItem { return resolvedBackdropPaths; } - /// Returns the best hero art path based on the container's aspect ratio. - String? heroArt({required double containerAspectRatio}) { - final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); - if (candidates.isEmpty) return null; - return candidates.first; - } - /// Returns hero art candidates in display-preference order. List heroArtCandidates({required double containerAspectRatio}) { final own = resolvedBackdropPaths; diff --git a/lib/media/media_playlist.dart b/lib/media/media_playlist.dart index 4b8b06fa..e142db04 100644 --- a/lib/media/media_playlist.dart +++ b/lib/media/media_playlist.dart @@ -61,11 +61,6 @@ class MediaPlaylist { /// Display-friendly title (alias of [title] for parity with [MediaItem]). String get displayTitle => title; - /// Whether this playlist's contents can be reordered/edited by the client. - /// Plex smart playlists are read-only; manual playlists and Jellyfin - /// playlists are editable. - bool get isEditable => !smart; - String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id; MediaPlaylist copyWith({ diff --git a/lib/models/plex/plex_user_profile.dart b/lib/models/plex/plex_user_profile.dart index 9e19b3da..88773fa7 100644 --- a/lib/models/plex/plex_user_profile.dart +++ b/lib/models/plex/plex_user_profile.dart @@ -10,8 +10,7 @@ part 'plex_user_profile.g.dart'; /// /// Every field parses tolerantly: the account API drifts (~July 2026 the /// language-list fields switched from arrays to CSV strings, #1488), and a -/// profile blob must never fail to parse — token minting embeds it (see -/// UserSwitchResponse.fromJson). +/// single drifted field must never sink the whole profile. @JsonSerializable() class PlexUserProfile implements MediaServerUserProfile { @JsonKey(fromJson: _boolOrTrue) @@ -62,19 +61,6 @@ class PlexUserProfile implements MediaServerUserProfile { this.mediaReviewsLanguages, }); - /// Neutral fallback matching the generated defaults — used when the account - /// API returns a profile blob that cannot be parsed at all (schema drift - /// must never break token minting, see UserSwitchResponse.fromJson). - factory PlexUserProfile.defaults() => PlexUserProfile( - autoSelectAudio: true, - defaultAudioAccessibility: 0, - autoSelectSubtitle: 0, - defaultSubtitleAccessibility: 0, - defaultSubtitleForced: 1, - watchedIndicator: 1, - mediaReviewsVisibility: 0, - ); - factory PlexUserProfile.fromJson(Map json) { final envelope = json['profile']; final profile = envelope is Map ? envelope : json; diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart index 03149da3..05c4e263 100644 --- a/lib/models/user_switch_response.dart +++ b/lib/models/user_switch_response.dart @@ -1,120 +1,13 @@ -import '../utils/app_logger.dart'; -import '../utils/json_utils.dart'; -import 'plex/plex_user_profile.dart'; - -class UserSwitchResponse { - final int id; - final String uuid; - final String username; - final String title; - final String email; - final String? friendlyName; - final String? locale; - final bool confirmed; - final int joinedAt; - final bool emailOnlyAuth; - final bool hasPassword; - final bool protected; - final String thumb; - final String authToken; - final bool? mailingListActive; - final String scrobbleTypes; - final String country; - final bool restricted; - final bool? anonymous; - final bool home; - final bool guest; - final int homeSize; - final bool homeAdmin; - final int maxHomeSize; - final PlexUserProfile profile; - final bool twoFactorEnabled; - final bool backupCodesCreated; - final String? attributionPartner; - - UserSwitchResponse({ - required this.id, - required this.uuid, - required this.username, - required this.title, - required this.email, - this.friendlyName, - this.locale, - required this.confirmed, - required this.joinedAt, - required this.emailOnlyAuth, - required this.hasPassword, - required this.protected, - required this.thumb, - required this.authToken, - this.mailingListActive, - required this.scrobbleTypes, - required this.country, - required this.restricted, - this.anonymous, - required this.home, - required this.guest, - required this.homeSize, - required this.homeAdmin, - required this.maxHomeSize, - required this.profile, - required this.twoFactorEnabled, - required this.backupCodesCreated, - this.attributionPartner, - }); - - /// INVARIANT (#1488): a successful token mint must never be lost to parsing - /// of decorative fields. `authToken` is the only field any caller consumes - /// (see plex_home_switch.dart) — it alone parses strictly; every other - /// field tolerates missing/wrong-typed values with sane defaults. Plex has - /// changed field shapes on this endpoint before (July 2026: profile - /// language lists became CSV strings), and each drift used to brick token - /// minting outright. - factory UserSwitchResponse.fromJson(Map json) { - final authToken = json['authToken']; - if (authToken is! String || authToken.isEmpty) { - throw const FormatException('Plex /switch response has no usable authToken'); - } - - PlexUserProfile profile; - try { - profile = PlexUserProfile.fromJson(json); - } catch (e, st) { - appLogger.w('UserSwitchResponse: profile blob failed to parse; using defaults', error: e, stackTrace: st); - profile = PlexUserProfile.defaults(); - } - - String? optString(String key) => json[key]?.toString(); - - return UserSwitchResponse( - id: flexibleInt(json['id']) ?? 0, - uuid: optString('uuid') ?? '', - username: optString('username') ?? '', - title: optString('title') ?? '', - email: optString('email') ?? '', - friendlyName: optString('friendlyName'), - locale: optString('locale'), - confirmed: flexibleBool(json['confirmed']), - joinedAt: flexibleInt(json['joinedAt']) ?? 0, - emailOnlyAuth: flexibleBool(json['emailOnlyAuth']), - hasPassword: flexibleBool(json['hasPassword']), - protected: flexibleBool(json['protected']), - thumb: optString('thumb') ?? '', - authToken: authToken, - mailingListActive: flexibleBoolNullable(json['mailingListActive']), - scrobbleTypes: optString('scrobbleTypes') ?? '', - country: optString('country') ?? '', - restricted: flexibleBool(json['restricted']), - anonymous: flexibleBoolNullable(json['anonymous']), - home: flexibleBool(json['home']), - guest: flexibleBool(json['guest']), - homeSize: flexibleInt(json['homeSize']) ?? 1, - homeAdmin: flexibleBool(json['homeAdmin']), - maxHomeSize: flexibleInt(json['maxHomeSize']) ?? 1, - profile: profile, - twoFactorEnabled: flexibleBool(json['twoFactorEnabled']), - backupCodesCreated: flexibleBool(json['backupCodesCreated']), - attributionPartner: optString('attributionPartner'), - ); +/// INVARIANT (#1488): a successful token mint must never be lost to parsing +/// of decorative fields. `authToken` is the only field any caller consumes +/// (see plex_home_switch.dart), so nothing else on the `/switch` body is +/// read. Plex has changed field shapes on this endpoint before (July 2026: +/// profile language lists became CSV strings), and each drift used to brick +/// token minting outright. +String parsePlexSwitchAuthToken(Map json) { + final authToken = json['authToken']; + if (authToken is! String || authToken.isEmpty) { + throw const FormatException('Plex /switch response has no usable authToken'); } + return authToken; } diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 37512204..ebd9fd50 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -145,6 +145,21 @@ class PlayerAndroid extends PlayerBase { } } + // A setting requested before the core is up is applied by _doInitialize from + // the stored fields; one requested while an init is in flight has to be + // replayed afterwards, but only if no newer request superseded it. + Future _applyWhenInitialized(Future Function() apply, bool Function() stillRequested) async { + final initFuture = _initFuture; + if (initialized) { + await apply(); + } else if (initFuture != null) { + await initFuture; + if (!disposed && initialized && stillRequested()) { + await apply(); + } + } + } + @override Future open( Media media, { @@ -279,15 +294,10 @@ class PlayerAndroid extends PlayerBase { break; case 'dv-conversion-mode': _dvConversionMode = value; - final initFuture = _initFuture; - if (initialized) { - await invoke('setDvConversionMode', {'mode': value}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _dvConversionMode == value) { - await invoke('setDvConversionMode', {'mode': value}); - } - } + await _applyWhenInitialized( + () => invoke('setDvConversionMode', {'mode': value}), + () => _dvConversionMode == value, + ); break; case 'sub-visibility': if (value == 'no') { @@ -316,15 +326,10 @@ class PlayerAndroid extends PlayerBase { Future setAudioNormalization(bool enabled) async { if (disposed) return; _audioNormalizationEnabled = enabled; - final initFuture = _initFuture; - if (initialized) { - await invoke('setAudioNormalization', {'enabled': enabled}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _audioNormalizationEnabled == enabled) { - await invoke('setAudioNormalization', {'enabled': enabled}); - } - } + await _applyWhenInitialized( + () => invoke('setAudioNormalization', {'enabled': enabled}), + () => _audioNormalizationEnabled == enabled, + ); // Keep the mpv af property flowing through setMpvProperty so the plugin's // pendingMpvProperties replay applies loudnorm if exo falls back to mpv. await super.setAudioNormalization(enabled); @@ -336,21 +341,10 @@ class PlayerAndroid extends PlayerBase { _downmixEnabled = enabled; _downmixCenterBoostDb = centerBoostDb; _downmixNormalize = normalize; - Future invokeNative() => - invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}); - final initFuture = _initFuture; - if (initialized) { - await invokeNative(); - } else if (initFuture != null) { - await initFuture; - if (!disposed && - initialized && - _downmixEnabled == enabled && - _downmixCenterBoostDb == centerBoostDb && - _downmixNormalize == normalize) { - await invokeNative(); - } - } + await _applyWhenInitialized( + () => invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}), + () => _downmixEnabled == enabled && _downmixCenterBoostDb == centerBoostDb && _downmixNormalize == normalize, + ); // Keep the mpv properties flowing through setMpvProperty so the plugin's // pendingMpvProperties replay applies downmix if exo falls back to mpv. await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize); @@ -360,15 +354,10 @@ class PlayerAndroid extends PlayerBase { Future setAudioPassthrough(bool enabled) async { if (disposed) return; _audioPassthroughEnabled = enabled; - final initFuture = _initFuture; - if (initialized) { - await invoke('setAudioPassthrough', {'enabled': enabled}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _audioPassthroughEnabled == enabled) { - await invoke('setAudioPassthrough', {'enabled': enabled}); - } - } + await _applyWhenInitialized( + () => invoke('setAudioPassthrough', {'enabled': enabled}), + () => _audioPassthroughEnabled == enabled, + ); await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); } diff --git a/lib/profiles/plex_home_switch.dart b/lib/profiles/plex_home_switch.dart index 1f101fa4..c772494b 100644 --- a/lib/profiles/plex_home_switch.dart +++ b/lib/profiles/plex_home_switch.dart @@ -51,8 +51,8 @@ Future switchPlexHomeUserWithPin({ if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null); } try { - final response = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); - return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); + final userToken = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); + return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, userToken); } on MediaServerHttpException catch (e) { if (e.statusCode == 403 && _isInvalidPin(e)) { error = t.profiles.incorrectPinTryAgain; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 41a3ab23..a1834bd7 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -889,6 +889,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Check if an item is in the queue /// For shows/seasons, checks if any episodes are queued + @visibleForTesting bool isQueued(String globalKey) { final progress = getProgress(globalKey); return progress?.status == DownloadStatus.queued; diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index 4f52aac7..7d64550d 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -76,6 +76,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi } /// Check if a specific library is hidden + @visibleForTesting bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey); /// Refresh hidden libraries from storage diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 533980c5..cf5c0757 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -65,9 +65,11 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi bool get isLoading => _loadState == LibrariesLoadState.loading; /// Whether libraries have been loaded at least once + @visibleForTesting bool get hasLoaded => _loadState == LibrariesLoadState.loaded; /// Current load state + @visibleForTesting LibrariesLoadState get loadState => _loadState; /// Error message if loading failed diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index c9541bc8..81b3c52a 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -75,9 +75,11 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Whether there is network connectivity (WiFi, mobile data, etc.) + @visibleForTesting bool get hasNetworkConnection => _hasNetworkConnection; /// Whether at least one media server (Plex or Jellyfin) is reachable + @visibleForTesting bool get hasServerConnection => _hasServerConnection; bool get _hasKnownVisibleServers => diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index d0fd9857..c4abd3f5 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -71,6 +71,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// 2. Metadata from download provider /// /// Returns null if no position is available. + @visibleForTesting Future getViewOffset(String globalKey) async { // First check local offline progress final localOffset = await _syncService.getLocalViewOffset(globalKey); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 6a25bedb..25cfacd6 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -88,6 +88,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W static const _themeChannel = MethodChannel('com.plezy/theme'); + @visibleForTesting Future setThemeMode(settings.ThemeMode mode) async { if (_themeMode == mode) return; final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance(); diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index a02c1949..f4cac153 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -97,6 +97,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return _exactEntryFor(globalKey); } + @visibleForTesting WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; WatchStateSnapshot? patchForItem(MediaItem item) { diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index b279e896..22e3e2f2 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -11,9 +11,7 @@ class SliverErrorState extends StatelessWidget { final String? retryLabel; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; final bool actionAutofocus; final bool actionUseBackgroundFocus; @@ -25,9 +23,7 @@ class SliverErrorState extends StatelessWidget { this.retryLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionAutofocus = false, this.actionUseBackgroundFocus = false, @@ -42,9 +38,7 @@ class SliverErrorState extends StatelessWidget { retryLabel: retryLabel, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, actionAutofocus: actionAutofocus, actionUseBackgroundFocus: actionUseBackgroundFocus, @@ -62,9 +56,7 @@ class SliverEmptyState extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const SliverEmptyState({ @@ -77,9 +69,7 @@ class SliverEmptyState extends StatelessWidget { this.actionIcon, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, }); @@ -94,9 +84,7 @@ class SliverEmptyState extends StatelessWidget { actionIcon: actionIcon, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, ), ); diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index cf6bf3fe..a1b113fe 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -39,9 +39,7 @@ class StateMessageWidget extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; /// Whether the action button should request focus when it appears. @@ -63,9 +61,7 @@ class StateMessageWidget extends StatelessWidget { this.actionLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionIcon, this.actionAutofocus = false, @@ -110,9 +106,7 @@ class StateMessageWidget extends StatelessWidget { FocusableButton( focusNode: actionFocusNode, onNavigateUp: onActionNavigateUp, - onNavigateDown: onActionNavigateDown, onNavigateLeft: onActionNavigateLeft, - onNavigateRight: onActionNavigateRight, onBack: onActionBack, onPressed: onAction, autofocus: actionAutofocus, @@ -155,9 +149,7 @@ class EmptyStateWidget extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const EmptyStateWidget({ @@ -171,9 +163,7 @@ class EmptyStateWidget extends StatelessWidget { this.actionIcon, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, }); @@ -189,9 +179,7 @@ class EmptyStateWidget extends StatelessWidget { actionIcon: actionIcon ?? Symbols.add_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, ); } @@ -218,9 +206,7 @@ class ErrorStateWidget extends StatelessWidget { final String? retryLabel; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const ErrorStateWidget({ @@ -231,9 +217,7 @@ class ErrorStateWidget extends StatelessWidget { this.retryLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionAutofocus = false, this.actionUseBackgroundFocus = false, @@ -251,9 +235,7 @@ class ErrorStateWidget extends StatelessWidget { actionIcon: Symbols.refresh_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, actionAutofocus: actionAutofocus, actionUseBackgroundFocus: actionUseBackgroundFocus, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 44776ddc..0418be05 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3562,6 +3562,42 @@ class _MediaDetailScreenState extends State ); } + /// The ordered metadata fields the TV detail line renders and its announcement reads, + /// built through [text] for plain fields and [rating] for the rating slot. + List _tvDetailMetadataParts( + MediaItem metadata, { + required T Function(String value) text, + required T? Function(MediaItem item) rating, + }) { + final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; + final parts = []; + + void add(T? part) { + if (part != null) parts.add(part); + } + + final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); + if (lineMetadata.isEpisode && episodeLabel != null) add(text(episodeLabel)); + if (lineMetadata.isMovie) { + add(text(t.discover.movie)); + } else if (lineMetadata.isShow) { + add(text(t.discover.tvShow)); + } + add(rating(lineMetadata)); + if (lineMetadata.contentRating != null) add(text(formatContentRating(lineMetadata.contentRating!))); + if (lineMetadata.durationMs != null) add(text(formatDurationTextual(lineMetadata.durationMs!))); + if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { + add(text(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!))); + } else if (lineMetadata.year != null) { + add(text(lineMetadata.year.toString())); + } + for (final label in buildMediaQualityLabels(lineMetadata)) { + add(text(label)); + } + + return parts; + } + String _tvDetailInformationSemanticLabel( MediaItem metadata, { required String? description, @@ -3578,23 +3614,13 @@ class _MediaDetailScreenState extends State add(metadata.displayTitle); if (!identical(lineMetadata, metadata)) add(lineMetadata.displayTitle); - final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); - if (lineMetadata.isEpisode) add(episodeLabel); - if (lineMetadata.isMovie) { - add(t.discover.movie); - } else if (lineMetadata.isShow) { - add(t.discover.tvShow); - } - add(MediaRatingBadge.semanticLabelForMedia(lineMetadata, fallbackItem: metadata)); - if (lineMetadata.contentRating != null) add(formatContentRating(lineMetadata.contentRating!)); - if (lineMetadata.durationMs != null) add(formatDurationTextual(lineMetadata.durationMs!)); - if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { - add(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)); - } else if (lineMetadata.year != null) { - add(lineMetadata.year.toString()); - } - for (final label in buildMediaQualityLabels(lineMetadata)) { - add(label); + final fields = _tvDetailMetadataParts( + metadata, + text: (value) => value, + rating: (item) => MediaRatingBadge.semanticLabelForMedia(item, fallbackItem: metadata), + ); + for (final field in fields) { + add(field); } if (genres.isNotEmpty) add(genres.join(', ')); add(description); @@ -3679,60 +3705,32 @@ class _MediaDetailScreenState extends State } Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { - final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; - final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); - final qualityLabels = buildMediaQualityLabels(lineMetadata); final textStyle = TextStyle( color: _tvDetailForegroundColor(context), fontSize: 18 * scale, fontWeight: .w700, letterSpacing: 0.1, ); - final children = []; - - void addSeparator() { - if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle)); - } - - void addTextPart(String text) { - addSeparator(); - children.add(Text(text, maxLines: 1, style: textStyle)); - } - - void addWidgetPart(Widget widget) { - addSeparator(); - children.add(widget); - } - - if (lineMetadata.isEpisode && episodeLabel != null) addTextPart(episodeLabel); - if (lineMetadata.isMovie) { - addTextPart(t.discover.movie); - } else if (lineMetadata.isShow) { - addTextPart(t.discover.tvShow); - } - final ratingBadge = MediaRatingBadge.inlineForMedia( - item: lineMetadata, - fallbackItem: metadata, - foregroundColor: textStyle.color, - iconSize: textStyle.fontSize, - spacing: 4 * scale, - textStyle: textStyle, + final fields = _tvDetailMetadataParts( + metadata, + text: (value) => Text(value, maxLines: 1, style: textStyle), + rating: (item) => MediaRatingBadge.inlineForMedia( + item: item, + fallbackItem: metadata, + foregroundColor: textStyle.color, + iconSize: textStyle.fontSize, + spacing: 4 * scale, + textStyle: textStyle, + ), ); - if (ratingBadge != null) { - addWidgetPart(ratingBadge); - } - if (lineMetadata.contentRating != null) addTextPart(formatContentRating(lineMetadata.contentRating!)); - if (lineMetadata.durationMs != null) addTextPart(formatDurationTextual(lineMetadata.durationMs!)); - if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { - addTextPart(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)); - } else if (lineMetadata.year != null) { - addTextPart(lineMetadata.year.toString()); - } - for (final label in qualityLabels) { - addTextPart(label); - } - if (children.isEmpty) return const SizedBox.shrink(); + if (fields.isEmpty) return const SizedBox.shrink(); + + final children = []; + for (final field in fields) { + if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle)); + children.add(field); + } return SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 86d7c143..e7a34eea 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -1223,82 +1223,17 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the // home rows from Latest plus optional playback rows. The richer Plex Discover surface // is intentionally left untranslated — see ServerCapabilities.richHubs. - final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry); - - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: latest, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - final results = await Future.wait([ - latestFuture, - _safeFetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'MediaTypes': 'Video', - 'Recursive': 'true', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry), - ]); - - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.continue', - title: t.discover.continueWatching, - type: 'mixed', - items: results[1], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.nextup', - title: t.discover.nextUp, - type: 'episode', - items: results[2], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: results.first, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); + return _playbackHubSet( + idPrefix: 'home', + limit: limit, + includePlaybackHubs: includePlaybackHubs, + includeNextUp: true, + retry: _homeHubRetry, + latestItemTypes: 'Movie,Series,Episode', + continueTitle: t.discover.continueWatching, + nextUpTitle: t.discover.nextUp, + recentTitle: t.discover.recentlyAdded, + ); } @override @@ -1330,86 +1265,92 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Issued in parallel so the recommended tab loads in one round-trip. // When the caller knows the library kind, skip NextUp for movie libraries; // Jellyfin can otherwise spend time scanning TV state only to return []. + return _playbackHubSet( + parentId: libraryId, + idPrefix: 'library.$libraryId', + limit: limit, + includePlaybackHubs: includePlaybackHubs, + includeNextUp: libraryKind == null || libraryKind == MediaKind.show, + retry: _libraryHubRetry, + continueTitle: t.discover.continueWatchingIn(library: libraryName), + nextUpTitle: t.discover.nextUpIn(library: libraryName), + recentTitle: t.discover.recentlyAddedIn(library: libraryName), + ); + } + + /// Latest + Continue Watching + Next Up row set shared by the home and + /// per-library surfaces. Both scopes issue the same three requests in the + /// same order and synthesise the same three rows; they differ only in + /// [parentId], the row identifier prefix, the titles, and the transport + /// policy. The Latest request fires before the [includePlaybackHubs] + /// short-circuit so callers that only want Recently Added still get it in + /// one round-trip. + Future> _playbackHubSet({ + required String idPrefix, + required int limit, + required bool includePlaybackHubs, + required bool includeNextUp, + required _HubRetryPolicy retry, + required String continueTitle, + required String nextUpTitle, + required String recentTitle, + String? parentId, + String? latestItemTypes, + }) async { final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { 'Limit': limit.toString(), - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Fields': _browseFields, + 'IncludeItemTypes': ?latestItemTypes, ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry); + }, retry: retry); - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ + MediaHub hub(String suffix, String title, String type, List> items) => JellyfinMappers.syntheticHub( mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: latest, + identifier: '$idPrefix.$suffix', + title: title, + type: type, + items: items, previewLimit: limit, serverId: serverId, serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); + ); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [hub('recent', recentTitle, 'mixed', latest)].where((h) => h.items.isNotEmpty).toList(); } - final includeNextUp = libraryKind == null || libraryKind == MediaKind.show; final results = await Future.wait([ latestFuture, _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry), + }, retry: retry), includeNextUp ? _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry) + }, retry: retry) : Future.value(const >[]), ]); return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.continue', - title: t.discover.continueWatchingIn(library: libraryName), - type: 'mixed', - items: results[1], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.nextup', - title: t.discover.nextUpIn(library: libraryName), - type: 'episode', - items: results[2], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: results.first, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), + hub('continue', continueTitle, 'mixed', results[1]), + hub('nextup', nextUpTitle, 'episode', results[2]), + hub('recent', recentTitle, 'mixed', results.first), ].where((h) => h.items.isNotEmpty).toList(); } diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 448794c8..43011acf 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -103,6 +103,7 @@ class KeyboardShortcutsService extends ChangeNotifier { Map get hotkeys => Map.from(_hotkeys); + @visibleForTesting HotKey? getHotkey(String action) { return _hotkeys[action]; } diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 50ddf00b..2235c380 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -271,12 +271,6 @@ class MultiServerManager { _emitStatus(); } - /// Plex-specific server config (name, machineId, connection candidates, - /// `owned` flag). Returns `null` for Jellyfin server ids — Jellyfin has no - /// `PlexServer` analogue. For "is this server registered?" use - /// [getClient] (works for both backends). - PlexServer? getPlexServer(ServerId serverId) => _plexServers[serverId]; - String serverDisplayName(ServerId serverId) => _clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId; @@ -311,12 +305,6 @@ class MultiServerManager { return result; } - /// Plex servers known to the manager. Jellyfin servers are NOT included - /// here — they have no `PlexServer` analogue (single-URL connections, - /// not connection-raced multi-endpoint structs). For an all-backends - /// view of online servers use [serverIds] or [onlineClients]. - Map get plexServers => Map.unmodifiable(_plexServers); - /// Check if a server is online bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false; diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index 3f1ed4a6..bd7e14c4 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -275,13 +275,6 @@ class PlaybackSubtitleResolver { return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks); } - static PlaybackSourceSubtitleChoice nextSourceChoice( - List tracks, - PlaybackSourceSubtitleChoice currentChoice, - ) { - return advanceSourceChoice(tracks, currentChoice, 1); - } - static PlaybackSourceSubtitleChoice advanceSourceChoice( List tracks, PlaybackSourceSubtitleChoice currentChoice, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 4db143bc..04df153f 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -262,8 +262,9 @@ class PlexAuthService { return PlexHome.fromJson(response.data as Map); } - /// Switch to a different user in the home - Future switchToUser(String userUUID, String currentToken, {String? pin}) async { + /// Switch to a different user in the home, returning the freshly minted + /// user-level token + Future switchToUser(String userUUID, String currentToken, {String? pin}) async { final queryParams = { 'includeSubscriptions': '1', 'includeProviders': '1', @@ -286,7 +287,7 @@ class PlexAuthService { ); _checkStatus(response); - return UserSwitchResponse.fromJson(response.data as Map); + return parsePlexSwitchAuthToken(response.data as Map); } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 0de09dd3..13cf3630 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -915,6 +915,7 @@ class PlexClient ); } + @visibleForTesting Future> getServerIdentity() async { final response = await _getWithFailover('/identity'); return response.data; diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index 39080475..60f87e34 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../models/seerr/seerr_details.dart'; @@ -65,6 +66,7 @@ class SeerrClient { // ---------- Auth ---------- + @visibleForTesting Future getMe() async { final data = await _request('GET', '/auth/me'); return SeerrUser.fromJson(data as Map); diff --git a/lib/services/sleep_timer_service.dart b/lib/services/sleep_timer_service.dart index 0ed53bd8..5d27d6f7 100644 --- a/lib/services/sleep_timer_service.dart +++ b/lib/services/sleep_timer_service.dart @@ -127,6 +127,7 @@ class SleepTimerService extends ChangeNotifier { } /// Execute the completion callback directly (fallback path) + @visibleForTesting void executeCompletion() { _executeCallback(); } diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index c30e56fb..e09adf5b 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; + import '../mpv/mpv.dart'; import '../media/media_item.dart'; @@ -82,6 +84,7 @@ class TrackManager { } /// Cached external subtitles for re-use after backend fallback. + @visibleForTesting List get lastExternalSubtitles => _lastExternalSubtitles; TrackManager({ diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 613a520f..7e1b3f9a 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -103,10 +103,6 @@ class VideoFilterManager { return _zoomScale; } - double adjustZoom(double delta) => setZoomScale(_zoomScale + delta); - - double resetZoom() => setZoomScale(1.0); - /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void cycleBoxFitMode() { _boxFitMode = (_boxFitMode + 1) % 3; diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index 4f8e7f66..d02b8744 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -26,17 +26,6 @@ class ContentTypeHelper { static bool isVideoContent(String type) => ContentTypes.videoTypes.contains(type.toLowerCase()); - static bool isMusicLibrary(dynamic lib) { - if (lib == null) return false; - try { - // ignore: avoid_dynamic_calls — duck-typed across library shapes - final type = (lib as dynamic).kind?.id as String?; - return type?.toLowerCase() == ContentTypes.artist; - } catch (e) { - return false; - } - } - static IconData getLibraryIcon(String type) { switch (type.toLowerCase()) { case ContentTypes.movie: diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index 002a101b..a3fef3d2 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -18,12 +18,8 @@ class ScreenBreakpoints { static bool isTablet(double width) => width >= mobile && width < desktop; - static bool isWideTablet(double width) => width >= wideTablet && width < desktop; - static bool isDesktop(double width) => width >= desktop && width < largeDesktop; - static bool isLargeDesktop(double width) => width >= largeDesktop; - static bool isDesktopOrLarger(double width) => width >= desktop; static bool isWideTabletOrLarger(double width) => width >= wideTablet; diff --git a/lib/utils/plex_cache_parser.dart b/lib/utils/plex_cache_parser.dart index a88d1de5..c47e39c7 100644 --- a/lib/utils/plex_cache_parser.dart +++ b/lib/utils/plex_cache_parser.dart @@ -18,10 +18,4 @@ class PlexCacheParser { if (list == null || list.isEmpty) return null; return list.first as Map; } - - static List? extractChapters(Map? cached) { - final metadata = extractFirstMetadata(cached); - if (metadata == null) return null; - return metadata['Chapter'] as List?; - } } diff --git a/lib/widgets/focus_builders.dart b/lib/widgets/focus_builders.dart index b38a99a4..12eb3113 100644 --- a/lib/widgets/focus_builders.dart +++ b/lib/widgets/focus_builders.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../focus/card_focus_scope.dart'; -import '../focus/focus_glow_overlay.dart'; +import '../focus/focus_chrome.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; import 'clickable_cursor.dart'; @@ -63,96 +62,13 @@ class FocusBuilders { ); } - /// Builds a card-style focusable widget with scale and border decoration. + /// Builds a card-style wrapper with scale and border decoration but no [Focus] + /// node — focus lives on an enclosing rail or screen that passes [isFocused] + /// down. /// - /// Used by FocusableMediaCard and _LockedHubItemWrapper. - /// - /// Parameters: - /// - [context]: Build context for theming - /// - [focusNode]: The focus node for this widget (optional for locked wrappers) - /// - [isFocused]: Whether this widget currently has focus - /// - [onKeyEvent]: Callback for handling key events (optional for locked wrappers) - /// - [onTap]: Callback for tap/click events - /// - [onLongPress]: Callback for long press events - /// - [borderRadius]: Border radius for the focus decoration - /// - [child]: The content to display inside the card - static Widget buildFocusableCard({ - required BuildContext context, - FocusNode? focusNode, - required bool isFocused, - KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, - VoidCallback? onTap, - VoidCallback? onLongPress, - double borderRadius = FocusTheme.defaultBorderRadius, - double focusScale = FocusTheme.focusScale, - bool useFocusGlow = false, - bool delegateFocusBorder = false, - Size? glowSize, - required Widget child, - }) { - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - // In touch mode, no item ever shows focus effects — skip animated wrappers - // entirely. This saves ~2 element levels per card on ARM32 Android phones. - if (!isKeyboardMode) { - final gestureWidget = (onTap != null || onLongPress != null) - ? ClickableCursor( - child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child), - ) - : child; - if (focusNode != null && onKeyEvent != null) { - return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); - } - return gestureWidget; - } - - final duration = FocusTheme.getAnimationDuration(context); - final showFocus = isFocused && isKeyboardMode; - // Glow (full-bleed cards) renders in an overlay above siblings so it stays - // symmetric; the in-card decoration only carries the border. - Widget card = delegateFocusBorder - ? CardFocusScope(showFocus: showFocus, child: child) - : AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius), - child: child, - ); - if (useFocusGlow) { - card = FocusGlowOverlay( - isFocused: showFocus, - borderRadius: borderRadius, - color: FocusTheme.getFocusBorderColor(context), - glowSize: glowSize, - child: card, - ); - } - - final focusedWidget = AnimatedScale( - scale: showFocus ? focusScale : 1.0, - duration: duration, - curve: Curves.easeOutCubic, - child: card, - ); - - // Wrap in GestureDetector if tap/long press handlers provided - final gestureWidget = (onTap != null || onLongPress != null) - ? ClickableCursor( - child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget), - ) - : focusedWidget; - - // Wrap in Focus if focus node and key event handler provided - if (focusNode != null && onKeyEvent != null) { - return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); - } - - return gestureWidget; - } - - /// Builds a simple locked wrapper (no Focus widget) with scale and border decoration. - /// - /// Used by _LockedHubItemWrapper where focus is managed at a higher level. + /// Used by the hub row, the TV browse rail, the cast strip and the extras row. + /// Cards that own their focus node use [FocusableWrapper] instead; both share + /// the same chrome through [buildFocusChrome]. /// /// Parameters: /// - [context]: Build context for theming @@ -173,19 +89,40 @@ class FocusBuilders { Size? glowSize, required Widget child, }) { - return buildFocusableCard( - context: context, - focusNode: null, - isFocused: isFocused, - onKeyEvent: null, - onTap: onTap, - onLongPress: onLongPress, - borderRadius: borderRadius, - focusScale: focusScale, - useFocusGlow: useFocusGlow, - delegateFocusBorder: delegateFocusBorder, - glowSize: glowSize, - child: child, + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + // In touch mode, no item ever shows focus effects — skip animated wrappers + // entirely. This saves ~2 element levels per card on ARM32 Android phones. + if (!isKeyboardMode) { + return (onTap != null || onLongPress != null) + ? ClickableCursor( + child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child), + ) + : child; + } + + final duration = FocusTheme.getAnimationDuration(context); + final focusedWidget = AnimatedScale( + scale: isFocused ? focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: buildFocusChrome( + context, + showFocus: isFocused, + duration: duration, + borderRadius: borderRadius, + useFocusGlow: useFocusGlow, + delegateFocusBorder: delegateFocusBorder, + glowSize: glowSize, + child: child, + ), ); + + // Wrap in GestureDetector if tap/long press handlers provided + return (onTap != null || onLongPress != null) + ? ClickableCursor( + child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget), + ) + : focusedWidget; } } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 53b96d54..dabc5cf7 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -54,6 +54,20 @@ final class _LibraryItemRow extends _LibraryNavRow { const _LibraryItemRow({required super.section, required this.library, this.showServerName = false}); } +/// SELECT activates the rail row, RIGHT hands off to the content area. +KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { + onSelect(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { + onNavigateRight(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +} + /// Reusable navigation rail item widget that handles focus, selection, and interaction class NavigationRailItem extends StatelessWidget { final IconData icon; @@ -63,6 +77,9 @@ class NavigationRailItem extends StatelessWidget { /// Playing item's equalizer). Should be at most [iconSize] tall/wide. final Widget? iconWidget; final Widget label; + + /// Widget rendered after the [label] (e.g. a section header's chevron). + final Widget? trailing; final bool isSelected; final bool isCollapsed; final bool useSimpleLayout; @@ -74,6 +91,11 @@ class NavigationRailItem extends StatelessWidget { final double horizontalPadding; final bool suppressSelectedBackground; + /// Background tint while keyboard-focused, and its stronger variant used + /// when the item also shows its selected background. + final double focusAlpha; + final double selectedFocusAlpha; + /// Called when RIGHT arrow is pressed to navigate to content area. final VoidCallback? onNavigateRight; @@ -83,6 +105,7 @@ class NavigationRailItem extends StatelessWidget { this.selectedIcon, this.iconWidget, required this.label, + this.trailing, required this.isSelected, this.isCollapsed = false, this.useSimpleLayout = false, @@ -93,6 +116,8 @@ class NavigationRailItem extends StatelessWidget { this.iconSize = 22, this.horizontalPadding = 17, this.suppressSelectedBackground = false, + this.focusAlpha = 0.12, + this.selectedFocusAlpha = 0.15, this.onNavigateRight, }); @@ -108,18 +133,7 @@ class NavigationRailItem extends StatelessWidget { return Focus( focusNode: focusNode, autofocus: autofocus, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onTap(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { - onNavigateRight!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight), child: Material( color: Colors.transparent, child: InkWell( @@ -129,8 +143,10 @@ class NavigationRailItem extends StatelessWidget { child: Container( decoration: BoxDecoration( color: () { - if (isCollapsed) return focused ? t.text.withValues(alpha: 0.12) : null; - if (focused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12); + if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null; + if (focused) { + return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha); + } if (showSelectedBackground) return t.text.withValues(alpha: 0.1); return null; }(), @@ -162,6 +178,7 @@ class NavigationRailItem extends StatelessWidget { return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label); }(), ), + ?trailing, ], ), ), @@ -975,107 +992,44 @@ class SideNavigationRailState extends State with MountedSetS }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; - final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null; - final librariesFocusNode = _focusTracker.get(_kLibraries); - final showLibrariesSelectedBackground = isLibrariesSelected && !widget.isSidebarFocused; + final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries; final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0; return Column( crossAxisAlignment: .start, children: [ - ListenableBuilder( - listenable: librariesFocusNode, - builder: (context, _) => Focus( - focusNode: librariesFocusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - setState(() { - _librariesExpanded = !_librariesExpanded; - }); - return KeyEventResult.handled; - } - // RIGHT arrow navigates to content area - if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { - widget.onNavigateToContent!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: Colors.transparent, - child: InkWell( - canRequestFocus: false, - onTap: () { - setState(() { - _librariesExpanded = !_librariesExpanded; - }); - }, - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - child: Container( - decoration: BoxDecoration( - color: () { - final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null; - if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1); - if (showFocus) return t.text.withValues(alpha: 0.08); - return null; - }(), - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - ), - clipBehavior: Clip.hardEdge, - child: UnconstrainedBox( - alignment: .centerLeft, - constrainedAxis: Axis.vertical, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: expandedWidth - 24, - child: Padding( - padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding), - child: Row( - children: [ - AppIcon( - Symbols.video_library_rounded, - fill: 1, - size: 22, - color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, - ), - const SizedBox(width: 11), - Expanded( - child: AnimatedOpacity( - opacity: isCollapsed ? 0.0 : 1.0, - duration: tokens(context).fast, - child: Text( - Translations.of(context).navigation.libraries, - style: TextStyle( - fontSize: 14, - fontWeight: widget.selectedTab == NavigationTabId.libraries - ? FontWeight.w600 - : FontWeight.w400, - color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, - ), - ), - ), - ), - AnimatedOpacity( - opacity: isCollapsed ? 0.0 : 1.0, - duration: tokens(context).fast, - child: AppIcon( - _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, - fill: 1, - size: 20, - color: t.textMuted, - ), - ), - ], - ), - ), - ), - ), - ), - ), + NavigationRailItem( + icon: Symbols.video_library_rounded, + label: Text( + Translations.of(context).navigation.libraries, + style: TextStyle( + fontSize: 14, + fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400, + color: isLibrariesTabSelected ? t.text : t.textMuted, ), ), + trailing: AnimatedOpacity( + opacity: isCollapsed ? 0.0 : 1.0, + duration: tokens(context).fast, + child: AppIcon( + _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, + fill: 1, + size: 20, + color: t.textMuted, + ), + ), + isSelected: isLibrariesTabSelected, + isCollapsed: isCollapsed, + onTap: () => setState(() => _librariesExpanded = !_librariesExpanded), + focusNode: _focusTracker.get(_kLibraries), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), + horizontalPadding: itemHorizontalPadding, + // A selected library owns the highlight; the header only shows it + // for the bare Libraries tab. + suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null, + focusAlpha: 0.08, + selectedFocusAlpha: 0.1, + onNavigateRight: widget.onNavigateToContent, ), TweenAnimationBuilder( @@ -1222,18 +1176,8 @@ class SideNavigationRailState extends State with MountedSetS listenable: focusNode, builder: (context, _) => Focus( focusNode: focusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onToggle(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { - widget.onNavigateToContent!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: (node, event) => + _handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent), child: Material( color: Colors.transparent, child: InkWell( diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 84846f1c..29cfd1f0 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -3,14 +3,9 @@ import 'package:flutter/services.dart'; import '../i18n/strings.g.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; import '../focus/focusable_text_field.dart'; -import '../focus/input_mode_tracker.dart'; -import '../focus/key_repeat_helper.dart'; import '../mixins/controller_disposer_mixin.dart'; -import '../theme/mono_tokens.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'app_icon.dart'; +import 'tv_number_spinner.dart'; /// A TV-friendly color picker using HSV sliders for D-pad navigation. /// @@ -106,6 +101,31 @@ class _TvColorPickerState extends State with ControllerDisposerMi widget.onColorChanged(color); } + Widget _channelRow({ + required String label, + required String semanticLabel, + required int value, + required int max, + required String suffix, + required ValueChanged onChanged, + bool autofocus = false, + }) { + return TvNumberSpinner( + label: label, + semanticLabel: semanticLabel, + value: value, + min: 0, + max: max, + step: 5, + suffix: suffix, + autofocus: autofocus, + onConfirm: widget.onConfirm, + onChanged: onChanged, + verticalKeysAdjustValue: false, + density: TvNumberSpinnerDensity.compact, + ); + } + @override Widget build(BuildContext context) { final currentColor = _currentColor(); @@ -123,46 +143,37 @@ class _TvColorPickerState extends State with ControllerDisposerMi ), ), const SizedBox(height: 16), - _ColorChannelRow( + _channelRow( label: 'H', semanticLabel: Translations.of(context).accessibility.hue, value: _hue, - min: 0, max: 360, - step: 5, suffix: '°', autofocus: true, - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _hue = v); _onChannelChanged(); }, ), const SizedBox(height: 8), - _ColorChannelRow( + _channelRow( label: 'S', semanticLabel: Translations.of(context).accessibility.saturation, value: _saturation, - min: 0, max: 100, - step: 5, suffix: '%', - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _saturation = v); _onChannelChanged(); }, ), const SizedBox(height: 8), - _ColorChannelRow( + _channelRow( label: 'V', semanticLabel: Translations.of(context).accessibility.brightness, value: _value, - min: 0, max: 100, - step: 5, suffix: '%', - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _value = v); _onChannelChanged(); @@ -185,211 +196,3 @@ class _TvColorPickerState extends State with ControllerDisposerMi ); } } - -/// A horizontal channel row for a single HSV component. -/// -/// LEFT/RIGHT adjust the value (with repeat timer for held keys). -/// UP/DOWN are ignored so focus traverses normally between rows. -class _ColorChannelRow extends StatefulWidget { - final String label; - final String semanticLabel; - final int value; - final int min; - final int max; - final int step; - final String suffix; - final bool autofocus; - final ValueChanged onChanged; - - /// Called when the user presses SELECT to confirm. - final VoidCallback? onConfirm; - - const _ColorChannelRow({ - required this.label, - required this.semanticLabel, - required this.value, - required this.min, - required this.max, - required this.step, - required this.suffix, - required this.onChanged, - this.autofocus = false, - this.onConfirm, - }); - - @override - State<_ColorChannelRow> createState() => _ColorChannelRowState(); -} - -class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> { - late FocusNode _focusNode; - bool _isFocused = false; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(debugLabel: 'ColorChannel_${widget.label}'); - } - - @override - void dispose() { - stopRepeat(); - _focusNode.dispose(); - super.dispose(); - } - - void _increment() { - final newValue = widget.value + widget.step; - if (newValue <= widget.max) { - widget.onChanged(newValue); - } - } - - void _decrement() { - final newValue = widget.value - widget.step; - if (newValue >= widget.min) { - widget.onChanged(newValue); - } - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Let UP/DOWN pass through for focus traversal between rows - if (key.isUpKey || key.isDownKey) { - return KeyEventResult.ignored; - } - - if (event is KeyDownEvent) { - if (key.isSelectKey && widget.onConfirm != null) { - widget.onConfirm!(); - return KeyEventResult.handled; - } - if (key.isRightKey) { - startRepeat(_increment); - return KeyEventResult.handled; - } else if (key.isLeftKey) { - startRepeat(_decrement); - return KeyEventResult.handled; - } - } else if (event is KeyRepeatEvent) { - // Consume repeat events for LEFT/RIGHT so they don't escape - // to the focus system as traversal actions. The repeat timer - // from KeyDown already handles value repetition. - if (key.isRightKey || key.isLeftKey) { - return KeyEventResult.handled; - } - } else if (event is KeyUpEvent) { - if (key.isRightKey || key.isLeftKey) { - stopRepeat(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final tokens = theme.extension(); - final canDecrement = widget.value > widget.min; - final canIncrement = widget.value < widget.max; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - descendantsAreFocusable: false, - onFocusChange: (hasFocus) { - setState(() => _isFocused = hasFocus); - if (!hasFocus) stopRepeat(); - }, - onKeyEvent: _handleKeyEvent, - child: AnimatedContainer( - duration: tokens?.fast ?? const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)), - border: Border.fromBorderSide( - BorderSide( - color: _isFocused && isKeyboardMode ? FocusTheme.getFocusBorderColor(context) : Colors.transparent, - width: FocusTheme.focusBorderWidth, - ), - ), - ), - child: Row( - children: [ - SizedBox( - width: 24, - child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), - ), - const SizedBox(width: 8), - _ChannelButton( - icon: Symbols.remove_rounded, - onPressed: canDecrement ? _decrement : null, - semanticLabel: Translations.of(context).accessibility.decreaseValue(label: widget.semanticLabel), - ), - const SizedBox(width: 8), - Container( - constraints: const BoxConstraints(minWidth: 56), - alignment: .center, - child: Text('${widget.value}${widget.suffix}', style: theme.textTheme.titleMedium), - ), - const SizedBox(width: 8), - _ChannelButton( - icon: Symbols.add_rounded, - onPressed: canIncrement ? _increment : null, - semanticLabel: Translations.of(context).accessibility.increaseValue(label: widget.semanticLabel), - ), - ], - ), - ), - ); - } -} - -class _ChannelButton extends StatelessWidget { - final IconData icon; - final VoidCallback? onPressed; - final String semanticLabel; - - const _ChannelButton({required this.icon, required this.onPressed, required this.semanticLabel}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEnabled = onPressed != null; - - return Semantics( - label: semanticLabel, - button: true, - enabled: isEnabled, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: const BorderRadius.all(Radius.circular(20)), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, - ), - child: Center( - child: AppIcon( - icon, - size: 18, - fill: 1, - color: isEnabled - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 3b6b0b9b..234802f6 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -11,10 +11,20 @@ import 'app_icon.dart'; import '../theme/mono_tokens.dart'; import 'package:material_symbols_icons/symbols.dart'; +/// Size variant for [TvNumberSpinner]. +enum TvNumberSpinnerDensity { + /// Large buttons with long-press repeat, for a spinner that owns the dialog. + standard, + + /// Smaller buttons sized to sit in a stack of labelled rows. + compact, +} + /// A TV-friendly number spinner with +/- buttons for D-pad navigation. /// -/// Displays a value with decrement/increment buttons on either side. -/// Supports keyboard repeat for faster value changes when holding arrows. +/// Displays a value with decrement/increment buttons on either side, optionally +/// behind a leading [label]. Supports keyboard repeat for faster value changes +/// when holding arrows. class TvNumberSpinner extends StatefulWidget { final int value; @@ -27,6 +37,13 @@ class TvNumberSpinner extends StatefulWidget { /// Optional suffix text (e.g., "s" for seconds). final String? suffix; + /// Optional leading label shown before the buttons (e.g., "H" for hue). + final String? label; + + /// When set, the +/- buttons announce themselves as adjusting this value + /// instead of using the generic increase/decrease labels. + final String? semanticLabel; + final ValueChanged onChanged; /// Called when the user presses SELECT to confirm. @@ -39,6 +56,13 @@ class TvNumberSpinner extends StatefulWidget { final bool autofocus; + /// When false, UP/DOWN are left alone so focus traverses between rows, and + /// held LEFT/RIGHT repeat events are consumed so they don't escape to the + /// focus system as traversal actions. + final bool verticalKeysAdjustValue; + + final TvNumberSpinnerDensity density; + const TvNumberSpinner({ super.key, required this.value, @@ -47,9 +71,13 @@ class TvNumberSpinner extends StatefulWidget { required this.onChanged, this.step = 1, this.suffix, + this.label, + this.semanticLabel, this.autofocus = false, this.onConfirm, this.onCancel, + this.verticalKeysAdjustValue = true, + this.density = TvNumberSpinnerDensity.standard, }); @override @@ -63,7 +91,8 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< @override void initState() { super.initState(); - _focusNode = FocusNode(debugLabel: 'TvNumberSpinner'); + final label = widget.label; + _focusNode = FocusNode(debugLabel: label == null ? 'TvNumberSpinner' : 'TvNumberSpinner_$label'); } @override @@ -89,6 +118,7 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; + final vertical = widget.verticalKeysAdjustValue; if (widget.onCancel != null) { final backResult = handleBackKeyAction(event, widget.onCancel!); @@ -97,20 +127,32 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< } } + // Let UP/DOWN pass through for focus traversal between rows. + if (!vertical && (key.isUpKey || key.isDownKey)) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent) { if (key.isSelectKey && widget.onConfirm != null) { widget.onConfirm!(); return KeyEventResult.handled; } - if (key.isUpKey || key.isRightKey) { + if ((vertical && key.isUpKey) || key.isRightKey) { startRepeat(_increment); return KeyEventResult.handled; - } else if (key.isDownKey || key.isLeftKey) { + } else if ((vertical && key.isDownKey) || key.isLeftKey) { startRepeat(_decrement); return KeyEventResult.handled; } + } else if (event is KeyRepeatEvent) { + // The repeat timer from KeyDown already handles value repetition, so + // swallow the OS repeats that would otherwise traverse focus. Only + // needed when UP/DOWN traverse — otherwise no direction escapes. + if (!vertical && (key.isRightKey || key.isLeftKey)) { + return KeyEventResult.handled; + } } else if (event is KeyUpEvent) { - if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) { + if ((vertical && (key.isUpKey || key.isDownKey)) || key.isRightKey || key.isLeftKey) { stopRepeat(); return KeyEventResult.handled; } @@ -126,6 +168,11 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< final canDecrement = widget.value > widget.min; final canIncrement = widget.value < widget.max; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final isCompact = widget.density == TvNumberSpinnerDensity.compact; + final gap = isCompact ? const SizedBox(width: 8) : const SizedBox(width: 16); + final label = widget.label; + final semanticLabel = widget.semanticLabel; + final a11y = Translations.of(context).accessibility; return Focus( focusNode: _focusNode, @@ -149,32 +196,43 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< ), ), child: Row( - mainAxisSize: .min, - mainAxisAlignment: .center, + mainAxisSize: isCompact ? .max : .min, + mainAxisAlignment: isCompact ? .start : .center, children: [ + if (label != null) ...[ + SizedBox( + width: 24, + child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), + ), + gap, + ], _SpinnerButton( icon: Symbols.remove_rounded, onPressed: canDecrement ? _decrement : null, - onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null, - onLongPressEnd: stopRepeat, - semanticLabel: Translations.of(context).accessibility.decrease, + onLongPressStart: !isCompact && canDecrement ? () => startRepeat(_decrement) : null, + onLongPressEnd: isCompact ? null : stopRepeat, + semanticLabel: semanticLabel != null ? a11y.decreaseValue(label: semanticLabel) : a11y.decrease, + compact: isCompact, ), - const SizedBox(width: 16), + gap, Container( - constraints: const BoxConstraints(minWidth: 60), + constraints: BoxConstraints(minWidth: isCompact ? 56 : 60), alignment: .center, child: Text( - widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}', - style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), + '${widget.value}${widget.suffix ?? ''}', + style: isCompact + ? theme.textTheme.titleMedium + : theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), ), ), - const SizedBox(width: 16), + gap, _SpinnerButton( icon: Symbols.add_rounded, onPressed: canIncrement ? _increment : null, - onLongPressStart: canIncrement ? () => startRepeat(_increment) : null, - onLongPressEnd: stopRepeat, - semanticLabel: Translations.of(context).accessibility.increase, + onLongPressStart: !isCompact && canIncrement ? () => startRepeat(_increment) : null, + onLongPressEnd: isCompact ? null : stopRepeat, + semanticLabel: semanticLabel != null ? a11y.increaseValue(label: semanticLabel) : a11y.increase, + compact: isCompact, ), ], ), @@ -190,6 +248,7 @@ class _SpinnerButton extends StatelessWidget { final VoidCallback? onLongPressStart; final VoidCallback? onLongPressEnd; final String semanticLabel; + final bool compact; const _SpinnerButton({ required this.icon, @@ -197,45 +256,49 @@ class _SpinnerButton extends StatelessWidget { this.onLongPressStart, this.onLongPressEnd, required this.semanticLabel, + this.compact = false, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isEnabled = onPressed != null; + final size = compact ? 36.0 : 48.0; - return Semantics( - label: semanticLabel, - button: true, - enabled: isEnabled, - child: GestureDetector( - onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null, - onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: const BorderRadius.all(Radius.circular(24)), - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, - ), - child: Center( - child: AppIcon( - icon, - fill: 1, - color: isEnabled - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), + Widget button = Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.all(Radius.circular(compact ? 20 : 24)), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, + ), + child: Center( + child: AppIcon( + icon, + size: compact ? 18 : null, + fill: 1, + color: isEnabled + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), ), ), ); + + if (onLongPressStart != null || onLongPressEnd != null) { + button = GestureDetector( + onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null, + onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null, + child: button, + ); + } + + return Semantics(label: semanticLabel, button: true, enabled: isEnabled, child: button); } } diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index af167a82..4a470ad3 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -95,21 +95,18 @@ void main() { final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/art']); - expect(movie.heroArt(containerAspectRatio: 1.0), '/square'); }); test('near-square containers fall back to wide cover art when square art is missing', () { final movie = _movie(artPath: '/art'); expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/art']); - expect(movie.heroArt(containerAspectRatio: 1.0), '/art'); }); test('wide containers prefer wide cover art before square art', () { final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art', '/square']); - expect(movie.heroArt(containerAspectRatio: 16 / 9), '/art'); }); test('episodes prefer show art before episode art for wide hero containers', () { @@ -126,7 +123,6 @@ void main() { ); expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), ['/show-art', '/episode-art', '/square']); - expect(episode.heroArt(containerAspectRatio: 16 / 9), '/show-art'); expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']); }); diff --git a/test/media/media_playlist_test.dart b/test/media/media_playlist_test.dart index 2ef0931f..441b48ce 100644 --- a/test/media/media_playlist_test.dart +++ b/test/media/media_playlist_test.dart @@ -126,16 +126,6 @@ void main() { }); }); - group('MediaPlaylist.isEditable', () { - test('smart playlists are read-only (Plex semantics)', () { - expect(_playlist(smart: true).isEditable, isFalse); - }); - - test('manual playlists are editable', () { - expect(_playlist(smart: false).isEditable, isTrue); - }); - }); - group('MediaPlaylist.globalKey', () { test('uses ":" when serverId is set', () { final pl = _playlist(id: 'pl-42', serverId: 'srv-9'); @@ -166,7 +156,6 @@ void main() { expect(minimal.serverName, isNull); expect(minimal.displayImagePath, isNull); expect(minimal.displayTitle, 'Min'); - expect(minimal.isEditable, isTrue); // Without a serverId, globalKey reduces to the bare id. expect(minimal.globalKey, 'pl'); }); diff --git a/test/models/plex_user_profile_test.dart b/test/models/plex_user_profile_test.dart index be75576c..5c5a2c26 100644 --- a/test/models/plex_user_profile_test.dart +++ b/test/models/plex_user_profile_test.dart @@ -70,23 +70,5 @@ void main() { expect(profile.watchedIndicator, 2); expect(profile.defaultSubtitleForced, 1); }); - - test('defaults() matches parsing an empty map', () { - final parsed = PlexUserProfile.fromJson(const {}); - final defaults = PlexUserProfile.defaults(); - - expect(defaults.autoSelectAudio, parsed.autoSelectAudio); - expect(defaults.defaultAudioAccessibility, parsed.defaultAudioAccessibility); - expect(defaults.defaultAudioLanguage, parsed.defaultAudioLanguage); - expect(defaults.defaultAudioLanguages, parsed.defaultAudioLanguages); - expect(defaults.defaultSubtitleLanguage, parsed.defaultSubtitleLanguage); - expect(defaults.defaultSubtitleLanguages, parsed.defaultSubtitleLanguages); - expect(defaults.autoSelectSubtitle, parsed.autoSelectSubtitle); - expect(defaults.defaultSubtitleAccessibility, parsed.defaultSubtitleAccessibility); - expect(defaults.defaultSubtitleForced, parsed.defaultSubtitleForced); - expect(defaults.watchedIndicator, parsed.watchedIndicator); - expect(defaults.mediaReviewsVisibility, parsed.mediaReviewsVisibility); - expect(defaults.mediaReviewsLanguages, parsed.mediaReviewsLanguages); - }); }); } diff --git a/test/models/user_switch_response_test.dart b/test/models/user_switch_response_test.dart index 1d0aa50b..6d3b47f4 100644 --- a/test/models/user_switch_response_test.dart +++ b/test/models/user_switch_response_test.dart @@ -49,34 +49,17 @@ Map driftedSwitchJson() => { }; void main() { - group('UserSwitchResponse.fromJson', () { - test('parses a realistic drifted 201 body, preserving the token', () { - final response = UserSwitchResponse.fromJson(driftedSwitchJson()); - - expect(response.authToken, 'minted-user-token'); - expect(response.uuid, 'e443d57860076fc3'); - expect(response.protected, isTrue); - expect(response.homeAdmin, isTrue); - expect(response.profile.defaultAudioLanguages, ['en', 'sv']); - expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + group('parsePlexSwitchAuthToken', () { + test('takes the token out of a realistic drifted 201 body', () { + expect(parsePlexSwitchAuthToken(driftedSwitchJson()), 'minted-user-token'); }); - test('parses a token-only body with defaults everywhere else', () { - final response = UserSwitchResponse.fromJson({'authToken': 'tok'}); - - expect(response.authToken, 'tok'); - expect(response.id, 0); - expect(response.uuid, ''); - expect(response.title, ''); - expect(response.confirmed, isFalse); - expect(response.homeSize, 1); - expect(response.maxHomeSize, 1); - expect(response.profile.autoSelectAudio, isTrue); - expect(response.profile.defaultAudioLanguages, isNull); + test('takes the token out of a token-only body', () { + expect(parsePlexSwitchAuthToken({'authToken': 'tok'}), 'tok'); }); test('never loses the token to wrong-typed decorative fields', () { - final response = UserSwitchResponse.fromJson({ + final token = parsePlexSwitchAuthToken({ 'authToken': 'tok', 'id': {}, 'uuid': 42, @@ -92,20 +75,13 @@ void main() { 'twoFactorEnabled': {}, }); - expect(response.authToken, 'tok'); - expect(response.id, 0); - expect(response.uuid, '42'); - expect(response.title, '7'); - expect(response.confirmed, isFalse); - expect(response.homeSize, 1); - expect(response.profile.autoSelectAudio, isTrue); - expect(response.profile.defaultAudioLanguages, isNull); + expect(token, 'tok'); }); test('throws when authToken is missing, empty, or not a string', () { - expect(() => UserSwitchResponse.fromJson(const {}), throwsFormatException); - expect(() => UserSwitchResponse.fromJson({'authToken': ''}), throwsFormatException); - expect(() => UserSwitchResponse.fromJson({'authToken': 12345}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken(const {}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken({'authToken': ''}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken({'authToken': 12345}), throwsFormatException); }); }); } diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 55ae4f51..e85b14ef 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -123,27 +123,16 @@ void main() { expect(m.serverIds, isEmpty); expect(m.onlineServerIds, isEmpty); expect(m.offlineServerIds, isEmpty); - expect(m.plexServers, isEmpty); expect(m.onlineClients, isEmpty); }); - test('getClient/getPlexServer return null for unknown ids', () { + test('getClient returns null for unknown ids', () { final m = MultiServerManager(); addTearDown(m.dispose); expect(m.getClient(ServerId('nope')), isNull); - expect(m.getPlexServer(ServerId('nope')), isNull); expect(m.isServerOnline(ServerId('nope')), isFalse); }); - - test('plexServers map is unmodifiable', () { - final m = MultiServerManager(); - addTearDown(m.dispose); - - // Map.unmodifiable rejects every mutating operation — clear() is the - // simplest no-arg one to exercise the wrapper. - expect(() => m.plexServers.clear(), throwsUnsupportedError); - }); }); // ============================================================ diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index f98eb4e6..72afe30e 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -113,15 +113,15 @@ void main() { final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)]; expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off()), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 1), const PlaybackSourceSubtitleChoice.source(0), ); expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0)), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0), 1), const PlaybackSourceSubtitleChoice.source(2), ); expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2)), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2), 1), const PlaybackSourceSubtitleChoice.off(), ); expect( diff --git a/test/services/plex_auth_service_test.dart b/test/services/plex_auth_service_test.dart index ea1dd78c..61de93eb 100644 --- a/test/services/plex_auth_service_test.dart +++ b/test/services/plex_auth_service_test.dart @@ -73,11 +73,9 @@ void main() { addTearDown(client.close); final auth = PlexAuthService.forTesting(http: client); - final response = await auth.switchToUser('uuid-1', 'account-token'); + final token = await auth.switchToUser('uuid-1', 'account-token'); - expect(response.authToken, 'minted-user-token'); - expect(response.profile.defaultAudioLanguages, ['en', 'sv']); - expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + expect(token, 'minted-user-token'); }); test('fetchServers tolerates scalar drift in server and connection fields', () async { diff --git a/test/services/video_filter_manager_test.dart b/test/services/video_filter_manager_test.dart index bc4e5379..d861a52b 100644 --- a/test/services/video_filter_manager_test.dart +++ b/test/services/video_filter_manager_test.dart @@ -13,7 +13,7 @@ void main() { expect(manager.setZoomScale(1.234), 1.23); expect(manager.zoomScale, 1.23); - expect(manager.adjustZoom(VideoFilterManager.zoomStep), 1.24); + expect(manager.setZoomScale(manager.zoomScale + VideoFilterManager.zoomStep), 1.24); expect(manager.zoomScale, 1.24); }); @@ -26,7 +26,7 @@ void main() { expect(manager.setZoomScale(1.00008), 1.0); expect(manager.zoomScale, 1.0); - expect(manager.resetZoom(), 1.0); + expect(manager.setZoomScale(1.0), 1.0); }); test('video zoom property is exact zero at normalized default', () async { diff --git a/test/utils/content_utils_test.dart b/test/utils/content_utils_test.dart index 49c9256c..f64d9592 100644 --- a/test/utils/content_utils_test.dart +++ b/test/utils/content_utils_test.dart @@ -95,10 +95,6 @@ void main() { expect(ContentTypeHelper.isVideoContent('artist'), isFalse); }); - test('isMusicLibrary returns false for null and non-matching types', () { - expect(ContentTypeHelper.isMusicLibrary(null), isFalse); - }); - test('getLibraryIcon normalizes type and falls back to folder', () { expect(ContentTypeHelper.getLibraryIcon('MOVIE'), Symbols.movie_rounded); expect(ContentTypeHelper.getLibraryIcon('show'), Symbols.tv_rounded); diff --git a/test/utils/layout_constants_test.dart b/test/utils/layout_constants_test.dart index 0bdfde51..4d4822ac 100644 --- a/test/utils/layout_constants_test.dart +++ b/test/utils/layout_constants_test.dart @@ -18,13 +18,6 @@ void main() { expect(ScreenBreakpoints.isTablet(1200), isFalse); }); - test('isWideTablet: 900 ≤ w < 1200', () { - expect(ScreenBreakpoints.isWideTablet(899.9), isFalse); - expect(ScreenBreakpoints.isWideTablet(900), isTrue); - expect(ScreenBreakpoints.isWideTablet(1199.9), isTrue); - expect(ScreenBreakpoints.isWideTablet(1200), isFalse); - }); - test('isDesktop: 1200 ≤ w < 1600', () { expect(ScreenBreakpoints.isDesktop(1199.9), isFalse); expect(ScreenBreakpoints.isDesktop(1200), isTrue); @@ -32,12 +25,6 @@ void main() { expect(ScreenBreakpoints.isDesktop(1600), isFalse); }); - test('isLargeDesktop: w ≥ 1600', () { - expect(ScreenBreakpoints.isLargeDesktop(1599.9), isFalse); - expect(ScreenBreakpoints.isLargeDesktop(1600), isTrue); - expect(ScreenBreakpoints.isLargeDesktop(10000), isTrue); - }); - test('isDesktopOrLarger: w ≥ 1200', () { expect(ScreenBreakpoints.isDesktopOrLarger(1199.9), isFalse); expect(ScreenBreakpoints.isDesktopOrLarger(1200), isTrue); diff --git a/test/utils/plex_cache_parser_test.dart b/test/utils/plex_cache_parser_test.dart index 0fbc4a24..118af79d 100644 --- a/test/utils/plex_cache_parser_test.dart +++ b/test/utils/plex_cache_parser_test.dart @@ -61,47 +61,4 @@ void main() { expect(result, equals(first)); }); }); - - group('PlexCacheParser.extractChapters', () { - test('returns null for null input', () { - expect(PlexCacheParser.extractChapters(null), isNull); - }); - - test('returns null when no metadata', () { - expect( - PlexCacheParser.extractChapters({ - 'MediaContainer': {'Metadata': []}, - }), - isNull, - ); - }); - - test('returns null when first metadata has no Chapter key', () { - expect( - PlexCacheParser.extractChapters({ - 'MediaContainer': { - 'Metadata': [ - {'ratingKey': '1'}, - ], - }, - }), - isNull, - ); - }); - - test('returns chapter list when present', () { - final chapters = [ - {'tag': 'Chapter 1'}, - {'tag': 'Chapter 2'}, - ]; - final result = PlexCacheParser.extractChapters({ - 'MediaContainer': { - 'Metadata': [ - {'ratingKey': '1', 'Chapter': chapters}, - ], - }, - }); - expect(result, equals(chapters)); - }); - }); } From 9429a76acc33bc05d7d2fbf0c799d22995885ca3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:44:35 +0200 Subject: [PATCH 10/12] 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. --- lib/media/episode_collection.dart | 40 +++ lib/mixins/debounced_media_search.dart | 16 ++ lib/providers/download_provider.dart | 50 ++-- lib/screens/catalog_search_screen.dart | 86 ++----- lib/screens/collection_detail_screen.dart | 6 +- lib/screens/livetv/live_tv_actions_mixin.dart | 2 + lib/screens/livetv/live_tv_screen.dart | 10 +- lib/screens/livetv/tabs/guide_tab.dart | 55 ++--- .../playlist/playlist_detail_screen.dart | 6 +- lib/screens/search_screen.dart | 115 +++------ lib/services/sync_rule_executor.dart | 42 +--- lib/utils/download_utils.dart | 69 ++---- lib/utils/live_tv_grouping.dart | 19 +- lib/utils/live_tv_matching.dart | 19 +- lib/widgets/device_code_dialog.dart | 92 ++----- lib/widgets/media_card.dart | 231 ++++++++---------- lib/widgets/media_context_menu.dart | 13 +- lib/widgets/oauth_proxy_dialog.dart | 113 +++------ lib/widgets/overlay_sheet.dart | 76 ++---- lib/widgets/pending_auth_dialog.dart | 108 ++++++++ lib/widgets/search_input_field.dart | 98 ++++++++ test/services/sync_rule_executor_test.dart | 11 +- 22 files changed, 576 insertions(+), 701 deletions(-) create mode 100644 lib/widgets/pending_auth_dialog.dart create mode 100644 lib/widgets/search_input_field.dart 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, From eb3ed45af1be50b70564ac837e4d4d37225e601a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:53:58 +0200 Subject: [PATCH 11/12] refactor: share future coalescing, Plex client access, and event helpers Deduplicates the hand-rolled coalescing/caching maps, the Plex client cast, the missing-serverId event guard and the progress-failure backoff, and drops the MusicPlaybackService availability gate, which could never fail in production. --- .../libraries/tabs/library_browse_tab.dart | 5 ++- lib/screens/music/artist_detail_screen.dart | 3 +- .../music/music_playback_service.dart | 6 ---- .../music/music_playback_service_impl.dart | 3 -- lib/services/playback_progress_tracker.dart | 28 ++++++++--------- .../anime_episode_progress_resolver.dart | 19 ++---------- .../trackers/anime_list_tracker_base.dart | 25 ++++++--------- lib/services/trackers/future_coalescer.dart | 31 +++++++++++++++++++ .../trackers/tracker_id_resolver.dart | 18 +++-------- lib/utils/deletion_notifier.dart | 8 ++--- lib/utils/media_event_keys.dart | 12 +++++++ lib/utils/music_navigation.dart | 24 +++----------- lib/utils/watch_state_notifier.dart | 22 +++++-------- lib/widgets/media_context_menu.dart | 12 +++---- .../sheets/subtitle_search_sheet.dart | 9 ++---- .../music/album_detail_screen_test.dart | 13 -------- .../music/now_playing_screen_test.dart | 3 -- test/screens/music/queue_sheet_test.dart | 3 -- .../trackers/future_coalescer_test.dart | 22 +++++++++++++ test/widgets/media_context_menu_test.dart | 3 -- test/widgets/music/mini_player_test.dart | 3 -- 21 files changed, 118 insertions(+), 154 deletions(-) diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 3b44e326..45735d34 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -41,7 +41,6 @@ import '../../../widgets/media_card_list_layout.dart'; import '../../../widgets/bottom_sheet_page_scaffold.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; -import '../../../services/plex_client.dart'; import '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; @@ -1030,8 +1029,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> _loadFilterValues(MediaFilter filter) async { if (!mounted) return const []; - final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId)); - if (client is PlexClient) return client.getFilterValues(filter.key); + final client = context.tryGetPlexClientForServer(serverIdOrNull(widget.library.serverId)); + if (client != null) return client.getFilterValues(filter.key); // Jellyfin's canonical filter values come from the cached `/Items/Filters` // payload. If that payload missed a category, there is no neutral endpoint diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index ee8ad628..b349c231 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -74,8 +74,7 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen _playAll({bool shuffle = false}) async { await playFetchedTracks( context, diff --git a/lib/services/music/music_playback_service.dart b/lib/services/music/music_playback_service.dart index 02f45b1f..dcba1352 100644 --- a/lib/services/music/music_playback_service.dart +++ b/lib/services/music/music_playback_service.dart @@ -38,10 +38,6 @@ class MusicPlayContext { /// discrete changes (track, status, queue shape, modes) — progress bars /// subscribe to [positionStream] instead. abstract class MusicPlaybackService extends ChangeNotifier { - /// False on the stub — playback affordances should render disabled or - /// fall back to a "not supported yet" notice. - bool get isAvailable; - MediaItem? get currentTrack; MusicPlaybackStatus get status; bool get isPlaying => status == MusicPlaybackStatus.playing; @@ -158,8 +154,6 @@ class StubMusicPlaybackService extends MusicPlaybackService { final ValueNotifier _volumeNotifier = ValueNotifier(100); int _playIntentGeneration = 0; int _queueSessionRevision = 0; - @override - bool get isAvailable => false; @override MediaItem? get currentTrack => null; diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index d8c81eef..402354f8 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -169,9 +169,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO // Getters // --------------------------------------------------------------------- - @override - bool get isAvailable => true; - @override MediaItem? get currentTrack => _currentTrack; diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index d37f050c..e35e2b8d 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -255,27 +255,14 @@ class PlaybackProgressTracker { } }) .catchError((Object e) { - _consecutiveFailures++; - // Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s) - _ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6); - appLogger.d( - 'Progress update failed ($_consecutiveFailures consecutive), ' - 'skipping next $_ticksToSkip tick(s)', - error: e, - ); + _recordProgressFailure(e); unawaited(_queueOnlineFailureProgress(position, duration)); }), ); } } catch (e) { if (!isOffline) { - _consecutiveFailures++; - _ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6); - appLogger.d( - 'Progress update failed ($_consecutiveFailures consecutive), ' - 'skipping next $_ticksToSkip tick(s)', - error: e, - ); + _recordProgressFailure(e); await _queueOnlineFailureProgress( attemptedPosition ?? player.state.position, attemptedDuration ?? player.state.duration, @@ -303,6 +290,17 @@ class PlaybackProgressTracker { } } + void _recordProgressFailure(Object e) { + _consecutiveFailures++; + // Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s) + _ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6); + appLogger.d( + 'Progress update failed ($_consecutiveFailures consecutive), ' + 'skipping next $_ticksToSkip tick(s)', + error: e, + ); + } + void _resetBackoff() { if (_consecutiveFailures > 0) { _consecutiveFailures = 0; diff --git a/lib/services/trackers/anime_episode_progress_resolver.dart b/lib/services/trackers/anime_episode_progress_resolver.dart index 1ae6fca8..da3620cc 100644 --- a/lib/services/trackers/anime_episode_progress_resolver.dart +++ b/lib/services/trackers/anime_episode_progress_resolver.dart @@ -3,6 +3,7 @@ import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../models/trackers/anime_lists_mapping.dart'; import '../../utils/app_logger.dart'; +import 'future_coalescer.dart'; enum AnimeProgressScope { show, season, mapped } @@ -30,7 +31,7 @@ abstract interface class AnimeEpisodeProgressLookup { class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { final MediaServerClient _client; - final Map?>> _seasonProgressLoads = {}; + final KeyedFutureCoalescer?> _seasonProgressLoads = KeyedFutureCoalescer(); AnimeEpisodeProgressResolver(this._client); @@ -61,7 +62,7 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { return includeCurrentEpisode ? ResolvedAnimeProgress(progress: animeMatch.anidbEpisode) : null; } - final progressBySeason = await _seasonProgressFor(showId); + final progressBySeason = await _seasonProgressLoads.run(showId, () => _loadSeasonProgress(showId)); if (progressBySeason == null) return null; final currentAlreadyWatched = (episode.viewCount ?? 0) > 0 || !includeCurrentEpisode; @@ -97,20 +98,6 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { } } - Future?> _seasonProgressFor(String showId) async { - final existing = _seasonProgressLoads[showId]; - if (existing != null) return existing; - - late final Future?> loading; - loading = _loadSeasonProgress(showId).whenComplete(() { - if (identical(_seasonProgressLoads[showId], loading)) { - final _ = _seasonProgressLoads.remove(showId); - } - }); - _seasonProgressLoads[showId] = loading; - return loading; - } - ResolvedAnimeProgress? _showProgress(Map seasons, bool currentAlreadyWatched) { if (seasons.isEmpty) return null; var watched = 0; diff --git a/lib/services/trackers/anime_list_tracker_base.dart b/lib/services/trackers/anime_list_tracker_base.dart index 0cacb5e8..d0f62adf 100644 --- a/lib/services/trackers/anime_list_tracker_base.dart +++ b/lib/services/trackers/anime_list_tracker_base.dart @@ -1,12 +1,13 @@ import '../../models/trackers/anime_ids.dart'; import '../../models/trackers/tracker_context.dart'; import '../../utils/app_logger.dart'; +import 'future_coalescer.dart'; import 'tracker.dart'; import 'tracker_id_resolver.dart'; mixin AnimeListTrackerBase on TrackerBase, ClientBackedTracker implements TrackerRatingSource { - final Map> _episodeCountLoads = {}; + final KeyedFutureCache _episodeCountLoads = KeyedFutureCache(); @override bool get needsFribb => true; @@ -83,19 +84,11 @@ mixin AnimeListTrackerBase on TrackerBa return (activeClient, id); } - Future _episodeCount(TClient activeClient, int id) { - final existing = _episodeCountLoads[id]; - if (existing != null) return existing; - - late final Future loading; - loading = loadAnimeEpisodeCount(activeClient, id).catchError((Object e) { - if (identical(_episodeCountLoads[id], loading)) { - final _ = _episodeCountLoads.remove(id); - } - appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e); - return null; - }); - _episodeCountLoads[id] = loading; - return loading; - } + Future _episodeCount(TClient activeClient, int id) => _episodeCountLoads + .run( + id, + () => loadAnimeEpisodeCount(activeClient, id), + onError: (e) => appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e), + ) + .catchError((Object _) => null); } diff --git a/lib/services/trackers/future_coalescer.dart b/lib/services/trackers/future_coalescer.dart index e2fbf83a..46bf3f45 100644 --- a/lib/services/trackers/future_coalescer.dart +++ b/lib/services/trackers/future_coalescer.dart @@ -38,4 +38,35 @@ class KeyedFutureCoalescer { _inFlight[key] = future; return future; } + + /// Detach every in-flight future — the keyed form of [FutureCoalescer.reset]. + void clear() { + _inFlight.clear(); + } +} + +/// Keyed cache of loads: like [KeyedFutureCoalescer], but a successful future +/// stays memoized instead of being dropped on completion, and only a failure +/// evicts the key so the next call retries. [onError] fires once per failed +/// load, before the error is rethrown to every caller. +class KeyedFutureCache { + final Map> _entries = {}; + + Future run(K key, Future Function() create, {void Function(Object error)? onError}) { + final existing = _entries[key]; + if (existing != null) return existing; + + late final Future future; + future = create().catchError((Object e) { + if (identical(_entries[key], future)) _entries.remove(key); + onError?.call(e); + throw e; + }); + _entries[key] = future; + return future; + } + + void clear() { + _entries.clear(); + } } diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index c77d0158..4a271939 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -8,6 +8,7 @@ import '../../utils/external_ids.dart'; import 'anime_episode_progress_resolver.dart'; import 'anime_lists_mapping_store.dart'; import 'fribb_mapping_store.dart'; +import 'future_coalescer.dart'; /// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus /// optional Fribb-sourced anime IDs (mal/anilist/simkl). Simkl uses [external] @@ -77,7 +78,7 @@ class TrackerIdResolver { /// Null entries mean "the server had no IDs" — cached so scrubbing on an /// un-matched item doesn't re-hit the server every position update. final Map _cache = {}; - final Map> _externalIdLoads = {}; + final KeyedFutureCache _externalIdLoads = KeyedFutureCache(); TrackerIdResolver( MediaServerClient client, { @@ -97,19 +98,8 @@ class TrackerIdResolver { /// [MediaServerClient.fetchExternalIds] surface — Plex hits /// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline /// `ProviderIds` map. - Future _fetchExternalIds(String itemId) { - final existing = _externalIdLoads[itemId]; - if (existing != null) return existing; - late final Future loading; - loading = _client.fetchExternalIds(itemId).catchError((Object e) { - if (identical(_externalIdLoads[itemId], loading)) { - final _ = _externalIdLoads.remove(itemId); - } - throw e; - }); - _externalIdLoads[itemId] = loading; - return loading; - } + Future _fetchExternalIds(String itemId) => + _externalIdLoads.run(itemId, () => _client.fetchExternalIds(itemId)); /// Resolve IDs for a movie. Future resolveForMovie(String itemId) async { diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index 61cf4dd1..49b01e23 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -4,6 +4,7 @@ import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; import 'hierarchical_event_mixin.dart'; +import 'media_event_keys.dart'; /// Event representing a media item deletion with parent chain for hierarchical invalidation class DeletionEvent with HierarchicalEventMixin { @@ -73,11 +74,8 @@ class DeletionNotifier extends BaseNotifier { } void notifyDeletedItem({required MediaItem item, bool isDownloadOnly = false}) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('DeletionNotifier: missing serverId for ${item.id}, skipping deletion event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'DeletionNotifier', event: 'deletion'); + if (serverId == null) return; notify( DeletionEvent( itemId: item.id, diff --git a/lib/utils/media_event_keys.dart b/lib/utils/media_event_keys.dart index 2d9f2069..5a1491c4 100644 --- a/lib/utils/media_event_keys.dart +++ b/lib/utils/media_event_keys.dart @@ -1,5 +1,6 @@ import '../media/ids.dart'; import '../media/media_item.dart'; +import 'app_logger.dart'; import 'global_key_utils.dart'; /// Builds the id filter for a screen showing [items]. @@ -34,3 +35,14 @@ Set? hierarchicalEventGlobalKeys(Iterable items, {String? fal } return keys; } + +/// The [ServerId] an event emitted for [item] should carry, or `null` — after +/// warning as `: … skipping event` — when the item carries +/// none, since an event without a server id cannot be routed to subscribers. +ServerId? serverIdForEvent(MediaItem item, {required String notifier, required String event}) { + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) { + appLogger.w('$notifier: missing serverId for ${item.id}, skipping $event event'); + } + return serverId; +} diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart index 25bb53a9..3558da41 100644 --- a/lib/utils/music_navigation.dart +++ b/lib/utils/music_navigation.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../navigation/profile_navigation_scope.dart'; import '../screens/music/album_detail_screen.dart'; @@ -14,7 +13,6 @@ import '../theme/mono_tokens.dart'; import 'app_logger.dart'; import 'platform_detector.dart'; import 'provider_extensions.dart'; -import 'snackbar_helper.dart'; /// Route name of the now-playing screen — the mini-player's route observer /// suppresses itself while this (or the video player) is in the stack. @@ -90,17 +88,7 @@ void _autoOpenNowPlayingOnTv(BuildContext context) { }); } -/// True when a real music playback engine is bound. On the stub this shows -/// the standard "not supported yet" notice and returns false — check it -/// BEFORE fetching tracks so the stub never costs a server round-trip. -bool ensureMusicPlaybackAvailable(BuildContext context) { - if (context.read().isAvailable) return true; - showAppSnackBar(context, t.messages.musicNotSupported); - return false; -} - -/// Start playback of [tracks] via the session [MusicPlaybackService], -/// surfacing the "not supported yet" notice while the stub is bound. +/// Start playback of [tracks] via the session [MusicPlaybackService]. Future playTracks( BuildContext context, { required List tracks, @@ -108,7 +96,6 @@ Future playTracks( required MusicPlayContext playContext, bool shuffle = false, }) async { - if (!ensureMusicPlaybackAvailable(context)) return; await context.read().playFromList( tracks: tracks, startTrack: startTrack, @@ -120,9 +107,9 @@ Future playTracks( /// Fetch a track list with [fetch], then play it — the shape every music /// entry point that needs a server round-trip before playback repeats: -/// availability gate → [MusicPlaybackService.beginPlayIntent] → fetch → -/// mounted/intent re-check → [playTracks]. Guarding the round-trip with the -/// intent keeps a slow fetch from replacing a queue the user started later. +/// [MusicPlaybackService.beginPlayIntent] → fetch → mounted/intent re-check → +/// [playTracks]. Guarding the round-trip with the intent keeps a slow fetch +/// from replacing a queue the user started later. /// /// [onError] reports a failed fetch and runs only while the intent is still /// current and [context] mounted; passing null instead lets the failure @@ -138,7 +125,6 @@ Future playFetchedTracks( MediaItem? startTrack, bool shuffle = false, }) async { - if (!ensureMusicPlaybackAvailable(context)) return; final service = context.read(); final intent = service.beginPlayIntent(); final List tracks; @@ -167,7 +153,6 @@ Future playFetchedTracks( /// must play under the *same* intent as the album fetch, so a stale fallback /// can never supersede a newer request. Future playTrackWithAlbumContext(BuildContext context, MediaItem track) async { - if (!ensureMusicPlaybackAvailable(context)) return; final service = context.read(); final intent = service.beginPlayIntent(); @@ -206,7 +191,6 @@ Future playTrackWithAlbumContext(BuildContext context, MediaItem track) as /// Only call when the seed's server advertises /// `ServerCapabilities.instantMix`. Future playInstantMix(BuildContext context, MediaItem seed) async { - if (!ensureMusicPlaybackAvailable(context)) return; await context.read().playInstantMix(seed); if (context.mounted) _autoOpenNowPlayingOnTv(context); } diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 77226245..7e6813bc 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -5,6 +5,7 @@ import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; import 'hierarchical_event_mixin.dart'; +import 'media_event_keys.dart'; enum WatchStateChangeType { watched, unwatched, progressUpdate, removedFromContinueWatching } @@ -98,11 +99,8 @@ class WatchStateNotifier extends BaseNotifier { /// Helper to emit a watched/unwatched event from a [MediaItem]. void notifyWatched({required MediaItem item, bool isNowWatched = true, String? cacheServerId}) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping watched event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'watched'); + if (serverId == null) return; notify( WatchStateEvent( itemId: item.id, @@ -127,11 +125,8 @@ class WatchStateNotifier extends BaseNotifier { String? cacheServerId, double watchedThreshold = 0.9, }) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping progress event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'progress'); + if (serverId == null) return; final isNowWatched = isWatchedProgress(positionMs: viewOffset, durationMs: duration, threshold: watchedThreshold); notify( @@ -151,11 +146,8 @@ class WatchStateNotifier extends BaseNotifier { /// Helper to emit a Continue Watching removal event. void notifyRemovedFromContinueWatching({required MediaItem item}) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping continue-watching removal event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'continue-watching removal'); + if (serverId == null) return; notify( WatchStateEvent( itemId: item.id, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index df209341..a6792dc9 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -296,15 +296,13 @@ class MediaContextMenuState extends State { _MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete, destructive: true), ); } else { - // Music (artist/album/track) playback + navigation actions. Play is - // always offered — the shared music_navigation helpers surface the - // "not supported yet" notice while the stub service is bound. Queue - // insertion only exists once a real playback engine is available. + // Music (artist/album/track) playback + navigation actions. Queue + // insertion only exists where a playback session is bound. final isMusicKind = mediaKind != null && mediaKind.isMusic; if (isMusicKind) { menuActions.add(_MenuAction(value: 'music_play', icon: Symbols.play_arrow_rounded, label: t.common.play)); - final musicAvailable = context.read()?.isAvailable ?? false; + final musicAvailable = context.read() != null; if (musicAvailable) { menuActions.add( _MenuAction(value: 'music_play_next', icon: Symbols.playlist_play_rounded, label: t.music.playNext), @@ -1076,8 +1074,8 @@ class MediaContextMenuState extends State { Future _handleMusicEnqueue(BuildContext context, {required bool playNext}) async { final service = context.read(); - // Menu entries are hidden on the stub; defensive re-check. - if (service == null || !service.isAvailable) return; + // Menu entries are hidden without a session; defensive re-check. + if (service == null) return; final queueSessionRevision = service.queueSessionRevision; List tracks; try { diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index ec96d8be..28e44f2b 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -10,7 +10,6 @@ import '../../../focus/input_mode_tracker.dart'; import '../../../i18n/strings.g.dart'; import '../../../mixins/controller_disposer_mixin.dart'; import '../../../models/plex/plex_subtitle_search_result.dart'; -import '../../../services/plex_client.dart'; import '../../../services/settings_service.dart'; import '../../../utils/language_codes.dart'; import '../../../utils/provider_extensions.dart'; @@ -103,8 +102,7 @@ class _SubtitleSearchSheetState extends State with Controll }); try { - final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); - final client = neutral is PlexClient ? neutral : null; + final client = context.tryGetPlexClientForServer(ServerId(widget.serverId)); if (client == null) { if (!mounted || generation != _searchGeneration) return; setState(() => _isSearching = false); @@ -185,10 +183,7 @@ class _SubtitleSearchSheetState extends State with Controll setState(() => _downloadingKey = result.key); try { - // Same Plex-only guard as in [_search]. Don't throw if a Jellyfin - // server somehow reaches the download path. - final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); - final client = neutral is PlexClient ? neutral : null; + final client = context.tryGetPlexClientForServer(ServerId(widget.serverId)); if (client == null) { if (!mounted) return; setState(() => _downloadingKey = null); diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart index 0d64c747..3e98a28a 100644 --- a/test/screens/music/album_detail_screen_test.dart +++ b/test/screens/music/album_detail_screen_test.dart @@ -52,19 +52,6 @@ void main() { // Track numbers restart per disc. expect(find.text('1'), findsNWidgets(2)); }); - - testWidgets('tapping a track on the stub service shows the not-supported notice', (tester) async { - final harness = await _createHarness(_multiDiscTracks()); - - await tester.pumpWidget(harness.wrap(const AlbumDetailScreen(album: _album))); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Track One')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - - expect(find.text(t.messages.musicNotSupported), findsOneWidget); - }); } const _album = MediaItem.plex( diff --git a/test/screens/music/now_playing_screen_test.dart b/test/screens/music/now_playing_screen_test.dart index b3b1371b..364fdd78 100644 --- a/test/screens/music/now_playing_screen_test.dart +++ b/test/screens/music/now_playing_screen_test.dart @@ -54,9 +54,6 @@ class _FakeMusicService extends StubMusicPlaybackService { _positionController.add(position); } - @override - bool get isAvailable => true; - @override MediaItem get currentTrack => track; diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index 0195479b..688e7e93 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -40,9 +40,6 @@ class _FakeQueueService extends StubMusicPlaybackService { _FakeQueueService(this.tracks); - @override - bool get isAvailable => true; - @override MediaItem? get currentTrack => tracks[1]; diff --git a/test/services/trackers/future_coalescer_test.dart b/test/services/trackers/future_coalescer_test.dart index b63b7c3f..f8db9996 100644 --- a/test/services/trackers/future_coalescer_test.dart +++ b/test/services/trackers/future_coalescer_test.dart @@ -34,4 +34,26 @@ void main() { expect(first, 1); expect(second, 2); }); + + test('KeyedFutureCache memoizes successes and evicts failures', () async { + final cache = KeyedFutureCache(); + final errors = []; + var calls = 0; + + Future create({required bool fail}) async { + calls++; + if (fail) throw StateError('boom'); + return calls; + } + + await expectLater(cache.run('a', () => create(fail: true), onError: errors.add), throwsStateError); + expect(errors, hasLength(1)); + + expect(await cache.run('a', () => create(fail: false)), 2); + expect(await cache.run('a', () => create(fail: false)), 2); + expect(calls, 2); + + cache.clear(); + expect(await cache.run('a', () => create(fail: false)), 3); + }); } diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 73f40f6e..3d6b4a53 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -670,9 +670,6 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService { bool? shuffle; int callCount = 0; - @override - bool get isAvailable => true; - @override Future playFromList({ required List tracks, diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index df3b1c20..4fd86043 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -74,9 +74,6 @@ class _FakeMusicService extends StubMusicPlaybackService { _FakeMusicService({this.track}); - @override - bool get isAvailable => true; - @override MediaItem? get currentTrack => track; From 13179f08cd2c9440a971f2e6593632e28a6fcac8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:06:13 +0200 Subject: [PATCH 12/12] refactor: move the Plex switch-token parser in with the Plex models user_switch_response.dart was left holding a single 13-line function after UserSwitchResponse's decorative fields were dropped, so the filename no longer described its contents and it sat at lib/models/ root while every other Plex model lives in lib/models/plex/. Renamed to lib/models/plex/plex_switch_response.dart, with the test moved alongside the other plex_*_test.dart files. The parser stays public so the #1488 drift characterization tests keep exercising it directly. --- .../plex_switch_response.dart} | 0 lib/services/plex_auth_service.dart | 2 +- ...switch_response_test.dart => plex_switch_response_test.dart} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/models/{user_switch_response.dart => plex/plex_switch_response.dart} (100%) rename test/models/{user_switch_response_test.dart => plex_switch_response_test.dart} (97%) diff --git a/lib/models/user_switch_response.dart b/lib/models/plex/plex_switch_response.dart similarity index 100% rename from lib/models/user_switch_response.dart rename to lib/models/plex/plex_switch_response.dart diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 04df153f..c6a415bf 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -8,7 +8,7 @@ import '../exceptions/media_server_exceptions.dart'; import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; import '../models/plex/plex_home_user.dart'; -import '../models/user_switch_response.dart'; +import '../models/plex/plex_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/device_identity.dart'; import '../utils/endpoint_race.dart'; diff --git a/test/models/user_switch_response_test.dart b/test/models/plex_switch_response_test.dart similarity index 97% rename from test/models/user_switch_response_test.dart rename to test/models/plex_switch_response_test.dart index 6d3b47f4..122c1de8 100644 --- a/test/models/user_switch_response_test.dart +++ b/test/models/plex_switch_response_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/user_switch_response.dart'; +import 'package:plezy/models/plex/plex_switch_response.dart'; /// A realistic `/api/v2/home/users/{uuid}/switch` 201 body using the /// July 2026 wire shape where profile language lists are CSV strings (#1488).