diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 18be415e..8cca18d2 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import '../media/ids.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; @@ -258,7 +259,7 @@ class AppDatabase extends _$AppDatabase { } /// Get pending watch actions for a specific server - Future> getPendingWatchActionsForServer(String serverId, {String? profileId}) { + Future> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) { return (select(offlineWatchProgress) ..where( (t) => @@ -381,14 +382,14 @@ class AppDatabase extends _$AppDatabase { /// Insert or update a progress action (merges with existing). Future upsertProgressAction({ String? profileId, - required String serverId, + required ServerId serverId, String? clientScopeId, required String ratingKey, required int viewOffset, required int? duration, required bool shouldMarkWatched, }) async { - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); final now = DateTime.now().millisecondsSinceEpoch; await transaction(() async { @@ -444,12 +445,12 @@ class AppDatabase extends _$AppDatabase { /// Removes conflicting actions for the same item. Future insertWatchAction({ String? profileId, - required String serverId, + required ServerId serverId, String? clientScopeId, required String ratingKey, required String actionType, // 'watched' or 'unwatched' }) async { - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); final now = DateTime.now().millisecondsSinceEpoch; // Remove conflicting actions (opposite action type and progress) @@ -524,7 +525,7 @@ class AppDatabase extends _$AppDatabase { Future insertSyncRule({ String profileId = '', - required String serverId, + required ServerId serverId, required String ratingKey, required String globalKey, required String targetType, @@ -570,7 +571,7 @@ class AppDatabase extends _$AppDatabase { if (profileId.isEmpty) return; final legacyRules = await (select(syncRules)..where((t) => t.profileId.equals(''))).get(); for (final rule in legacyRules) { - final scopedKey = buildProfileScopedGlobalKey(profileId, rule.serverId, rule.ratingKey); + final scopedKey = buildProfileScopedGlobalKey(profileId, ServerId(rule.serverId), rule.ratingKey); final duplicate = await getSyncRule(scopedKey); if (duplicate != null) { await (delete(syncRules)..where((t) => t.id.equals(rule.id))).go(); diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index d5374419..b6de07eb 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import '../media/ids.dart'; import 'app_database.dart'; import '../models/download_models.dart'; @@ -76,7 +77,7 @@ extension DownloadDatabaseOperations on AppDatabase { } Future insertDownload({ - required String serverId, + required ServerId serverId, String? clientScopeId, required String ratingKey, required String globalKey, @@ -209,14 +210,14 @@ extension DownloadDatabaseOperations on AppDatabase { Future> getEpisodesBySeason( String seasonKey, { - String? serverId, + ServerId? serverId, String? clientScopeId, bool filterClientScope = false, }) { return (select(downloadedMedia)..where( (t) => t.parentRatingKey.equals(seasonKey) & - _optionalServerPredicate(t.serverId, serverId) & + _optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) & _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope), )) .get(); @@ -224,24 +225,24 @@ extension DownloadDatabaseOperations on AppDatabase { Future> getEpisodesByShow( String showKey, { - String? serverId, + ServerId? serverId, String? clientScopeId, bool filterClientScope = false, }) { return (select(downloadedMedia)..where( (t) => t.grandparentRatingKey.equals(showKey) & - _optionalServerPredicate(t.serverId, serverId) & + _optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) & _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope), )) .get(); } - Future> getDownloadsByServerId(String serverId) { + Future> getDownloadsByServerId(ServerId serverId) { return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get(); } - Expression _optionalServerPredicate(GeneratedColumn column, String? serverId) { + Expression _optionalServerPredicate(GeneratedColumn column, ServerId? serverId) { return serverId == null ? const Constant(true) : column.equals(serverId); } diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index 88bcf41c..f378da1d 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -56,7 +56,7 @@ class FocusTheme { spreadRadius: focusGlowSpreadRadius, ), BoxShadow( - color: isFocused ? focusColor.withValues(alpha: 0.20) : Colors.transparent, + color: isFocused ? focusColor.withValues(alpha: 0.2) : Colors.transparent, blurRadius: focusGlowOuterBlurRadius, ), ], diff --git a/lib/main.dart b/lib/main.dart index 7b156bcd..83c49301 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'media/ids.dart'; import 'dart:io' show Directory, Platform, ProcessInfo; import 'dart:ui' show AppExitResponse; import 'package:flutter/foundation.dart'; @@ -772,7 +773,7 @@ class _MainAppState extends State with WidgetsBindingObserver { provider.setActiveProfileId(activeProfile.activeId); provider.setActiveClientScopesByServer({ for (final serverId in multiServer.serverManager.serverIds) - serverId: multiServer.serverManager.getClient(serverId)?.cacheServerId, + serverId: multiServer.serverManager.getClient(ServerId(serverId))?.cacheServerId, }); return provider; }, @@ -957,7 +958,7 @@ class _AppleTvScale extends StatelessWidget { // dead margin and zero them out — the UI can use the full surface. return Transform.scale( scale: _scale, - alignment: Alignment.topLeft, + alignment: .topLeft, transformHitTests: true, child: SizedBox( width: logicalSize.width, @@ -966,10 +967,10 @@ class _AppleTvScale extends StatelessWidget { data: outerQ.copyWith( size: logicalSize, devicePixelRatio: outerQ.devicePixelRatio * _scale, - padding: EdgeInsets.zero, - viewPadding: EdgeInsets.zero, - viewInsets: EdgeInsets.zero, - systemGestureInsets: EdgeInsets.zero, + padding: .zero, + viewPadding: .zero, + viewInsets: .zero, + systemGestureInsets: .zero, ), child: child!, ), @@ -1346,7 +1347,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { const failColor = Color(0xFFEF5350); return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: _serverStatus.entries.map((entry) { final (name, connected) = entry.value; final Widget statusIcon; @@ -1365,7 +1366,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { key: ValueKey(entry.key), padding: const EdgeInsets.symmetric(vertical: 2), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ statusIcon, const SizedBox(width: 8), diff --git a/lib/media/ids.dart b/lib/media/ids.dart new file mode 100644 index 00000000..3f33da0b --- /dev/null +++ b/lib/media/ids.dart @@ -0,0 +1,18 @@ +/// Typed identifiers for media-server entities. +/// +/// These are zero-cost [extension type] wrappers over [String]. Each +/// `implements String`, so a value flows freely into String-keyed maps, URLs, +/// JSON payloads, and drift columns without unwrapping — while the type system +/// still rejects a bare `String` (or a *different* id type) being passed where a +/// specific id is expected. Construct one with `ServerId('abc')`; it compares, +/// hashes, and interpolates exactly like its underlying string. +library; + +/// Identifies a media server: a Plex `machineIdentifier` or a Jellyfin server +/// machine id. This is the key under which a [MediaServerClient] is registered +/// and the left half of a `serverId:ratingKey` global key. +extension type const ServerId(String value) implements String {} + +/// Wraps a nullable raw id, preserving `null`. Use at boundaries where a +/// `String?` from a model/storage row crosses into [ServerId]-typed code. +ServerId? serverIdOrNull(String? value) => value == null ? null : ServerId(value); diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index ec0e4a55..dcf8b04d 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -1,6 +1,7 @@ // ignore_for_file: invalid_annotation_target import 'package:freezed_annotation/freezed_annotation.dart'; +import 'ids.dart'; import '../services/settings_service.dart' show EpisodePosterMode; import '../utils/global_key_utils.dart'; @@ -353,10 +354,11 @@ sealed class MediaItem with _$MediaItem { /// Global unique identifier across all servers (`serverId:id`). Falls back /// to bare [id] if [serverId] is missing. - String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id; /// Global unique identifier of this item's library section. - String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(serverId!, libraryId!) : null; + String? get libraryGlobalKey => + serverId != null && libraryId != null ? buildGlobalKey(ServerId(serverId!), libraryId!) : null; /// Parent rating keys for hierarchical invalidation. For an episode: /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. diff --git a/lib/media/media_library.dart b/lib/media/media_library.dart index b9fdc6c9..683b58a3 100644 --- a/lib/media/media_library.dart +++ b/lib/media/media_library.dart @@ -1,4 +1,5 @@ import '../utils/global_key_utils.dart'; +import 'ids.dart'; import 'media_backend.dart'; import 'media_kind.dart'; @@ -45,7 +46,7 @@ class MediaLibrary { this.serverName, }); - String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id; MediaLibrary copyWith({ String? id, diff --git a/lib/media/media_playlist.dart b/lib/media/media_playlist.dart index f50ed16f..4b8b06fa 100644 --- a/lib/media/media_playlist.dart +++ b/lib/media/media_playlist.dart @@ -1,4 +1,5 @@ import '../utils/global_key_utils.dart'; +import 'ids.dart'; import 'media_backend.dart'; /// Backend-neutral playlist record. Holds metadata only — items are fetched @@ -65,7 +66,7 @@ class MediaPlaylist { /// playlists are editable. bool get isEditable => !smart; - String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id; MediaPlaylist copyWith({ String? id, diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 48e40c0c..2ed22953 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -6,6 +6,7 @@ import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse; import '../utils/external_ids.dart'; import 'download_resolution.dart'; +import 'ids.dart'; import 'library_filter_result.dart'; import 'library_first_character.dart'; import 'library_query.dart'; @@ -67,7 +68,7 @@ abstract interface class GracefullyCloseable { } abstract class MediaServerClient { - String get serverId; + ServerId get serverId; String? get serverName; MediaBackend get backend; ServerCapabilities get capabilities; @@ -587,7 +588,7 @@ mixin MediaServerCacheMixin implements MediaServerClient { bool cacheResponse = true, }) async { if (isOfflineMode) { - final cached = await cache.get(cacheServerId, cacheKey); + final cached = await cache.get(ServerId(cacheServerId), cacheKey); if (cached != null) return parseCache(cached); return null; } @@ -603,7 +604,7 @@ mixin MediaServerCacheMixin implements MediaServerClient { return parseResponse(response); } catch (e) { appLogger.w('Network request failed for $cacheKey, trying cache', error: e); - final cached = await cache.get(cacheServerId, cacheKey); + final cached = await cache.get(ServerId(cacheServerId), cacheKey); if (cached != null) return parseCache(cached); rethrow; } @@ -620,7 +621,7 @@ mixin MediaServerCacheMixin implements MediaServerClient { required T? Function(MediaServerResponse response) parseResponse, bool cacheResponse = true, }) async { - final cached = await cache.get(cacheServerId, cacheKey); + final cached = await cache.get(ServerId(cacheServerId), cacheKey); if (cached != null) return parseCache(cached); if (isOfflineMode) return null; final response = await networkCall(); @@ -636,7 +637,7 @@ mixin MediaServerCacheMixin implements MediaServerClient { Future _putCacheResponse(String cacheKey, dynamic data) async { if (data is Map) { - await cache.put(cacheServerId, cacheKey, data); + await cache.put(ServerId(cacheServerId), cacheKey, data); } else if (data != null) { appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}'); } diff --git a/lib/media/media_version.dart b/lib/media/media_version.dart index 2993f0fa..adde96f7 100644 --- a/lib/media/media_version.dart +++ b/lib/media/media_version.dart @@ -121,7 +121,7 @@ class MediaVersion { for (final sig in acceptedSignatures) { final parts = sig.split(':'); if (parts.length != 3) continue; - final targetRes = parts[0]; + final targetRes = parts.first; final targetCodec = parts[1]; for (int i = 0; i < versions.length; i++) { diff --git a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart index d2124cca..1f9457d9 100644 --- a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart +++ b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart @@ -383,7 +383,7 @@ String? _jellyfinDate(String value, Object? originalIso) { String _imageContentType(List bytes, String? fileName) { if (bytes.length >= 8 && - bytes[0] == 0x89 && + bytes.first == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47 && @@ -393,7 +393,7 @@ String _imageContentType(List bytes, String? fileName) { bytes[7] == 0x0a) { return 'image/png'; } - if (bytes.length >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) { + if (bytes.length >= 3 && bytes.first == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) { return 'image/jpeg'; } if (bytes.length >= 6) { @@ -405,7 +405,7 @@ String _imageContentType(List bytes, String? fileName) { final webp = String.fromCharCodes(bytes.skip(8).take(4)); if (riff == 'RIFF' && webp == 'WEBP') return 'image/webp'; } - if (bytes.length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4d) return 'image/bmp'; + if (bytes.length >= 2 && bytes.first == 0x42 && bytes[1] == 0x4d) return 'image/bmp'; final lowerName = fileName?.toLowerCase() ?? ''; if (lowerName.endsWith('.png')) return 'image/png'; diff --git a/lib/mixins/item_updatable.dart b/lib/mixins/item_updatable.dart index 2e445ff4..095ed78d 100644 --- a/lib/mixins/item_updatable.dart +++ b/lib/mixins/item_updatable.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import '../media/media_item.dart'; import '../utils/provider_extensions.dart'; @@ -26,7 +27,7 @@ mixin ItemUpdatable on State { try { final serverId = itemServerId; if (serverId == null) return; - final updatedItem = await context.tryGetMediaClientForServer(serverId)?.fetchItem(itemId); + final updatedItem = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(itemId); if (updatedItem != null) { if (!mounted) return; setState(() { diff --git a/lib/mixins/server_bound_media_mixin.dart b/lib/mixins/server_bound_media_mixin.dart index ed14f136..96662e84 100644 --- a/lib/mixins/server_bound_media_mixin.dart +++ b/lib/mixins/server_bound_media_mixin.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import '../media/ids.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; @@ -14,15 +15,15 @@ mixin ServerBoundMediaMixin on State { String? get serverBoundServerId => serverBoundMetadata.serverId; - String toServerBoundGlobalKey(String ratingKey, {String? serverId}) => - buildGlobalKey(serverId ?? serverBoundServerId ?? '', ratingKey); + String toServerBoundGlobalKey(String ratingKey, {ServerId? serverId}) => + buildGlobalKey(ServerId(serverId ?? serverBoundServerId ?? ''), ratingKey); /// Returns the [PlexClient] for the bound server, or null when offline / /// the server is Jellyfin / not registered. Use [getServerBoundMediaClient] /// for backend-neutral flows. PlexClient? getServerBoundPlexClient(BuildContext context) { if (isServerBoundOffline) return null; - return context.tryGetPlexClientForServer(serverBoundMetadata.serverId); + return context.tryGetPlexClientForServer(serverIdOrNull(serverBoundMetadata.serverId)); } /// Returns a backend-neutral [MediaServerClient] for the bound server, or diff --git a/lib/models/livetv_program.dart b/lib/models/livetv_program.dart index 5287b2b0..0a5cd7a0 100644 --- a/lib/models/livetv_program.dart +++ b/lib/models/livetv_program.dart @@ -1,4 +1,5 @@ import '../utils/json_utils.dart'; +import '../media/ids.dart'; /// Represents an EPG program entry (what's on a channel at a given time) class LiveTvProgram { @@ -96,7 +97,7 @@ class LiveTvProgram { ); } - LiveTvProgram copyWith({String? serverId, String? serverName, String? liveDvrKey, String? providerIdentifier}) { + LiveTvProgram copyWith({ServerId? serverId, String? serverName, String? liveDvrKey, String? providerIdentifier}) { return LiveTvProgram( key: key, ratingKey: ratingKey, diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 603731b1..8efea8ee 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/foundation.dart'; @@ -251,7 +252,7 @@ class ActiveProfileBinder { // would leak servers attached to other profiles. for (final serverId in serverManager.serverIds.toList()) { if (!visibleServerIds.contains(serverId)) { - serverManager.removeServer(serverId); + serverManager.removeServer(ServerId(serverId)); } } multiServerProvider.setExpectedVisibleServerIds(expectedServerIds); @@ -635,7 +636,7 @@ class ActiveProfileBinder { void _clearBoundServers() { for (final serverId in serverManager.serverIds.toList()) { - serverManager.removeServer(serverId); + serverManager.removeServer(ServerId(serverId)); } multiServerProvider.setExpectedVisibleServerIds({}); multiServerProvider.setVisibleServerIds({}); diff --git a/lib/profiles/profile_avatar.dart b/lib/profiles/profile_avatar.dart index 8c37f657..3185e578 100644 --- a/lib/profiles/profile_avatar.dart +++ b/lib/profiles/profile_avatar.dart @@ -36,7 +36,7 @@ class ProfileAvatar extends StatelessWidget { child: Container( width: lockBadgeSize, height: lockBadgeSize, - alignment: Alignment.center, + alignment: .center, decoration: BoxDecoration( color: theme.colorScheme.surface, shape: BoxShape.circle, @@ -75,10 +75,10 @@ class ProfileAvatar extends StatelessWidget { Widget _initialFallback(ThemeData theme, Profile p) { return Container( color: colorForName(p.displayName, theme), - alignment: Alignment.center, + alignment: .center, child: Text( initialOf(p.displayName), - style: TextStyle(color: Colors.white, fontSize: size * 0.42, fontWeight: FontWeight.w600, height: 1.0), + style: TextStyle(color: Colors.white, fontSize: size * 0.42, fontWeight: .w600, height: 1.0), ), ); } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 61cbffea..b2ed9460 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io'; import 'package:flutter/foundation.dart'; import '../media/media_backend.dart'; @@ -40,7 +41,7 @@ class DownloadedArtwork { const DownloadedArtwork({this.thumbPath}); /// Get the local file path for this artwork - String? getLocalPath(DownloadStorageService storage, String serverId) { + String? getLocalPath(DownloadStorageService storage, ServerId serverId) { if (thumbPath == null) return null; return DownloadArtworkService.localPathSync(storage, serverId, thumbPath); } @@ -315,7 +316,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // The fallback dispatches by backend. final cached = allMetadata[item.globalKey] ?? - await _downloadManager.lookupMetadata(item.serverId, item.ratingKey, preferActiveScope: true); + await _downloadManager.lookupMetadata(ServerId(item.serverId), item.ratingKey, preferActiveScope: true); if (cached != null) { _metadata[item.globalKey] = cached; @@ -324,7 +325,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _loadParentMetadataFromMap( cached, allMetadata, - clientScopeId: _downloadManager.activeClientScopeIdForServer(item.serverId) ?? item.clientScopeId, + clientScopeId: + _downloadManager.activeClientScopeIdForServer(ServerId(item.serverId)) ?? item.clientScopeId, ); } } @@ -423,16 +425,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin MediaItem? lookupParent(String ratingKey) { if (clientScopeId != null && clientScopeId.isNotEmpty) { - final scoped = allMetadata[buildGlobalKey(clientScopeId, ratingKey)]; + final scoped = allMetadata[buildGlobalKey(ServerId(clientScopeId), ratingKey)]; if (scoped != null) return scoped; } - return allMetadata[buildGlobalKey(serverId, ratingKey)]; + return allMetadata[buildGlobalKey(ServerId(serverId), ratingKey)]; } // Load show metadata final showRatingKey = episode.grandparentId; if (showRatingKey != null) { - final showGlobalKey = buildGlobalKey(serverId, showRatingKey); + final showGlobalKey = buildGlobalKey(ServerId(serverId), showRatingKey); if (!_metadata.containsKey(showGlobalKey)) { final showMetadata = lookupParent(showRatingKey); if (showMetadata != null) { @@ -447,7 +449,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Load season metadata final seasonRatingKey = episode.parentId; if (seasonRatingKey != null) { - final seasonGlobalKey = buildGlobalKey(serverId, seasonRatingKey); + final seasonGlobalKey = buildGlobalKey(ServerId(serverId), seasonRatingKey); if (!_metadata.containsKey(seasonGlobalKey)) { final seasonMetadata = lookupParent(seasonRatingKey); if (seasonMetadata != null) { @@ -486,11 +488,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final snapshot = WatchStateResolver.fromEvent(event); if (snapshot.isEmpty) return; - final globalKey = buildGlobalKey(event.serverId, event.itemId); + final globalKey = buildGlobalKey(ServerId(event.serverId), event.itemId); final base = _metadata[globalKey]; if (base == null) return; final eventScope = event.cacheServerId; - final activeScope = _downloadManager.activeClientScopeIdForServer(event.serverId); + final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(event.serverId)); if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) { return; } @@ -509,7 +511,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin unawaited( ApiCache.forBackend(base.backend) .applyWatchState( - serverId: event.cacheServerId ?? event.serverId, + serverId: ServerId(event.cacheServerId ?? event.serverId), itemId: event.itemId, isWatched: isWatched, ) @@ -547,7 +549,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final showRatingKey = meta.grandparentId; if (showRatingKey != null && !shows.containsKey(showRatingKey)) { // Try to get stored show metadata first - final showGlobalKey = buildGlobalKey(meta.serverId!, showRatingKey); + final showGlobalKey = buildGlobalKey(ServerId(meta.serverId!), showRatingKey); final storedShow = _metadata[showGlobalKey]; if (storedShow != null && storedShow.isShow) { @@ -600,7 +602,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Get local file path for any artwork type (thumb, art, clearLogo, etc.) /// Returns null if artwork directory isn't initialized or artworkPath is null - String? getArtworkLocalPath(String serverId, String? artworkPath) { + String? getArtworkLocalPath(ServerId serverId, String? artworkPath) { if (artworkPath == null) return null; return DownloadArtworkService.localPathSync(DownloadStorageService.instance, serverId, artworkPath); } @@ -635,7 +637,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Calculate aggregate progress for a show (based on all its episodes) /// Returns synthetic DownloadProgress with aggregated values - DownloadProgress? getAggregateProgressForShow(String serverId, String showRatingKey) { + DownloadProgress? getAggregateProgressForShow(ServerId serverId, String showRatingKey) { return _calculateAggregateProgress( serverId: serverId, ratingKey: showRatingKey, @@ -646,7 +648,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Calculate aggregate progress for a season (based on all its episodes) /// Returns synthetic DownloadProgress with aggregated values - DownloadProgress? getAggregateProgressForSeason(String serverId, String seasonRatingKey) { + DownloadProgress? getAggregateProgressForSeason(ServerId serverId, String seasonRatingKey) { return _calculateAggregateProgress( serverId: serverId, ratingKey: seasonRatingKey, @@ -657,12 +659,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Shared helper to calculate aggregate download progress for shows/seasons DownloadProgress? _calculateAggregateProgress({ - required String serverId, + required ServerId serverId, required String ratingKey, required List episodes, required String entityType, }) { - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); // The progress ring reflects only the episodes the user actually queued for // this show/season — not the show's full episode count. _getEpisodeDownloads @@ -1098,13 +1100,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (serverId == null) return; await _fetchAndStoreRelatedMetadata( - serverId: serverId, + serverId: ServerId(serverId), ratingKey: episode.grandparentId, client: client, context: context, ); await _fetchAndStoreRelatedMetadata( - serverId: serverId, + serverId: ServerId(serverId), ratingKey: episode.parentId, client: client, context: context, @@ -1113,13 +1115,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Fetch, persist, and download artwork for a related metadata item (show or season). Future _fetchAndStoreRelatedMetadata({ - required String serverId, + required ServerId serverId, required String? ratingKey, required MediaServerClient client, required _RelatedMetadataDownloadContext context, }) async { if (ratingKey == null) return; - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); MediaItem? metadata = _metadata[globalKey]; var fetchedFreshMetadata = false; @@ -1442,7 +1444,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Backend-aware metadata lookup for offline UI. Routes through /// [DownloadManagerService] which dispatches to [PlexApiCache] or /// [JellyfinApiCache] based on the connection's `kind`. - Future lookupOfflineMetadata(String serverId, String itemId) => + Future lookupOfflineMetadata(ServerId serverId, String itemId) => _downloadManager.lookupMetadata(serverId, itemId); /// Refresh only metadata from API cache (after watch state sync). @@ -1560,10 +1562,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// All sync rules for the active profile (profile-scoped globalKey -> SyncRuleItem). Map get syncRules => Map.unmodifiable(_syncRules); - String syncRuleKeyFor(String serverId, String ratingKey, {String? profileId}) { + String syncRuleKeyFor(ServerId serverId, String ratingKey, {String? profileId}) { final owner = profileId ?? _activeProfileId; - if (owner == null || owner.isEmpty) return buildGlobalKey(serverId, ratingKey); - return buildProfileScopedGlobalKey(owner, serverId, ratingKey); + if (owner == null || owner.isEmpty) return buildGlobalKey(ServerId(serverId), ratingKey); + return buildProfileScopedGlobalKey(owner, ServerId(serverId), ratingKey); } String syncRuleKeyForGlobalKey(String globalKey) { @@ -1576,7 +1578,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin return syncRuleKeyFor(parsed.serverId, parsed.ratingKey); } - String syncRuleKeyForClient(MediaServerClient client, String ratingKey, {String? serverId}) { + String syncRuleKeyForClient(MediaServerClient client, String ratingKey, {ServerId? serverId}) { return syncRuleKeyFor(serverId ?? client.serverId, ratingKey); } @@ -1586,7 +1588,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (profileId == null || profileId.isEmpty) return const {}; final keys = {}; void add(String ratingKey) { - keys.add(syncRuleKeyFor(event.serverId, ratingKey, profileId: profileId)); + keys.add(syncRuleKeyFor(ServerId(event.serverId), ratingKey, profileId: profileId)); } add(event.itemId); @@ -1609,7 +1611,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// rating key — useful for collection/playlist rules where no underlying /// episode download would otherwise populate it. Future createSyncRule({ - required String serverId, + required ServerId serverId, required String ratingKey, required String targetType, required int episodeCount, @@ -1618,8 +1620,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin MediaItem? targetMetadata, }) async { final profileId = _requireActiveProfileId(); - final publicGlobalKey = buildGlobalKey(serverId, ratingKey); - final scopedGlobalKey = syncRuleKeyFor(serverId, ratingKey, profileId: profileId); + final publicGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey); + final scopedGlobalKey = syncRuleKeyFor(ServerId(serverId), ratingKey, profileId: profileId); await _database.insertSyncRule( profileId: profileId, serverId: serverId, @@ -1685,7 +1687,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future deleteSyncRule(String globalKey) async { _requireActiveProfileId(); final existing = _syncRules[globalKey] ?? await _database.getSyncRule(globalKey); - final publicGlobalKey = existing == null ? globalKey : buildGlobalKey(existing.serverId, existing.ratingKey); + final publicGlobalKey = existing == null + ? globalKey + : buildGlobalKey(ServerId(existing.serverId), existing.ratingKey); await _database.deleteSyncRule(globalKey); _syncRules.remove(globalKey); // createSyncRule may have stashed targetMetadata for collection/playlist diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index bd2e1af4..3a4d86f7 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/foundation.dart'; @@ -98,7 +99,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// connection inline (without a profile switch), so the new server /// becomes visible without the binder having to re-run. Initializes the /// filter to a one-element set when no filter is currently set. - void addToVisibleServerIds(String serverId) { + void addToVisibleServerIds(ServerId serverId) { final current = _visibleServerIds; if (current == null) { _visibleServerIds = {serverId}; @@ -176,14 +177,14 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi DataAggregationService get aggregationService => _aggregationService; /// Get client for specific server. - MediaServerClient? getClientForServer(String serverId) { + MediaServerClient? getClientForServer(ServerId serverId) { return _serverManager.getClient(serverId); } /// Get the [PlexClient] for a server, or `null` if the server is Jellyfin /// (or not registered). Use for Plex-only flows that don't yet have a /// backend-neutral equivalent. - PlexClient? getPlexClientForServer(String serverId) { + PlexClient? getPlexClientForServer(ServerId serverId) { return _serverManager.getPlexClient(serverId); } @@ -212,7 +213,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Check if a server is online (and visible under the active profile). - bool isServerOnline(String serverId) { + bool isServerOnline(ServerId serverId) { final filter = _visibleServerIds; if (filter != null && !filter.contains(serverId)) return false; return _serverManager.isServerOnline(serverId); @@ -230,7 +231,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Whether at least one online server is a Plex server. Used to gate /// Plex-only chrome (server-activities popover, conflict-resolution /// helpers) so they don't render against a Jellyfin-only profile. - bool get hasOnlinePlexServers => onlineServerIds.any((id) => _serverManager.getPlexClient(id) != null); + bool get hasOnlinePlexServers => onlineServerIds.any((id) => _serverManager.getPlexClient(ServerId(id)) != null); /// Visibility-filtered server ids whose latest health probe was rejected /// with HTTP 401/403 (token expired or revoked). UI uses this to show a @@ -247,8 +248,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Display names for the visible auth-errored servers, in stable order. /// Falls back to the server id when the client doesn't expose a name. - List<({String serverId, String displayName})> get authErrorServers { - return authErrorServerIds.map((id) => (serverId: id, displayName: _serverManager.serverDisplayName(id))).toList(); + List<({ServerId serverId, String displayName})> get authErrorServers { + return authErrorServerIds + .map((id) => (serverId: ServerId(id), displayName: _serverManager.serverDisplayName(ServerId(id)))) + .toList(); } /// Clear all server connections @@ -277,7 +280,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi final newLiveTvServers = []; for (final serverId in onlineServerIds) { - final genericClient = _serverManager.getClient(serverId); + final genericClient = _serverManager.getClient(ServerId(serverId)); if (genericClient == null) continue; try { diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index 631c90a7..cc5b4fcc 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import '../media/ids.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; @@ -145,13 +146,13 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Emit a watch state change event for immediate UI update. void _emitWatchStateChange({ - required String serverId, + required ServerId serverId, required String itemId, required bool isNowWatched, required WatchStateChangeType changeType, String? cacheServerId, }) { - final globalKey = buildGlobalKey(serverId, itemId); + final globalKey = buildGlobalKey(ServerId(serverId), itemId); final metadata = _downloadProvider.getMetadata(globalKey); if (metadata != null) { WatchStateNotifier().notifyWatched(item: metadata, isNowWatched: isNowWatched, cacheServerId: cacheServerId); @@ -174,7 +175,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Mark an item as watched while offline. /// /// This queues the action for sync when online and emits a [WatchStateEvent]. - Future markAsWatched({required String serverId, required String itemId}) async { + Future markAsWatched({required ServerId serverId, required String itemId}) async { final cacheServerId = await _syncService.queueMarkWatched(serverId: serverId, itemId: itemId); _emitWatchStateChange( serverId: serverId, @@ -188,11 +189,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM } /// Auto-delete a download if the auto-remove setting is enabled. - void _autoDeleteIfWatched(String serverId, String itemId) { + void _autoDeleteIfWatched(ServerId serverId, String itemId) { final settings = SettingsService.instanceOrNull; if (settings == null || !settings.read(SettingsService.autoRemoveWatchedDownloads)) return; - final globalKey = buildGlobalKey(serverId, itemId); + final globalKey = buildGlobalKey(ServerId(serverId), itemId); final meta = _downloadProvider.getMetadata(globalKey); if (meta == null) return; if (!meta.isEpisode && !meta.isMovie) return; @@ -216,7 +217,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Mark an item as unwatched while offline. /// /// This queues the action for sync when online and emits a [WatchStateEvent]. - Future markAsUnwatched({required String serverId, required String itemId}) async { + Future markAsUnwatched({required ServerId serverId, required String itemId}) async { final cacheServerId = await _syncService.queueMarkUnwatched(serverId: serverId, itemId: itemId); _emitWatchStateChange( serverId: serverId, diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index fda7ce0f..d2c98dcc 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -74,7 +74,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin anilistAccountStore.load(_activeUserUuid), simklAccountStore.load(_activeUserUuid), ]); - _mal = results[0] as MalSession?; + _mal = results.first as MalSession?; _anilist = results[1] as AnilistSession?; _simkl = results[2] as SimklSession?; _rebindAll(); diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index ac17ac2f..e2caf267 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/foundation.dart'; @@ -181,7 +182,7 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi JellyfinClient? _resolveJellyfinClient(JellyfinConnection conn) { final manager = _serverManager; if (manager == null) return null; - final client = manager.getClient(conn.serverMachineId); + final client = manager.getClient(ServerId(conn.serverMachineId)); return client is JellyfinClient ? client : null; } diff --git a/lib/providers/watch_state_overlay_provider.dart b/lib/providers/watch_state_overlay_provider.dart index d872cc9e..04a40452 100644 --- a/lib/providers/watch_state_overlay_provider.dart +++ b/lib/providers/watch_state_overlay_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/foundation.dart'; @@ -62,7 +63,7 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti if (parsed != null) { final scoped = _activeClientScopesByServer[parsed.serverId]; if (scoped != null && scoped.isNotEmpty) { - scopedEntry = _patches[buildGlobalKey(scoped, parsed.ratingKey)]; + scopedEntry = _patches[buildGlobalKey(ServerId(scoped), parsed.ratingKey)]; } } final unscopedEntry = _patches[globalKey]; @@ -116,7 +117,7 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti final cacheServerId = event.cacheServerId; final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId - ? buildGlobalKey(cacheServerId, event.itemId) + ? buildGlobalKey(ServerId(cacheServerId), event.itemId) : event.globalKey; _patches[key] = _WatchStateOverlayEntry(patch, ++_sequence); safeNotifyListeners(); diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 1f2aab51..87a9210e 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../media/library_query.dart'; import '../media/media_backend.dart'; @@ -78,7 +79,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen super.dispose(); } - MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId); + MediaServerClient get _mediaClient => context.getMediaClientForServer(ServerId(widget.serverId)); @override Future> fetchPage(int start, int size, AbortController? abort) { @@ -132,7 +133,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen final theme = Theme.of(context); return SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ ClipRRect( @@ -150,13 +151,13 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen const SizedBox(width: 16), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( widget.actorName, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: .bold), maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (widget.characterName != null) ...[ const SizedBox(height: 4), @@ -164,7 +165,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen widget.characterName!, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], if (totalSize > 0) ...[ diff --git a/lib/screens/auth/plex_pin_auth_flow.dart b/lib/screens/auth/plex_pin_auth_flow.dart index ce8eba32..158975c6 100644 --- a/lib/screens/auth/plex_pin_auth_flow.dart +++ b/lib/screens/auth/plex_pin_auth_flow.dart @@ -212,8 +212,8 @@ class _PlexPinAuthFlowState extends State { final builder = widget.initialButtonsBuilder ?? _defaultInitialButtons; return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ builder(context, () => _start(useQr: false), () => _start(useQr: true), _authService == null), if (_errorMessage != null) ...[ @@ -230,8 +230,8 @@ class _PlexPinAuthFlowState extends State { Widget _defaultInitialButtons(BuildContext context, VoidCallback browser, VoidCallback qr, bool busy) { return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ FocusableButton( onPressed: busy ? null : browser, @@ -248,7 +248,7 @@ class _PlexPinAuthFlowState extends State { Widget _buildQr(ThemeData theme, double qrSize) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text( t.auth.scanQRToSignIn, @@ -290,7 +290,7 @@ class _PlexPinAuthFlowState extends State { Widget _buildBrowserWaiting(ThemeData theme) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const Center(child: CircularProgressIndicator()), const SizedBox(height: 16), diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 3678e00e..33b6b00c 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -214,18 +214,18 @@ class _AuthScreenState extends State { padding: const EdgeInsets.all(24), child: isDesktop ? Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: .center, children: [ Expanded( child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: .center, + crossAxisAlignment: .center, children: [ Image.asset('assets/plezy.png', width: 120, height: 120), const SizedBox(height: 24), Text( t.app.title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold), textAlign: TextAlign.center, ), ], @@ -236,8 +236,8 @@ class _AuthScreenState extends State { child: Center( child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [_buildAuthBody()], ), ), @@ -247,14 +247,14 @@ class _AuthScreenState extends State { ) : SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ Image.asset('assets/plezy.png', width: 120, height: 120), const SizedBox(height: 24), Text( t.app.title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold), textAlign: TextAlign.center, ), const SizedBox(height: 48), @@ -271,7 +271,7 @@ class _AuthScreenState extends State { Widget _buildAuthBody() { if (_isAuthenticating) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const Center(child: CircularProgressIndicator()), const SizedBox(height: 16), @@ -294,8 +294,8 @@ class _AuthScreenState extends State { final isTV = PlatformDetector.isTV(); final isAppleTV = PlatformDetector.isAppleTV(); return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ if (isTV) ...[ FocusableButton( @@ -305,8 +305,8 @@ class _AuthScreenState extends State { onPressed: busy ? null : startQr, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: .center, + mainAxisSize: .min, children: [ const BackendBadge(backend: MediaBackend.plex, size: 18), const SizedBox(width: 8), @@ -487,7 +487,7 @@ class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDis return AlertDialog( title: const Text('Debug: Enter Plex Token'), content: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ FocusableTextFormField( controller: _tokenController, diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 3fd13b4d..398a2ddb 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -1,4 +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'; @@ -60,7 +61,7 @@ abstract class BaseMediaListDetailScreen extends State if (serverId == null) { throw Exception(t.errors.noClientAvailable); } - return context.getMediaClientWithFallback(serverId); + return context.getMediaClientWithFallback(ServerId(serverId)); } @override diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 1d7d1d6c..b8f4df0f 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,4 +1,5 @@ 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'; @@ -191,7 +192,11 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen().syncRuleKeyForClient(mediaClient, widget.collection.id, serverId: serverId); + return context.read().syncRuleKeyForClient( + mediaClient, + widget.collection.id, + serverId: ServerId(serverId), + ); } Future _deleteCollection() async { diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index fee38404..2da3b38b 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -64,7 +64,7 @@ class _MobileRemoteScreenState extends State { if (provider.status == RemoteSessionStatus.reconnecting) { return Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ const CircularProgressIndicator(), const SizedBox(height: 24), @@ -76,7 +76,7 @@ class _MobileRemoteScreenState extends State { ), const SizedBox(height: 32), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ OutlinedButton(onPressed: () => provider.cancelReconnect(), child: Text(t.common.cancel)), const SizedBox(width: 16), @@ -161,7 +161,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( device.name, @@ -236,7 +236,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { children: [ const SizedBox(height: 16), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: .spaceEvenly, children: [ _RemoteButton( icon: Icons.home, @@ -303,7 +303,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { children: [ const SizedBox(height: 16), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ _RemoteButton( icon: Icons.skip_previous, @@ -328,7 +328,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { ), const SizedBox(height: 24), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ _RemoteButton( icon: Icons.replay_10, @@ -353,7 +353,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { Text(t.companionRemote.remote.volume, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 16), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ _RemoteButton( icon: Icons.volume_off, @@ -611,7 +611,7 @@ class _RemoteButton extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ SizedBox( width: size, @@ -621,7 +621,7 @@ class _RemoteButton extends StatelessWidget { HapticFeedback.lightImpact(); onPressed(); }, - style: FilledButton.styleFrom(padding: EdgeInsets.zero, shape: const CircleBorder()), + style: FilledButton.styleFrom(padding: .zero, shape: const CircleBorder()), child: Icon(icon, size: iconSize), ), ), @@ -675,9 +675,9 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerD @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16), + padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ FocusableTextField( controller: _controller, @@ -717,7 +717,7 @@ class _RemoteCard extends StatelessWidget { }, borderRadius: const BorderRadius.all(Radius.circular(12)), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ Icon(icon, size: 32), const SizedBox(height: 8), diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index d62e5e23..c01cb662 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; @@ -99,7 +100,7 @@ class _DiscoverScreenState extends State try { final serverId = _serverIdForItem(itemId); if (serverId == null) return; - final updated = await context.tryGetMediaClientForServer(serverId)?.fetchItem(itemId); + final updated = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(itemId); if (updated == null || !mounted) return; setState(() { updateItemInLists(itemId, updated); @@ -167,12 +168,12 @@ class _DiscoverScreenState extends State final serverId = item.serverId; if (serverId == null) return null; - keys.add(buildGlobalKey(serverId, item.id)); + keys.add(buildGlobalKey(ServerId(serverId), item.id)); if (item.parentId != null) { - keys.add(buildGlobalKey(serverId, item.parentId!)); + keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); } if (item.grandparentId != null) { - keys.add(buildGlobalKey(serverId, item.grandparentId!)); + keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); } } return keys; @@ -217,7 +218,7 @@ class _DiscoverScreenState extends State if (serverId == null) { return context.tryGetMediaClientForServer(null); } - return context.tryGetMediaClientForServer(serverId); + return context.tryGetMediaClientForServer(ServerId(serverId)); } /// Update hub keys when hubs list changes — reuse existing keys to avoid @@ -1132,7 +1133,7 @@ class _DiscoverScreenState extends State children: [ ProfileAvatar(profile: p, size: 24), const SizedBox(width: 12), - Expanded(child: Text(p.displayName, overflow: TextOverflow.ellipsis)), + Expanded(child: Text(p.displayName, overflow: .ellipsis)), if (p.isPinProtected) ...[ const SizedBox(width: 8), AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant), @@ -1235,7 +1236,7 @@ class _DiscoverScreenState extends State ), ), child: Padding( - padding: EdgeInsets.only(top: statusBarHeight, left: 16, right: 16, bottom: 8), + padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( @@ -1243,9 +1244,7 @@ class _DiscoverScreenState extends State if (!PlatformDetector.isTV()) Text( t.discover.title, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), ), const Spacer(), Consumer2( @@ -1292,11 +1291,7 @@ class _DiscoverScreenState extends State ), child: Text( '${watchTogether.participantCount}', - style: TextStyle( - color: colorScheme.onPrimary, - fontSize: 10, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), ), ), ), @@ -1469,7 +1464,7 @@ class _DiscoverScreenState extends State child: Container( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Container( width: 200, @@ -1506,7 +1501,7 @@ class _DiscoverScreenState extends State SliverFillRemaining( child: Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), const SizedBox(height: 16), @@ -1592,7 +1587,7 @@ class _DiscoverScreenState extends State if (_errorMessage != null) Center( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const AppIcon(Symbols.error_outline_rounded, fill: 1, size: 64, color: Colors.grey), const SizedBox(height: 16), @@ -1605,7 +1600,7 @@ class _DiscoverScreenState extends State if (!_isLoading && _errorMessage == null && browseHubs.isEmpty && !_areHubsLoading) Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), const SizedBox(height: 16), @@ -1690,7 +1685,7 @@ class _DiscoverScreenState extends State left: -26, right: 0, child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ // Pause/Play button ClickableCursor( @@ -1736,7 +1731,7 @@ class _DiscoverScreenState extends State borderRadius: BorderRadius.circular(dotSize / 2), ), child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Container( width: fillWidth, height: dotSize, @@ -1787,7 +1782,7 @@ class _DiscoverScreenState extends State final heroLogoHeight = isTv ? TvLayoutConstants.heroLogoHeight : 120.0; final heroTitleStyle = theme.textTheme.displaySmall?.copyWith( color: colorScheme.onSurface, - fontWeight: FontWeight.bold, + fontWeight: .bold, fontSize: isTv ? 52 : null, shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], ); @@ -1920,7 +1915,7 @@ class _DiscoverScreenState extends State ? 200 : 0, child: Padding( - padding: EdgeInsets.symmetric( + padding: .symmetric( horizontal: isTv ? TvLayoutConstants.horizontalInset : isLargeScreen @@ -1935,7 +1930,7 @@ class _DiscoverScreenState extends State ), child: Column( crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ // Show logo or name/title if (heroItem.clearLogoPath != null) @@ -2004,7 +1999,7 @@ class _DiscoverScreenState extends State style: TextStyle( color: colorScheme.onSurface, fontSize: isTv ? 18 : 14, - fontWeight: FontWeight.w600, + fontWeight: .w600, ), textAlign: alignLeft ? TextAlign.left : TextAlign.center, ), @@ -2018,7 +2013,7 @@ class _DiscoverScreenState extends State const SizedBox(height: 12), RichText( maxLines: isTv ? 3 : 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, textAlign: alignLeft ? TextAlign.left : TextAlign.center, text: TextSpan( style: TextStyle( @@ -2030,7 +2025,7 @@ class _DiscoverScreenState extends State if (isEpisode && heroItem.parentIndex != null && heroItem.index != null) TextSpan( text: 'S${heroItem.parentIndex}, E${heroItem.index}: ', - style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.onSurface), + style: TextStyle(fontWeight: .bold, color: colorScheme.onSurface), ), TextSpan( text: heroItem.summary?.isNotEmpty == true @@ -2048,7 +2043,7 @@ class _DiscoverScreenState extends State Text( 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}', maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, textAlign: alignLeft ? TextAlign.left : TextAlign.center, style: TextStyle( color: colorScheme.onSurface.withValues(alpha: 0.7), @@ -2077,7 +2072,7 @@ class _DiscoverScreenState extends State final hasProgress = heroItem.hasActiveProgress; final isTv = PlatformDetector.isTV(); - final minutesLeft = hasProgress ? ((heroItem.durationMs! - heroItem.viewOffsetMs!) / 60000).round() : 0; + final minutesLeft = hasProgress ? ((heroItem.durationMs! - heroItem.viewOffsetMs!) / 60_000).round() : 0; final progress = hasProgress ? heroItem.viewOffsetMs! / heroItem.durationMs! : 0.0; @@ -2097,7 +2092,7 @@ class _DiscoverScreenState extends State child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOutCubic, - padding: EdgeInsets.symmetric(horizontal: isTv ? 34 : 24, vertical: isTv ? 16 : 12), + padding: .symmetric(horizontal: isTv ? 34 : 24, vertical: isTv ? 16 : 12), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.all(Radius.circular(isTv ? 32 : 24)), @@ -2106,7 +2101,7 @@ class _DiscoverScreenState extends State : null, ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(Symbols.play_arrow_rounded, fill: 1, size: isTv ? 28 : 20, color: foregroundColor), SizedBox(width: isTv ? 12 : 8), @@ -2120,7 +2115,7 @@ class _DiscoverScreenState extends State borderRadius: BorderRadius.all(Radius.circular(isTv ? 4 : 3)), ), child: FractionallySizedBox( - alignment: Alignment.centerLeft, + alignment: .centerLeft, widthFactor: progress, child: Container( decoration: BoxDecoration( diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 4089f56f..55d9f9a5 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -1,4 +1,5 @@ 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'; @@ -184,7 +185,7 @@ class DownloadsScreenState extends State if (!PlatformDetector.shouldUseSideNavigation(context)) Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -212,7 +213,7 @@ class DownloadsScreenState extends State // (not a [PlexClient]) for both code paths. getClient(String globalKey) { final serverId = parseGlobalKey(globalKey)?.serverId ?? globalKey; - return serverProvider.serverManager.getClient(serverId); + return serverProvider.serverManager.getClient(ServerId(serverId)); } return DownloadTreeView( diff --git a/lib/screens/downloads/sync_rules_screen.dart b/lib/screens/downloads/sync_rules_screen.dart index 3785bbfb..a7a6420e 100644 --- a/lib/screens/downloads/sync_rules_screen.dart +++ b/lib/screens/downloads/sync_rules_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../connection/connection.dart'; @@ -134,7 +135,7 @@ class _SyncRuleTileState extends State<_SyncRuleTile> { } _RuleServerInfo _serverLabelForRule() { - final activeName = multiServerProvider.getClientForServer(rule.serverId)?.serverName; + final activeName = multiServerProvider.getClientForServer(ServerId(rule.serverId))?.serverName; if (activeName != null && activeName.isNotEmpty) { return _RuleServerInfo(label: activeName, isKnown: true); } @@ -168,7 +169,7 @@ class _SyncRuleTileState extends State<_SyncRuleTile> { if (!serverInfo.isKnown) return t.downloads.syncRuleUnknownServer; if (multiServerProvider.authErrorServerIds.contains(rule.serverId)) return t.downloads.syncRuleSignInRequired; if (!multiServerProvider.serverIds.contains(rule.serverId)) return t.downloads.syncRuleNotAvailableForProfile; - return multiServerProvider.isServerOnline(rule.serverId) + return multiServerProvider.isServerOnline(ServerId(rule.serverId)) ? t.downloads.syncRuleAvailable : t.downloads.syncRuleOffline; } @@ -235,13 +236,13 @@ class _SyncRuleTileState extends State<_SyncRuleTile> { visualDensity: const VisualDensity(vertical: -3), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), leading: Icon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), - title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(title, maxLines: 1, overflow: .ellipsis), subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ - Text(_subtitle(), maxLines: 1, overflow: TextOverflow.ellipsis), - Text(serverLine, maxLines: 1, overflow: TextOverflow.ellipsis), + Text(_subtitle(), maxLines: 1, overflow: .ellipsis), + Text(serverLine, maxLines: 1, overflow: .ellipsis), ], ), trailing: FocusableWrapper( @@ -303,7 +304,7 @@ class _SwipeRevealDeleteActionState extends State<_SwipeRevealDeleteAction> { Positioned.fill( right: 8, child: Align( - alignment: Alignment.centerRight, + alignment: .centerRight, child: SizedBox( width: _deleteWidth, child: ExcludeFocus( @@ -319,7 +320,7 @@ class _SwipeRevealDeleteActionState extends State<_SwipeRevealDeleteAction> { child: Tooltip( message: t.downloads.removeSyncRule, child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ Icon(Symbols.delete_rounded, color: colorScheme.onError, size: 20), const SizedBox(height: 2), @@ -327,7 +328,7 @@ class _SwipeRevealDeleteActionState extends State<_SwipeRevealDeleteAction> { t.common.delete, style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onError, - fontWeight: FontWeight.w600, + fontWeight: .w600, ), ), ], diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 6165a019..fe7af0ee 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -141,7 +142,7 @@ class _HubDetailScreenState extends State final sectionId = match.group(1)!; appLogger.d('Loading sorts for section: $sectionId'); - final client = context.tryGetMediaClientForServer(serverId); + final client = context.tryGetMediaClientForServer(ServerId(serverId)); final sorts = client == null ? const [] : await client.fetchSortOptions(sectionId); appLogger.d('Loaded ${sorts.length} sorts'); @@ -272,7 +273,7 @@ class _HubDetailScreenState extends State try { final loader = widget.loadItems; - final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); final List items; int totalCount; int loadedCount; @@ -397,7 +398,7 @@ class _HubDetailScreenState extends State void _retryHubContinuation() { final serverId = widget.hub.serverId; - final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null || _isLoadingMore) return; final generation = _loadGeneration; if (client.backend == MediaBackend.plex) { @@ -429,7 +430,7 @@ class _HubDetailScreenState extends State if (serverId == null) return; try { - final updated = await context.tryGetMediaClientForServer(serverId)?.fetchItem(ratingKey); + final updated = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(ratingKey); if (updated == null || !mounted) return; setState(() { final currentItemIndex = _items.indexWhere((item) => item.id == ratingKey); @@ -457,7 +458,7 @@ class _HubDetailScreenState extends State child: error == null ? const CircularProgressIndicator() : Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text(error, textAlign: TextAlign.center), const SizedBox(height: 8), diff --git a/lib/screens/libraries/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart index dcdb4632..67bee552 100644 --- a/lib/screens/libraries/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -241,7 +241,7 @@ class _AlphaJumpBarState extends State { borderRadius: const BorderRadius.all(Radius.circular(10)), ), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: .spaceEvenly, children: List.generate(_displayed.length, (i) { final letter = _displayed[i]; final isCurrent = letter == currentLetter && !_hasFocus; @@ -273,7 +273,7 @@ class _AlphaJumpBarState extends State { width: markerSize, height: markerSize, decoration: decoration, - alignment: Alignment.center, + alignment: .center, child: Text( letter, style: TextStyle( diff --git a/lib/screens/libraries/alpha_scroll_handle.dart b/lib/screens/libraries/alpha_scroll_handle.dart index e7ec8eea..c2bde8b1 100644 --- a/lib/screens/libraries/alpha_scroll_handle.dart +++ b/lib/screens/libraries/alpha_scroll_handle.dart @@ -187,7 +187,7 @@ class _AlphaScrollHandleState extends State with SingleTicker width: _touchTargetWidth, height: _handleHeight + _touchTargetVerticalPadding * 2, child: Align( - alignment: Alignment.centerRight, + alignment: .centerRight, child: Container( margin: const EdgeInsets.only(right: 2), width: _handleWidth, @@ -211,14 +211,10 @@ class _AlphaScrollHandleState extends State with SingleTicker width: _bubbleSize, height: _bubbleSize, decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), - alignment: Alignment.center, + alignment: .center, child: Text( _dragLetter!, - style: TextStyle( - color: colorScheme.onPrimary, - fontSize: _bubbleFontSize, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: colorScheme.onPrimary, fontSize: _bubbleFontSize, fontWeight: .bold), ), ), ), diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index fc004c9d..fde2456b 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -1,4 +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 '../../focus/focusable_button.dart'; @@ -99,7 +100,7 @@ class _FiltersBottomSheetState extends State { if (cached != null) { values = cached; } else { - final client = context.tryGetMediaClientForServer(widget.serverId); + final client = context.tryGetMediaClientForServer(ServerId(widget.serverId)); if (client is PlexClient) { values = await client.getFilterValues(filter.key); } else { @@ -295,14 +296,14 @@ class _FiltersBottomSheetState extends State { autofocus: index == 0 && autofocusFirst, title: Text(filter.title), trailing: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (displayValue != null) Flexible( child: Text( displayValue, - style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, + style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: .w500), + overflow: .ellipsis, ), ), if (displayValue != null) const SizedBox(width: 8), diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index ad08626a..d665d8b8 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -1,4 +1,5 @@ import 'dart:ui'; +import '../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; @@ -123,7 +124,7 @@ class FolderTreeItem extends StatelessWidget { final expandIcon = isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded; return Container( - padding: EdgeInsets.only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), + padding: .only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), child: Row( children: [ SizedBox( @@ -136,9 +137,9 @@ class FolderTreeItem extends StatelessWidget { Expanded( child: Text( _rowTitle(), - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + style: const TextStyle(fontSize: 14, fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ), ], @@ -161,9 +162,9 @@ class FolderTreeItem extends StatelessWidget { final metadataLine = _buildMetadataLine(); return Container( - padding: EdgeInsets.only(left: 16.0 + indentation, right: 16.0, top: 6.0, bottom: 6.0), + padding: .only(left: 16.0 + indentation, right: 16.0, top: 6.0, bottom: 6.0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: .center, children: [ // Thumbnail with progress overlay SizedBox( @@ -186,14 +187,14 @@ class FolderTreeItem extends StatelessWidget { // Metadata column Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text( _rowTitle(), - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, height: 1.2), + style: const TextStyle(fontSize: 13, fontWeight: .w500, height: 1.2), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (subtitle != null) ...[ const SizedBox(height: 2), @@ -205,7 +206,7 @@ class FolderTreeItem extends StatelessWidget { height: 1.2, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], if (metadataLine.isNotEmpty) ...[ @@ -218,7 +219,7 @@ class FolderTreeItem extends StatelessWidget { height: 1.2, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ], @@ -238,7 +239,7 @@ class FolderTreeItem extends StatelessWidget { ) { final posterUrl = item.posterThumb(mode: episodePosterMode); // Backend-neutral so Jellyfin items render via Jellyfin's transcoder. - final client = context.tryGetMediaClientWithFallback(serverId); + final client = context.tryGetMediaClientWithFallback(serverIdOrNull(serverId)); final shouldBlur = hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; @@ -305,10 +306,10 @@ class FolderTreeItem extends StatelessWidget { shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], ), - alignment: Alignment.center, + alignment: .center, child: Text( '${item.leafCount! - item.viewedLeafCount!}', - style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: FontWeight.bold), + style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: .bold), ), ), ), @@ -389,7 +390,7 @@ class FolderTreeItem extends StatelessWidget { tooltip: t.common.play, iconSize: 18, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - padding: EdgeInsets.zero, + padding: .zero, visualDensity: VisualDensity.compact, ), ), @@ -409,7 +410,7 @@ class FolderTreeItem extends StatelessWidget { tooltip: t.common.shuffle, iconSize: 18, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - padding: EdgeInsets.zero, + padding: .zero, visualDensity: VisualDensity.compact, ), ), diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index e2b6f849..165b9108 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../media/media_backend.dart'; import '../../media/media_item.dart'; @@ -82,7 +83,7 @@ class FolderTreeViewState extends State { }); try { - final client = context.getMediaClientForServer(widget.serverId!); + final client = context.getMediaClientForServer(ServerId(widget.serverId!)); final folders = await _fetchRootFolders(client); if (!mounted) return; @@ -123,7 +124,7 @@ class FolderTreeViewState extends State { }); try { - final client = context.getMediaClientForServer(widget.serverId!); + final client = context.getMediaClientForServer(ServerId(widget.serverId!)); final children = await _fetchFolderChildren(client, folder); if (!mounted) return; @@ -184,7 +185,7 @@ class FolderTreeViewState extends State { final folderKey = _folderKey(folder); if (folderKey == null) return; - final client = context.getPlexClientForServer(widget.serverId!); + final client = context.getPlexClientForServer(ServerId(widget.serverId!)); final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); await launcher.launchFromFolder( folderKey: folderKey, @@ -203,7 +204,7 @@ class FolderTreeViewState extends State { final folderKey = _folderKey(folder); if (folderKey == null) return; - final client = context.getPlexClientForServer(widget.serverId!); + final client = context.getPlexClientForServer(ServerId(widget.serverId!)); final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); await launcher.launchFromFolder( folderKey: folderKey, diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index b252993d..ab5f93f1 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -817,9 +817,9 @@ class _LibrariesScreenState extends State final serverName = library.serverName ?? fallbackServerName; if (serverName == null || serverName.isEmpty) return const SizedBox.shrink(); - final text = Text(serverName, style: style, overflow: TextOverflow.ellipsis); + final text = Text(serverName, style: style, overflow: .ellipsis); return Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ BackendBadge(backend: library.backend, size: badgeSize, color: style?.color), const SizedBox(width: 4), @@ -830,7 +830,7 @@ class _LibrariesScreenState extends State PopupMenuItem _buildLibraryServerHeaderMenuItem(MediaLibrary library, String serverKey) { final style = Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w600, + fontWeight: .w600, letterSpacing: 0.4, color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.65), ); @@ -862,8 +862,8 @@ class _LibrariesScreenState extends State const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text( library.title, @@ -931,7 +931,7 @@ class _LibrariesScreenState extends State // On desktop/TV with side nav, show tabs in app bar (library name is in side nav) if (PlatformDetector.shouldUseSideNavigation(context)) { return Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ for (int i = 0; i < _visibleTabs.length; i++) ...[ if (i > 0) const SizedBox(width: 8), @@ -969,14 +969,14 @@ class _LibrariesScreenState extends State child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.kind.id), fill: 1, size: 20), const SizedBox(width: 8), if (_hasMultipleServers(visibleLibraries) && selectedLibrary.serverName != null) Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleMedium), _buildLibraryServerLabel( @@ -1445,11 +1445,11 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { builder: (context) => SafeArea( top: false, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Padding( padding: const EdgeInsets.all(16), - child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: .w600)), ), ...menuItems.indexed.map( (entry) => ListTile( @@ -1537,10 +1537,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { const AppIcon(Symbols.edit_rounded, fill: 1), const SizedBox(width: 12), Expanded( - child: Text( - t.libraries.manageLibraries, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), + child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)), ), IconButton( icon: const AppIcon(Symbols.close_rounded, fill: 1), @@ -1656,7 +1653,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { child: ListTile( tileColor: tileColor, leading: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ ReorderableDragStartListener( index: index, @@ -1681,7 +1678,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ) : null, trailing: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Container( decoration: FocusTheme.focusBackgroundDecoration(isFocused: isVisibilityButtonFocused, borderRadius: 20), diff --git a/lib/screens/libraries/library_filter_sort_loader.dart b/lib/screens/libraries/library_filter_sort_loader.dart index 0cfffd55..c42bd2e7 100644 --- a/lib/screens/libraries/library_filter_sort_loader.dart +++ b/lib/screens/libraries/library_filter_sort_loader.dart @@ -36,7 +36,7 @@ class LibraryFilterSortLoader { client.fetchLibraryFiltersWithValues(library.id), client.fetchSortOptions(library.id, libraryType: library.kind.id), ]); - final filterResult = results[0] as LibraryFilterResult; + final filterResult = results.first as LibraryFilterResult; final sorts = results[1] as List; return LoadedFiltersAndSorts(filters: filterResult.filters, sorts: sorts, cachedValues: filterResult.cachedValues); } diff --git a/lib/screens/libraries/library_quick_picker_sheet.dart b/lib/screens/libraries/library_quick_picker_sheet.dart index 1792539e..749d7ca6 100644 --- a/lib/screens/libraries/library_quick_picker_sheet.dart +++ b/lib/screens/libraries/library_quick_picker_sheet.dart @@ -69,7 +69,7 @@ class LibraryQuickPickerSheet extends StatelessWidget { Widget _buildServerHeader(BuildContext context, MediaLibrary library, String fallbackServerName) { final theme = Theme.of(context); final labelStyle = theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w600, + fontWeight: .w600, letterSpacing: 0.4, color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.65), ); @@ -80,12 +80,7 @@ class LibraryQuickPickerSheet extends StatelessWidget { BackendBadge(backend: library.backend, size: 12, color: labelStyle?.color), const SizedBox(width: 6), Expanded( - child: Text( - library.serverName ?? fallbackServerName, - style: labelStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: Text(library.serverName ?? fallbackServerName, style: labelStyle, maxLines: 1, overflow: .ellipsis), ), ], ), @@ -97,12 +92,12 @@ class LibraryQuickPickerSheet extends StatelessWidget { context, ).textTheme.bodySmall?.copyWith(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6)); return Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ BackendBadge(backend: library.backend, size: 10, color: style?.color), const SizedBox(width: 4), Flexible( - child: Text(library.serverName!, style: style, maxLines: 1, overflow: TextOverflow.ellipsis), + child: Text(library.serverName!, style: style, maxLines: 1, overflow: .ellipsis), ), ], ); @@ -123,7 +118,7 @@ class LibraryQuickPickerSheet extends StatelessWidget { title: Text( library.title, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: TextStyle(fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: foregroundColor), ), subtitle: showServerName ? _buildServerSubtitle(context, library) : null, @@ -137,17 +132,17 @@ class LibraryQuickPickerSheet extends StatelessWidget { final theme = Theme.of(context); return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Text(t.libraries.selectLibrary, style: theme.textTheme.titleMedium), ), ), if (isLoading && libraries.isEmpty) - const Padding(padding: EdgeInsets.symmetric(vertical: 32), child: CircularProgressIndicator()) + const Padding(padding: .symmetric(vertical: 32), child: CircularProgressIndicator()) else if (libraries.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 84734da2..7bbb3432 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -101,7 +101,7 @@ class _SortBottomSheetState extends State { @override Widget build(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ BottomSheetHeader( title: t.libraries.sortBy, diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index 1fa975fb..9f526da7 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -58,7 +58,7 @@ class StateMessageWidget extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(24.0), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ if (icon != null) ...[ AppIcon( diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index bd46b71d..a94d0b65 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -95,8 +96,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.serverId; - String _toGlobalKey(String ratingKey, {String? serverId}) => - buildGlobalKey(serverId ?? widget.library.serverId ?? '', ratingKey); + String _toGlobalKey(String ratingKey, {ServerId? serverId}) => + buildGlobalKey(ServerId(serverId ?? widget.library.serverId ?? ''), ratingKey); @override String? get deletionServerId => widget.library.serverId; @@ -115,7 +116,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState().serverManager; - return manager.getPlexClient(library.serverId ?? '')!; + return manager.getPlexClient(ServerId(library.serverId ?? ''))!; }, libraryKey: library.id, isShared: library.isShared, @@ -698,7 +699,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( showDragHandle: true, builder: (sheetContext) => Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), @@ -750,13 +753,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 8.0; return SliverPadding( - padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8), + padding: .fromLTRB(8, topPadding, rightPadding, 8), sliver: SliverLayoutBuilder( builder: (context, constraints) { final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 3048387b..128cbc64 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -94,9 +95,9 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState[] : List.of( @@ -300,7 +301,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState on State { required String posterServerId, }) { final multiServer = context.read(); - final client = multiServer.getClientForServer(posterServerId); + final client = multiServer.getClientForServer(ServerId(posterServerId)); String? posterUrl; if (posterThumb != null && client != null) { posterUrl = MediaImageHelper.getOptimizedImageUrl( diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index bd1461ca..5ee4a151 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../media/ids.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -164,7 +165,7 @@ class _LiveTvScreenState extends State final multiServer = context.read(); final futures = >[]; for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null || !client.capabilities.liveTvDvr) continue; futures.add(_reloadGuideSafe(client, serverInfo.dvrKey)); } @@ -188,7 +189,7 @@ class _LiveTvScreenState extends State final multiServer = context.read(); final futures = >[]; for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null || !client.capabilities.liveTvDvr) continue; futures.add(_processRulesSafe(client)); } @@ -212,7 +213,7 @@ class _LiveTvScreenState extends State /// libraries-screen pattern at libraries_screen.dart:365). void _refreshVisibleTabs(MultiServerProvider multiServer) { final hasDvr = multiServer.liveTvServers.any((s) { - final c = multiServer.getClientForServer(s.serverId); + final c = multiServer.getClientForServer(ServerId(s.serverId)); return c != null && c.capabilities.liveTvDvr; }); final newTabs = [LiveTvTab.guide, LiveTvTab.whatsOn, if (hasDvr) LiveTvTab.recordings]; @@ -305,7 +306,7 @@ class _LiveTvScreenState extends State for (final serverInfo in liveTvServers) { try { - final genericClient = multiServer.getClientForServer(serverInfo.serverId); + final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (genericClient == null) continue; final liveTv = genericClient.liveTv; @@ -391,7 +392,7 @@ class _LiveTvScreenState extends State final fetchedStores = {}; final seenFavorites = {}; for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; final liveTv = client.liveTv; final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); @@ -481,7 +482,7 @@ class _LiveTvScreenState extends State } final writtenStores = {}; for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(serverInfo.serverId); + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; final liveServerKey = _liveServerScopeKey(serverInfo); final storeKey = _favoriteStoreByLiveServer[liveServerKey]; @@ -614,7 +615,7 @@ class _LiveTvScreenState extends State if (_error != null) { return Center( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error), const SizedBox(height: 16), @@ -644,7 +645,7 @@ class _LiveTvScreenState extends State if (!useSideNav) Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: _buildTabChipItems()), diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 1d0cde27..bdf45435 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -51,7 +52,7 @@ class _LiveTvShowScheduleScreenState extends State Future _loadSchedule() async { final multiServer = context.read(); - final genericClient = multiServer.getClientForServer(widget.serverId); + final genericClient = multiServer.getClientForServer(ServerId(widget.serverId)); if (genericClient == null) { setStateIfMounted(() => _isLoading = false); return; @@ -88,12 +89,12 @@ class _LiveTvShowScheduleScreenState extends State /// schedule screen is opened with a single [serverId], so no per-program /// lookup is needed. bool get _canRecord { - final client = context.read().getClientForServer(widget.serverId); + final client = context.read().getClientForServer(ServerId(widget.serverId)); return client != null && client.capabilities.liveTvDvr; } Future _onRecordShow() async { - final client = context.read().getClientForServer(widget.serverId); + final client = context.read().getClientForServer(ServerId(widget.serverId)); if (client == null) return; // Use the first program with a guid as the seed for `getSubscriptionTemplate`. // The template returned by Plex includes both episode-level and series-level @@ -231,16 +232,16 @@ class _ScheduleListTile extends StatelessWidget { : null, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ Expanded( child: Text( titleText, - style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ), if (isLive) ...[ @@ -255,7 +256,7 @@ class _ScheduleListTile extends StatelessWidget { subtitle, style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted), maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], if (channel != null) ...[ diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 57032b21..dad16d88 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -277,11 +277,11 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ if (widget.posterUrl != null) ...[ ClipRRect( @@ -302,7 +302,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent ], Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ @@ -316,7 +316,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent ), child: Text( t.liveTv.live, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), + style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 11), ), ), ], @@ -327,7 +327,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent if (channel != null) channel.displayName, if (program.startTime != null && program.endTime != null) '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} - ${formatClockTime(program.endTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))}', - if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), + if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60_000), ].join(' · '), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 68d3196d..71d42d45 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -180,14 +180,14 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> { return Padding( padding: const EdgeInsets.all(20), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Row( children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( widget.isEdit ? t.liveTv.editRule : t.liveTv.recordOptions, @@ -198,7 +198,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> { widget.headerTitle, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ), @@ -237,7 +237,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> { ), const SizedBox(height: 12), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: .end, children: [ FocusableButton( onPressed: _saving ? null : _close, @@ -366,7 +366,7 @@ class _BoolSettingRow extends StatelessWidget { children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium), if (setting.summary != null && setting.summary!.isNotEmpty) @@ -433,7 +433,7 @@ class _EnumSettingRow extends StatelessWidget { children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium), if (setting.summary != null && setting.summary!.isNotEmpty) @@ -495,7 +495,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) @@ -561,7 +561,7 @@ class _TextSettingRowState extends State<_TextSettingRow> with ControllerDispose return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index 06621098..e526eb51 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -1,4 +1,5 @@ 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'; @@ -220,7 +221,7 @@ class _ReorderFavoritesSheetState extends State { final isKeyboardMode = InputModeTracker.isKeyboardMode(context); return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ BottomSheetHeader(title: t.liveTv.reorderFavorites, icon: Symbols.swap_vert_rounded), Expanded( @@ -268,7 +269,7 @@ class _ReorderFavoritesSheetState extends State { }) { final colorScheme = Theme.of(context).colorScheme; final multiServer = context.read(); - final client = multiServer.getClientForServer(channel?.serverId ?? ''); + final client = multiServer.getClientForServer(ServerId(channel?.serverId ?? '')); Color? tileColor; if (isMoving) { @@ -285,7 +286,7 @@ class _ReorderFavoritesSheetState extends State { key: key, tileColor: tileColor, leading: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ ReorderableDragStartListener( index: index, @@ -311,7 +312,7 @@ class _ReorderFavoritesSheetState extends State { ), ], ), - title: Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(displayName, maxLines: 1, overflow: .ellipsis), subtitle: channelNumber != null ? Text( channelNumber, diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 39e88903..a0f573dc 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -234,7 +235,7 @@ class GuideTabState extends State with MountedSetStateMixin { for (final serverInfo in liveTvServers) { if (!queriedServers.add(serverInfo.serverId)) continue; try { - final genericClient = multiServer.getClientForServer(serverInfo.serverId); + final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (genericClient == null) continue; final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; @@ -246,7 +247,7 @@ class GuideTabState extends State with MountedSetStateMixin { allPrograms.addAll(programs); await _addScheduledRecordingKeysForServer( client: genericClient, - serverId: serverInfo.serverId, + serverId: ServerId(serverInfo.serverId), keys: scheduledRecordingKeys, ); } catch (e) { @@ -287,11 +288,11 @@ class GuideTabState extends State with MountedSetStateMixin { for (final serverInfo in multiServer.liveTvServers) { if (!queriedServers.add(serverInfo.serverId)) continue; - final client = multiServer.getClientForServer(serverInfo.serverId); + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; await _addScheduledRecordingKeysForServer( client: client, - serverId: serverInfo.serverId, + serverId: ServerId(serverInfo.serverId), keys: scheduledRecordingKeys, ); } @@ -302,14 +303,14 @@ class GuideTabState extends State with MountedSetStateMixin { Future _addScheduledRecordingKeysForServer({ required MediaServerClient client, - required String serverId, + required ServerId serverId, required Set keys, }) async { if (!client.capabilities.liveTvDvr) return; try { final grabs = await client.liveTv.fetchScheduledRecordings(); for (final grab in grabs) { - _addRecordingKeysForGrab(grab, serverId: serverId, keys: keys); + _addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys); } } catch (e) { appLogger.d('Failed to load scheduled recordings for $serverId', error: e); @@ -319,7 +320,7 @@ class GuideTabState extends State with MountedSetStateMixin { final rules = await client.liveTv.fetchRecordingRules(includeGrabs: true, includeStorage: false); for (final rule in rules) { for (final grab in rule.grabOperations) { - _addRecordingKeysForGrab(grab, serverId: serverId, keys: keys); + _addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys); } } } catch (e) { @@ -327,7 +328,7 @@ class GuideTabState extends State with MountedSetStateMixin { } } - void _addRecordingKeysForGrab(MediaGrabOperation grab, {required String serverId, required Set keys}) { + void _addRecordingKeysForGrab(MediaGrabOperation grab, {required ServerId serverId, required Set keys}) { if (!_isActiveScheduledGrab(grab)) return; final program = grab.program; if (program == null) return; @@ -367,7 +368,7 @@ class GuideTabState extends State with MountedSetStateMixin { final keys = {}; void addMediaId(String? value) { final normalized = _nonEmpty(value); - if (normalized != null) keys.add(_recordingKey(serverId, 'media', normalized)); + if (normalized != null) keys.add(_recordingKey(ServerId(serverId), 'media', normalized)); } addMediaId(program.ratingKey); @@ -377,13 +378,13 @@ class GuideTabState extends State with MountedSetStateMixin { final channelIdentifier = _nonEmpty(program.channelIdentifier); final beginsAt = program.beginsAt; if (channelIdentifier != null && beginsAt != null) { - keys.add(_recordingKey(serverId, 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}')); + keys.add(_recordingKey(ServerId(serverId), 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}')); } return keys; } - String _recordingKey(String serverId, String type, String value) => '$serverId\u0000$type\u0000$value'; + String _recordingKey(ServerId serverId, String type, String value) => '$serverId\u0000$type\u0000$value'; String? _nonEmpty(String? value) { final trimmed = value?.trim(); @@ -936,7 +937,7 @@ class GuideTabState extends State with MountedSetStateMixin { children: [ AppIcon(Symbols.chevron_left_rounded, size: 20, color: theme.colorScheme.onSurface), const SizedBox(width: 8), - Text(label, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text(label, style: theme.textTheme.titleSmall?.copyWith(fontWeight: .bold)), ], ), ), @@ -1001,7 +1002,7 @@ class GuideTabState extends State with MountedSetStateMixin { ), Expanded( child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ _timeNavFocusWrap( index: 1, @@ -1013,7 +1014,7 @@ class GuideTabState extends State with MountedSetStateMixin { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text(dayLabel, style: theme.textTheme.labelLarge), const SizedBox(width: 2), @@ -1057,7 +1058,7 @@ class GuideTabState extends State with MountedSetStateMixin { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Text( timeStr, style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), @@ -1083,15 +1084,12 @@ class GuideTabState extends State with MountedSetStateMixin { right: BorderSide(color: theme.dividerColor.withValues(alpha: 0.3)), ), ), - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Text( label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant, fontWeight: .w700), maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ); } @@ -1111,18 +1109,18 @@ class GuideTabState extends State with MountedSetStateMixin { return Transform.translate(offset: Offset(scrollOffset, 0), child: child); }, child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8), - fontWeight: FontWeight.w700, + fontWeight: .w700, letterSpacing: 0.3, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ), ), @@ -1133,7 +1131,7 @@ class GuideTabState extends State with MountedSetStateMixin { Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) { final multiServer = context.read(); - final client = multiServer.getClientForServer(channel.serverId ?? ''); + final client = multiServer.getClientForServer(ServerId(channel.serverId ?? '')); final isFocused = _hasFocus && _focusZone == _GuideZone.grid && _gridColumn == 0 && _gridChannelIndex == index; @@ -1154,7 +1152,7 @@ class GuideTabState extends State with MountedSetStateMixin { Widget _buildChannelNameFallback(LiveTvChannel channel, ThemeData theme) { return Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ if (channel.number != null) Text( @@ -1164,9 +1162,9 @@ class GuideTabState extends State with MountedSetStateMixin { ), Text( channel.displayName, - style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + style: theme.textTheme.bodySmall?.copyWith(fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, textAlign: TextAlign.center, ), ], @@ -1313,10 +1311,10 @@ class GuideTabState extends State with MountedSetStateMixin { final leftInset = (scrollOffset - tileLeft).clamp(0.0, maxInset); return Container( color: isFocused ? null : materialColor, - padding: EdgeInsets.fromLTRB(basePadding + leftInset, 4, basePadding, 4), + padding: .fromLTRB(basePadding + leftInset, 4, basePadding, 4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: .start, + mainAxisAlignment: .center, children: [ Row( children: [ @@ -1327,12 +1325,9 @@ class GuideTabState extends State with MountedSetStateMixin { Expanded( child: Text( program.grandparentTitle ?? program.title, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: titleColor, - ), + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600, color: titleColor), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ), ], @@ -1342,14 +1337,14 @@ class GuideTabState extends State with MountedSetStateMixin { '${program.parentIndex != null && program.index != null ? 'S${program.parentIndex}E${program.index} · ' : ''}${program.title}', style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (program.startTime != null) Text( - '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60000)}', + '${formatClockTime(program.startTime!, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context))} · ${formatDurationTextual(program.durationMinutes * 60_000)}', style: theme.textTheme.labelSmall?.copyWith(color: subtitleColor), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ), @@ -1364,7 +1359,7 @@ class GuideTabState extends State with MountedSetStateMixin { void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { final multiServer = context.read(); - final client = multiServer.getClientForServer(channel.serverId ?? ''); + final client = multiServer.getClientForServer(ServerId(channel.serverId ?? '')); String? posterUrl; if (program.thumb != null && client != null) { posterUrl = MediaImageHelper.getOptimizedImageUrl( @@ -1472,7 +1467,7 @@ class _ChannelCellState extends State<_ChannelCell> { ), ), child: Stack( - alignment: Alignment.center, + alignment: .center, children: [ AnimatedOpacity( opacity: showAction ? 0.3 : 1.0, diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 7c06b7e5..9fef3ef5 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -119,7 +120,7 @@ class RecordingsTabState extends State { for (final serverInfo in multiServer.liveTvServers) { if (!seenServers.add(serverInfo.serverId)) continue; - final client = multiServer.getClientForServer(serverInfo.serverId); + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; if (!client.capabilities.liveTvDvr) continue; try { @@ -270,7 +271,7 @@ class _EmptyMessage extends StatelessWidget { return Padding( padding: const EdgeInsets.all(24), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(Symbols.fiber_manual_record_rounded, size: 40, color: theme.colorScheme.onSurfaceVariant), const SizedBox(height: 12), @@ -354,13 +355,13 @@ class _GrabTile extends StatelessWidget { children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( title, - style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (subtitle.isNotEmpty) ...[ const SizedBox(height: 2), @@ -368,7 +369,7 @@ class _GrabTile extends StatelessWidget { subtitle, style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ], @@ -441,13 +442,13 @@ class _RuleTile extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( title, - style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + style: theme.textTheme.bodyLarge?.copyWith(fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (subtitleParts.isNotEmpty) ...[ const SizedBox(height: 2), @@ -455,7 +456,7 @@ class _RuleTile extends StatelessWidget { subtitleParts.join(' · '), style: theme.textTheme.bodySmall?.copyWith(color: tokens(context).textMuted), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ], @@ -479,7 +480,7 @@ class _StatusBadge extends StatelessWidget { decoration: BoxDecoration(color: color, borderRadius: const BorderRadius.all(Radius.circular(4))), child: Text( label, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), + style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 11), ), ); } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 1e55589f..b631cf04 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -87,7 +88,7 @@ class WhatsOnTabState extends State with LiveTvActionsMixin with MountedSetSta Widget _buildContent(BuildContext context, bool hasFocus, int libraryDensity) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const AppIcon(Symbols.live_tv_rounded, fill: 1), const SizedBox(width: 8), @@ -446,7 +447,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetSta child: Text( widget.hub.title, style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, maxLines: 1, ), ), @@ -542,7 +543,7 @@ class _LiveTvPosterCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox( width: double.infinity, @@ -550,7 +551,7 @@ class _LiveTvPosterCard extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: OptimizedMediaImage.poster( - client: context.tryGetMediaClientWithFallback(metadata.serverId), + client: context.tryGetMediaClientWithFallback(serverIdOrNull(metadata.serverId)), imagePath: posterImage, width: double.infinity, height: double.infinity, @@ -562,14 +563,14 @@ class _LiveTvPosterCard extends StatelessWidget { Text( metadata.displayTitle, maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1), + overflow: .ellipsis, + style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1), ), if (metadata.displaySubtitle != null) Text( metadata.displaySubtitle!, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 489d9f69..3e03c981 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io' show Platform, exit; import 'package:flutter/material.dart'; @@ -501,7 +502,7 @@ class _MainScreenState extends State void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) { if (_downloadResumeFired || !mounted) return; for (final serverId in mp.onlineServerIds) { - final onlineClient = mp.getClientForServer(serverId); + final onlineClient = mp.getClientForServer(ServerId(serverId)); if (onlineClient == null) continue; _downloadResumeFired = true; unawaited( @@ -699,7 +700,7 @@ class _MainScreenState extends State } /// Navigate to media when host switches content in Watch Together session - Future _navigateToWatchTogetherMedia(String ratingKey, String serverId) async { + Future _navigateToWatchTogetherMedia(String ratingKey, ServerId serverId) async { if (!mounted) return; // Check before any context usage try { @@ -1659,7 +1660,7 @@ class _MainScreenState extends State child: Scaffold( body: _buildTickerAwareStack(), bottomNavigationBar: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ // Reconnect bar when offline if (_isOffline) @@ -1670,7 +1671,7 @@ class _MainScreenState extends State child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ if (_isReconnecting) SizedBox( @@ -1688,7 +1689,7 @@ class _MainScreenState extends State t.common.reconnect, style: TextStyle( fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: .w500, color: Theme.of(context).colorScheme.primary, ), ), diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 99c2e05f..eda23845 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -7,7 +7,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0; final playButtonLabel = _getPlayButtonLabel(metadata); final playIconSize = isTv ? 22 * tvScale : 20.0; - final playTextStyle = TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: FontWeight.w700); + final playTextStyle = TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: .w700); final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: playIconSize); Future onPlayPressed() async { @@ -108,11 +108,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { onPressed: onPlayPressed, style: actionButtonStyle( showFocus: state.showFocus, - padding: EdgeInsets.symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0), + padding: .symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0), ), child: playButtonLabel.isNotEmpty ? Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ playButtonIcon, SizedBox(width: isTv ? 7 * tvScale : 8), @@ -212,11 +212,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final allActions = [ playAction, - if (trailerAction != null) trailerAction, - if (shuffleAction != null) shuffleAction, - if (downloadAction != null) downloadAction, + ?trailerAction, + ?shuffleAction, + ?downloadAction, watchedAction, - if (moreActionsAction != null) moreActionsAction, + ?moreActionsAction, ]; double playButtonWidthEstimate() { @@ -246,18 +246,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { return compact; } - final medium = [ - playAction, - if (downloadAction != null) downloadAction, - watchedAction, - if (moreActionsAction != null) moreActionsAction, - ]; + final medium = [playAction, ?downloadAction, watchedAction, ?moreActionsAction]; if (!maxWidth.isFinite || estimatedRowWidth(medium) <= maxWidth) return medium; - final compact = [playAction, watchedAction, if (moreActionsAction != null) moreActionsAction]; + final compact = [playAction, watchedAction, ?moreActionsAction]; if (estimatedRowWidth(compact) <= maxWidth) return compact; - return [playAction, if (moreActionsAction != null) moreActionsAction]; + return [playAction, ?moreActionsAction]; } Widget actionBar(List actions) { @@ -292,9 +287,9 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // Offline mode: queue action for later sync final offlineWatch = context.read(); if (isWatched) { - await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, itemId: metadata.id); + await offlineWatch.markAsUnwatched(serverId: ServerId(metadata.serverId!), itemId: metadata.id); } else { - await offlineWatch.markAsWatched(serverId: metadata.serverId!, itemId: metadata.id); + await offlineWatch.markAsWatched(serverId: ServerId(metadata.serverId!), itemId: metadata.id); } if (mounted) { showAppSnackBar(context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline); @@ -304,7 +299,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { // Jellyfin items hit /UserPlayedItems and Plex items hit /:/scrobble. final serverId = metadata.serverId; if (serverId == null) return; - final client = context.tryGetMediaClientForServer(serverId); + final client = context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) return; if (isWatched) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index fbe47b3a..c3e39489 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io'; import 'package:cached_network_image_ce/cached_network_image.dart'; @@ -240,12 +241,12 @@ class _MediaDetailScreenState extends State final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = {toServerBoundGlobalKey(_metadata.id, serverId: serverId)}; + final keys = {toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))}; for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.id, serverId: season.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId))); } for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.id, serverId: ep.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId))); } return keys; } @@ -561,12 +562,12 @@ class _MediaDetailScreenState extends State final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = {toServerBoundGlobalKey(_metadata.id, serverId: serverId)}; + final keys = {toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))}; for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.id, serverId: season.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId))); } for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.id, serverId: ep.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId))); } return keys; } @@ -871,7 +872,7 @@ class _MediaDetailScreenState extends State width: size, height: size, child: Stack( - alignment: Alignment.center, + alignment: .center, children: [ // Background circle (only show if we have determinate progress) if (progressPercent != null && progressPercent > 0) @@ -898,20 +899,20 @@ class _MediaDetailScreenState extends State final isTv = PlatformDetector.isTV(); final textWidget = Text( text, - style: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: FontWeight.w600), + style: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600), ); final hasLeading = leading != null || icon != null; return Container( - padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6), + padding: .symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6), decoration: BoxDecoration( color: colorScheme.secondaryContainer.withValues(alpha: 0.8), borderRadius: const BorderRadius.all(Radius.circular(100)), ), child: hasLeading ? Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (leading != null) leading @@ -1026,7 +1027,7 @@ class _MediaDetailScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon( iconData, @@ -1037,7 +1038,7 @@ class _MediaDetailScreenState extends State const SizedBox(width: 4), Text( label, - style: TextStyle(color: fgColor, fontSize: 13, fontWeight: FontWeight.w500), + style: TextStyle(color: fgColor, fontSize: 13, fontWeight: .w500), ), ], ), @@ -1066,7 +1067,7 @@ class _MediaDetailScreenState extends State /// Build a combined RT chip showing critic + audience side by side. Widget _buildCombinedRtChip(RatingInfo critic, RatingInfo audience) { final colorScheme = Theme.of(context).colorScheme; - final textStyle = TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: FontWeight.w500); + final textStyle = TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: .w500); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -1075,7 +1076,7 @@ class _MediaDetailScreenState extends State borderRadius: const BorderRadius.all(Radius.circular(100)), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ SvgPicture.asset(critic.assetPath, width: 16, height: 16), const SizedBox(width: 4), @@ -1098,7 +1099,7 @@ class _MediaDetailScreenState extends State MediaServerClient? _getArtworkMediaClient(BuildContext context) { if (!widget.isOffline) return _getMediaClientForMetadata(context); - return context.tryGetMediaClientForServer(_metadata.serverId); + return context.tryGetMediaClientForServer(serverIdOrNull(_metadata.serverId)); } Widget? _buildOfflineArtworkIfAvailable( @@ -1131,7 +1132,7 @@ class _MediaDetailScreenState extends State String? _offlineArtworkLocalPath(BuildContext context, String? artworkPath) { if (!widget.isOffline || _metadata.serverId == null) return null; - final localPath = context.read().getArtworkLocalPath(_metadata.serverId!, artworkPath); + final localPath = context.read().getArtworkLocalPath(ServerId(_metadata.serverId!), artworkPath); if (localPath == null || !File(localPath).existsSync()) return null; return localPath; } @@ -1189,7 +1190,7 @@ class _MediaDetailScreenState extends State final serverId = metadata.serverId; final client = _getMediaClientForMetadata(context); if (client == null || serverId == null) return metadata.globalKey; - return downloadProvider.syncRuleKeyForClient(client, metadata.id, serverId: serverId); + return downloadProvider.syncRuleKeyForClient(client, metadata.id, serverId: ServerId(serverId)); } void _navigateToActorMedia(MediaRole actor) { @@ -1295,7 +1296,7 @@ class _MediaDetailScreenState extends State // Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.) if (widget.isOffline) { final cachedMetadata = await context.read().lookupOfflineMetadata( - _metadata.serverId ?? '', + ServerId(_metadata.serverId ?? ''), _metadata.id, ); if (!mounted) return; @@ -1412,7 +1413,7 @@ class _MediaDetailScreenState extends State }); final serverId = _metadata.serverId; - final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) { setStateIfMounted(() { _isLoadingSeasons = false; @@ -1435,7 +1436,7 @@ class _MediaDetailScreenState extends State : Future.value({}); final results = await Future.wait([seasonsFuture, prefsFuture]); - final seasons = results[0] as List; + final seasons = results.first as List; final prefs = results[1] as Map; // Preserve serverId for each season. @@ -1522,7 +1523,7 @@ class _MediaDetailScreenState extends State final seasonId = firstEp.parentId ?? ''; final seasonGlobalKey = _metadata.serverId == null || seasonId.isEmpty ? null - : buildGlobalKey(_metadata.serverId!, seasonId); + : buildGlobalKey(ServerId(_metadata.serverId!), seasonId); final storedSeason = seasonGlobalKey == null ? null : downloadProvider.getMetadata(seasonGlobalKey); if (storedSeason != null && storedSeason.isSeason) { return _withFallbackLibrary( @@ -1683,7 +1684,7 @@ class _MediaDetailScreenState extends State // Resolve the right backend client so Jellyfin (where the typed // PlexClient helper returns null) loads episodes too. final serverId = _metadata.serverId; - final mediaClient = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + final mediaClient = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); if (serverId == null || mediaClient == null) { _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const []); return; @@ -1730,7 +1731,7 @@ class _MediaDetailScreenState extends State if (seasonIdsToWarm.isEmpty) return; final serverId = _metadata.serverId; - final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); if (serverId == null || client == null) return; final seasonsById = {for (final season in seasons) season.id: season}; @@ -1751,7 +1752,7 @@ class _MediaDetailScreenState extends State if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; if (page.items.isEmpty) break; - final enriched = _enrichPlayableEpisodes(page.items, serverId); + final enriched = _enrichPlayableEpisodes(page.items, ServerId(serverId)); for (final episode in enriched) { final seasonId = _seasonIdForEpisode(episode, seasonsById: seasonsById, seasonsByIndex: seasonsByIndex); if (seasonId == null || !seasonIdsToWarm.contains(seasonId)) continue; @@ -1765,7 +1766,7 @@ class _MediaDetailScreenState extends State _completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation); } catch (e, st) { appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st); - await _warmTvSeasonEpisodeCachesBySeason(seasons, seasonIdsToWarm, client, serverId, generation); + await _warmTvSeasonEpisodeCachesBySeason(seasons, seasonIdsToWarm, client, ServerId(serverId), generation); } } @@ -1773,7 +1774,7 @@ class _MediaDetailScreenState extends State List seasons, Set seasonIdsToWarm, MediaServerClient client, - String serverId, + ServerId serverId, int generation, ) async { final episodesBySeasonId = >{}; @@ -1922,7 +1923,7 @@ class _MediaDetailScreenState extends State } final serverId = _metadata.serverId; - final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) { markLoaded(); return; @@ -2479,7 +2480,7 @@ class _MediaDetailScreenState extends State } static const Widget _sectionLoading = Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), + child: Padding(padding: .all(32), child: CircularProgressIndicator()), ); Widget _sectionEmpty(BuildContext context, String message) { @@ -2497,12 +2498,12 @@ class _MediaDetailScreenState extends State return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, + padding: .zero, itemCount: _episodes.length + (_isLoadingAllEpisodes ? 1 : 0), itemBuilder: (context, index) { if (index == _episodes.length) { return const Padding( - padding: EdgeInsets.all(24), + padding: .all(24), child: Center(child: CircularProgressIndicator()), ); } @@ -2510,7 +2511,7 @@ class _MediaDetailScreenState extends State String? localPosterPath; if (widget.isOffline && episode.serverId != null) { final artworkRef = context.read().getArtworkPaths(episode.globalKey); - localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, episode.serverId!); + localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, ServerId(episode.serverId!)); } return EpisodeCard( episode: episode, @@ -2602,7 +2603,7 @@ class _MediaDetailScreenState extends State }); return; } - final client = context.tryGetMediaClientForServer(serverId); + final client = context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) { setStateIfMounted(() { _isLoadingEpisodes = false; @@ -2619,7 +2620,7 @@ class _MediaDetailScreenState extends State try { final firstPage = await client.fetchPlayableDescendantsPage(_metadata.id, start: 0, size: _episodesPageSize); if (!mounted || generation != _episodesLoadGeneration) return; - final enriched = _enrichPlayableEpisodes(firstPage.items, serverId); + final enriched = _enrichPlayableEpisodes(firstPage.items, ServerId(serverId)); setStateIfMounted(() { _episodes = enriched; _isLoadingEpisodes = false; @@ -2627,7 +2628,9 @@ class _MediaDetailScreenState extends State _hasLoadedEpisodes = true; }); if (firstPage.items.length < firstPage.totalCount) { - unawaited(_fetchRemainingEpisodes(client, serverId, generation, firstPage.items.length, firstPage.totalCount)); + unawaited( + _fetchRemainingEpisodes(client, ServerId(serverId), generation, firstPage.items.length, firstPage.totalCount), + ); } } catch (e, st) { appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st); @@ -2639,7 +2642,7 @@ class _MediaDetailScreenState extends State } } - List _enrichPlayableEpisodes(List episodes, String serverId) { + List _enrichPlayableEpisodes(List episodes, ServerId serverId) { // Enrich each episode with serverId/serverName/grandparent fields — // Jellyfin's recursive query doesn't always populate them, and the copy is // a no-op for Plex where the mapper already does. @@ -2665,7 +2668,7 @@ class _MediaDetailScreenState extends State Future _fetchRemainingEpisodes( MediaServerClient client, - String serverId, + ServerId serverId, int generation, int startOffset, int totalCount, @@ -2677,7 +2680,7 @@ class _MediaDetailScreenState extends State final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); if (!mounted || generation != _episodesLoadGeneration) return; if (page.items.isEmpty) break; - final enriched = _enrichPlayableEpisodes(page.items, serverId); + final enriched = _enrichPlayableEpisodes(page.items, ServerId(serverId)); setStateIfMounted(() { _episodes.addAll(enriched); }); @@ -2810,10 +2813,7 @@ class _MediaDetailScreenState extends State final isMobile = PlatformDetector.isMobile(context); final isTv = PlatformDetector.isTV(); final theme = Theme.of(context); - final sectionTitleStyle = theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - fontSize: isTv ? 28 : null, - ); + final sectionTitleStyle = theme.textTheme.titleLarge?.copyWith(fontWeight: .bold, fontSize: isTv ? 28 : null); // Show loading state while fetching full metadata if (_isLoadingMetadata) { @@ -2863,12 +2863,12 @@ class _MediaDetailScreenState extends State // Main content SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.symmetric( + padding: .symmetric( horizontal: isTv ? TvLayoutConstants.horizontalInset : 16, vertical: isTv ? 8 : 16, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ // Summary if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ @@ -2985,7 +2985,7 @@ class _MediaDetailScreenState extends State onKeyEvent: _handleInfoRowsKeyEvent, child: Column( key: _infoRowsSectionKey, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ if (metadata.studio != null) ...[ _buildInfoRow(t.discover.studio, metadata.studio!), @@ -3002,7 +3002,7 @@ class _MediaDetailScreenState extends State ), ), ), - SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom)), + SliverPadding(padding: .only(bottom: MediaQuery.paddingOf(context).bottom)), ], ), // Sticky top bar with fading background @@ -3220,12 +3220,12 @@ class _MediaDetailScreenState extends State child: SizedBox( height: availableHeight, child: Align( - alignment: Alignment.bottomLeft, + alignment: .bottomLeft, child: SizedBox( height: contentHeight <= availableHeight ? contentHeight : availableHeight, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ if (showLogo) ...[ _buildDetailLogoOrTitle( @@ -3237,7 +3237,7 @@ class _MediaDetailScreenState extends State context, title, fontSize: 56 * scale, - fontWeight: FontWeight.w800, + fontWeight: .w800, shadowBlur: 12, color: foregroundColor, shadowColor: _tvDetailTitleShadowColor(context), @@ -3247,10 +3247,7 @@ class _MediaDetailScreenState extends State ], SizedBox( height: metadataLineHeight, - child: Align( - alignment: Alignment.centerLeft, - child: _buildTvDetailMetadataLine(context, metadata, scale), - ), + child: Align(alignment: .centerLeft, child: _buildTvDetailMetadataLine(context, metadata, scale)), ), if (hasDescription && summaryMaxLines > 0) ...[ SizedBox(height: summaryGap), @@ -3259,7 +3256,7 @@ class _MediaDetailScreenState extends State child: Text( description, maxLines: summaryMaxLines, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: theme.textTheme.bodyLarge?.copyWith( color: mutedForegroundColor, fontSize: summaryFontSize, @@ -3309,7 +3306,7 @@ class _MediaDetailScreenState extends State context, artworkPaths: [metadata.clearLogoPath], fit: BoxFit.contain, - alignment: Alignment.centerLeft, + alignment: .centerLeft, imageType: ImageType.logo, errorWidget: (context, url, error) => titleFallback(context), ); @@ -3334,7 +3331,7 @@ class _MediaDetailScreenState extends State cacheManager: PlexImageCacheManager.instance, filterQuality: FilterQuality.medium, fit: BoxFit.contain, - alignment: Alignment.centerLeft, + alignment: .centerLeft, memCacheWidth: (width * dpr).clamp(200, 1000).round(), placeholder: (context, url) => const SizedBox.shrink(), errorBuilder: (context, error, stackTrace) => titleFallback(context), @@ -3365,11 +3362,11 @@ class _MediaDetailScreenState extends State return Text( parts.join(' • '), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: TextStyle( color: _tvDetailForegroundColor(context), fontSize: 18 * scale, - fontWeight: FontWeight.w700, + fontWeight: .w700, letterSpacing: 0.1, ), ); @@ -3779,14 +3776,14 @@ class _MediaDetailScreenState extends State child: SizedBox( height: availableHeight, child: Align( - alignment: Alignment.bottomLeft, + alignment: .bottomLeft, child: SizedBox( height: contentHeight.clamp(0.0, availableHeight).toDouble(), child: Align( - alignment: Alignment.bottomLeft, + alignment: .bottomLeft, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ if (showLogo) ...[ _buildDetailLogoOrTitle( @@ -3798,7 +3795,7 @@ class _MediaDetailScreenState extends State context, title, fontSize: titleFontSize, - fontWeight: FontWeight.bold, + fontWeight: .bold, shadowBlur: 8, ), ), @@ -3809,7 +3806,7 @@ class _MediaDetailScreenState extends State child: ConstrainedBox( constraints: BoxConstraints(maxHeight: chipHeight), child: Align( - alignment: Alignment.bottomLeft, + alignment: .bottomLeft, heightFactor: 1, child: Wrap(spacing: 8, runSpacing: 8, children: chips), ), @@ -3879,7 +3876,7 @@ class _MediaDetailScreenState extends State final containerHeight = imageSize + innerPadding * 2 + 58 + 10; final theme = Theme.of(context); - final actorNameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600); + final actorNameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600); final actorRoleStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant); return Focus( @@ -3916,7 +3913,7 @@ class _MediaDetailScreenState extends State child: SizedBox( width: cardWidth, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), @@ -3933,22 +3930,12 @@ class _MediaDetailScreenState extends State const SizedBox(height: 8), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ - Text( - actor.tag, - style: actorNameStyle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + Text(actor.tag, style: actorNameStyle, maxLines: 2, overflow: .ellipsis), if (actor.role != null) ...[ const SizedBox(height: 2), - Text( - actor.role!, - style: actorRoleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Text(actor.role!, style: actorRoleStyle, maxLines: 1, overflow: .ellipsis), ], ], ), @@ -4031,13 +4018,13 @@ class _MediaDetailScreenState extends State Widget _buildInfoRow(String label, String value) { final theme = Theme.of(context); return Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox( width: 120, child: Text( label, - style: TextStyle(fontWeight: FontWeight.w600, color: theme.colorScheme.onSurfaceVariant), + style: TextStyle(fontWeight: .w600, color: theme.colorScheme.onSurfaceVariant), ), ), Expanded(child: Text(value, style: theme.textTheme.bodyLarge)), diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 4a5eff21..60ef88f6 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -1,4 +1,5 @@ import 'package:file_picker/file_picker.dart'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -46,7 +47,7 @@ class _MetadataEditScreenState extends State { Future _loadMetadata() async { try { - final client = context.getMediaClientWithFallback(widget.metadata.serverId); + final client = context.getMediaClientWithFallback(serverIdOrNull(widget.metadata.serverId)); final adapter = metadataEditAdapterFor(client); if (adapter == null || !adapter.supportsKind(widget.metadata.kind)) { if (!mounted) return; @@ -273,7 +274,7 @@ class _MetadataEditScreenState extends State { title: Text(t.metadataEdit.screenTitle), actions: [ if (_isSaving) - const Padding(padding: EdgeInsets.all(12), child: LoadingIndicatorBox(size: 24)) + const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24)) else IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)), ], @@ -293,14 +294,11 @@ class _MetadataEditScreenState extends State { Widget _buildSectionCard(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditSection section) { return Card( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Padding( padding: const EdgeInsets.all(16), - child: Text( - section.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), + child: Text(section.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold)), ), for (final field in section.fields) _buildField(adapter, draft, field), ], @@ -347,7 +345,7 @@ class _MetadataEditScreenState extends State { subtitle: Text( displayValue, maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: isNotSet ? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)) : null, @@ -491,7 +489,7 @@ class _ArtworkPickerDialogState extends State { child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), ), actions: [ - if (_isApplying) const Padding(padding: EdgeInsets.all(8), child: LoadingIndicatorBox(size: 24)), + if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)), FocusableButton( onPressed: _addFromUrl, child: TextButton.icon( diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 5c689937..afd2fc6c 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -122,7 +123,11 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen().syncRuleKeyForClient(mediaClient, widget.playlist.id, serverId: serverId); + return context.read().syncRuleKeyForClient( + mediaClient, + widget.playlist.id, + serverId: ServerId(serverId), + ); } Future _managePlaylistSyncRule() => @@ -701,12 +706,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen with ContextMenuTap color: Colors.transparent, height: 90, padding: const EdgeInsets.only(right: 4), - alignment: Alignment.center, + alignment: .center, child: Container( padding: const EdgeInsets.fromLTRB(2, 8, 6, 8), decoration: isDragHandleFocused @@ -140,15 +141,15 @@ class _PlaylistItemCardState extends State with ContextMenuTap // Title and metadata Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ // Title Text( item.displayTitle, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + style: const TextStyle(fontSize: 15, fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), const SizedBox(height: 4), @@ -158,7 +159,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap _buildSubtitle(item), style: TextStyle(fontSize: 13, color: Colors.grey[400]), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), // Progress indicator if partially watched @@ -216,7 +217,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap child: OptimizedMediaImage.poster( // Backend-neutral lookup so Jellyfin items render via their own // image transcoder; null falls through to the placeholder below. - client: context.tryGetMediaClientWithFallback(item.serverId), + client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), imagePath: posterUrl, width: 60, height: 90, diff --git a/lib/screens/plex_match_screen.dart b/lib/screens/plex_match_screen.dart index 3ee07a61..745525f7 100644 --- a/lib/screens/plex_match_screen.dart +++ b/lib/screens/plex_match_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -60,7 +61,7 @@ class _PlexMatchScreenState extends State with ControllerDispos @override void initState() { super.initState(); - _client = context.getPlexClientWithFallback(widget.metadata.serverId); + _client = context.getPlexClientWithFallback(serverIdOrNull(widget.metadata.serverId)); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (InputModeTracker.isKeyboardMode(context)) { @@ -143,7 +144,7 @@ class _PlexMatchScreenState extends State with ControllerDispos ) else SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 24), + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 24), sliver: SliverList.builder( itemCount: _results!.length, itemBuilder: (context, index) => _buildResultTile(_results![index]), @@ -155,7 +156,7 @@ class _PlexMatchScreenState extends State with ControllerDispos Widget _buildSearchForm(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ Row( children: [ @@ -231,9 +232,9 @@ class _PlexMatchScreenState extends State with ControllerDispos ), ), ), - title: Text(titleText, style: const TextStyle(fontWeight: FontWeight.w600)), + title: Text(titleText, style: const TextStyle(fontWeight: .w600)), subtitle: result.summary != null && result.summary!.isNotEmpty - ? Text(result.summary!, maxLines: 2, overflow: TextOverflow.ellipsis) + ? Text(result.summary!, maxLines: 2, overflow: .ellipsis) : null, trailing: isApplyingThis ? const LoadingIndicatorBox(size: 24) @@ -257,7 +258,7 @@ class _ScoreChip extends StatelessWidget { decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(100)), child: Text( '$score', - style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600), + style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: .w600), ), ); } diff --git a/lib/screens/profile/borrow_connection_screen.dart b/lib/screens/profile/borrow_connection_screen.dart index 796a4d4b..3776b447 100644 --- a/lib/screens/profile/borrow_connection_screen.dart +++ b/lib/screens/profile/borrow_connection_screen.dart @@ -73,7 +73,7 @@ class _BorrowConnectionScreenState extends State { profileRegistry.list(), StorageService.getInstance(), ]); - final allPcs = results[0] as List; + final allPcs = results.first as List; final allConns = results[1] as List; final localProfiles = results[2] as List; final storage = results[3] as StorageService; @@ -377,18 +377,18 @@ class _BorrowTile extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + padding: const EdgeInsets.all(12), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ BackendBadge(backend: candidate.connection.backend, size: 28), const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ - Text(candidate.connectionLabel, style: theme.textTheme.titleMedium, overflow: TextOverflow.ellipsis), + Text(candidate.connectionLabel, style: theme.textTheme.titleMedium, overflow: .ellipsis), const SizedBox(height: 2), Row( children: [ @@ -413,7 +413,7 @@ class _BorrowTile extends StatelessWidget { ], ), ), - const Padding(padding: EdgeInsets.only(left: 8, top: 4), child: AppIcon(Symbols.add_rounded, fill: 1)), + const Padding(padding: .only(left: 8, top: 4), child: AppIcon(Symbols.add_rounded, fill: 1)), ], ), ), diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 2be67791..5469a898 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -92,8 +92,8 @@ class _PinEntryDialogState extends State with SingleTickerProvid return AlertDialog( title: _buildTitle(theme), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ _TvPinInput( key: _pinInputKey, @@ -134,8 +134,8 @@ class _PinEntryDialogState extends State with SingleTickerProvid borderRadius: BorderRadius.circular(28), ), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ _buildTitle(theme), const SizedBox(height: 12), @@ -161,7 +161,7 @@ class _PinEntryDialogState extends State with SingleTickerProvid children: [ AppIcon(Symbols.lock_outline_rounded, fill: 1, size: 24, color: theme.colorScheme.primary), const SizedBox(width: 12), - Expanded(child: Text(widget.userName, overflow: TextOverflow.ellipsis)), + Expanded(child: Text(widget.userName, overflow: .ellipsis)), ], ); } @@ -479,20 +479,20 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { Widget _buildKeypadLayout(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [_buildDigitRow(context, obscureDigits: true), const SizedBox(height: 18), _buildKeypad(context)], ); } Widget _buildKeypad(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ for (int row = 0; row < _rows.length; row++) ...[ if (row > 0) const SizedBox(height: _rowGap), Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: .center, + mainAxisSize: .min, children: [ for (int col = 0; col < _keypadColumns; col++) ...[ if (col > 0) const SizedBox(width: _keyGap), @@ -524,7 +524,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { duration: const Duration(milliseconds: 120), width: _keySize, height: _keySize, - alignment: Alignment.center, + alignment: .center, decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -544,15 +544,15 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { child: Text( key.label, maxLines: 1, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foreground, fontWeight: FontWeight.w800), + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foreground, fontWeight: .w800), ), ); } Widget _buildDigitRow(BuildContext _, {bool obscureDigits = false}) { return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: .min, + mainAxisAlignment: .center, children: [ for (int i = 0; i < 4; i++) ...[ if (i > 0) const SizedBox(width: 10), @@ -571,7 +571,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { behavior: HitTestBehavior.opaque, onTap: _requestMobileKeyboardFocus, child: Stack( - alignment: Alignment.center, + alignment: .center, children: [ SizedBox( width: 222, @@ -591,11 +591,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { showCursor: false, cursorColor: Colors.transparent, style: const TextStyle(color: Colors.transparent, fontSize: 1, height: 1), - decoration: const InputDecoration( - counterText: '', - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), + decoration: const InputDecoration(counterText: '', border: InputBorder.none, contentPadding: .zero), inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4)], onChanged: _onMobilePinChanged, onSubmitted: (_) => _trySubmit(), @@ -622,13 +618,13 @@ class _DigitBox extends StatelessWidget { final focusColor = FocusTheme.getFocusBorderColor(context); return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AnimatedContainer( duration: const Duration(milliseconds: 150), width: 48, height: 56, - alignment: Alignment.center, + alignment: .center, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)), border: Border.fromBorderSide( @@ -642,7 +638,7 @@ class _DigitBox extends StatelessWidget { child: Text( digit != null ? (obscureDigit || !isActive ? '•' : digit.toString()) : '–', style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + fontWeight: .bold, color: digit != null ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), diff --git a/lib/screens/profile/pin_status_row.dart b/lib/screens/profile/pin_status_row.dart index 07de3387..4a47212c 100644 --- a/lib/screens/profile/pin_status_row.dart +++ b/lib/screens/profile/pin_status_row.dart @@ -22,7 +22,7 @@ class PinStatusRow extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration(color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8)), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(Symbols.lock_rounded, fill: 1, color: theme.colorScheme.onPrimaryContainer, size: 18), const SizedBox(width: 6), diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index a6a2891c..cd624698 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -279,7 +279,7 @@ class _ConnectionsList extends StatelessWidget { final pcs = snapshot.data ?? const []; if (snapshot.connectionState == ConnectionState.waiting) { return const Padding( - padding: EdgeInsets.symmetric(vertical: 20), + padding: .symmetric(vertical: 20), child: Center(child: CircularProgressIndicator()), ); } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index bce2dfd4..400c7ec7 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -413,24 +413,20 @@ class _ProfileTile extends StatelessWidget { onTap: isActive ? null : onTap, borderRadius: BorderRadius.circular(12), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + padding: const EdgeInsets.all(12), child: Row( children: [ ProfileAvatar(profile: profile, size: 44), const SizedBox(width: 14), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Row( children: [ Flexible( - child: Text( - profile.displayName, - style: theme.textTheme.titleMedium, - overflow: TextOverflow.ellipsis, - ), + child: Text(profile.displayName, style: theme.textTheme.titleMedium, overflow: .ellipsis), ), if (isActive) ...[ const SizedBox(width: 8), @@ -466,7 +462,7 @@ class _ProfileTile extends StatelessWidget { ], ) else if (!isActive) - const Padding(padding: EdgeInsets.only(left: 8), child: AppIcon(Symbols.chevron_right_rounded, fill: 1)), + const Padding(padding: .only(left: 8), child: AppIcon(Symbols.chevron_right_rounded, fill: 1)), ], ), ), @@ -555,7 +551,7 @@ class _ConnectionChips extends StatelessWidget { borderRadius: BorderRadius.circular(6), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ BackendBadge(backend: c.backend, size: 12), const SizedBox(width: 4), diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 04574bef..09e9b3a4 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -33,10 +33,7 @@ class AboutScreen extends StatelessWidget { const SizedBox(height: 24), Image.asset('assets/plezy.png', width: 80, height: 80), const SizedBox(height: 16), - Text( - appName, - style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), - ), + Text(appName, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold)), const SizedBox(height: 8), Text( t.about.versionLabel(version: appVersion), diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart index 660f819d..71b98767 100644 --- a/lib/screens/settings/add_connection_screen.dart +++ b/lib/screens/settings/add_connection_screen.dart @@ -55,7 +55,7 @@ class AddConnectionScreen extends StatelessWidget { ), slivers: [ SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ Text( @@ -141,7 +141,7 @@ class _BackendCard extends StatelessWidget { const SizedBox(width: 16), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(title, style: theme.textTheme.titleMedium), const SizedBox(height: 4), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 1ec4a613..a2951c02 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -442,11 +442,11 @@ class _AddJellyfinScreenState extends State with AsyncFormSta title: Text(t.addServer.addJellyfinTitle), slivers: [ SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + padding: const EdgeInsets.all(16), sliver: SliverToBoxAdapter( child: Form( key: _formKey, - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildBodyChildren(theme)), + child: Column(crossAxisAlignment: .stretch, children: _buildBodyChildren(theme)), ), ), ), @@ -587,7 +587,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(_serverInfo!.serverName, style: theme.textTheme.titleSmall), Text( @@ -695,7 +695,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta textAlign: TextAlign.center, style: theme.textTheme.displayMedium?.copyWith( fontFamily: 'monospace', - fontWeight: FontWeight.bold, + fontWeight: .bold, letterSpacing: 8, ), ), @@ -706,7 +706,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta Text(t.auth.quickConnectInstructions, style: theme.textTheme.bodyMedium), const SizedBox(height: 20), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ const LoadingIndicatorBox(), const SizedBox(width: 12), @@ -769,15 +769,15 @@ class _DiscoveredJellyfinServerTile extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text(server.name, style: theme.textTheme.titleSmall), const SizedBox(height: 2), Text( server.address, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index e6b03ea9..cf52d29f 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -163,18 +163,18 @@ class _AddPlexAccountScreenState extends State with AsyncF title: Text(t.addServer.addPlexTitle), slivers: [ SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + padding: const EdgeInsets.all(24), sliver: SliverToBoxAdapter( child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium), const SizedBox(height: 24), PlexPinAuthFlow( onTokenReceived: _onTokenReceived, initialButtonsBuilder: (context, browser, qr, busy) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ FocusableButton( useBackgroundFocus: true, diff --git a/lib/screens/settings/connection_persistence.dart b/lib/screens/settings/connection_persistence.dart index 01cba3d9..c2cce4c6 100644 --- a/lib/screens/settings/connection_persistence.dart +++ b/lib/screens/settings/connection_persistence.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../media/ids.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; @@ -46,7 +47,7 @@ Future persistAndBindConnection({ final mp = context.read(); if (visibleServerId != null) { - mp.addToVisibleServerIds(visibleServerId); + mp.addToVisibleServerIds(ServerId(visibleServerId)); } unawaited(context.read().loadLibraries()); return true; diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 66651776..e1615e1e 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -84,12 +84,12 @@ class _EditJellyfinConnectionScreenState extends State { content: SizedBox( width: 300, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ FocusableTextField( controller: _nameController, diff --git a/lib/screens/settings/hotkey_recorder_widget.dart b/lib/screens/settings/hotkey_recorder_widget.dart index 1a7dab95..1958a34c 100644 --- a/lib/screens/settings/hotkey_recorder_widget.dart +++ b/lib/screens/settings/hotkey_recorder_widget.dart @@ -40,13 +40,10 @@ class _HotKeyRecorderWidgetState extends State { width: double.maxFinite, child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ - Text( - 'Current shortcut:', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), - ), + Text('Current shortcut:', style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .bold)), const SizedBox(height: 6), Container( width: double.infinity, @@ -75,7 +72,7 @@ class _HotKeyRecorderWidgetState extends State { _recordedHotKey = null; }); }, - padding: EdgeInsets.zero, + padding: .zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), tooltip: t.hotkeys.clearShortcut, ), diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index 1026a6d4..ded15a96 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -28,9 +28,9 @@ class KeyboardShortcutsScreen extends StatelessWidget { slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: const EdgeInsets.only(left: 16, top: 16, right: 16), child: Align( - alignment: Alignment.centerRight, + alignment: .centerRight, child: FocusableButton( onPressed: () => _resetShortcuts(context), child: TextButton(onPressed: () => _resetShortcuts(context), child: Text(t.common.reset)), diff --git a/lib/screens/settings/licenses_screen.dart b/lib/screens/settings/licenses_screen.dart index 5f06f9d5..c092c874 100644 --- a/lib/screens/settings/licenses_screen.dart +++ b/lib/screens/settings/licenses_screen.dart @@ -71,7 +71,7 @@ class LicensesScreen extends StatelessWidget { child: ListTile( title: Text( packageName, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold), ), subtitle: mergedLicense.licenseEntries.length > 1 ? Text(t.licenses.licensesCount(count: mergedLicense.licenseEntries.length)) @@ -120,11 +120,11 @@ class _LicenseDetailScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( t.licenses.relatedPackages, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold), ), const SizedBox(height: 8), Text(mergedLicense.allPackageNames.join(', '), style: Theme.of(context).textTheme.bodyMedium), @@ -146,11 +146,11 @@ class _LicenseDetailScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( isMultipleLicenses ? t.licenses.licenseNumber(number: index + 1) : t.licenses.license, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold), ), const SizedBox(height: 16), ...license.paragraphs.map((paragraph) { diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index a8fe719a..2ea5195f 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -159,7 +159,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { Text('${t.messages.logId}: '), SelectableText( id, - style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 18), + style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18), ), const SizedBox(width: 8), IconButton( @@ -243,7 +243,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { spans.add( TextSpan( text: '[${log.level.name.toUpperCase()}] ', - style: TextStyle(color: color, fontWeight: FontWeight.bold), + style: TextStyle(color: color, fontWeight: .bold), ), ); spans.add(TextSpan(text: log.message)); diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index b0cb4f93..67752c3a 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -194,13 +194,13 @@ class _MpvConfigScreenState extends State with SettingsEffectMi pref: SettingsService.mpvPresets, builder: (context, presets, _) => Card( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( t.mpvConfig.presets, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold), ), ), ListTile( diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 15a87c0d..c09223c8 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -255,7 +255,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun : t.connections.addConnectionSubtitleScoped(displayName: active.displayName); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.connections.sectionTitle), // Connections are managed per-profile (via the Profiles section @@ -304,7 +304,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun final isCustom = storageService.isUsingCustomPath(); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.settings.downloads), if (!Platform.isIOS) @@ -316,7 +316,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun focusNode: _focusTracker.get(_kDownloadLocation), leading: const AppIcon(Symbols.folder_rounded, fill: 1), title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault), - subtitle: Text(currentPath, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text(currentPath, maxLines: 2, overflow: .ellipsis), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showDownloadLocationDialog(), ); @@ -344,7 +344,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun if (_keyboardService == null) return const SizedBox.shrink(); return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.settings.keyboardShortcuts), SettingNavigationTile( @@ -372,7 +372,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun Widget _buildAdvancedSection() { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.settings.advanced), ListTile( @@ -448,7 +448,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun Widget _buildBackupSection() { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.settings.backup), ListTile( @@ -482,7 +482,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun Widget _buildUpdateSection() { if (UpdateService.useNativeUpdater) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.settings.updates), ListTile( @@ -500,7 +500,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SettingsSectionHeader(t.settings.updates), ListTile( @@ -539,8 +539,8 @@ class _SettingsScreenState extends State with FocusableTab, Moun builder: (dialogContext) => AlertDialog( title: Text(t.settings.downloads), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text(t.settings.downloadLocationDescription), const SizedBox(height: 16), diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 3dba2370..d4eb6591 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -112,7 +112,7 @@ Future showSelectionDialog({ contentPadding: const EdgeInsets.only(top: 12, bottom: 24), content: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: options.map((option) { final selected = option.value == currentValue; return FocusableListTile( @@ -189,7 +189,7 @@ void _showNumericInputDialogTV({ title: title, contentBuilder: (dialogContext, context, setDialogState, saveFocusNode) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ TvNumberSpinner( value: spinnerValue, diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index c4cf0b8a..fdda77b4 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -82,7 +82,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const AppIcon(Symbols.error_rounded, color: Colors.white70, size: 44, fill: 1), const SizedBox(height: 16), @@ -93,7 +93,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { ), const SizedBox(height: 24), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ FocusableButton( autofocus: true, diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index 9517e3d3..f254007f 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -21,7 +21,7 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { if (_currentMetadata.backend != MediaBackend.plex) return; try { - final client = context.getPlexClientForServer(_currentMetadata.serverId!); + final client = context.getPlexClientForServer(ServerId(_currentMetadata.serverId!)); final playbackState = context.read(); diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 59efa8b3..9844ff66 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -254,7 +254,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { if (serverInfo == null) return; - final genericClient = multiServer.getClientForServer(serverInfo.serverId); + final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); if (resolution != null) { // Jellyfin: pre-resolved negotiated URL. @@ -283,7 +283,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { } // Plex-only: DVR tune flow (Jellyfin Live TV uses pre-resolved URLs). - final client = multiServer.getPlexClientForServer(serverInfo.serverId); + final client = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId)); if (client == null) return; final tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key); diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index c1b4d30d..34f01391 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -422,7 +422,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // BIF (Plex) or trickplay sprite sheets (Jellyfin) and returns null // when the inputs aren't sufficient. Guard against media-change // races during the async load. - final mediaClient = context.tryGetMediaClientForServer(_currentMetadata.serverId); + final mediaClient = context.tryGetMediaClientForServer(serverIdOrNull(_currentMetadata.serverId)); final mediaInfoAtStart = _currentMediaInfo; if (mediaInfoAtStart != null && !_isOfflinePlayback && mediaClient != null) { unawaited( diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index c72254ae..5d5abead 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -53,7 +53,7 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { if (watchTogether.isHost && watchTogether.isInSession) { watchTogether.setCurrentMedia( ratingKey: targetMetadata.id, - serverId: targetMetadata.serverId!, + serverId: ServerId(targetMetadata.serverId!), mediaTitle: targetMetadata.displayTitle, ); } @@ -77,7 +77,7 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { /// Handle media switch from host (guest only) /// Uses VideoPlayerScreen's context for proper navigation (pushReplacement) - Future _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async { + Future _handlePlayerMediaSwitch(String ratingKey, ServerId serverId, String title) async { if (!mounted) return; appLogger.d('WatchTogether: Guest handling media switch to $title'); diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index beef5463..134a4795 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -27,7 +27,7 @@ class VideoPlayerMacPipPlaceholder extends StatelessWidget { color: Colors.black, child: Center( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Icon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)), const SizedBox(height: 12), @@ -115,7 +115,7 @@ class VideoPlayerWatchTogetherOverlays extends StatelessWidget { borderRadius: BorderRadius.all(Radius.circular(20)), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (PlatformDetector.isTV()) const Icon(Symbols.sync_rounded, size: 14, color: Colors.white) @@ -210,8 +210,8 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(12)), ), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ _PlayNextEpisodeHeader(episode: episode), const SizedBox(height: 12), @@ -253,7 +253,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 12), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ if (autoPlayCountdown > 0) ...[ Text('$autoPlayCountdown'), @@ -290,7 +290,7 @@ class _PlayNextEpisodeHeader extends StatelessWidget { children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Consumer( builder: (context, playbackState, child) { @@ -299,11 +299,7 @@ class _PlayNextEpisodeHeader extends StatelessWidget { children: [ Text( 'Next Episode', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontWeight: FontWeight.w500, - ), + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), ), if (isShuffleActive) ...[ const SizedBox(width: 4), @@ -317,16 +313,16 @@ class _PlayNextEpisodeHeader extends StatelessWidget { if (episode.parentIndex != null && episode.index != null) Text( 'S${episode.parentIndex} E${episode.index} · ${episode.title}', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ) else Text( episode.title!, - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ), @@ -380,21 +376,17 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(12)), ), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text( t.videoControls.stillWatching, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontWeight: FontWeight.w500, - ), + 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: FontWeight.w600), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), ), const SizedBox(height: 12), Row( @@ -435,7 +427,7 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 12), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ Text('$countdown'), const SizedBox(width: 4), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0f779bab..83640d16 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io'; import 'dart:math'; @@ -422,15 +423,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin MediaServerClient? _getMediaServerClient(BuildContext context) { final id = _currentMetadata.serverId; if (id == null) return null; - return context.read().serverManager.getClient(id); + return context.read().serverManager.getClient(ServerId(id)); } MediaServerClient? _getOnlineMediaServerClient(BuildContext context) { final id = _currentMetadata.serverId; if (id == null) return null; final manager = context.read().serverManager; - if (!manager.isClientOnline(id)) return null; - return manager.getClient(id); + if (!manager.isClientOnline(ServerId(id))) return null; + return manager.getClient(ServerId(id)); } bool get _usesLocalPlaybackSource => _effectiveIsOffline; diff --git a/lib/services/api_cache.dart b/lib/services/api_cache.dart index 43b09028..e2565354 100644 --- a/lib/services/api_cache.dart +++ b/lib/services/api_cache.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'package:drift/drift.dart'; @@ -62,11 +63,11 @@ abstract class ApiCache { /// joins on adjacent tables). AppDatabase get database => _db; - String _buildKey(String serverId, String endpoint) { + String _buildKey(ServerId serverId, String endpoint) { return '$serverId:$endpoint'; } - Future?> get(String serverId, String endpoint) async { + Future?> get(ServerId serverId, String endpoint) async { final key = _buildKey(serverId, endpoint); final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull(); if (result != null) { @@ -75,7 +76,7 @@ abstract class ApiCache { return null; } - Future put(String serverId, String endpoint, Map data) async { + Future put(ServerId serverId, String endpoint, Map data) async { final key = _buildKey(serverId, endpoint); final encoded = await tryIsolateRun(() => jsonEncode(data)); await _db @@ -85,26 +86,26 @@ abstract class ApiCache { ); } - Future deleteForServer(String serverId) async { + Future deleteForServer(ServerId serverId) async { await (_db.delete(_db.apiCache)..where((t) => t.cacheKey.like('$serverId:%'))).go(); } /// Pin an endpoint's response so the row survives cache eviction. - Future pin(String serverId, String endpoint) async { + Future pin(ServerId serverId, String endpoint) async { final key = _buildKey(serverId, endpoint); await (_db.update( _db.apiCache, )..where((t) => t.cacheKey.equals(key))).write(const ApiCacheCompanion(pinned: Value(true))); } - Future unpin(String serverId, String endpoint) async { + Future unpin(ServerId serverId, String endpoint) async { final key = _buildKey(serverId, endpoint); await (_db.update( _db.apiCache, )..where((t) => t.cacheKey.equals(key))).write(const ApiCacheCompanion(pinned: Value(false))); } - Future isPinned(String serverId, String endpoint) async { + Future isPinned(ServerId serverId, String endpoint) async { final key = _buildKey(serverId, endpoint); final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull(); return result?.pinned ?? false; @@ -145,7 +146,7 @@ abstract class ApiCache { /// [keyPattern] from each `cacheKey`. Returns the unique set of captured /// ids — backend subclasses use this to enumerate their pinned items /// (Plex ratingKeys, Jellyfin item ids). - Future> extractPinnedIds(String serverId, RegExp keyPattern) async { + Future> extractPinnedIds(ServerId serverId, RegExp keyPattern) async { final rows = await (_db.select( _db.apiCache, )..where((t) => t.cacheKey.like('$serverId:%') & t.pinned.equals(true))).get(); @@ -162,29 +163,29 @@ abstract class ApiCache { /// from the prefix before the first colon. Backend subclasses use this to /// batch-load all pinned metadata into their own model type without /// re-implementing the row walker. - Future> listPinnedRowsByPattern(RegExp keyPattern) async { + Future> listPinnedRowsByPattern(RegExp keyPattern) async { final rows = await (_db.select(_db.apiCache)..where((t) => t.pinned.equals(true))).get(); - final out = <({String serverId, String id, String data})>[]; + final out = <({ServerId serverId, String id, String data})>[]; for (final row in rows) { final colon = row.cacheKey.indexOf(':'); if (colon < 0) continue; final match = keyPattern.firstMatch(row.cacheKey); if (match == null) continue; - out.add((serverId: row.cacheKey.substring(0, colon), id: match.group(1)!, data: row.data)); + out.add((serverId: ServerId(row.cacheKey.substring(0, colon)), id: match.group(1)!, data: row.data)); } return out; } /// Fetch and parse cached [MediaItem] for [itemId] on [serverId]. Returns /// `null` when the item isn't cached. - Future getMetadata(String serverId, String itemId); + Future getMetadata(ServerId serverId, String itemId); /// Pin the cached metadata row(s) for [itemId] so they survive cache /// eviction (used by the offline-download pipeline). - Future pinForOffline(String serverId, String itemId); + Future pinForOffline(ServerId serverId, String itemId); /// Delete cached metadata for [itemId] (used when removing a download). - Future deleteForItem(String serverId, String itemId); + Future deleteForItem(ServerId serverId, String itemId); /// Persist a watched/unwatched flip into the cached metadata JSON for /// [itemId] so reloads (`getMetadata` / `getAllPinnedMetadata`) reflect the @@ -207,7 +208,7 @@ abstract class ApiCache { /// The mutations are too short (~3 lines per backend) for a shared /// adapter to be a net win, so they live duplicated by design. Future applyWatchState({ - required String serverId, + required ServerId serverId, required String itemId, required bool isWatched, int? viewOffsetMs, @@ -216,7 +217,7 @@ abstract class ApiCache { }); /// Bulk-load every pinned metadata row into a [MediaItem] map keyed by - /// `buildGlobalKey(serverId, itemId)`. Used by [DownloadManagerService] on + /// `buildGlobalKey(ServerId(serverId), itemId)`. Used by [DownloadManagerService] on /// cold start to hydrate offline state in a single query per backend. Future> getAllPinnedMetadata(); } diff --git a/lib/services/cached_playback_metadata_service.dart b/lib/services/cached_playback_metadata_service.dart index 6715d803..47a9e7af 100644 --- a/lib/services/cached_playback_metadata_service.dart +++ b/lib/services/cached_playback_metadata_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'package:drift/drift.dart'; @@ -22,7 +23,7 @@ class CachedPlaybackMetadataService { }) async { try { return switch (backend) { - MediaBackend.plex => _fetchPlexMediaSourceInfo(cacheServerId, itemId, mediaIndex: mediaIndex), + MediaBackend.plex => _fetchPlexMediaSourceInfo(ServerId(cacheServerId), itemId, mediaIndex: mediaIndex), MediaBackend.jellyfin => _fetchJellyfinMediaSourceInfo(cacheServerId, itemId, mediaIndex: mediaIndex), }; } catch (e) { @@ -42,7 +43,7 @@ class CachedPlaybackMetadataService { try { return switch (backend) { MediaBackend.plex => _fetchPlexPlaybackExtras( - cacheServerId, + ServerId(cacheServerId), itemId, introPattern: introPattern, creditsPattern: creditsPattern, @@ -63,22 +64,22 @@ class CachedPlaybackMetadataService { } static Future _fetchPlexMediaSourceInfo( - String serverId, + ServerId serverId, String itemId, { required int mediaIndex, }) async { - final metadata = await _plexMetadata(serverId, itemId); + final metadata = await _plexMetadata(ServerId(serverId), itemId); return metadata == null ? null : plexMediaSourceInfoFromCacheJson(metadata, mediaIndex: mediaIndex); } static Future _fetchPlexPlaybackExtras( - String serverId, + ServerId serverId, String itemId, { String? introPattern, String? creditsPattern, bool forceChapterFallback = false, }) async { - final metadata = await _plexMetadata(serverId, itemId); + final metadata = await _plexMetadata(ServerId(serverId), itemId); if (metadata == null) return null; return plexPlaybackExtrasFromCacheJson( metadata, @@ -88,7 +89,7 @@ class CachedPlaybackMetadataService { ); } - static Future?> _plexMetadata(String serverId, String itemId) async { + static Future?> _plexMetadata(ServerId serverId, String itemId) async { final cached = await ApiCache.forBackend(MediaBackend.plex).get(serverId, '/library/metadata/$itemId'); return PlexCacheParser.extractFirstMetadata(cached); } @@ -129,7 +130,7 @@ class CachedPlaybackMetadataService { try { final raw = await ApiCache.forBackend( MediaBackend.jellyfin, - ).get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId)); + ).get(ServerId(cacheServerId), JellyfinApiCache.mediaSegmentsEndpoint(itemId)); return jellyfinMediaSegmentsToMarkers(raw); } catch (e) { appLogger.d('Cached Jellyfin media segments unavailable for $cacheServerId:$itemId', error: e); diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index bc71c821..564fb2be 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; @@ -78,7 +79,7 @@ class DataAggregationService { if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { filteredOnDeck = allOnDeck.where((item) { if (item.libraryId == null || item.serverId == null) return true; - final globalKey = buildGlobalKey(item.serverId!, item.libraryId!); + final globalKey = buildGlobalKey(ServerId(item.serverId!), item.libraryId!); return !hiddenLibraryKeys.contains(globalKey); }).toList(); } @@ -175,11 +176,11 @@ class DataAggregationService { final keys = {}; final serverId = item.serverId; final targetId = _continueWatchingIdentityTargetId(item); - final client = serverId == null ? null : _serverManager.getClient(serverId); + final client = serverId == null ? null : _serverManager.getClient(ServerId(serverId)); if (client != null && targetId != null && targetId.isNotEmpty) { try { - final cacheKey = buildGlobalKey(serverId!, targetId); + final cacheKey = buildGlobalKey(ServerId(serverId!), targetId); final externalIds = await externalIdLoads.putIfAbsent(cacheKey, () => client.fetchExternalIds(targetId)); _addExternalIdentityKeys(keys, scope, externalIds); } catch (e, stackTrace) { @@ -273,7 +274,7 @@ class DataAggregationService { includePlaybackHubs: includePlaybackHubs, libraries: useGlobalHubs ? serverLibraries : null, ); - return _postProcessHubs(hubs, serverId: serverId, hiddenLibraryKeys: hiddenLibraryKeys); + return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys); } catch (e, stackTrace) { appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); return []; @@ -334,7 +335,7 @@ class DataAggregationService { } /// Filter hidden-library items and drop empty hubs. - List _postProcessHubs(List hubs, {required String serverId, Set? hiddenLibraryKeys}) { + List _postProcessHubs(List hubs, {required ServerId serverId, Set? hiddenLibraryKeys}) { var filtered = hubs; if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { filtered = filtered @@ -342,7 +343,7 @@ class DataAggregationService { final filteredItems = hub.items.where((item) { final libraryId = item.libraryId; if (libraryId == null) return true; - final globalKey = buildGlobalKey(serverId, libraryId); + final globalKey = buildGlobalKey(ServerId(serverId), libraryId); return !hiddenLibraryKeys.contains(globalKey); }).toList(); if (filteredItems.isEmpty) return null; diff --git a/lib/services/download_artwork_service.dart b/lib/services/download_artwork_service.dart index 57f48ebb..e639cb49 100644 --- a/lib/services/download_artwork_service.dart +++ b/lib/services/download_artwork_service.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import '../media/ids.dart'; import '../media/download_resolution.dart'; import '../media/media_item.dart'; @@ -29,21 +30,21 @@ class DownloadArtworkService { static String normalizeKey(String pathOrUrl) => artworkStorageKey(pathOrUrl); - static String? localPathSync(DownloadStorageService storageService, String serverId, String? pathOrUrl) { + static String? localPathSync(DownloadStorageService storageService, ServerId serverId, String? pathOrUrl) { if (pathOrUrl == null || pathOrUrl.isEmpty) return null; return storageService.getArtworkPathSync(serverId, normalizeKey(pathOrUrl)); } - Future localPath(String serverId, String pathOrUrl) { + Future localPath(ServerId serverId, String pathOrUrl) { return storageService.getArtworkPathFromThumb(serverId, normalizeKey(pathOrUrl)); } - Future existsUsable(String serverId, String pathOrUrl) async { + Future existsUsable(ServerId serverId, String pathOrUrl) async { final file = File(await localPath(serverId, pathOrUrl)); return isUsableArtworkFile(file); } - Future hasMissingArtwork(String serverId, Iterable specs) async { + Future hasMissingArtwork(ServerId serverId, Iterable specs) async { for (final spec in specs) { if (!await existsUsable(serverId, spec.localKey)) return true; } @@ -53,10 +54,10 @@ class DownloadArtworkService { Future ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { final serverId = metadata.serverId; if (serverId == null) return; - await ensureArtworkSpecs(serverId, client.resolveDownloadArtwork(metadata)); + await ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata)); } - Future ensureArtworkSpecs(String serverId, Iterable specs) async { + Future ensureArtworkSpecs(ServerId serverId, Iterable specs) async { for (final spec in specs) { await downloadSingleArtwork(serverId, spec); } @@ -66,7 +67,7 @@ class DownloadArtworkService { /// /// The HTTP helper writes atomically. This method validates the final file so /// HTML/JSON error bodies do not poison future existence checks. - Future downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async { + Future downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async { if (spec.url.isEmpty) { appLogger.w('Empty artwork URL for: ${spec.localKey}'); return; @@ -90,7 +91,7 @@ class DownloadArtworkService { } } - Future _downloadSingleArtworkToPath(String serverId, DownloadArtworkSpec spec, String filePath) async { + Future _downloadSingleArtworkToPath(ServerId serverId, DownloadArtworkSpec spec, String filePath) async { try { if (await existsUsable(serverId, spec.localKey)) { appLogger.d('Artwork already exists: ${spec.localKey}'); diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 37efc03a..763ee534 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1,6 +1,7 @@ // ignore_for_file: prefer_initializing_formals import 'dart:async'; +import '../media/ids.dart'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -31,7 +32,7 @@ import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -typedef MediaClientResolver = MediaServerClient? Function(String serverId, {String? clientScopeId}); +typedef MediaClientResolver = MediaServerClient? Function(ServerId serverId, {String? clientScopeId}); typedef _NativeTaskForId = Future Function(String taskId); typedef _NativeResumeTask = Future Function(DownloadTask task); @@ -191,7 +192,7 @@ class DownloadManagerService { /// Look up the correct client for [serverId]. /// Returns null if the server is offline — callers should skip/defer the work. - MediaServerClient? _getClient(String? serverId, {String? clientScopeId}) { + MediaServerClient? _getClient(ServerId? serverId, {String? clientScopeId}) { if (serverId != null && _clientResolver != null) { return _clientResolver!(serverId, clientScopeId: clientScopeId); } @@ -205,7 +206,7 @@ class DownloadManagerService { return _getClient(parsed.serverId, clientScopeId: record?.clientScopeId); } - String? activeClientScopeIdForServer(String serverId) { + String? activeClientScopeIdForServer(ServerId serverId) { final client = _getClient(serverId); final scopeId = client?.cacheServerId; if (scopeId == null || scopeId == serverId || scopeId.isEmpty) return null; @@ -213,22 +214,22 @@ class DownloadManagerService { } /// Bulk-load every backend's pinned metadata into one map keyed by - /// `buildGlobalKey(serverId, itemId)`. Plex and Jellyfin entries never + /// `buildGlobalKey(ServerId(serverId), itemId)`. Plex and Jellyfin entries never /// collide because `serverId` is globally unique across backends. Future> getAllPinnedMetadata({bool preferActiveScope = false}) async { final results = await Future.wait(MediaBackend.values.map((b) => ApiCache.forBackend(b).getAllPinnedMetadata())); final merged = {for (final r in results) ...r}; for (final item in await _database.getAllDownloadedMetadata()) { - final client = _getClient(item.serverId, clientScopeId: item.clientScopeId); - final backend = client?.backend ?? await _backendForServer(item.serverId); + final client = _getClient(ServerId(item.serverId), clientScopeId: item.clientScopeId); + final backend = client?.backend ?? await _backendForServer(ServerId(item.serverId)); if (backend == null) continue; for (final scopeId in _metadataScopeCandidates( - item.serverId, + ServerId(item.serverId), downloadedClientScopeId: item.clientScopeId, preferActiveScope: preferActiveScope, )) { - final scoped = await ApiCache.forBackend(backend).getMetadata(scopeId, item.ratingKey); + final scoped = await ApiCache.forBackend(backend).getMetadata(ServerId(scopeId), item.ratingKey); if (scoped != null) { merged[item.globalKey] = scoped; break; @@ -241,8 +242,8 @@ class DownloadManagerService { /// Public mirror of [_lookupMetadata] for callers that hydrate offline /// state outside the manager (e.g. [DownloadProvider]). - Future lookupMetadata(String serverId, String itemId, {bool preferActiveScope = false}) async { - final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + Future lookupMetadata(ServerId serverId, String itemId, {bool preferActiveScope = false}) async { + final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); for (final scopeId in _metadataScopeCandidates( serverId, downloadedClientScopeId: download?.clientScopeId, @@ -255,14 +256,14 @@ class DownloadManagerService { } List _metadataScopeCandidates( - String serverId, { + ServerId serverId, { String? downloadedClientScopeId, required bool preferActiveScope, }) { final candidates = [ - if (preferActiveScope) ?activeClientScopeIdForServer(serverId), + if (preferActiveScope) ?activeClientScopeIdForServer(ServerId(serverId)), ?downloadedClientScopeId, - ?_getClient(serverId, clientScopeId: downloadedClientScopeId)?.cacheServerId, + ?_getClient(ServerId(serverId), clientScopeId: downloadedClientScopeId)?.cacheServerId, serverId, ]; return { @@ -281,8 +282,8 @@ class DownloadManagerService { /// data, schema reset, etc.) — without it, downloaded items render with /// no title and sync rules show their rating key instead of the show /// name. - Future fetchAndPinMetadata(String serverId, String itemId, {bool preferActiveScope = false}) async { - final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + Future fetchAndPinMetadata(ServerId serverId, String itemId, {bool preferActiveScope = false}) async { + final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); final clientScopeId = preferActiveScope ? activeClientScopeIdForServer(serverId) ?? download?.clientScopeId : download?.clientScopeId; @@ -291,7 +292,7 @@ class DownloadManagerService { try { final metadata = await client.fetchItem(itemId); if (metadata == null) return null; - await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, itemId); + await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), itemId); return metadata; } catch (e) { appLogger.d('fetchAndPinMetadata failed for $serverId:$itemId', error: e); @@ -309,7 +310,7 @@ class DownloadManagerService { /// (mirrors [JellyfinApiCache._serverContext]) so any `_` / `%` chars in /// [serverId] are treated literally; `LIKE '$serverId/%'` would interpret /// them as wildcards. - Future _backendForServer(String serverId) async { + Future _backendForServer(ServerId serverId) async { // Prefer a live client — `MediaServerClient.backend` is in memory. final live = _getClient(serverId); if (live != null) return live.backend; @@ -336,16 +337,18 @@ class DownloadManagerService { /// download rows still reference it), fan out to every registered backend /// cache instead of silently defaulting to Plex. Otherwise Jellyfin items /// would render with blank metadata after a connection is severed. - Future _lookupMetadata(String serverId, String itemId, {String? clientScopeId}) async { + Future _lookupMetadata(ServerId serverId, String itemId, {String? clientScopeId}) async { final backend = await _backendForServer(serverId); final live = _getClient(serverId, clientScopeId: clientScopeId); if (backend != null) { - return ApiCache.forBackend(backend).getMetadata(clientScopeId ?? live?.cacheServerId ?? serverId, itemId); + return ApiCache.forBackend( + backend, + ).getMetadata(ServerId(clientScopeId ?? live?.cacheServerId ?? serverId), itemId); } appLogger.w('Cache lookup for $serverId:$itemId — backend unresolved; trying all registered backends'); for (final candidate in MediaBackend.values) { if (clientScopeId != null && clientScopeId.isNotEmpty) { - final scopedHit = await ApiCache.forBackend(candidate).getMetadata(clientScopeId, itemId); + final scopedHit = await ApiCache.forBackend(candidate).getMetadata(ServerId(clientScopeId), itemId); if (scopedHit != null) return scopedHit; } final hit = await ApiCache.forBackend(candidate).getMetadata(serverId, itemId); @@ -377,14 +380,16 @@ class DownloadManagerService { appLogger.w('fetchItem failed during offline-pin for ${metadata.globalKey}', error: e); } } - await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, metadata.id); + await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id); } - Future _deleteForItemByServer(String serverId, String itemId, {String? clientScopeId}) async { + Future _deleteForItemByServer(ServerId serverId, String itemId, {String? clientScopeId}) async { final backend = await _backendForServer(serverId); final live = _getClient(serverId, clientScopeId: clientScopeId); if (backend != null) { - await ApiCache.forBackend(backend).deleteForItem(clientScopeId ?? live?.cacheServerId ?? serverId, itemId); + await ApiCache.forBackend( + backend, + ).deleteForItem(ServerId(clientScopeId ?? live?.cacheServerId ?? serverId), itemId); return; } // Backend unresolved — purge from every registered backend so a stale @@ -393,7 +398,7 @@ class DownloadManagerService { appLogger.w('Cache delete for $serverId:$itemId — backend unresolved; clearing all registered backends'); for (final candidate in MediaBackend.values) { if (clientScopeId != null && clientScopeId.isNotEmpty) { - await ApiCache.forBackend(candidate).deleteForItem(clientScopeId, itemId); + await ApiCache.forBackend(candidate).deleteForItem(ServerId(clientScopeId), itemId); } await ApiCache.forBackend(candidate).deleteForItem(serverId, itemId); } @@ -727,22 +732,22 @@ class DownloadManagerService { final client = await _getClientForDownloadKey(row.globalKey); if (client == null) continue; - final metadata = await _lookupMetadata(row.serverId, row.ratingKey, clientScopeId: row.clientScopeId); + final metadata = await _lookupMetadata(ServerId(row.serverId), row.ratingKey, clientScopeId: row.clientScopeId); if (metadata == null) continue; - final withServer = _repairMetadataWithServer(metadata, row.serverId); + final withServer = _repairMetadataWithServer(metadata, ServerId(row.serverId)); await _artworkService.ensureArtworkForMetadata(withServer, client); await _backfillArtworkPath(row, withServer); if (!withServer.isEpisode) continue; await _repairParentArtwork( - row.serverId, + ServerId(row.serverId), withServer.grandparentId, client, ensuredParentKeys, clientScopeId: row.clientScopeId, ); await _repairParentArtwork( - row.serverId, + ServerId(row.serverId), withServer.parentId, client, ensuredParentKeys, @@ -757,39 +762,39 @@ class DownloadManagerService { } Future _repairParentArtwork( - String serverId, + ServerId serverId, String? ratingKey, MediaServerClient client, Set ensuredKeys, { String? clientScopeId, }) async { if (ratingKey == null || ratingKey.isEmpty) return; - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); if (!ensuredKeys.add(globalKey)) return; - final cached = await _lookupMetadata(serverId, ratingKey, clientScopeId: clientScopeId); + final cached = await _lookupMetadata(ServerId(serverId), ratingKey, clientScopeId: clientScopeId); var metadata = cached; if (!_isOffline) { try { final fetched = await client.fetchItem(ratingKey); if (fetched != null) { metadata = _mergeFetchedRepairMetadata(serverId: serverId, cached: cached, fetched: fetched); - await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, metadata.id); + await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id); } } catch (e) { appLogger.d('Artwork repair parent metadata fetch failed for $globalKey', error: e); } } if (metadata == null) return; - final withServer = _repairMetadataWithServer(metadata, serverId); + final withServer = _repairMetadataWithServer(metadata, ServerId(serverId)); await _artworkService.ensureArtworkForMetadata(withServer, client); } - MediaItem _repairMetadataWithServer(MediaItem metadata, String serverId) { + MediaItem _repairMetadataWithServer(MediaItem metadata, ServerId serverId) { return metadata.serverId == null ? metadata.copyWith(serverId: serverId) : metadata; } MediaItem _mergeFetchedRepairMetadata({ - required String serverId, + required ServerId serverId, required MediaItem? cached, required MediaItem fetched, }) { @@ -860,7 +865,7 @@ class DownloadManagerService { } await _downloadArtwork(globalKey, metadata, itemClient); - await _downloadChapterThumbnails(metadata.serverId!, metadata.id, itemClient); + await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, itemClient); // Attempt subtitles try { @@ -1016,7 +1021,7 @@ class DownloadManagerService { } await _database.insertDownload( - serverId: metadata.serverId!, + serverId: ServerId(metadata.serverId!), clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId, ratingKey: metadata.id, globalKey: globalKey, @@ -1776,7 +1781,7 @@ class DownloadManagerService { if (metadata != null && client != null) { if (downloadArtwork) { await _downloadArtwork(globalKey, metadata, client); - await _downloadChapterThumbnails(metadata.serverId!, metadata.id, client); + await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); } if (downloadSubtitles) { var subtitles = ctx?.subtitles; @@ -1821,7 +1826,7 @@ class DownloadManagerService { } /// Look up the year of the parent show for an episode (used for folder naming). - Future _fetchShowYear(String serverId, String? grandparentRatingKey, {String? clientScopeId}) async { + Future _fetchShowYear(ServerId serverId, String? grandparentRatingKey, {String? clientScopeId}) async { if (grandparentRatingKey == null) return null; return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year; } @@ -1864,7 +1869,7 @@ class DownloadManagerService { Future _resolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) async { final serverId = metadata.serverId; if (!metadata.isEpisode || serverId == null) return null; - return _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: clientScopeId); + return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId); } Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { @@ -1875,7 +1880,7 @@ class DownloadManagerService { final serverId = metadata.serverId!; final specs = client.resolveDownloadArtwork(metadata); - await _artworkService.ensureArtworkSpecs(serverId, specs); + await _artworkService.ensureArtworkSpecs(ServerId(serverId), specs); final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!); await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath); @@ -1891,7 +1896,7 @@ class DownloadManagerService { /// Download a single artwork blob if not already on disk. The [spec] carries /// both the storage key (used to hash the local filename) and the absolute /// URL to fetch. - Future _downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async { + Future _downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async { await _artworkService.downloadSingleArtwork(serverId, spec); } @@ -1900,14 +1905,14 @@ class DownloadManagerService { Future downloadArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return; final serverId = metadata.serverId!; - await _artworkService.ensureArtworkSpecs(serverId, client.resolveDownloadArtwork(metadata)); + await _artworkService.ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata)); } /// Download chapter thumbnail images for a media item. Works for any /// backend whose [MediaServerClient.fetchPlaybackExtras] returns chapters /// with a `thumb` path — Plex's `/library/parts/X/indexes/sd/Y` and /// Jellyfin's `/Items/X/Images/Chapter/N?tag=Y` both pass through. - Future _downloadChapterThumbnails(String serverId, String ratingKey, MediaServerClient client) async { + Future _downloadChapterThumbnails(ServerId serverId, String ratingKey, MediaServerClient client) async { try { final extras = await client.fetchPlaybackExtras(ratingKey); @@ -1946,7 +1951,12 @@ class DownloadManagerService { // Get user-friendly subtitle path based on media type final String subtitlePath; if (_storageService.isUsingSaf) { - subtitlePath = await _storageService.getSubtitlePath(metadata.serverId!, metadata.id, subtitle.id, extension); + subtitlePath = await _storageService.getSubtitlePath( + ServerId(metadata.serverId!), + metadata.id, + subtitle.id, + extension, + ); } else if (metadata.isEpisode) { subtitlePath = await _storageService.getEpisodeSubtitlePath( metadata, @@ -1958,7 +1968,12 @@ class DownloadManagerService { subtitlePath = await _storageService.getMovieSubtitlePath(metadata, subtitle.id, extension); } else { // Fallback to old structure - subtitlePath = await _storageService.getSubtitlePath(metadata.serverId!, metadata.id, subtitle.id, extension); + subtitlePath = await _storageService.getSubtitlePath( + ServerId(metadata.serverId!), + metadata.id, + subtitle.id, + extension, + ); } // Download subtitle file @@ -2207,7 +2222,7 @@ class DownloadManagerService { } /// Calculate total items to delete (for progress tracking) - Future _getTotalItemsToDelete(MediaItem metadata, String serverId, {String? clientScopeId}) async { + Future _getTotalItemsToDelete(MediaItem metadata, ServerId serverId, {String? clientScopeId}) async { switch (metadata.kind) { case MediaKind.episode: case MediaKind.movie: @@ -2223,9 +2238,9 @@ class DownloadManagerService { } } - Future _deleteMediaFilesWithMetadata(String serverId, String ratingKey, {String? clientScopeId}) async { + Future _deleteMediaFilesWithMetadata(ServerId serverId, String ratingKey, {String? clientScopeId}) async { try { - final gk = buildGlobalKey(serverId, ratingKey); + final gk = buildGlobalKey(ServerId(serverId), ratingKey); final downloadRecord = await _database.getDownloadedMedia(gk); final scopeId = clientScopeId ?? downloadRecord?.clientScopeId; final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId); @@ -2279,7 +2294,7 @@ class DownloadManagerService { /// `fetchPlaybackExtras` consults each backend's cache first, so this /// stays cheap during deletion (no network round-trip when the metadata /// is already cached, which it always is for downloaded items). - Future> _getChapterThumbPaths(String serverId, String ratingKey, {String? clientScopeId}) async { + Future> _getChapterThumbPaths(ServerId serverId, String ratingKey, {String? clientScopeId}) async { try { final client = _getClient(serverId, clientScopeId: clientScopeId); if (client == null) return []; @@ -2300,9 +2315,9 @@ class DownloadManagerService { /// Pre-loads all chapter paths for other items on the same server in one pass, /// then checks membership in a Set — O(items * chapters) instead of /// O(thumbs * items * chapters) with repeated DB queries. - Future _deleteChapterThumbnails(String serverId, String ratingKey, {String? clientScopeId}) async { + Future _deleteChapterThumbnails(ServerId serverId, String ratingKey, {String? clientScopeId}) async { try { - final record = await _database.getDownloadedMedia(buildGlobalKey(serverId, ratingKey)); + final record = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), ratingKey)); final scopeId = clientScopeId ?? record?.clientScopeId; final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey, clientScopeId: scopeId); @@ -2351,7 +2366,7 @@ class DownloadManagerService { } } - Future _deleteEpisodeFiles(MediaItem episode, String serverId, {String? clientScopeId}) async { + Future _deleteEpisodeFiles(MediaItem episode, ServerId serverId, {String? clientScopeId}) async { try { final parentMetadata = episode.grandparentId != null ? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId) @@ -2387,7 +2402,7 @@ class DownloadManagerService { } } - Future _deleteSeasonFiles(MediaItem season, String serverId, {String? clientScopeId}) async { + Future _deleteSeasonFiles(MediaItem season, ServerId serverId, {String? clientScopeId}) async { try { final parentMetadata = season.parentId != null ? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId) @@ -2422,7 +2437,7 @@ class DownloadManagerService { /// and parent directories are wiped in one recursive call by the caller. Future _deleteEpisodesInCollection({ required List episodes, - required String serverId, + required ServerId serverId, String? clientScopeId, required String parentKey, required String parentTitle, @@ -2430,11 +2445,11 @@ class DownloadManagerService { final isSaf = _storageService.isUsingSaf; for (int i = 0; i < episodes.length; i++) { final episode = episodes[i]; - final episodeGlobalKey = buildGlobalKey(serverId, episode.ratingKey); + final episodeGlobalKey = buildGlobalKey(ServerId(serverId), episode.ratingKey); _emitDeletionProgress( DeletionProgress( - globalKey: buildGlobalKey(serverId, parentKey), + globalKey: buildGlobalKey(ServerId(serverId), parentKey), itemTitle: parentTitle, currentItem: i + 1, totalItems: episodes.length, @@ -2444,7 +2459,11 @@ class DownloadManagerService { if (isSaf) { final episodeScopeId = episode.clientScopeId ?? clientScopeId; - final episodeMetadata = await _lookupMetadata(serverId, episode.ratingKey, clientScopeId: episodeScopeId); + final episodeMetadata = await _lookupMetadata( + ServerId(serverId), + episode.ratingKey, + clientScopeId: episodeScopeId, + ); if (episodeMetadata != null) { await _deleteEpisodeFilesSaf( episodeMetadata, @@ -2453,24 +2472,28 @@ class DownloadManagerService { skipSafVideoAndParents: true, ); } else { - await _deleteChapterThumbnails(serverId, episode.ratingKey, clientScopeId: episodeScopeId); + await _deleteChapterThumbnails(ServerId(serverId), episode.ratingKey, clientScopeId: episodeScopeId); await _deleteByFilePath(episode); } } else { await _deleteChapterThumbnails( - serverId, + ServerId(serverId), episode.ratingKey, clientScopeId: episode.clientScopeId ?? clientScopeId, ); await _deleteByFilePath(episode); } - await _deleteForItemByServer(serverId, episode.ratingKey, clientScopeId: episode.clientScopeId ?? clientScopeId); + await _deleteForItemByServer( + ServerId(serverId), + episode.ratingKey, + clientScopeId: episode.clientScopeId ?? clientScopeId, + ); await _database.deleteDownload(episodeGlobalKey); } } - Future _deleteShowFiles(MediaItem show, String serverId, {String? clientScopeId}) async { + Future _deleteShowFiles(MediaItem show, ServerId serverId, {String? clientScopeId}) async { try { final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); @@ -2493,7 +2516,7 @@ class DownloadManagerService { } } - Future _deleteMovieFiles(MediaItem movie, String serverId, {String? clientScopeId}) async { + Future _deleteMovieFiles(MediaItem movie, ServerId serverId, {String? clientScopeId}) async { try { final movieDir = await _storageService.getMovieDirectory(movie); if (await movieDir.exists()) { @@ -2510,7 +2533,7 @@ class DownloadManagerService { } } - Future _deleteMovieFilesSaf(MediaItem movie, String serverId, {String? clientScopeId}) async { + Future _deleteMovieFilesSaf(MediaItem movie, ServerId serverId, {String? clientScopeId}) async { try { final safBaseUri = _storageService.safBaseUri; if (safBaseUri != null) { @@ -2533,13 +2556,13 @@ class DownloadManagerService { /// dir — so we skip the SAF video delete and parent walk-up here. Future _deleteEpisodeFilesSaf( MediaItem episode, - String serverId, { + ServerId serverId, { String? clientScopeId, bool skipSafVideoAndParents = false, }) async { try { final parentMetadata = episode.grandparentId != null - ? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId) + ? await _lookupMetadata(ServerId(serverId), episode.grandparentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; @@ -2553,7 +2576,7 @@ class DownloadManagerService { saf.getChild(safBaseUri, _storageService.getEpisodeSafPathComponents(episode, showYear: showYear)), saf.getChild(safBaseUri, _storageService.getShowSafPathComponents(episode, showYear: showYear)), ]); - seasonDirUri = resolved[0]?.uri; + seasonDirUri = resolved.first?.uri; showDirUri = resolved[1]?.uri; if (seasonDirUri != null) { @@ -2577,18 +2600,18 @@ class DownloadManagerService { appLogger.i('Deleted episode subtitles: ${subsDir.path}'); } - await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId); + await _deleteChapterThumbnails(ServerId(serverId), episode.id, clientScopeId: clientScopeId); if (!skipSafVideoAndParents) { await _deleteEmptySafDirsInOrder([seasonDirUri, showDirUri]); - await _ensureDbFileDeleted(serverId, episode.id); + await _ensureDbFileDeleted(ServerId(serverId), episode.id); } } catch (e, stack) { appLogger.e('Error deleting SAF episode files', error: e, stackTrace: stack); } } - Future _deleteSeasonFilesSaf(MediaItem season, String serverId, {String? clientScopeId}) async { + Future _deleteSeasonFilesSaf(MediaItem season, ServerId serverId, {String? clientScopeId}) async { try { final parentMetadata = season.parentId != null ? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId) @@ -2628,7 +2651,7 @@ class DownloadManagerService { } } - Future _deleteShowFilesSaf(MediaItem show, String serverId, {String? clientScopeId}) async { + Future _deleteShowFilesSaf(MediaItem show, ServerId serverId, {String? clientScopeId}) async { try { final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id} (SAF)'); @@ -2657,9 +2680,9 @@ class DownloadManagerService { /// Safety net: after metadata-based deletion, verify the actual DB-recorded /// video file is gone. If not, delete it and clean up parent directories. - Future _ensureDbFileDeleted(String serverId, String ratingKey) async { + Future _ensureDbFileDeleted(ServerId serverId, String ratingKey) async { try { - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); final record = await _database.getDownloadedMedia(globalKey); if (record?.videoFilePath == null) return; diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index a14d41c0..59ebf6da 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; @@ -160,7 +161,7 @@ class DownloadStorageService { /// `/Items/.../Images/Primary` paths both round-trip cleanly. /// Returns path to cached artwork file using hash of the thumb URL, or null if not initialized. /// Example: artwork/a1b2c3d4e5f6.jpg - String? getArtworkPathSync(String serverId, String thumbPath) { + String? getArtworkPathSync(ServerId serverId, String thumbPath) { if (_artworkDirectoryPath == null) return null; final hash = _hashArtworkPath(serverId, thumbPath); return path.join(_artworkDirectoryPath!, '$hash.jpg'); @@ -168,34 +169,34 @@ class DownloadStorageService { /// Get artwork file path from a server-side thumb path (async version). /// Backend-neutral — see [getArtworkPathSync] for details. - Future getArtworkPathFromThumb(String serverId, String thumbPath) async { + Future getArtworkPathFromThumb(ServerId serverId, String thumbPath) async { final artworkDir = await getArtworkDirectory(); final hash = _hashArtworkPath(serverId, thumbPath); return path.join(artworkDir.path, '$hash.jpg'); } - Future artworkExists(String serverId, String thumbPath) async { + Future artworkExists(ServerId serverId, String thumbPath) async { final artworkPath = await getArtworkPathFromThumb(serverId, thumbPath); return File(artworkPath).exists(); } /// Hash artwork path for filename using MD5 for stability across app restarts - String _hashArtworkPath(String serverId, String thumbPath) { + String _hashArtworkPath(ServerId serverId, String thumbPath) { final combined = '$serverId:$thumbPath'; return md5.convert(utf8.encode(combined)).toString(); } - Future getMediaDirectory(String serverId, String ratingKey) async { + Future getMediaDirectory(ServerId serverId, String ratingKey) async { final baseDir = await getDownloadsDirectory(); return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey))); } - Future getVideoFilePath(String serverId, String ratingKey, String extension) async { + Future getVideoFilePath(ServerId serverId, String ratingKey, String extension) async { final mediaDir = await getMediaDirectory(serverId, ratingKey); return path.join(mediaDir.path, 'video.$extension'); } - Future getSubtitlesDirectory(String serverId, String ratingKey) async { + Future getSubtitlesDirectory(ServerId serverId, String ratingKey) async { final mediaDir = await getMediaDirectory(serverId, ratingKey); final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles')); if (!await subtitlesDir.exists()) { @@ -204,7 +205,7 @@ class DownloadStorageService { return subtitlesDir; } - Future getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async { + Future getSubtitlePath(ServerId serverId, String ratingKey, int trackId, String extension) async { final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey); return path.join(subtitlesDir.path, '$trackId.$extension'); } diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index a34417f3..2e67c727 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -120,7 +121,7 @@ class EpisodeNavigationService { } var allEpisodes = _readSeriesCache(seriesId); if (allEpisodes == null) { - final client = serverManager.getClient(metadata.serverId!); + final client = serverManager.getClient(ServerId(metadata.serverId!)); if (client == null) return; try { allEpisodes = await client.fetchClientSideEpisodeQueue(seriesId); diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 3787e414..771551cd 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -250,7 +251,7 @@ class ExternalPlayerService { final serverId = metadata.serverId; if (offlineWatchService == null || serverId == null) return; await offlineWatchService.queueProgressUpdate( - serverId: serverId, + serverId: ServerId(serverId), itemId: metadata.id, viewOffset: duration == null ? position.inMilliseconds diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index 6dd872f5..06483e57 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'package:drift/drift.dart'; @@ -39,7 +40,7 @@ class JellyfinApiCache extends ApiCache { static final RegExp _itemKeyPattern = RegExp(r'/Users/[^/]+/Items/([^/?]+)$'); - String _itemPattern(String serverId, String itemId) => '$serverId:/Users/%/Items/$itemId'; + String _itemPattern(ServerId serverId, String itemId) => '$serverId:/Users/%/Items/$itemId'; static String mediaSegmentsEndpoint(String itemId) => '/MediaSegments/${Uri.encodeComponent(itemId)}'; @@ -47,7 +48,7 @@ class JellyfinApiCache extends ApiCache { /// Children-list endpoints are out of scope for v1 — they'll get cleaned up /// via [deleteForServer] or [clearAll]. @override - Future deleteForItem(String serverId, String itemId) async { + Future deleteForItem(ServerId serverId, String itemId) async { final endpoint = mediaSegmentsEndpoint(itemId); await (database.delete( database.apiCache, @@ -56,12 +57,12 @@ class JellyfinApiCache extends ApiCache { /// Pin the metadata row(s) for [itemId] so they survive cache eviction. @override - Future pinForOffline(String serverId, String itemId) async { + Future pinForOffline(ServerId serverId, String itemId) async { final endpoint = mediaSegmentsEndpoint(itemId); await Future.wait([pinByKeyPattern(_itemPattern(serverId, itemId)), pin(serverId, endpoint)]); } - Future unpinForOffline(String serverId, String itemId) async { + Future unpinForOffline(ServerId serverId, String itemId) async { final endpoint = mediaSegmentsEndpoint(itemId); await Future.wait([unpinByKeyPattern(_itemPattern(serverId, itemId)), unpin(serverId, endpoint)]); } @@ -70,9 +71,9 @@ class JellyfinApiCache extends ApiCache { /// /// Named `isPinnedItemId` to avoid colliding with the inherited /// [ApiCache.isPinned]'s identical Dart signature. - Future isPinnedItemId(String serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId)); + Future isPinnedItemId(ServerId serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId)); - Future> getPinnedItemIds(String serverId) => extractPinnedIds(serverId, _itemKeyPattern); + Future> getPinnedItemIds(ServerId serverId) => extractPinnedIds(serverId, _itemKeyPattern); /// Fetch and parse a [MediaItem] from cache. /// @@ -90,7 +91,7 @@ class JellyfinApiCache extends ApiCache { /// is cheap and matches [PlexApiCache.getMetadata]'s shape. Bulk-load /// callers go through [getAllPinnedMetadata] which still parallelises. @override - Future getMetadata(String serverId, String itemId) async { + Future getMetadata(ServerId serverId, String itemId) async { final row = await (database.select( database.apiCache, )..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)))).get(); @@ -102,7 +103,12 @@ class JellyfinApiCache extends ApiCache { try { final data = jsonDecode(row.first.data) as Map; final absolutizer = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken); - return JellyfinMappers.mediaItem(data, serverId: ctx.machineId, serverName: ctx.name, absolutizer: absolutizer); + return JellyfinMappers.mediaItem( + data, + serverId: ServerId(ctx.machineId), + serverName: ctx.name, + absolutizer: absolutizer, + ); } catch (_) { return null; } @@ -122,14 +128,15 @@ class JellyfinApiCache extends ApiCache { /// with the Plex caller. @override Future applyWatchState({ - required String serverId, + required ServerId serverId, required String itemId, required bool isWatched, int? viewOffsetMs, int? lastViewedAt, int? viewedLeafCount, }) async { - final query = database.select(database.apiCache)..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId))); + final query = database.select(database.apiCache) + ..where((t) => t.cacheKey.like(_itemPattern(ServerId(serverId), itemId))); final rows = await query.get(); if (rows.isEmpty) return; for (final row in rows) { @@ -139,7 +146,7 @@ class JellyfinApiCache extends ApiCache { ? (data['UserData'] as Map) : {}; userData['Played'] = isWatched; - final positionTicks = viewOffsetMs != null ? viewOffsetMs * 10000 : 0; + final positionTicks = viewOffsetMs != null ? viewOffsetMs * 10_000 : 0; if (isWatched) { final current = (userData['PlayCount'] as num?)?.toInt() ?? 0; userData['PlayCount'] = current < 1 ? 1 : current; @@ -170,7 +177,7 @@ class JellyfinApiCache extends ApiCache { /// Load all pinned Jellyfin metadata in a single query. /// - /// Returns a map keyed by `buildGlobalKey(serverId, itemId)` for O(1) + /// Returns a map keyed by `buildGlobalKey(ServerId(serverId), itemId)` for O(1) /// lookups, mirroring [PlexApiCache.getAllPinnedMetadata] so callers can /// spread-merge the two results. @override @@ -202,12 +209,12 @@ class JellyfinApiCache extends ApiCache { final data = jsonDecode(entry.data) as Map; final mapped = JellyfinMappers.mediaItem( data, - serverId: ctx.machineId, + serverId: ServerId(ctx.machineId), serverName: ctx.name, absolutizer: absolutizer, ); if (mapped != null) { - result[buildGlobalKey(entry.serverId, entry.id)] = mapped; + result[buildGlobalKey(ServerId(entry.serverId), entry.id)] = mapped; } } catch (_) { // Skip malformed entries @@ -230,7 +237,9 @@ class JellyfinApiCache extends ApiCache { /// /// Returns `null` when no row matches or the row carries an empty /// `baseUrl` (no honest URL we can build). - Future<({String machineId, String name, String baseUrl, String accessToken})?> _serverContext(String serverId) async { + Future<({String machineId, String name, String baseUrl, String accessToken})?> _serverContext( + ServerId serverId, + ) async { // Match either the bare machineId (Plex) or the compound // `{machineId}/{userId}` (Jellyfin). The compound match uses a // [substr]-based prefix check so any `_` / `%` in the runtime diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index b91a8db1..2b3cdc79 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -21,6 +21,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_library.dart'; import '../media/media_playlist.dart'; +import '../media/ids.dart'; import '../media/media_server_client.dart'; import '../media/playback_report_metadata.dart'; import '../media/server_capabilities.dart'; @@ -223,7 +224,7 @@ class JellyfinClient items.map(_mapItem).whereType().toList(); @override - String get serverId => connection.serverMachineId; + ServerId get serverId => ServerId(connection.serverMachineId); @override String get scopedServerId => connection.id; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 723743fa..8efb35c3 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -410,7 +410,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { // - Pure transport errors (no HTTP response) → fall back to cached row // when present, otherwise rethrow. if (isOfflineMode) { - final cached = await cache.get(cacheServerId, endpoint); + final cached = await cache.get(ServerId(cacheServerId), endpoint); if (cached is Map) return _mapItem(cached); return null; } @@ -420,7 +420,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final data = response.data; if (data is! Map) return null; try { - await cache.put(cacheServerId, endpoint, data); + await cache.put(ServerId(cacheServerId), endpoint, data); } catch (e, st) { appLogger.w('JellyfinClient.fetchItem cache write failed', error: e, stackTrace: st); } @@ -432,7 +432,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { // Transport-layer failure: socket error, DNS, TLS, etc. Try cache. appLogger.w('JellyfinClient.fetchItem network call failed', error: e); try { - final cached = await cache.get(cacheServerId, endpoint); + final cached = await cache.get(ServerId(cacheServerId), endpoint); if (cached is Map) return _mapItem(cached); } catch (cacheError, st) { appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st); @@ -449,12 +449,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}'; if (isOfflineMode) { - final cachedSeasons = await cache.get(cacheServerId, seasonsKey); + final cachedSeasons = await cache.get(ServerId(cacheServerId), seasonsKey); if (cachedSeasons != null) { final items = _itemsArray(cachedSeasons); if (items.isNotEmpty) return _mapItems(items); } - final cachedChildren = await cache.get(cacheServerId, childrenKey); + final cachedChildren = await cache.get(ServerId(cacheServerId), childrenKey); if (cachedChildren != null) { return _mapItems(_itemsArray(cachedChildren)); } @@ -474,7 +474,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final data = seasons.data; final items = _itemsArray(data); if (items.isNotEmpty && data is Map) { - await cache.put(cacheServerId, seasonsKey, data); + await cache.put(ServerId(cacheServerId), seasonsKey, data); return _mapItems(items); } } @@ -511,7 +511,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { startIndex += page.length; } try { - await cache.put(cacheServerId, childrenKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length}); + await cache.put(ServerId(cacheServerId), childrenKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length}); } catch (e, st) { appLogger.w('JellyfinClient.fetchChildren cache write failed', error: e, stackTrace: st); } @@ -531,7 +531,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { Future> _fetchFolderChildren(String parentId) async { final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; if (isOfflineMode) { - final cached = await cache.get(cacheServerId, cacheKey); + final cached = await cache.get(ServerId(cacheServerId), cacheKey); return cached == null ? const [] : _mapItems(_itemsArray(cached)); } @@ -573,7 +573,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { }); try { - await cache.put(cacheServerId, cacheKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length}); + await cache.put(ServerId(cacheServerId), cacheKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length}); } catch (e, st) { appLogger.w('JellyfinClient.fetchFolderChildren cache write failed', error: e, stackTrace: st); } @@ -804,7 +804,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { }), ]); - return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count); + return _mergeContinueWatchingAndNextUp( + resume: _mapItems(results.first), + nextUp: _mapItems(results[1]), + limit: count, + ); } @override @@ -879,7 +883,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { identifier: 'home.recent', title: t.discover.recentlyAdded, type: 'mixed', - items: results[0], + items: results.first, serverId: serverId, serverName: serverName, ), @@ -972,7 +976,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { identifier: 'library.$libraryId.recent', title: t.discover.recentlyAddedIn(library: libraryName), type: 'mixed', - items: results[0], + items: results.first, serverId: serverId, serverName: serverName, ), diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index ea6c30e6..477cdd84 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -33,14 +33,14 @@ mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { double? aspectRatio; if (aspectRatioString != null && aspectRatioString.contains(':')) { final parts = aspectRatioString.split(':'); - final num = double.tryParse(parts[0]); + final num = double.tryParse(parts.first); final den = double.tryParse(parts[1]); if (num != null && den != null && den != 0) aspectRatio = num / den; } aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null; final runtimeTicks = source['RunTimeTicks'] as int?; - final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10000) : null; + final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10_000) : null; final bitrateBps = source['Bitrate'] as int?; final videoBitrateBps = videoStream?['BitRate'] as int?; diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index e48af401..e47b52d6 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -13,7 +13,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { }); Future?> getPlaybackInfo( String itemId, { - int? maxStreamingBitrate = 100000000, + int? maxStreamingBitrate = 100_000_000, String? mediaSourceId, String? liveStreamId, int? startTimeTicks, diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart index 3d797c2e..266853b1 100644 --- a/lib/services/jellyfin_client/parts/metadata_edit.dart +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -78,7 +78,7 @@ mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { Future _deleteMetadataEditCache(String itemId) async { try { - await cache.deleteForItem(cacheServerId, itemId); + await cache.deleteForItem(ServerId(cacheServerId), itemId); } catch (e, st) { appLogger.w('Jellyfin metadata edit cache invalidation failed', error: e, stackTrace: st); } diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index b766045d..43a2714a 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -35,7 +35,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { String? creditsPattern, bool forceChapterFallback = false, }) async { - final item = await cache.getMetadata(cacheServerId, itemId); + final item = await cache.getMetadata(ServerId(cacheServerId), itemId); if (item == null) return null; final markers = await _fetchCachedMediaSegmentMarkers(itemId); return jellyfinPlaybackExtrasFromRaw( @@ -50,7 +50,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { @override Future fetchCachedMediaSourceInfo(String itemId) async { - final item = await cache.getMetadata(cacheServerId, itemId); + final item = await cache.getMetadata(ServerId(cacheServerId), itemId); final raw = item?.raw; if (raw is! Map) return null; final sources = raw['MediaSources']; @@ -106,7 +106,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { Future> _fetchCachedMediaSegmentMarkers(String itemId) async { try { - final data = await cache.get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId)); + final data = await cache.get(ServerId(cacheServerId), JellyfinApiCache.mediaSegmentsEndpoint(itemId)); return jellyfinMediaSegmentsToMarkers(data); } catch (e) { appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e); @@ -160,7 +160,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { final preset = options.qualityPreset; final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); - final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100000) * 1000; + final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100_000) * 1000; final resumeOffsetMs = metadata.viewOffsetMs; final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0 ? msToJellyfinTicks(resumeOffsetMs) @@ -454,7 +454,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// when picking codec compatibility). Future?> getPlaybackInfo( String itemId, { - int? maxStreamingBitrate = 100000000, + int? maxStreamingBitrate = 100_000_000, String? mediaSourceId, String? liveStreamId, int? startTimeTicks, diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index daa83a53..66c8ad68 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -1,4 +1,5 @@ import '../media/media_backend.dart'; +import '../media/ids.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -141,7 +142,7 @@ class JellyfinMappers { /// `.whereType()`. static MediaItem? mediaItem( Map item, { - required String serverId, + required ServerId serverId, String? serverName, required JellyfinImageAbsolutizer? absolutizer, }) { @@ -228,7 +229,7 @@ class JellyfinMappers { /// [MediaLibrary]. The CollectionType field maps onto [MediaKind] roughly. /// Returns `null` when the view is missing `Id` — same rationale as /// [mediaItem]. - static MediaLibrary? library(Map view, {required String serverId, String? serverName}) { + static MediaLibrary? library(Map view, {required ServerId serverId, String? serverName}) { final id = view['Id'] as String?; if (id == null || id.isEmpty) return null; final collectionType = view['CollectionType'] as String?; @@ -259,7 +260,7 @@ class JellyfinMappers { required String title, required String type, required List> items, - required String serverId, + required ServerId serverId, String? serverName, MediaItem? Function(Map)? mapItem, }) { diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 02a18f1c..e78163d3 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -67,7 +68,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { showLoading: showLoadingIndicator, actionLabel: shuffle ? t.common.shuffle : t.common.play, execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(serverId); + final client = clientForTesting ?? _resolveClient(ServerId(serverId)); if (client == null) { await dismissLoading(); if (context.mounted) { @@ -142,7 +143,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { showLoading: showLoadingIndicator, actionLabel: shuffle ? t.common.shuffle : t.common.play, execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(serverId); + final client = clientForTesting ?? _resolveClient(ServerId(serverId)); if (client == null) { await dismissLoading(); if (context.mounted) { @@ -218,7 +219,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { showLoading: showLoadingIndicator, actionLabel: t.common.shuffle, execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(serverId); + final client = clientForTesting ?? _resolveClient(ServerId(serverId)); if (client == null) { await dismissLoading(); if (context.mounted) { @@ -261,7 +262,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { /// Resolve the [MediaServerClient] for [serverId] through /// [MultiServerProvider]. Returns null when the server isn't online or /// the provider isn't in scope. - MediaServerClient? _resolveClient(String serverId) { + MediaServerClient? _resolveClient(ServerId serverId) { final provider = Provider.of(context, listen: false); return provider.serverManager.getClient(serverId); } diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 1a2111ef..4d27d557 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; @@ -59,7 +60,7 @@ class MultiServerManager { /// device row on plex.tv). final Map _clientIdByServer = {}; - String? _resolveClientIdentifier(String serverId) => _clientIdByServer[serverId]; + String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId]; /// All Jellyfin clients ever added, keyed by the compound connection id /// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin @@ -99,13 +100,13 @@ class MultiServerManager { List get offlineServerIds => _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList(); /// Get client for specific server. - MediaServerClient? getClient(String serverId) => _clients[serverId]; + MediaServerClient? getClient(ServerId serverId) => _clients[serverId]; /// Get the [PlexClient] for a server, or `null` if the server is Jellyfin /// (or not registered). Use for Plex-only flows (Live TV, server prefs, /// endpoint optimization) that don't yet have a backend-neutral /// equivalent on [MediaServerClient]. - PlexClient? getPlexClient(String serverId) { + PlexClient? getPlexClient(ServerId serverId) { final client = _clients[serverId]; return client is PlexClient ? client : null; } @@ -129,7 +130,7 @@ class MultiServerManager { } @visibleForTesting - void debugMarkAuthErrorForTesting(String serverId) { + void debugMarkAuthErrorForTesting(ServerId serverId) { _serverStatus[serverId] = false; _authErrorServers.add(serverId); _statusController.add(Map.from(_serverStatus)); @@ -153,9 +154,9 @@ class MultiServerManager { /// `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(String serverId) => _plexServers[serverId]; + PlexServer? getPlexServer(ServerId serverId) => _plexServers[serverId]; - String serverDisplayName(String serverId) => + String serverDisplayName(ServerId serverId) => _clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId; /// Backend-neutral "is this user an owner/admin on [serverId]?" probe used @@ -166,7 +167,7 @@ class MultiServerManager { /// `ActiveProfileProvider`). /// - Jellyfin: `JellyfinConnection.isAdministrator` captured at sign-in. /// - Unknown server: `false`. - bool isOwnerOrAdmin(String serverId) { + bool isOwnerOrAdmin(ServerId serverId) { final client = _clients[serverId]; if (client is PlexClient) { return _plexServers[serverId]?.owned == true; @@ -196,10 +197,10 @@ class MultiServerManager { Map get plexServers => Map.unmodifiable(_plexServers); /// Check if a server is online - bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false; + bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false; /// Check whether the active or scoped client for [serverId] is online. - bool isClientOnline(String serverId, {String? clientScopeId}) { + bool isClientOnline(ServerId serverId, {String? clientScopeId}) { if (clientScopeId != null && clientScopeId.isNotEmpty) { return _jellyfinHealthByCompoundId[clientScopeId] == HealthStatus.online; } @@ -215,7 +216,7 @@ class MultiServerManager { // Get storage and load cached endpoint for this server final storage = await StorageService.getInstance(); - final cachedEndpoint = storage.getServerEndpoint(serverId); + final cachedEndpoint = storage.getServerEndpoint(ServerId(serverId)); // The connection race already hits `/` on the winning endpoint — capture // `transcoderVideo` from that response so PlexClient.create can skip the @@ -248,19 +249,19 @@ class MultiServerManager { final client = await PlexClient.create( config, - serverId: serverId, + serverId: ServerId(serverId), serverName: server.name, prioritizedEndpoints: prioritizedEndpoints, onEndpointChanged: (newUrl) async { - await storage.saveServerEndpoint(serverId, newUrl); + await storage.saveServerEndpoint(ServerId(serverId), newUrl); appLogger.i('Updated endpoint for ${server.name} after failover: $newUrl'); }, - onAllEndpointsExhausted: () => _onServerEndpointsExhausted(serverId), + onAllEndpointsExhausted: () => _onServerEndpointsExhausted(ServerId(serverId)), seedTranscoderVideoSupport: observedTranscoderVideo, ); // Save the initial endpoint - await storage.saveServerEndpoint(serverId, baseUrl); + await storage.saveServerEndpoint(ServerId(serverId), baseUrl); // Drain remaining stream values in background to apply better connections _drainOptimizationStream(streamIterator, client: client, server: server, storage: storage); @@ -275,7 +276,7 @@ class MultiServerManager { required StorageService storage, required String newUrl, }) async { - await storage.saveServerEndpoint(server.clientIdentifier, newUrl); + await storage.saveServerEndpoint(ServerId(server.clientIdentifier), newUrl); final newEndpoints = server.prioritizedEndpointUrls(preferredFirst: newUrl); await client.updateEndpointPreferences(newEndpoints, switchToFirst: true); } @@ -315,7 +316,7 @@ class MultiServerManager { } /// Remove a server connection - void removeServer(String serverId) { + void removeServer(ServerId serverId) { final jellyfinCompoundIds = _jellyfinByCompoundId.entries .where((entry) => entry.value.connection.serverMachineId == serverId) .map((entry) => entry.key) @@ -369,7 +370,7 @@ class MultiServerManager { Future addPlexAccount( PlexAccountConnection connection, { Duration timeout = MediaServerTimeouts.perServerConnect, - Function(String serverId, bool success)? onServerStatus, + Function(ServerId serverId, bool success)? onServerStatus, }) async { if (connection.servers.isEmpty) return 0; appLogger.i( @@ -391,12 +392,12 @@ class MultiServerManager { if (oldClient != null) _closeClient(oldClient); _clients[serverId] = client; _serverStatus[serverId] = true; - onServerStatus?.call(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, false); + onServerStatus?.call(ServerId(serverId), false); } }); @@ -540,7 +541,7 @@ class MultiServerManager { final health = await client.checkHealth(); final healthy = health == HealthStatus.online; _jellyfinHealthByCompoundId[compoundId] = health; - _applyHealth(machineId, health); + _applyHealth(ServerId(machineId), health); appLogger.i('Added Jellyfin server: ${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}'); if (_connectivitySubscription == null && healthy) { @@ -599,7 +600,7 @@ class MultiServerManager { /// /// Clears the auth-error flag — callers that observed an auth failure /// should use [_applyHealth] instead. - void updateServerStatus(String serverId, bool isOnline) { + void updateServerStatus(ServerId serverId, bool isOnline) { final prevOnline = _serverStatus[serverId]; final hadAuthError = _authErrorServers.remove(serverId); if (prevOnline != isOnline || hadAuthError) { @@ -612,7 +613,7 @@ class MultiServerManager { /// Apply a health-probe outcome to both online state and auth-error /// tracking. Used by the manager's own health checks; external callers /// without an auth-distinct signal should use [updateServerStatus]. - void _applyHealth(String serverId, HealthStatus status) { + void _applyHealth(ServerId serverId, HealthStatus status) { final isOnline = status == HealthStatus.online; final isAuthError = status == HealthStatus.authError; final prevOnline = _serverStatus[serverId]; @@ -668,7 +669,7 @@ class MultiServerManager { return; } } - _applyHealth(serverId, status); + _applyHealth(ServerId(serverId), status); if (status != HealthStatus.online) { appLogger.w('Server $serverId health check failed: ${status.name}'); } @@ -745,14 +746,14 @@ class MultiServerManager { continue; } - if (!isServerOnline(serverId)) { + if (!isServerOnline(ServerId(serverId))) { // Attempt reconnection for offline servers - _activeOptimizations[serverId] = _reconnectServer(serverId, server).whenComplete(() { + _activeOptimizations[serverId] = _reconnectServer(ServerId(serverId), server).whenComplete(() { _activeOptimizations.remove(serverId); }); } else { // Re-optimize online servers - _activeOptimizations[serverId] = _reoptimizeServer(serverId: serverId, server: server, reason: reason) + _activeOptimizations[serverId] = _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) .whenComplete(() { _activeOptimizations.remove(serverId); }); @@ -764,7 +765,7 @@ class MultiServerManager { for (final entry in _activeJellyfinMachine.entries) { final serverId = entry.key; if (_activeOptimizations.containsKey(serverId)) continue; - if (isServerOnline(serverId)) continue; + if (isServerOnline(ServerId(serverId))) continue; final client = _jellyfinByCompoundId[entry.value]; if (client == null) continue; @@ -780,7 +781,11 @@ class MultiServerManager { /// Today this only runs against Plex servers — the connection-racing logic /// is built around [PlexServer.findBestWorkingConnection]. Non-Plex /// clients short-circuit until a backend-agnostic equivalent lands. - Future _reoptimizeServer({required String serverId, required PlexServer server, required String reason}) async { + Future _reoptimizeServer({ + required ServerId serverId, + required PlexServer server, + required String reason, + }) async { final storage = await StorageService.getInstance(); final raw = _clients[serverId]; final client = raw is PlexClient ? raw : null; @@ -819,7 +824,7 @@ class MultiServerManager { } /// Attempt full reconnection for a single offline server - Future _reconnectServer(String serverId, PlexServer server) async { + Future _reconnectServer(ServerId serverId, PlexServer server) async { final clientId = _resolveClientIdentifier(serverId); if (clientId == null) { appLogger.w('Cannot reconnect ${server.name}: no client identifier cached'); @@ -858,7 +863,7 @@ class MultiServerManager { appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}'); return; } - _applyHealth(machineId, status); + _applyHealth(ServerId(machineId), status); if (status == HealthStatus.online) { appLogger.i('Successfully reconnected to ${client.connection.serverName}'); } else { @@ -901,7 +906,7 @@ class MultiServerManager { if (forceRediscovery) { final storage = await StorageService.getInstance(); - await Future.wait(offline.map(storage.clearServerEndpoint)); + await Future.wait(offline.map((id) => storage.clearServerEndpoint(ServerId(id)))); } final futures = offline.map((serverId) { @@ -910,7 +915,7 @@ class MultiServerManager { final server = _plexServers[serverId]; if (server != null) { - final future = _reconnectServer(serverId, server) + final future = _reconnectServer(ServerId(serverId), server) .timeout( const Duration(seconds: 15), onTimeout: () { @@ -950,7 +955,7 @@ class MultiServerManager { /// Called when all failover endpoints are exhausted for a server. /// Debounced per-server to prevent cascading reconnections from parallel failures. - void _onServerEndpointsExhausted(String serverId) { + void _onServerEndpointsExhausted(ServerId serverId) { // Cancel any existing debounce timer for this server _reconnectDebounce[serverId]?.cancel(); diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 1e7bb01c..5e9f225c 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; @@ -53,7 +54,7 @@ class OfflineWatchSyncService extends ChangeNotifier { /// 2. Jellyfin's fixed [MediaServerClient.watchedThreshold] (0.9) /// 3. Cached value in SettingsService /// 4. Default 90% - double getWatchedThreshold(String serverId) { + double getWatchedThreshold(ServerId serverId) { final client = _serverManager.getClient(serverId); if (client is PlexClient && client.serverPrefs.isNotEmpty) { return client.watchedThresholdPercent / 100.0; @@ -214,13 +215,14 @@ class OfflineWatchSyncService extends ChangeNotifier { /// preserved as the on-disk column name; the in-memory parameter renamed /// here is just the API-level identifier. Future queueProgressUpdate({ - required String serverId, + required ServerId serverId, required String itemId, required int viewOffset, required int? duration, }) async { - final shouldMarkWatched = duration != null && isWatchedByProgress(viewOffset, duration, serverId: serverId); - final clientScopeId = await _clientScopeIdForItem(serverId, itemId); + final shouldMarkWatched = + duration != null && isWatchedByProgress(viewOffset, duration, serverId: ServerId(serverId)); + final clientScopeId = await _clientScopeIdForItem(ServerId(serverId), itemId); await _database.upsertProgressAction( profileId: _activeProfileId, @@ -244,18 +246,18 @@ class OfflineWatchSyncService extends ChangeNotifier { return clientScopeId; } - Future queueMarkWatched({required String serverId, required String itemId}) => + Future queueMarkWatched({required ServerId serverId, required String itemId}) => _queueWatchStatusAction(serverId: serverId, itemId: itemId, actionType: OfflineActionType.watched.id); - Future queueMarkUnwatched({required String serverId, required String itemId}) => + Future queueMarkUnwatched({required ServerId serverId, required String itemId}) => _queueWatchStatusAction(serverId: serverId, itemId: itemId, actionType: OfflineActionType.unwatched.id); Future _queueWatchStatusAction({ - required String serverId, + required ServerId serverId, required String itemId, required String actionType, }) async { - final clientScopeId = await _clientScopeIdForItem(serverId, itemId); + final clientScopeId = await _clientScopeIdForItem(ServerId(serverId), itemId); await _database.insertWatchAction( profileId: _activeProfileId, serverId: serverId, @@ -270,7 +272,7 @@ class OfflineWatchSyncService extends ChangeNotifier { } /// Check if an item should be considered watched based on progress percentage. - bool isWatchedByProgress(int viewOffset, int duration, {String? serverId}) { + bool isWatchedByProgress(int viewOffset, int duration, {ServerId? serverId}) { if (duration == 0) return false; final threshold = serverId != null ? getWatchedThreshold(serverId) : 0.9; return (viewOffset / duration) >= threshold; @@ -409,7 +411,7 @@ class OfflineWatchSyncService extends ChangeNotifier { } } - Future _clientScopeIdForItem(String serverId, String itemId) async { + Future _clientScopeIdForItem(ServerId serverId, String itemId) async { // A downloaded row's clientScopeId is a cache/source hint, not an owner. // Offline watch actions are user-owned, so a new local action follows the // currently active scoped Jellyfin client. Once queued, _clientForAction @@ -419,7 +421,7 @@ class OfflineWatchSyncService extends ChangeNotifier { final scopeId = client.cacheServerId; if (scopeId != serverId) return scopeId; } - final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); final downloadedScopeId = download?.clientScopeId; if (downloadedScopeId != null && downloadedScopeId.isNotEmpty) return downloadedScopeId; return null; @@ -431,7 +433,7 @@ class OfflineWatchSyncService extends ChangeNotifier { final scoped = _serverManager.getJellyfinClientByCompoundId(scopeId); if (scoped != null) return (client: scoped, clientScopeId: scopeId); } - final client = _serverManager.getClient(action.serverId); + final client = _serverManager.getClient(ServerId(action.serverId)); if (client == null) return null; if (client.backend == MediaBackend.jellyfin && client.cacheServerId != action.serverId) { appLogger.w( @@ -453,7 +455,7 @@ class OfflineWatchSyncService extends ChangeNotifier { return false; } - if (!_serverManager.isClientOnline(action.serverId, clientScopeId: resolved.clientScopeId)) { + if (!_serverManager.isClientOnline(ServerId(action.serverId), clientScopeId: resolved.clientScopeId)) { appLogger.d('Server ${action.serverId} scope ${resolved.clientScopeId} is offline, skipping'); return false; } @@ -477,14 +479,14 @@ class OfflineWatchSyncService extends ChangeNotifier { return _activeClientScopeIdForServer(parsed.serverId); } - String? _activeClientScopeIdForServer(String serverId) { + String? _activeClientScopeIdForServer(ServerId serverId) { final client = _serverManager.getClient(serverId); if (client == null) return null; final scopeId = client.cacheServerId; return scopeId == serverId ? null : scopeId; } - Future _clientForDownloadScope(String serverId, String? clientScopeId) async { + Future _clientForDownloadScope(ServerId serverId, String? clientScopeId) async { if (clientScopeId != null && clientScopeId.isNotEmpty) { final scoped = _serverManager.getJellyfinClientByCompoundId(clientScopeId); if (scoped != null) return scoped; @@ -493,17 +495,17 @@ class OfflineWatchSyncService extends ChangeNotifier { } Future _withOnlineClientForDownloadScope( - String serverId, + ServerId serverId, String? clientScopeId, Future Function(MediaServerClient client) callback, ) async { - final client = await _clientForDownloadScope(serverId, clientScopeId); + final client = await _clientForDownloadScope(ServerId(serverId), clientScopeId); if (client == null) { appLogger.d('No client for server $serverId scope $clientScopeId, skipping'); return null; } - if (!_serverManager.isClientOnline(serverId, clientScopeId: clientScopeId)) { + if (!_serverManager.isClientOnline(ServerId(serverId), clientScopeId: clientScopeId)) { appLogger.d('Server $serverId scope $clientScopeId is offline, skipping'); return null; } @@ -595,7 +597,7 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Returns the number of episodes synced, or -1 on failure. Future _syncSeasonEpisodes( MediaServerClient client, - String serverId, + ServerId serverId, String seasonRatingKey, Set downloadedEpisodeKeys, ) async { @@ -607,7 +609,7 @@ class OfflineWatchSyncService extends ChangeNotifier { if (!downloadedEpisodeKeys.contains(episode.id)) continue; final cacheServerId = client.cacheServerId; - final prior = await client.cache.getMetadata(cacheServerId, episode.id); + final prior = await client.cache.getMetadata(ServerId(cacheServerId), episode.id); final isWatched = (episode.viewCount ?? 0) > 0; if (prior != null) { @@ -615,7 +617,7 @@ class OfflineWatchSyncService extends ChangeNotifier { // fields without disturbing Media/Chapter blobs. final wasWatched = (prior.viewCount ?? 0) > 0; await client.cache.applyWatchState( - serverId: cacheServerId, + serverId: ServerId(cacheServerId), itemId: episode.id, isWatched: isWatched, viewOffsetMs: episode.viewOffsetMs, @@ -638,7 +640,7 @@ class OfflineWatchSyncService extends ChangeNotifier { try { await client.fetchItem(episode.id); await client.cache.applyWatchState( - serverId: cacheServerId, + serverId: ServerId(cacheServerId), itemId: episode.id, isWatched: isWatched, viewOffsetMs: episode.viewOffsetMs, @@ -698,12 +700,15 @@ class OfflineWatchSyncService extends ChangeNotifier { // etc.), using the active scoped client for user-owned Jellyfin watch // state. Downloads are shared, but server watch state is per user. // Structure: (serverId, clientScopeId) -> seasonRatingKey -> Set - final episodesByScopeAndSeason = <({String serverId, String? clientScopeId}), Map>>{}; + final episodesByScopeAndSeason = <({ServerId serverId, String? clientScopeId}), Map>>{}; // Structure: (serverId, clientScopeId) -> List - final nonEpisodeItems = <({String serverId, String? clientScopeId}), List>{}; + final nonEpisodeItems = <({ServerId serverId, String? clientScopeId}), List>{}; for (final item in downloadedItems) { - final scope = (serverId: item.serverId, clientScopeId: _activeClientScopeIdForServer(item.serverId)); + final scope = ( + serverId: ServerId(item.serverId), + clientScopeId: _activeClientScopeIdForServer(ServerId(item.serverId)), + ); if (item.type == 'episode' && item.parentRatingKey != null) { // Group episodes by server and season for batch fetching episodesByScopeAndSeason @@ -745,7 +750,7 @@ class OfflineWatchSyncService extends ChangeNotifier { try { // Snapshot prior viewCount through the neutral cache so we // can detect a watched-status change from another device. - final prior = await client.cache.getMetadata(client.cacheServerId, ratingKey); + final prior = await client.cache.getMetadata(ServerId(client.cacheServerId), ratingKey); final wasWatched = (prior?.viewCount ?? 0) > 0; // fetchItem already caches the full API response (with diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index d77c093f..626e1ec3 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -58,12 +59,12 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { PlexClient? plexClient; if (itemServerId != null) { // Plex-only: server-side `/playQueues` resource has no Jellyfin equivalent. - plexClient = provider.getPlexClientForServer(itemServerId); + plexClient = provider.getPlexClientForServer(ServerId(itemServerId)); } if (plexClient == null) { // Fall back to the first online Plex client. for (final id in provider.onlineServerIds) { - final c = provider.getPlexClientForServer(id); + final c = provider.getPlexClientForServer(ServerId(id)); if (c != null) { plexClient = c; break; @@ -157,7 +158,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { return _launchFromQueue( playQueue: playQueue, ratingKey: ratingKey, - serverId: itemServerId, + serverId: serverIdOrNull(itemServerId), serverName: itemServerName, libraryId: sourceLibraryId, libraryTitle: sourceLibraryTitle, @@ -193,7 +194,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { return _launchFromQueue( playQueue: playQueue, ratingKey: playlist.id, - serverId: serverId, + serverId: serverIdOrNull(serverId), serverName: serverName, selectedItem: _resolveSelectedMediaItem(playQueue), ); @@ -240,7 +241,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { return _launchFromQueue( playQueue: playQueue, ratingKey: showRatingKey, - serverId: metadata.serverId ?? serverId, + serverId: serverIdOrNull(metadata.serverId ?? serverId), serverName: metadata.serverName ?? serverName, libraryId: metadata.libraryId, libraryTitle: metadata.libraryTitle, @@ -289,7 +290,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { return _launchFromQueue( playQueue: playQueue, ratingKey: folderKey, - serverId: serverId, + serverId: serverIdOrNull(serverId), serverName: serverName, libraryId: libraryId, libraryTitle: libraryTitle, @@ -302,7 +303,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { Future _launchFromQueue({ required PlayQueueResponse? playQueue, required String ratingKey, - String? serverId, + ServerId? serverId, String? serverName, String? libraryId, String? libraryTitle, diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 43ba5527..39494d3a 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import '../media/ids.dart'; import 'package:path/path.dart' as p; @@ -46,7 +47,7 @@ class PlaybackInitializationService { /// Returns the local file path if the video is downloaded and completed. /// Returns null if not available offline or database is not provided. Future getOfflineVideoPath( - String serverId, + ServerId serverId, String ratingKey, { int mediaIndex = 0, String? selectedMediaSourceId, @@ -60,7 +61,7 @@ class PlaybackInitializationService { // makes this an O(log n) lookup. Filtering by (serverId, ratingKey) // would only use the serverId index and then linear-scan matching rows. final query = database!.select(database!.downloadedMedia) - ..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, ratingKey))); + ..where((tbl) => tbl.globalKey.equals(buildGlobalKey(ServerId(serverId), ratingKey))); final downloadedItem = await query.getSingleOrNull(); @@ -142,7 +143,7 @@ class PlaybackInitializationService { String? offlineVideoPath; if (serverId != null && (preferOffline || client == null) && database != null) { offlineVideoPath = await getOfflineVideoPath( - serverId, + ServerId(serverId), metadata.id, mediaIndex: selectedMediaIndex, selectedMediaSourceId: selectedMediaSourceId, @@ -233,7 +234,7 @@ class PlaybackInitializationService { try { final row = await (db.select( db.downloadedMedia, - )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, metadata.id)))).getSingleOrNull(); + )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(ServerId(serverId), metadata.id)))).getSingleOrNull(); return row?.clientScopeId ?? serverId; } catch (_) { return serverId; @@ -300,7 +301,7 @@ class PlaybackInitializationService { } else if (metadata.isMovie && metadata.title != null) { dirs.add(await storage.getMovieSubtitlesDirectory(metadata)); } - dirs.add(await storage.getSubtitlesDirectory(serverId, metadata.id)); + dirs.add(await storage.getSubtitlesDirectory(ServerId(serverId), metadata.id)); return dirs; } } diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 6d52f8d8..0250942c 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import '../mpv/mpv.dart'; @@ -437,7 +438,7 @@ class PlaybackProgressTracker { } await offlineWatchService!.queueProgressUpdate( - serverId: serverId, + serverId: ServerId(serverId), itemId: metadata.id, viewOffset: position.inMilliseconds, duration: duration.inMilliseconds, diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index 17abaf79..bca2dd3e 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -1,4 +1,5 @@ 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'; @@ -23,7 +24,7 @@ class PlaybackSourceResolver { String? sessionIdentifier, String? transcodeSessionId, }) async { - final reportingClient = _playbackClient(metadata.serverId, offlineLibraryMode: offlineLibraryMode); + final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode); final service = PlaybackInitializationService(client: reportingClient, database: database); final result = await service.getPlaybackData( metadata: metadata, @@ -77,7 +78,7 @@ class PlaybackSourceResolver { return headers; } - MediaServerClient? _playbackClient(String? serverId, {required bool offlineLibraryMode}) { + MediaServerClient? _playbackClient(ServerId? serverId, {required bool offlineLibraryMode}) { if (serverId == null) return null; final client = serverManager.getClient(serverId); if (offlineLibraryMode && !serverManager.isClientOnline(serverId)) return null; diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index aeb42cbf..517e7466 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'package:drift/drift.dart'; @@ -39,7 +40,7 @@ class PlexApiCache extends ApiCache { /// Delete cached data for a specific item (when removing a download). @override - Future deleteForItem(String serverId, String ratingKey) async { + Future deleteForItem(ServerId serverId, String ratingKey) async { final metadataKey = '$serverId:/library/metadata/$ratingKey'; final childrenKey = '$serverId:/library/metadata/$ratingKey/children'; @@ -49,11 +50,11 @@ class PlexApiCache extends ApiCache { } @override - Future pinForOffline(String serverId, String ratingKey) async { + Future pinForOffline(ServerId serverId, String ratingKey) async { return pin(serverId, '/library/metadata/$ratingKey'); } - Future unpinForOffline(String serverId, String ratingKey) async { + Future unpinForOffline(ServerId serverId, String ratingKey) async { return unpin(serverId, '/library/metadata/$ratingKey'); } @@ -61,14 +62,14 @@ class PlexApiCache extends ApiCache { /// /// Named `isPinnedRatingKey` to avoid colliding with the inherited /// [ApiCache.isPinned]'s identical Dart signature. - Future isPinnedRatingKey(String serverId, String ratingKey) { + Future isPinnedRatingKey(ServerId serverId, String ratingKey) { return isPinned(serverId, '/library/metadata/$ratingKey'); } // Rating keys can be alphanumeric, not just numeric. static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$'); - Future> getPinnedKeys(String serverId) => extractPinnedIds(serverId, _metadataKeyPattern); + Future> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern); /// Fetch and parse a [MediaItem] from cache. /// @@ -77,7 +78,7 @@ class PlexApiCache extends ApiCache { /// [MediaItem] at the boundary. Returns `null` when the endpoint is not /// cached or contains no metadata. @override - Future getMetadata(String serverId, String ratingKey) async { + Future getMetadata(ServerId serverId, String ratingKey) async { final cached = await get(serverId, '/library/metadata/$ratingKey'); final container = PlexCacheParser.extractMediaContainer(cached); final json = PlexCacheParser.extractFirstMetadata(cached); @@ -108,7 +109,7 @@ class PlexApiCache extends ApiCache { /// callers passing them have a more accurate read of server state. @override Future applyWatchState({ - required String serverId, + required ServerId serverId, required String itemId, required bool isWatched, int? viewOffsetMs, @@ -116,7 +117,7 @@ class PlexApiCache extends ApiCache { int? viewedLeafCount, }) async { final endpoint = '/library/metadata/$itemId'; - final cached = await get(serverId, endpoint); + final cached = await get(ServerId(serverId), endpoint); final json = PlexCacheParser.extractFirstMetadata(cached); if (cached == null || json == null) return; if (isWatched) { @@ -130,12 +131,12 @@ class PlexApiCache extends ApiCache { if (lastViewedAt != null) json['lastViewedAt'] = lastViewedAt; } if (viewedLeafCount != null) json['viewedLeafCount'] = viewedLeafCount; - await put(serverId, endpoint, cached); + await put(ServerId(serverId), endpoint, cached); } /// Load all pinned Plex metadata in a single query. /// - /// Returns a map keyed by `buildGlobalKey(serverId, ratingKey)` for O(1) + /// Returns a map keyed by `buildGlobalKey(ServerId(serverId), ratingKey)` for O(1) /// lookups. Used by DownloadProvider to batch-load metadata on startup /// instead of issuing per-item DB queries. @override @@ -151,7 +152,7 @@ class PlexApiCache extends ApiCache { final container = PlexCacheParser.extractMediaContainer(data); final json = PlexCacheParser.extractFirstMetadata(data); if (json == null) continue; - result[buildGlobalKey(entry.serverId, entry.id)] = PlexMappers.mediaItemFromCacheJson( + result[buildGlobalKey(ServerId(entry.serverId), entry.id)] = PlexMappers.mediaItemFromCacheJson( _withContainerLibrary(json, container), serverId: entry.serverId, ); diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index b4ec04bd..68fb77e4 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -891,7 +891,7 @@ class PlexServer { static bool _isPrivateOrLocalAddress(InternetAddress address) { final bytes = address.rawAddress; if (address.type == InternetAddressType.IPv4 && bytes.length == 4) { - final a = bytes[0]; + final a = bytes.first; final b = bytes[1]; return a == 0 || a == 10 || @@ -903,7 +903,7 @@ class PlexServer { } if (address.type == InternetAddressType.IPv6 && bytes.length == 16) { - final first = bytes[0]; + final first = bytes.first; final second = bytes[1]; final isLoopback = bytes.take(15).every((b) => b == 0) && bytes[15] == 1; final isUnspecified = bytes.every((b) => b == 0); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 4de9fe63..07379775 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -15,6 +15,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_library.dart'; import '../media/media_playlist.dart'; +import '../media/ids.dart'; import '../media/media_server_client.dart'; import '../media/playback_report_metadata.dart'; import '../media/server_capabilities.dart'; @@ -81,7 +82,7 @@ class _LibraryContentResult { /// Top-level function so it can be passed to [Isolate.run]. List _processHubResponse( Map decoded, - String serverId, + ServerId serverId, String? serverName, { int? librarySectionID, String? librarySectionTitle, @@ -100,7 +101,7 @@ List _processHubResponse( final hubSectionID = _librarySectionIdFromJson(hubMap) ?? containerSectionID; final hubSectionTitle = _librarySectionTitleFromJson(hubMap) ?? containerSectionTitle; final hub = _plexHubWithLibrarySection( - PlexHubDto.fromJson(hubMap, serverId: serverId, serverName: serverName), + PlexHubDto.fromJson(hubMap, serverId: ServerId(serverId), serverName: serverName), librarySectionID: hubSectionID, librarySectionTitle: hubSectionTitle, ); @@ -214,7 +215,7 @@ class PlexClient /// Server identifier - all PlexMetadataDto items created by this client are tagged with this @override - final String serverId; + final ServerId serverId; /// Server name - all PlexMetadataDto items created by this client are tagged with this @override @@ -284,7 +285,7 @@ class PlexClient /// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers. static Future create( PlexConfig config, { - required String serverId, + required ServerId serverId, String? serverName, List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, @@ -294,7 +295,7 @@ class PlexClient }) async { final client = PlexClient._( config, - serverId: serverId, + serverId: ServerId(serverId), serverName: serverName, prioritizedEndpoints: prioritizedEndpoints, onEndpointChanged: onEndpointChanged, @@ -345,7 +346,7 @@ class PlexClient @visibleForTesting static PlexClient forTesting({ required PlexConfig config, - required String serverId, + required ServerId serverId, String? serverName, required http.Client httpClient, List? prioritizedEndpoints, @@ -356,7 +357,7 @@ class PlexClient }) { final client = PlexClient._( config, - serverId: serverId, + serverId: ServerId(serverId), serverName: serverName, httpClient: httpClient, prioritizedEndpoints: prioritizedEndpoints, @@ -2337,13 +2338,12 @@ class PlexClient queryParameters: queryParameters, abort: abort, ); - final result = _extractLibraryContentResult( + return _extractLibraryContentResult( response, librarySectionID: _librarySectionIdFromString(sectionId), start: start, requestedSize: size, ); - return result; } /// Get all collections for a library section. diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 856f6e0f..29c0262e 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -8,7 +8,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { MediaServerHttpClient get _http; @override - String get serverId; + ServerId get serverId; @override String? get serverName; diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index bc801127..d0c77305 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -12,6 +12,7 @@ // resolution and server-tagging. import 'package:json_annotation/json_annotation.dart'; +import '../media/ids.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../media/media_backend.dart'; @@ -247,7 +248,7 @@ class PlexLibraryDto { factory PlexLibraryDto.fromJson(Map json) => _$PlexLibraryDtoFromJson(json); - PlexLibraryDto copyWith({String? serverId, String? serverName, bool? isShared}) { + PlexLibraryDto copyWith({ServerId? serverId, String? serverName, bool? isShared}) { return PlexLibraryDto( key: key, title: title, @@ -265,7 +266,7 @@ class PlexLibraryDto { ); } - String get globalKey => serverId != null ? buildGlobalKey(serverId!, key) : key; + String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), key) : key; } @JsonSerializable(createToJson: false) @@ -329,7 +330,7 @@ class PlexPlaylistDto { factory PlexPlaylistDto.fromJson(Map json) => _$PlexPlaylistDtoFromJson(kBlurArtwork ? _obfuscatePlaylistJson(json) : json); - PlexPlaylistDto copyWith({String? serverId, String? serverName}) { + PlexPlaylistDto copyWith({ServerId? serverId, String? serverName}) { return PlexPlaylistDto( ratingKey: ratingKey, key: key, @@ -386,7 +387,7 @@ class PlexHubDto { this.serverName, }); - factory PlexHubDto.fromJson(Map json, {String? serverId, String? serverName}) { + factory PlexHubDto.fromJson(Map json, {ServerId? serverId, String? serverName}) { final parsed = _$PlexHubDtoFromJson(json); final items = serverId == null && serverName == null ? parsed.items @@ -629,7 +630,7 @@ class PlexMetadataDto { return copy; } - String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; + String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), ratingKey) : ratingKey; bool get isLibrarySection => key != null && key!.startsWith('/library/sections/'); @@ -702,7 +703,7 @@ class PlexMetadataDto { String? subtype, int? extraType, String? primaryExtraKey, - String? serverId, + ServerId? serverId, String? serverName, String? clearLogo, String? backgroundSquare, @@ -807,7 +808,7 @@ class PlexMappers { PlexMappers._(); /// Map a Plex `Metadata` JSON entry directly into a [PlexMediaItem]. - static PlexMediaItem mediaItemFromJson(Map json, {String? serverId, String? serverName}) { + static PlexMediaItem mediaItemFromJson(Map json, {ServerId? serverId, String? serverName}) { final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId, serverName: serverName); return mediaItem(dto); } @@ -815,7 +816,7 @@ class PlexMappers { /// Parse a Plex `/library/metadata/{id}` JSON object into a neutral /// [MediaItem]. Used by the offline cache layer to convert persisted Plex /// JSON back into MediaItem without depending on the Plex client surface. - static MediaItem mediaItemFromCacheJson(Map json, {required String serverId}) { + static MediaItem mediaItemFromCacheJson(Map json, {required ServerId serverId}) { final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId); return mediaItem(dto); } @@ -1008,11 +1009,13 @@ class PlexMappers { /// Map a Plex `/library/sections` Directory entry into a [MediaLibrary]. static MediaLibrary mediaLibraryFromJson( Map json, { - String? serverId, + ServerId? serverId, String? serverName, bool isShared = false, }) { - final dto = PlexLibraryDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName, isShared: isShared); + final dto = PlexLibraryDto.fromJson( + json, + ).copyWith(serverId: serverIdOrNull(serverId), serverName: serverName, isShared: isShared); return mediaLibrary(dto); } @@ -1032,7 +1035,7 @@ class PlexMappers { } /// Map a Plex `/hubs` Hub JSON entry directly into a [MediaHub]. - static MediaHub mediaHubFromJson(Map json, {String? serverId, String? serverName}) { + static MediaHub mediaHubFromJson(Map json, {ServerId? serverId, String? serverName}) { return mediaHub(PlexHubDto.fromJson(json, serverId: serverId, serverName: serverName)); } @@ -1060,7 +1063,7 @@ class PlexMappers { } /// Map a Plex `/playlists` Metadata entry directly into a [MediaPlaylist]. - static MediaPlaylist mediaPlaylistFromJson(Map json, {String? serverId, String? serverName}) { + static MediaPlaylist mediaPlaylistFromJson(Map json, {ServerId? serverId, String? serverName}) { final dto = PlexPlaylistDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName); return mediaPlaylist(dto); } diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index 718041d7..527a7d1c 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -132,7 +132,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( MediaFileInfo? parsePlexFileInfoFromJson(Map? metadataJson) { final mediaList = _mapList(metadataJson?['Media']); if (mediaList.isNotEmpty) { - final media = mediaList[0]; + final media = mediaList.first; final partList = _mapList(media['Part']); final version = PlexMappers.mediaVersionFromJson(Map.from(media)); final partIndex = partList.isEmpty ? 0 : _firstPlayablePartIndex(version).clamp(0, partList.length - 1).toInt(); diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index 9ab7b338..d6cbc7a4 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -306,14 +306,13 @@ class SettingsExportService { } try { - final savedPath = await FilePickerService.instance.saveFile( + return await FilePickerService.instance.saveFile( dialogTitle: 'Export Plezy settings', fileName: fileName, bytes: bytes, type: FileType.custom, allowedExtensions: const [fileExtension], ); - return savedPath; } catch (e, st) { appLogger.e('Settings export failed', error: e, stackTrace: st); throw const SettingsExportException('Could not write export file'); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 86698a8b..85f90346 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; @@ -443,7 +444,7 @@ class SettingsService extends BaseSharedPreferencesService { decode: _decodeMpvPresets, ); - static IntPref watchedThresholdPref(String serverId) => IntPref('watched_threshold_$serverId', defaultValue: 90); + static IntPref watchedThresholdPref(ServerId serverId) => IntPref('watched_threshold_$serverId', defaultValue: 90); static EnumPref trackerFilterModePref(TrackerService s) => EnumPref( 'tracker_library_filter_mode_${s.name}', diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a578661e..1e27285c 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../media/ids.dart'; import 'package:uuid/uuid.dart'; @@ -86,16 +87,16 @@ class StorageService extends BaseSharedPreferencesService { } // Per-Server Endpoint URL (for multi-server connection caching) - Future saveServerEndpoint(String serverId, String url) async { + Future saveServerEndpoint(ServerId serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); LogRedactionManager.registerServerUrl(url); } - String? getServerEndpoint(String serverId) { + String? getServerEndpoint(ServerId serverId) { return prefs.getString('$_prefixServerEndpoint$serverId'); } - Future clearServerEndpoint(String serverId) async { + Future clearServerEndpoint(ServerId serverId) async { await prefs.remove('$_prefixServerEndpoint$serverId'); } diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 016e2349..ed30d587 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -1,4 +1,5 @@ import 'package:connectivity_plus/connectivity_plus.dart'; +import '../media/ids.dart'; import '../database/app_database.dart'; import '../media/media_item.dart'; @@ -192,8 +193,8 @@ class SyncRuleExecutor { required Map metadata, required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, }) async { - final client = serverManager.getClient(rule.serverId); - if (client == null || !serverManager.isServerOnline(rule.serverId)) { + final client = serverManager.getClient(ServerId(rule.serverId)); + if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) { appLogger.d('Skipping sync rule ${rule.globalKey} — server offline or unavailable'); return null; } @@ -219,7 +220,7 @@ class SyncRuleExecutor { return _executeEpisodeRule( rule: rule, client: client, - clientScopeId: _clientScopeIdFor(client, rule.serverId), + clientScopeId: _clientScopeIdFor(client, ServerId(rule.serverId)), profileId: rule.profileId, downloads: downloads, metadata: resolvedMetadata, @@ -230,7 +231,7 @@ class SyncRuleExecutor { return _executeListRule( rule: rule, client: client, - clientScopeId: _clientScopeIdFor(client, rule.serverId), + clientScopeId: _clientScopeIdFor(client, ServerId(rule.serverId)), profileId: rule.profileId, downloads: downloads, metadata: resolvedMetadata, @@ -242,7 +243,7 @@ class SyncRuleExecutor { } } - String? _clientScopeIdFor(MediaServerClient client, String serverId) { + String? _clientScopeIdFor(MediaServerClient client, ServerId serverId) { final cacheServerId = client.cacheServerId; return cacheServerId == serverId || cacheServerId.isEmpty ? null : cacheServerId; } @@ -280,7 +281,7 @@ class SyncRuleExecutor { final unwatchedEpisodes = await _excludeLocallyWatched( episodes: fromServer, - serverId: rule.serverId, + serverId: ServerId(rule.serverId), profileId: profileId, clientScopeId: clientScopeId, ); @@ -293,7 +294,7 @@ class SyncRuleExecutor { int alreadyHave = 0; for (final ep in unwatchedEpisodes) { - final gk = buildGlobalKey(rule.serverId, ep.id); + final gk = buildGlobalKey(ServerId(rule.serverId), ep.id); if (_isActiveDownload(downloads[gk])) alreadyHave++; } @@ -310,7 +311,7 @@ class SyncRuleExecutor { for (final ep in unwatchedEpisodes) { if (queued >= deficit) break; - final gk = buildGlobalKey(rule.serverId, ep.id); + final gk = buildGlobalKey(ServerId(rule.serverId), ep.id); if (_isActiveDownload(downloads[gk])) continue; final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId); @@ -369,7 +370,7 @@ class SyncRuleExecutor { final candidates = unwatchedOnly ? await _excludeLocallyWatched( episodes: collected, - serverId: rule.serverId, + serverId: ServerId(rule.serverId), profileId: profileId, clientScopeId: clientScopeId, ) @@ -383,7 +384,7 @@ class SyncRuleExecutor { int queued = 0; for (final item in candidates) { - final gk = buildGlobalKey(rule.serverId, item.id); + final gk = buildGlobalKey(ServerId(rule.serverId), item.id); if (_isActiveDownload(downloads[gk])) continue; final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId); @@ -454,12 +455,12 @@ class SyncRuleExecutor { /// user just marked watched on a downloaded-detail screen. Future> _excludeLocallyWatched({ required List episodes, - required String serverId, + required ServerId serverId, required String profileId, String? clientScopeId, }) async { if (episodes.isEmpty) return episodes; - final keys = episodes.map((ep) => buildGlobalKey(serverId, ep.id)).toSet(); + final keys = episodes.map((ep) => buildGlobalKey(ServerId(serverId), ep.id)).toSet(); final actions = await _database.getLatestWatchActionsForKeys( keys, profileId: profileId, @@ -468,7 +469,7 @@ class SyncRuleExecutor { ); if (actions.isEmpty) return episodes; return episodes.where((ep) { - final action = actions[buildGlobalKey(serverId, ep.id)]; + final action = actions[buildGlobalKey(ServerId(serverId), ep.id)]; if (action == null) return true; if (action.actionType == OfflineActionType.watched.id) return false; if (action.actionType == OfflineActionType.progress.id && action.shouldMarkWatched) return false; diff --git a/lib/services/trackers/anime_lists_mapping_store.dart b/lib/services/trackers/anime_lists_mapping_store.dart index d4f4d8a2..cc8f216f 100644 --- a/lib/services/trackers/anime_lists_mapping_store.dart +++ b/lib/services/trackers/anime_lists_mapping_store.dart @@ -326,10 +326,7 @@ AnimeListSeasonRef? _seasonRef(String? value) { List _intList(String? value) { if (value == null || value.isEmpty) return const []; - return [ - for (final part in value.split(',')) - if (flexibleInt(part.trim()) case final parsed?) parsed, - ]; + return [for (final part in value.split(',')) ?flexibleInt(part.trim())]; } List _stringList(String? value) { diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index 68b6e69b..7a3ec122 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -142,7 +142,7 @@ class SimklTracker extends TrackerBase { } Map _ratingBody(TrackerRatingContext ctx, Map ids, {int? rating}) { - final item = {'ids': ids, if (rating != null) 'rating': rating}; + final item = {'ids': ids, 'rating': ?rating}; return ctx.isMovie ? { 'movies': [item], diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 6b1bbe9c..2ecf4078 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -180,7 +180,7 @@ class TraktScrobbleService { Map _ratingBody(TrackerRatingContext ctx, {int? rating}) { final ids = TraktIds.fromExternal(ctx.ids.external).toJson(); - final item = {'ids': ids, if (rating != null) 'rating': rating}; + final item = {'ids': ids, 'rating': ?rating}; return switch (ctx.kind) { MediaKind.movie => { @@ -194,7 +194,7 @@ class TraktScrobbleService { { 'ids': ids, 'seasons': [ - {'number': ctx.season, if (rating != null) 'rating': rating}, + {'number': ctx.season, 'rating': ?rating}, ], }, ], @@ -207,7 +207,7 @@ class TraktScrobbleService { { 'number': ctx.season, 'episodes': [ - {'number': ctx.episodeNumber, if (rating != null) 'rating': rating}, + {'number': ctx.episodeNumber, 'rating': ?rating}, ], }, ], diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index 0d5c9e15..c1fe9da7 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../media/ids.dart'; import 'dart:collection'; import '../../media/media_item.dart'; @@ -97,7 +98,7 @@ class TraktSyncService { bool get _canPush => _isEnabled && _client != null; - TrackerIdResolver? _resolverFor(String serverId) { + TrackerIdResolver? _resolverFor(ServerId serverId) { final cached = _resolvers[serverId]; if (cached != null) return cached; @@ -112,7 +113,7 @@ class TraktSyncService { return resolver; } - MediaServerClient? _clientFor(String serverId) => _serverManager?.getClient(serverId); + MediaServerClient? _clientFor(ServerId serverId) => _serverManager?.getClient(serverId); Future _onWatchStateEvent(WatchStateEvent event) async { if (!_canPush) return; @@ -131,7 +132,7 @@ class TraktSyncService { await _push( op: op, ratingKey: event.itemId, - serverId: event.serverId, + serverId: ServerId(event.serverId), libraryGlobalKey: event.librarySectionGlobalKey, kind: TraktMediaKind.movie, watchedAtIso: watchedAtIso, @@ -140,7 +141,7 @@ class TraktSyncService { await _push( op: op, ratingKey: event.itemId, - serverId: event.serverId, + serverId: ServerId(event.serverId), libraryGlobalKey: event.librarySectionGlobalKey, kind: TraktMediaKind.episode, watchedAtIso: watchedAtIso, @@ -155,7 +156,7 @@ class TraktSyncService { required WatchStateEvent event, required String watchedAtIso, }) async { - final mediaClient = _clientFor(event.serverId); + final mediaClient = _clientFor(ServerId(event.serverId)); if (mediaClient == null) { appLogger.d('Trakt sync: no client registered for server ${event.serverId}, skipping ${event.mediaType}'); return; @@ -188,7 +189,7 @@ class TraktSyncService { await _push( op: op, ratingKey: episode.id, - serverId: event.serverId, + serverId: ServerId(event.serverId), libraryGlobalKey: episode.libraryGlobalKey ?? event.librarySectionGlobalKey, kind: TraktMediaKind.episode, watchedAtIso: watchedAtIso, @@ -200,13 +201,13 @@ class TraktSyncService { Future _push({ required TraktSyncOp op, required String ratingKey, - required String serverId, + required ServerId serverId, required String? libraryGlobalKey, required TraktMediaKind kind, required String watchedAtIso, MediaItem? episodeMeta, }) async { - final resolver = _resolverFor(serverId); + final resolver = _resolverFor(ServerId(serverId)); if (resolver == null) { appLogger.d('Trakt sync: no client registered for server $serverId, skipping'); return; @@ -223,7 +224,7 @@ class TraktSyncService { // doesn't carry the index, so fetch episode metadata via the neutral // MediaServerClient surface (Plex `/library/metadata`, Jellyfin // `/Users/{id}/Items/{id}`). - final mediaClient = _clientFor(serverId); + final mediaClient = _clientFor(ServerId(serverId)); if (mediaClient == null) return; final metadata = episodeMeta ?? await mediaClient.fetchItem(ratingKey); if (metadata == null) return; diff --git a/lib/services/watch_next_service.dart b/lib/services/watch_next_service.dart index c504e1bc..868520c5 100644 --- a/lib/services/watch_next_service.dart +++ b/lib/services/watch_next_service.dart @@ -1,4 +1,5 @@ import 'dart:io' show Platform; +import '../media/ids.dart'; import 'package:flutter/services.dart'; @@ -56,7 +57,7 @@ class WatchNextService { /// Sync On Deck items to Watch Next row. Future syncFromOnDeck( List onDeckItems, - MediaServerClient Function(String serverId) getClientForServerId, { + MediaServerClient Function(ServerId serverId) getClientForServerId, { bool hideSpoilers = false, }) async { if (!Platform.isAndroid) return false; @@ -88,7 +89,7 @@ class WatchNextService { } /// Remove a single item from Watch Next. - Future removeItem(String serverId, String ratingKey) async { + Future removeItem(ServerId serverId, String ratingKey) async { if (!Platform.isAndroid) return false; try { final contentId = _buildContentId(serverId, ratingKey); @@ -100,29 +101,29 @@ class WatchNextService { } /// Build a content ID. Format: plezy_{serverId}_{ratingKey} - static String _buildContentId(String? serverId, String ratingKey) { + static String _buildContentId(ServerId? serverId, String ratingKey) { return 'plezy_${serverId ?? 'unknown'}_$ratingKey'; } /// Parse a content ID back to (serverId, ratingKey), or null if invalid. - static (String serverId, String ratingKey)? parseContentId(String contentId) { + static (ServerId serverId, String ratingKey)? parseContentId(String contentId) { if (!contentId.startsWith('plezy_')) return null; final parts = contentId.substring(6).split('_'); if (parts.length < 2) return null; - return (parts.first, parts.sublist(1).join('_')); + return (ServerId(parts.first), parts.sublist(1).join('_')); } Map _convertToWatchNextItem( MediaItem item, - MediaServerClient Function(String serverId) getClientForServerId, { + MediaServerClient Function(ServerId serverId) getClientForServerId, { bool hideSpoilers = false, }) { - final contentId = _buildContentId(item.serverId, item.id); + final contentId = _buildContentId(serverIdOrNull(item.serverId), item.id); String? posterUri; try { if (item.serverId != null) { - final client = getClientForServerId(item.serverId!); + final client = getClientForServerId(ServerId(item.serverId!)); String? thumbPath; if (hideSpoilers && item.shouldHideSpoiler) { thumbPath = item.spoilerSafeArt; diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index aacfc3e3..2a8f0caf 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -86,20 +86,20 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { scrolledUnderElevation: 0, centerTitle: false, foregroundColor: c.text, - titleTextStyle: TextStyle(color: c.text, fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.2), + titleTextStyle: TextStyle(color: c.text, fontSize: 18, fontWeight: .w700, letterSpacing: -0.2), ), textTheme: Typography.englishLike2021 .apply(bodyColor: c.text, displayColor: c.text) .copyWith( - displayLarge: const TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.5), - titleMedium: const TextStyle(fontWeight: FontWeight.w600), + displayLarge: const TextStyle(fontWeight: .w700, letterSpacing: -0.5), + titleMedium: const TextStyle(fontWeight: .w600), bodyMedium: TextStyle(color: c.text), bodySmall: TextStyle(color: c.textMuted), ), cardTheme: CardThemeData( color: c.surface, elevation: 0, - margin: EdgeInsets.zero, + margin: .zero, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))), ), inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted), @@ -145,7 +145,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { actionTextColor: c.text, elevation: 6, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + insetPadding: const EdgeInsets.all(16), ), ); diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index 4809ab39..d9f50c84 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -1,4 +1,5 @@ import '../media/media_item.dart'; +import '../media/ids.dart'; import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; @@ -16,7 +17,7 @@ class DeletionEvent with HierarchicalEventMixin { /// Server this item belongs to @override - final String serverId; + final ServerId serverId; /// Parent chain for hierarchical invalidation /// For an episode: [seasonId, showId] @@ -43,7 +44,7 @@ class DeletionEvent with HierarchicalEventMixin { required this.mediaType, this.leafCount = 1, this.isDownloadOnly = false, - }) : globalKey = buildGlobalKey(serverId, itemId); + }) : globalKey = buildGlobalKey(ServerId(serverId), itemId); @override String toString() => 'DeletionEvent(deleted: $globalKey, type: $mediaType, parents: $parentChain)'; @@ -60,7 +61,7 @@ class DeletionNotifier extends BaseNotifier { DeletionNotifier._internal(); - Stream forServer(String serverId) => stream.where((e) => e.serverId == serverId); + Stream forServer(ServerId serverId) => stream.where((e) => e.serverId == serverId); Stream forItem(String itemId) => stream.where((e) => e.affectsItem(itemId)); @@ -75,7 +76,7 @@ class DeletionNotifier extends BaseNotifier { notify( DeletionEvent( itemId: item.id, - serverId: item.serverId ?? '', + serverId: ServerId(item.serverId ?? ''), parentChain: item.parentChain, mediaType: item.kind.id, leafCount: item.leafCount ?? 1, diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index 7b63b074..7c0f8792 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -78,7 +78,7 @@ class DesktopAppBarHelper { final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; final paddedWidget = Padding( - padding: EdgeInsets.only(left: leftPadding), + padding: .only(left: leftPadding), child: leading, ); @@ -167,7 +167,7 @@ class DesktopTitleBarPadding extends StatelessWidget { return child; } return Padding( - padding: EdgeInsets.only(right: right), + padding: .only(right: right), child: child, ); } @@ -186,7 +186,7 @@ class DesktopTitleBarPadding extends StatelessWidget { } return Padding( - padding: EdgeInsets.only(left: left, right: right), + padding: .only(left: left, right: right), child: child, ); }, diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 29312a43..9e79c565 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; @@ -139,11 +140,11 @@ Future showDownloadOptionsAndQueue( bool syncRuleUpdated = false; if (keepSynced) { final syncCount = maxCount ?? 0; // 0 means "all unwatched" for the rule - final ruleKey = downloadProvider.syncRuleKeyFor(metadata.serverId ?? client.serverId, metadata.id); + final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(metadata.serverId ?? client.serverId), metadata.id); syncRuleUpdated = downloadProvider.hasSyncRule(ruleKey); await downloadProvider.createSyncRule( - serverId: metadata.serverId ?? client.serverId, + serverId: ServerId(metadata.serverId ?? client.serverId), ratingKey: metadata.id, targetType: metadata.kind.id.isNotEmpty ? metadata.kind.id : ContentTypes.show, episodeCount: syncCount, @@ -212,13 +213,13 @@ Future showListDownloadOptionsAndQueue( bool syncRuleUpdated = false; if (syncChoice == _SyncChoice.keepSynced) { - final ruleKey = downloadProvider.syncRuleKeyFor(serverId, rootMetadata.id); + final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id); if (downloadProvider.hasSyncRule(ruleKey)) { await downloadProvider.updateSyncRuleFilter(ruleKey, filterString); syncRuleUpdated = true; } else { await downloadProvider.createSyncRule( - serverId: serverId, + serverId: ServerId(serverId), ratingKey: rootMetadata.id, targetType: targetType, episodeCount: 0, diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index c94c6126..d3cca20e 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -114,7 +114,7 @@ String formatSyncOffset(double offsetMs) { final absMs = offsetMs.abs().round(); final durationLocale = _getDurationLocale(); - if (absMs >= 10000) { + if (absMs >= 10_000) { final seconds = (offsetMs.abs() / 1000).toStringAsFixed(1); final unit = durationLocale.second(1, true); return '$sign$seconds$unit'; diff --git a/lib/utils/global_key_utils.dart b/lib/utils/global_key_utils.dart index f5a05000..4b513343 100644 --- a/lib/utils/global_key_utils.dart +++ b/lib/utils/global_key_utils.dart @@ -1,4 +1,6 @@ -String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKey'; +import '../media/ids.dart'; + +String buildGlobalKey(ServerId serverId, String ratingKey) => '$serverId:$ratingKey'; /// Separator used by profile-owned rows whose public media identity is still /// [buildGlobalKey]. Profile ids are generated by the app and do not contain @@ -6,7 +8,7 @@ String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKe const String profileScopedGlobalKeySeparator = '|'; /// Builds a profile-owned sync-rule key from [profileId] and public media id. -String buildProfileScopedGlobalKey(String profileId, String serverId, String ratingKey) { +String buildProfileScopedGlobalKey(String profileId, ServerId serverId, String ratingKey) { return '$profileId$profileScopedGlobalKeySeparator${buildGlobalKey(serverId, ratingKey)}'; } @@ -14,14 +16,14 @@ String buildProfileScopedGlobalKey(String profileId, String serverId, String rat /// /// Returns `null` if the key does not contain a colon separator. /// Uses [indexOf] so ratingKeys containing colons are handled correctly. -({String serverId, String ratingKey})? parseGlobalKey(String globalKey) { +({ServerId serverId, String ratingKey})? parseGlobalKey(String globalKey) { final idx = globalKey.indexOf(':'); if (idx < 0) return null; - return (serverId: globalKey.substring(0, idx), ratingKey: globalKey.substring(idx + 1)); + return (serverId: ServerId(globalKey.substring(0, idx)), ratingKey: globalKey.substring(idx + 1)); } /// Parses a profile-owned sync-rule key, returning `null` for legacy public keys. -({String profileId, String serverId, String ratingKey})? parseProfileScopedGlobalKey(String globalKey) { +({String profileId, ServerId serverId, String ratingKey})? parseProfileScopedGlobalKey(String globalKey) { final idx = globalKey.indexOf(profileScopedGlobalKeySeparator); if (idx < 0) return null; final publicKey = parseGlobalKey(globalKey.substring(idx + 1)); diff --git a/lib/utils/hierarchical_event_mixin.dart b/lib/utils/hierarchical_event_mixin.dart index e68832c1..e4b8f0a8 100644 --- a/lib/utils/hierarchical_event_mixin.dart +++ b/lib/utils/hierarchical_event_mixin.dart @@ -1,4 +1,5 @@ import 'global_key_utils.dart'; +import '../media/ids.dart'; /// Mixin providing hierarchical event matching methods. /// @@ -11,7 +12,7 @@ mixin HierarchicalEventMixin { String get globalKey; - String get serverId; + ServerId get serverId; /// Parent chain for hierarchical matching. /// For an episode: [seasonId, showId] diff --git a/lib/utils/jellyfin_time.dart b/lib/utils/jellyfin_time.dart index 799c04ec..cb9fea32 100644 --- a/lib/utils/jellyfin_time.dart +++ b/lib/utils/jellyfin_time.dart @@ -7,7 +7,7 @@ /// across mappers, the client, and the playback bundle. library; -const int _ticksPerMs = 10000; +const int _ticksPerMs = 10_000; /// Jellyfin ticks → milliseconds. Returns `null` for non-numeric input. int? jellyfinTicksToMs(Object? ticks) { diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index 65620b91..13db6fcc 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; @@ -92,7 +93,7 @@ Future tuneAndNavigateToLiveTv( return; } - final genericClient = multiServer.getClientForServer(serverInfo.serverId); + final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (genericClient == null) { showErrorSnackBar(context, 'Live TV server is not connected.'); return; @@ -112,7 +113,7 @@ Future tuneAndNavigateToLiveTv( return; } - final plexClient = multiServer.getPlexClientForServer(serverInfo.serverId); + final plexClient = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId)); if (plexClient == null) { appLogger.w('Failed to resolve live stream URL for ${channel.displayName} on ${genericClient.backend.id}'); showErrorSnackBar(context, 'Unable to start this live TV channel.'); diff --git a/lib/utils/media_hub_ordering.dart b/lib/utils/media_hub_ordering.dart index f090ebde..fadb4c6e 100644 --- a/lib/utils/media_hub_ordering.dart +++ b/lib/utils/media_hub_ordering.dart @@ -1,4 +1,5 @@ import '../media/media_hub.dart'; +import '../media/ids.dart'; import '../media/media_library.dart'; import 'global_key_utils.dart'; @@ -35,13 +36,13 @@ bool sortMediaHubsByLibraryOrder(List hubs, List library } int? _hubLibraryOrderIndex(MediaHub hub, Map orderByGlobalKey) { - final hubLibraryKey = _globalKey(hub.serverId, hub.libraryId); + final hubLibraryKey = _globalKey(serverIdOrNull(hub.serverId), hub.libraryId); final hubIndex = hubLibraryKey == null ? null : orderByGlobalKey[hubLibraryKey]; if (hubIndex != null) return hubIndex; int? bestIndex; for (final item in hub.items) { - final key = _globalKey(item.serverId ?? hub.serverId, item.libraryId); + final key = _globalKey(serverIdOrNull(item.serverId ?? hub.serverId), item.libraryId); final index = key == null ? null : orderByGlobalKey[key]; if (index != null && (bestIndex == null || index < bestIndex)) { bestIndex = index; @@ -50,7 +51,7 @@ int? _hubLibraryOrderIndex(MediaHub hub, Map orderByGlobalKey) { return bestIndex; } -String? _globalKey(String? serverId, String? libraryId) { +String? _globalKey(ServerId? serverId, String? libraryId) { if (serverId == null || libraryId == null) return null; - return buildGlobalKey(serverId, libraryId); + return buildGlobalKey(ServerId(serverId), libraryId); } diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 6f59c4d9..ba35b3d1 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_playlist.dart'; @@ -72,7 +73,7 @@ Future navigateToMediaItem( if (mi.isLibrarySection) { final sectionKey = mi.librarySectionKey; if (sectionKey != null && mi.serverId != null) { - final libraryGlobalKey = buildGlobalKey(mi.serverId!, sectionKey); + final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), sectionKey); 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 d0ed9455..5fb9fb01 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -399,7 +399,7 @@ class MediaServerHttpClient { void add(String key, Object? value) { if (value == null) return; - if (value is Iterable && value is! String) { + if (value is Iterable) { for (final item in value) { add(key, item); } diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 9cee9e01..10a09c22 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import 'package:provider/provider.dart'; import '../media/media_item.dart'; import '../media/media_library.dart'; @@ -24,7 +25,7 @@ extension ProviderExtensions on BuildContext { /// Plex-only flows that have no neutral equivalent (DVR tuning, match). /// Backend-agnostic flows use the [_resolveMediaClient] /// helpers below. - PlexClient? _resolveClient(String? serverId) { + PlexClient? _resolveClient(ServerId? serverId) { final provider = Provider.of(this, listen: false); return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getPlexClientForServer); } @@ -32,7 +33,7 @@ extension ProviderExtensions on BuildContext { /// Internal: like [_resolveClient] but throws a localized exception when /// no client is available. The thrown message is the canonical /// `t.errors.noClientAvailable` so callers can surface it directly. - PlexClient _requireClient(String? serverId, {bool fallback = true}) { + PlexClient _requireClient(ServerId? serverId, {bool fallback = true}) { final provider = Provider.of(this, listen: false); if (serverId != null) { final client = provider.getPlexClientForServer(serverId); @@ -49,17 +50,17 @@ extension ProviderExtensions on BuildContext { return client; } - PlexClient getPlexClientForServer(String serverId) => _requireClient(serverId, fallback: false); + PlexClient getPlexClientForServer(ServerId serverId) => _requireClient(serverId, fallback: false); - PlexClient? tryGetPlexClientForServer(String? serverId) { + PlexClient? tryGetPlexClientForServer(ServerId? serverId) { if (serverId == null) return null; final provider = Provider.of(this, listen: false); return provider.getPlexClientForServer(serverId); } - PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(library.serverId); + PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(serverIdOrNull(library.serverId)); - PlexClient getPlexClientWithFallback(String? serverId) => _requireClient(serverId); + PlexClient getPlexClientWithFallback(ServerId? serverId) => _requireClient(serverId); // ── Backend-neutral helpers ────────────────────────────────────── // These return [MediaServerClient] regardless of backend kind so callers @@ -68,12 +69,12 @@ extension ProviderExtensions on BuildContext { // when you specifically need a [PlexClient] (Plex-only flows like Live TV or // match/fix-match). - MediaServerClient? _resolveMediaClient(String? serverId) { + MediaServerClient? _resolveMediaClient(ServerId? serverId) { final provider = Provider.of(this, listen: false); return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getClientForServer); } - MediaServerClient? tryGetMediaClientForServer(String? serverId) { + MediaServerClient? tryGetMediaClientForServer(ServerId? serverId) { if (serverId == null) return null; final provider = Provider.of(this, listen: false); return provider.getClientForServer(serverId); @@ -82,14 +83,14 @@ extension ProviderExtensions on BuildContext { /// Get a [MediaServerClient] for the given serverId. Throws when the /// server isn't registered or is offline. Mirrors the throwing variant of /// the Plex-typed [getPlexClientForServer] helpers. - MediaServerClient getMediaClientForServer(String serverId) { + MediaServerClient getMediaClientForServer(ServerId serverId) { final c = tryGetMediaClientForServer(serverId); if (c == null) throw Exception(t.errors.noClientAvailable); return c; } MediaServerClient getMediaClientForLibrary(MediaLibrary library) { - final c = _resolveMediaClient(library.serverId); + final c = _resolveMediaClient(serverIdOrNull(library.serverId)); if (c == null) throw Exception(t.errors.noClientAvailable); return c; } @@ -98,12 +99,12 @@ extension ProviderExtensions on BuildContext { /// when the server isn't online. MediaServerClient? getMediaClientForItemOrNull(MediaItem item, {bool isOffline = false}) { if (isOffline) return null; - return tryGetMediaClientForServer(item.serverId); + return tryGetMediaClientForServer(serverIdOrNull(item.serverId)); } /// Get a [MediaServerClient] for [serverId], falling back to the first /// online server when not found. Throws if no client is available. - MediaServerClient getMediaClientWithFallback(String? serverId) { + MediaServerClient getMediaClientWithFallback(ServerId? serverId) { final c = _resolveMediaClient(serverId); if (c == null) throw Exception(t.errors.noClientAvailable); return c; @@ -113,19 +114,19 @@ extension ProviderExtensions on BuildContext { /// when no client is registered. Use this for non-critical surfaces (image /// loaders, list cards) that can render a fallback when the client isn't /// available — throwing during `build` would crash the widget instead. - MediaServerClient? tryGetMediaClientWithFallback(String? serverId) => _resolveMediaClient(serverId); + MediaServerClient? tryGetMediaClientWithFallback(ServerId? serverId) => _resolveMediaClient(serverId); } /// Try [preferred] first, then fall back through [fallbacks] in order. Returns /// the first non-null result from [resolve], or `null` if every candidate /// resolves to null. -T? _resolvePrioritized(String? preferred, Iterable fallbacks, T? Function(String) resolve) { +T? _resolvePrioritized(String? preferred, Iterable fallbacks, T? Function(ServerId) resolve) { if (preferred != null) { - final c = resolve(preferred); + final c = resolve(ServerId(preferred)); if (c != null) return c; } for (final id in fallbacks) { - final c = resolve(id); + final c = resolve(ServerId(id)); if (c != null) return c; } return null; diff --git a/lib/utils/search_relevance.dart b/lib/utils/search_relevance.dart index 557f446e..f67f3011 100644 --- a/lib/utils/search_relevance.dart +++ b/lib/utils/search_relevance.dart @@ -34,8 +34,8 @@ double mediaSearchRelevanceScore(MediaItem item, String query) { (value: item.title, weight: 1.0), (value: item.titleSort, weight: 0.98), (value: item.originalTitle, weight: 0.96), - (value: item.grandparentTitle, weight: 0.90), - (value: item.parentTitle, weight: 0.80), + (value: item.grandparentTitle, weight: 0.9), + (value: item.parentTitle, weight: 0.8), ]; var best = 0.0; diff --git a/lib/utils/smart_deletion_handler.dart b/lib/utils/smart_deletion_handler.dart index 5f1262a5..6fcd39f5 100644 --- a/lib/utils/smart_deletion_handler.dart +++ b/lib/utils/smart_deletion_handler.dart @@ -45,7 +45,7 @@ class SmartDeletionHandler { if (progress == null) { return AlertDialog( content: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [const CircularProgressIndicator(), const SizedBox(width: 20), Text(t.downloads.deleting)], ), ); diff --git a/lib/utils/update_dialog.dart b/lib/utils/update_dialog.dart index c9af2c71..a26b1b55 100644 --- a/lib/utils/update_dialog.dart +++ b/lib/utils/update_dialog.dart @@ -21,8 +21,8 @@ Future showUpdateAvailableDialog( return AlertDialog( title: Text(title), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text( t.update.versionAvailable(version: latestVersion), diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 89ae31aa..6ed3bc21 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; @@ -65,8 +66,8 @@ Future navigateToVideoPlayer( final manager = context.read().serverManager; final offlineWatchService = context.read(); final serverId = metadata.serverId ?? ''; - final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(serverId)) - ? manager.getClient(serverId) + final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(ServerId(serverId))) + ? manager.getClient(ServerId(serverId)) : null; int mediaIndex = selectedMediaIndex ?? 0; @@ -203,11 +204,11 @@ Future navigateToVideoPlayerWithRefresh( Future navigateToWatchTogetherPlayback( BuildContext context, { required String ratingKey, - required String serverId, + required ServerId serverId, VoidCallback? onBeforeNavigate, }) async { final multiServer = context.read(); - final client = multiServer.getClientForServer(serverId); + final client = multiServer.getClientForServer(ServerId(serverId)); if (client == null) { throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable'); diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index abfd9956..70414258 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -1,4 +1,5 @@ import '../media/media_item.dart'; +import '../media/ids.dart'; import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; @@ -18,7 +19,7 @@ class WatchStateEvent with HierarchicalEventMixin { /// Server this item belongs to @override - final String serverId; + final ServerId serverId; /// Optional backend-private cache namespace for user-scoped servers. /// @@ -60,12 +61,13 @@ class WatchStateEvent with HierarchicalEventMixin { this.viewOffset, this.isNowWatched, this.librarySectionID, - }) : globalKey = buildGlobalKey(serverId, itemId); + }) : globalKey = buildGlobalKey(ServerId(serverId), itemId); /// `serverId:librarySectionID`, matching [MediaLibrary.globalKey]. Null when /// the library section is unknown; tracker filters treat unknown as allowed /// only when no filter is configured. - String? get librarySectionGlobalKey => librarySectionID != null ? buildGlobalKey(serverId, librarySectionID!) : null; + String? get librarySectionGlobalKey => + librarySectionID != null ? buildGlobalKey(ServerId(serverId), librarySectionID!) : null; @override String toString() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)'; @@ -82,7 +84,7 @@ class WatchStateNotifier extends BaseNotifier { WatchStateNotifier._internal(); - Stream forServer(String serverId) => stream.where((e) => e.serverId == serverId); + Stream forServer(ServerId serverId) => stream.where((e) => e.serverId == serverId); Stream forItem(String itemId) => stream.where((e) => e.affectsItem(itemId)); @@ -98,7 +100,7 @@ class WatchStateNotifier extends BaseNotifier { notify( WatchStateEvent( itemId: item.id, - serverId: item.serverId ?? '', + serverId: ServerId(item.serverId ?? ''), cacheServerId: cacheServerId, changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched, parentChain: item.parentChain, @@ -123,7 +125,7 @@ class WatchStateNotifier extends BaseNotifier { notify( WatchStateEvent( itemId: item.id, - serverId: item.serverId ?? '', + serverId: ServerId(item.serverId ?? ''), changeType: WatchStateChangeType.progressUpdate, parentChain: item.parentChain, mediaType: item.kind.id, @@ -139,7 +141,7 @@ class WatchStateNotifier extends BaseNotifier { notify( WatchStateEvent( itemId: item.id, - serverId: item.serverId ?? '', + serverId: ServerId(item.serverId ?? ''), changeType: WatchStateChangeType.removedFromContinueWatching, parentChain: item.parentChain, mediaType: item.kind.id, diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 74e04d3b..1d12971c 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../media/ids.dart'; import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -12,7 +13,7 @@ import '../services/watch_together_peer_service.dart'; import '../services/watch_together_sync_manager.dart'; /// Callback type for when media switches (for guest navigation) -typedef MediaSwitchCallback = void Function(String ratingKey, String serverId, String mediaTitle); +typedef MediaSwitchCallback = void Function(String ratingKey, ServerId serverId, String mediaTitle); /// Provider for Watch Together functionality /// @@ -111,14 +112,14 @@ class WatchTogetherProvider with ChangeNotifier { _displayName = name; } - String? _buildPlaybackKey(String? ratingKey, String? serverId) { + String? _buildPlaybackKey(String? ratingKey, ServerId? serverId) { if (ratingKey == null || serverId == null) return null; return '$serverId:$ratingKey'; } void _updateCurrentPlaybackSnapshot({ required String ratingKey, - required String serverId, + required ServerId serverId, required String mediaTitle, }) { _session = _session?.copyWith(mediaRatingKey: ratingKey, mediaServerId: serverId, mediaTitle: mediaTitle); @@ -141,7 +142,7 @@ class WatchTogetherProvider with ChangeNotifier { void _dispatchCurrentPlayback({ required String ratingKey, - required String serverId, + required ServerId serverId, required String mediaTitle, required String source, }) { @@ -151,12 +152,12 @@ class WatchTogetherProvider with ChangeNotifier { return; } - _lastHandledCurrentPlaybackKey = _buildPlaybackKey(ratingKey, serverId); + _lastHandledCurrentPlaybackKey = _buildPlaybackKey(ratingKey, ServerId(serverId)); appLogger.d('WatchTogether: Dispatching current playback from $source: $mediaTitle'); - callback(ratingKey, serverId, mediaTitle); + callback(ratingKey, ServerId(serverId), mediaTitle); } - void markCurrentPlaybackHandled({required String ratingKey, required String serverId}) { + void markCurrentPlaybackHandled({required String ratingKey, required ServerId serverId}) { _lastHandledCurrentPlaybackKey = _buildPlaybackKey(ratingKey, serverId); } @@ -612,12 +613,12 @@ class WatchTogetherProvider with ChangeNotifier { } if (message.ratingKey != null && message.serverId != null && message.mediaTitle != null) { - final playbackKey = _buildPlaybackKey(message.ratingKey, message.serverId); + final playbackKey = _buildPlaybackKey(message.ratingKey, serverIdOrNull(message.serverId)); final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; _updateCurrentPlaybackSnapshot( ratingKey: message.ratingKey!, - serverId: message.serverId!, + serverId: ServerId(message.serverId!), mediaTitle: message.mediaTitle!, ); notifyListeners(); @@ -625,7 +626,7 @@ class WatchTogetherProvider with ChangeNotifier { if (shouldDispatch) { _dispatchCurrentPlayback( ratingKey: message.ratingKey!, - serverId: message.serverId!, + serverId: ServerId(message.serverId!), mediaTitle: message.mediaTitle!, source: 'session config', ); @@ -649,7 +650,7 @@ class WatchTogetherProvider with ChangeNotifier { /// /// Call this when the host starts playing new content. /// Guests will receive a media switch notification and should navigate. - void setCurrentMedia({required String ratingKey, required String serverId, required String mediaTitle}) { + void setCurrentMedia({required String ratingKey, required ServerId serverId, required String mediaTitle}) { if (!isHost || _session == null || _peerService == null) { appLogger.w('WatchTogether: Cannot set media - not host or not in session'); return; @@ -682,12 +683,12 @@ class WatchTogetherProvider with ChangeNotifier { return; } - final playbackKey = _buildPlaybackKey(message.ratingKey, message.serverId); + final playbackKey = _buildPlaybackKey(message.ratingKey, serverIdOrNull(message.serverId)); final shouldDispatch = playbackKey != _lastHandledCurrentPlaybackKey; _updateCurrentPlaybackSnapshot( ratingKey: message.ratingKey!, - serverId: message.serverId!, + serverId: ServerId(message.serverId!), mediaTitle: message.mediaTitle!, ); notifyListeners(); @@ -700,7 +701,7 @@ class WatchTogetherProvider with ChangeNotifier { appLogger.d('WatchTogether: Received media switch: ${message.mediaTitle}'); _dispatchCurrentPlayback( ratingKey: message.ratingKey!, - serverId: message.serverId!, + serverId: ServerId(message.serverId!), mediaTitle: message.mediaTitle!, source: 'media switch', ); diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index b098f44f..022d095f 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import '../../media/ids.dart'; import 'package:flutter/material.dart'; import '../../utils/future_extensions.dart'; @@ -127,7 +128,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta child: Padding( padding: const EdgeInsets.all(24), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Icon(Symbols.group_rounded, size: 80, color: theme.colorScheme.primary), const SizedBox(height: 24), @@ -187,7 +188,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta if (_recentRooms.isNotEmpty) ...[ const SizedBox(height: 32), Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Text(t.watchTogether.recentRooms, style: theme.textTheme.titleSmall), ), const SizedBox(height: 8), @@ -411,7 +412,7 @@ class _RecentRoomTile extends StatelessWidget { child: ListTile( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), leading: isEntering ? const LoadingIndicatorBox(size: 24) : const Icon(Symbols.meeting_room_rounded), - title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(title, maxLines: 1, overflow: .ellipsis), subtitle: room.name != null ? Text( room.code, @@ -431,7 +432,7 @@ class _RecentRoomTile extends StatelessWidget { context, builder: (context) => SafeArea( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ ListTile( leading: const Icon(Symbols.edit_rounded), @@ -467,13 +468,13 @@ class _ActiveSessionContent extends StatelessWidget { final session = watchTogether.session!; return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ @@ -484,7 +485,7 @@ class _ActiveSessionContent extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( watchTogether.isHost ? t.watchTogether.hostingSession : t.watchTogether.inSession, @@ -528,7 +529,7 @@ class _ActiveSessionContent extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ @@ -650,9 +651,9 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { await navigateToWatchTogetherPlayback( context, ratingKey: ratingKey, - serverId: serverId, + serverId: ServerId(serverId), onBeforeNavigate: () { - widget.watchTogether.markCurrentPlaybackHandled(ratingKey: ratingKey, serverId: serverId); + widget.watchTogether.markCurrentPlaybackHandled(ratingKey: ratingKey, serverId: ServerId(serverId)); }, ); } catch (e, stackTrace) { @@ -679,7 +680,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ @@ -687,7 +688,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(t.watchTogether.currentPlayback, style: theme.textTheme.titleMedium), const SizedBox(height: 4), @@ -743,7 +744,7 @@ class _SessionCodeRow extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text( '${t.watchTogether.sessionCode}: $sessionId', diff --git a/lib/watch_together/services/watch_together_sync_manager.dart b/lib/watch_together/services/watch_together_sync_manager.dart index 73fa33c1..38391322 100644 --- a/lib/watch_together/services/watch_together_sync_manager.dart +++ b/lib/watch_together/services/watch_together_sync_manager.dart @@ -379,7 +379,7 @@ class WatchTogetherSyncManager { final t3 = DateTime.now().millisecondsSinceEpoch; final rtt = t3 - t1; - if (rtt < 0 || rtt > 10000) { + if (rtt < 0 || rtt > 10_000) { appLogger.w('WatchTogether: Discarding clock sample with RTT=${rtt}ms'); return; } diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index a3194ed9..44d03642 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -42,8 +42,8 @@ class _JoinSessionDialogState extends State with ControllerDi child: Form( key: _formKey, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ Row( children: [ diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 44caf873..081720b5 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -70,7 +70,7 @@ class _SessionIndicator extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ // Sync indicator or group icon if (isSyncing) @@ -89,7 +89,7 @@ class _SessionIndicator extends StatelessWidget { // Participant count Text( '$participantCount', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14), + style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 14), ), // Host badge @@ -103,7 +103,7 @@ class _SessionIndicator extends StatelessWidget { ), child: Text( t.watchTogether.hostBadge, - style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold), + style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: .bold), ), ), ], @@ -129,8 +129,8 @@ class _SessionMenuSheet extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ // Header Row( @@ -139,7 +139,7 @@ class _SessionMenuSheet extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(t.watchTogether.title, style: theme.textTheme.titleMedium), Text( @@ -179,7 +179,7 @@ class _SessionMenuSheet extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text( '${t.watchTogether.sessionCode}: ', @@ -187,10 +187,7 @@ class _SessionMenuSheet extends StatelessWidget { ), Text( provider.sessionId!, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - ), + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontWeight: .bold), ), const SizedBox(width: 8), Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant), @@ -229,7 +226,7 @@ class _SessionMenuSheet extends StatelessWidget { ) : null, dense: true, - contentPadding: EdgeInsets.zero, + contentPadding: .zero, ), ), @@ -248,7 +245,7 @@ class _SessionMenuSheet extends StatelessWidget { OverlaySheetController.of(context).close(); _confirmLeave(context); }, - contentPadding: EdgeInsets.zero, + contentPadding: .zero, ), ], ), @@ -332,7 +329,7 @@ class _ParticipantNotificationOverlayState extends State t.watchTogether.participantJoined(name: n.event.displayName), @@ -412,7 +409,7 @@ class _StatusPill extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.all(Radius.circular(20))), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ SizedBox( width: 14, diff --git a/lib/widgets/auth_error_banner.dart b/lib/widgets/auth_error_banner.dart index ed3fe2d1..8be6605b 100644 --- a/lib/widgets/auth_error_banner.dart +++ b/lib/widgets/auth_error_banner.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -23,7 +24,7 @@ class AuthErrorBanner extends StatelessWidget { @override Widget build(BuildContext context) { - final entries = context.select>( + final entries = context.select>( (p) => p.authErrorServers, ); if (entries.isEmpty) return const SizedBox.shrink(); @@ -47,10 +48,7 @@ class AuthErrorBanner extends StatelessWidget { Expanded( child: Text( label, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onErrorContainer, - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onErrorContainer, fontWeight: .w500), ), ), const SizedBox(width: 8), diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index ce760aba..24c75c39 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -80,7 +80,7 @@ class BottomSheetHeader extends StatelessWidget { resolvedLeading = AppIcon(icon!, fill: 1, color: iconColor); } - final effectiveTitleStyle = titleStyle ?? TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: titleColor); + final effectiveTitleStyle = titleStyle ?? TextStyle(fontSize: 18, fontWeight: .bold, color: titleColor); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index bf79f47e..b1aa89cb 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -161,7 +161,7 @@ class _CollapsibleTextState extends State { final isSmall = widget.small; return Container( margin: const EdgeInsets.only(left: 6), - padding: EdgeInsets.symmetric(horizontal: isSmall ? 6 : 8, vertical: isSmall ? 0 : 2), + padding: .symmetric(horizontal: isSmall ? 6 : 8, vertical: isSmall ? 0 : 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.12), borderRadius: BorderRadius.all(Radius.circular(isSmall ? 8 : 10)), @@ -170,7 +170,7 @@ class _CollapsibleTextState extends State { '\u00B7\u00B7\u00B7', style: TextStyle( fontSize: isSmall ? 10 : 12, - fontWeight: FontWeight.bold, + fontWeight: .bold, color: Theme.of(context).colorScheme.onSurfaceVariant, letterSpacing: isSmall ? 1.5 : 2, ), diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index cf5496ba..5ff21b95 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -201,7 +201,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi return SingleChildScrollView( padding: const EdgeInsets.all(24.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ Text( t.companionRemote.pairing.discoveryDescription, @@ -301,7 +301,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi } return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(t.companionRemote.pairing.availableDevices, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), @@ -322,7 +322,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi Widget _buildManualEntrySection() { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ FocusableWrapper( focusNode: _manualToggleFocusNode, @@ -359,7 +359,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi Form( key: _formKey, child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ FocusableTextFormField( controller: _hostAddressController, diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index f046ead9..bf60bdcf 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -98,7 +98,7 @@ class _RemoteSessionDialogState extends State with MountedS child: Padding( padding: const EdgeInsets.all(32.0), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), @@ -113,8 +113,8 @@ class _RemoteSessionDialogState extends State with MountedS return AlertDialog( title: Text(t.common.error), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text(t.companionRemote.session.failedToCreate), const SizedBox(height: 8), @@ -149,8 +149,8 @@ class _RemoteSessionDialogState extends State with MountedS child: SingleChildScrollView( padding: const EdgeInsets.all(24.0), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ Row( children: [ @@ -158,7 +158,7 @@ class _RemoteSessionDialogState extends State with MountedS const SizedBox(width: 16), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(t.companionRemote.title, style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 4), @@ -187,7 +187,7 @@ class _RemoteSessionDialogState extends State with MountedS const SizedBox(height: 24), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: .end, children: [ FocusableButton( autofocus: true, @@ -264,7 +264,7 @@ class _RemoteSessionDialogState extends State with MountedS const SizedBox(width: 16), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( isRunning ? t.companionRemote.session.serverRunning : t.companionRemote.session.serverStopped, diff --git a/lib/widgets/deletion_progress_dialog.dart b/lib/widgets/deletion_progress_dialog.dart index e19f48fa..22286b58 100644 --- a/lib/widgets/deletion_progress_dialog.dart +++ b/lib/widgets/deletion_progress_dialog.dart @@ -15,7 +15,7 @@ class DeletionProgressDialog extends StatelessWidget { canPop: false, // Prevent back button dismissal child: AlertDialog( content: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const SizedBox(width: 48, height: 48, child: CircularProgressIndicator()), diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index dc29c165..c1422181 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -39,8 +39,8 @@ class DeviceCodeDialog extends StatelessWidget { return AlertDialog( title: Text(t.trackers.deviceCode.title(service: serviceName)), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text(t.trackers.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium), const SizedBox(height: 16), @@ -55,7 +55,7 @@ class DeviceCodeDialog extends StatelessWidget { style: theme.textTheme.displaySmall?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], letterSpacing: 4, - fontWeight: FontWeight.w600, + fontWeight: .w600, ), ), ), diff --git a/lib/widgets/download_status_icon.dart b/lib/widgets/download_status_icon.dart index d8bac88c..59a4abd1 100644 --- a/lib/widgets/download_status_icon.dart +++ b/lib/widgets/download_status_icon.dart @@ -73,7 +73,7 @@ class DownloadStatusIcon extends StatelessWidget { width: size, height: size, child: Stack( - alignment: Alignment.center, + alignment: .center, children: [ CircularProgressIndicator( value: 1.0, diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 5e2d73a7..c7fa0457 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -1,4 +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 '../focus/focusable_wrapper.dart'; @@ -111,7 +112,7 @@ class _DownloadTreeViewState extends State { } return ListView.builder( - padding: EdgeInsets.zero, + padding: .zero, itemCount: flattenedNodes.length, itemBuilder: (context, index) { final item = flattenedNodes[index]; @@ -454,11 +455,11 @@ String? resolveDownloadContainerGlobalKey(DownloadTreeNode node, Map { final hasActions = _buttonFocusNodes.isNotEmpty; return Padding( - padding: EdgeInsets.only(left: widget.depth * 16.0), + padding: .only(left: widget.depth * 16.0), child: FocusableWrapper( focusNode: _rowFocusNode, autofocus: widget.autofocus, @@ -688,8 +689,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { // Title and info Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text( widget.node.title, @@ -697,7 +698,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { fontWeight: canExpand ? FontWeight.w600 : FontWeight.normal, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (canExpand) ...[ @@ -742,7 +743,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { widget.node.downloadProgress!.errorMessage!, style: theme.textTheme.bodySmall?.copyWith(color: Colors.red.withValues(alpha: 0.8)), maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ], @@ -767,7 +768,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { final actions = isContainer ? _buildContainerActions() : _buildItemActions(); - return Row(mainAxisSize: MainAxisSize.min, children: actions); + return Row(mainAxisSize: .min, children: actions); } List _buildItemActions() { diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index e5e2377e..019cd40c 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -85,7 +85,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin 0) ...[ dot, const Padding( - padding: EdgeInsets.only(top: 2), + padding: .only(top: 2), child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber), ), const SizedBox(width: 2), @@ -153,7 +153,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin( selector: (_, p) => @@ -284,7 +284,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin { padding: const EdgeInsets.all(16), children: [ if (widget.title.isNotEmpty) ...[ - Text(widget.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + Text(widget.title, style: const TextStyle(fontSize: 16, fontWeight: .w500)), const SizedBox(height: 20), ], @@ -109,7 +109,7 @@ class _FileInfoBottomSheetState extends State { } Widget _buildSectionHeader(String title) { - return Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)); + return Text(title, style: const TextStyle(fontSize: 18, fontWeight: .bold)); } Widget _buildInfoRow(String label, String value, {bool isMonospace = false}) { @@ -158,7 +158,7 @@ class _FocusableInfoRowState extends State<_FocusableInfoRow> { child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox( width: 140, diff --git a/lib/widgets/focusable_filter_chip.dart b/lib/widgets/focusable_filter_chip.dart index d1f5aa85..e976d80b 100644 --- a/lib/widgets/focusable_filter_chip.dart +++ b/lib/widgets/focusable_filter_chip.dart @@ -107,7 +107,7 @@ class _FocusableFilterChipState extends State with Focusabl padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), backgroundColor: backgroundColor, child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(icon, fill: 1, size: 16, color: foregroundColor), const SizedBox(width: 6), diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index 989c6d6d..f18d4a08 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -153,7 +153,7 @@ class _FocusableTabChipState extends State with FocusableChipS borderRadius: hasImage ? 12 : 20, child: hasImage ? Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ ClipRRect(borderRadius: BorderRadius.circular(6), child: widget.topImage!), const SizedBox(height: 6), diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 9690a710..8decad38 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -394,10 +394,10 @@ class HubSectionState extends State with MountedSetStateMixin { ).textTheme.titleLarge?.copyWith(fontSize: isTv ? 26 : null, fontWeight: isTv ? FontWeight.w700 : null); return Padding( - padding: EdgeInsets.only(bottom: isTv && !widget.inset ? TvLayoutConstants.shelfVerticalGap : 0), + padding: .only(bottom: isTv && !widget.inset ? TvLayoutConstants.shelfVerticalGap : 0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ // Hub header (NOT focusable - titles should not be focusable) Padding( @@ -414,12 +414,12 @@ class HubSectionState extends State with MountedSetStateMixin { ? const EdgeInsets.symmetric(vertical: 2) : const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(widget.icon, fill: 1, size: isTv ? 28 : null), SizedBox(width: isTv ? 12 : 8), Flexible( - child: Text(widget.hub.title, style: titleStyle, overflow: TextOverflow.ellipsis, maxLines: 1), + child: Text(widget.hub.title, style: titleStyle, overflow: .ellipsis, maxLines: 1), ), if (widget.showServerName && widget.hub.serverName != null) ...[ const SizedBox(width: 8), @@ -523,7 +523,7 @@ class HubSectionState extends State with MountedSetStateMixin { height: containerHeight - 10, child: Center( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Icon( Symbols.arrow_forward_rounded, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index a66e8cfd..10df29d7 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,4 +1,5 @@ import 'dart:ui'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; @@ -199,7 +200,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin _navigateToDetail(context, item, isOffline: widget.isOffline), ) else Text( item is MediaPlaylist ? item.title : (item as MediaItem).displayTitle, maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1), + overflow: .ellipsis, + style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1), ), // Subtitle if (item is MediaPlaylist) @@ -581,7 +582,7 @@ class _MediaCardList extends StatelessWidget { ), Text('$episodeNum · ', style: style), Expanded( - child: Text(episodeTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: style), + child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: style), ), ], ); @@ -604,7 +605,7 @@ class _MediaCardList extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox( width: _posterWidth(), @@ -628,32 +629,32 @@ class _MediaCardList extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: .start, + mainAxisAlignment: .start, children: [ if (item is MediaItem && _hasClickableTitle(item as MediaItem)) _ClickableText( text: (item as MediaItem).displayTitle, - style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2), + style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2), onTap: () => _navigateToDetail(context, item as MediaItem, isOffline: isOffline), ) else Text( _displayTitle(), maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2), + overflow: .ellipsis, + style: TextStyle(fontWeight: .w600, fontSize: _titleFontSize, height: 1.2), ), const SizedBox(height: 4), if (metadataLine.isNotEmpty) ...[ Text( metadataLine, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: tokens(context).textMuted.withValues(alpha: 0.9), fontSize: _metadataFontSize, - fontWeight: FontWeight.w500, + fontWeight: .w500, ), ), const SizedBox(height: 2), @@ -668,7 +669,7 @@ class _MediaCardList extends StatelessWidget { Text( subtitle, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: tokens(context).textMuted.withValues(alpha: 0.85), fontSize: _subtitleFontSize, @@ -683,7 +684,7 @@ class _MediaCardList extends StatelessWidget { Text( _summary()!, maxLines: _summaryMaxLines, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: tokens(context).textMuted.withValues(alpha: 0.7), fontSize: _summaryFontSize, @@ -705,7 +706,7 @@ class _MediaCardList extends StatelessWidget { child: Text( (item as MediaItem).serverName!, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: tokens(context).textMuted.withValues(alpha: 0.6), fontSize: _metadataFontSize, @@ -750,7 +751,7 @@ Widget _buildPosterImage( posterUrl = item.displayImagePath; return OptimizedMediaImage.playlist( - client: isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId), + client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), imagePath: posterUrl, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, @@ -768,7 +769,7 @@ Widget _buildPosterImage( final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext); final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl); posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl; - final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId); + final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)); final fallbackIcon = _mediaPosterFallbackIcon(item); Widget image; @@ -831,7 +832,7 @@ class _MediaCardHelpers { return Text( t.playlists.itemCount(count: playlist.leafCount!), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1), @@ -853,7 +854,7 @@ class _MediaCardHelpers { return Text( t.playlists.itemCount(count: count), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: subtitleStyle, ); } @@ -874,7 +875,7 @@ class _MediaCardHelpers { ), Text('$episodeSuffix · ', style: subtitleStyle), Expanded( - child: Text(episodeTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle), + child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: subtitleStyle), ), ], ); @@ -882,22 +883,22 @@ class _MediaCardHelpers { return Text( 'S${mi.parentIndex}$episodeSuffix · $episodeTitle', maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: subtitleStyle, ); } // For other media types, show subtitle/parent/year if (mi.displaySubtitle != null) { - return Text(mi.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle); + return Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle); } else if (mi.parentTitle != null) { - return Text(mi.parentTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle); + return Text(mi.parentTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle); } else if (mi.year != null) { final edition = mi.editionTitle; return Text( edition != null ? '${mi.year} · $edition' : '${mi.year}', maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: subtitleStyle, ); } @@ -944,10 +945,10 @@ class _MediaCardHelpers { shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], ), - alignment: Alignment.center, + alignment: .center, child: Text( '${mi.leafCount! - mi.viewedLeafCount!}', - style: TextStyle(color: tokens(context).bg, fontSize: 12, fontWeight: FontWeight.bold), + style: TextStyle(color: tokens(context).bg, fontSize: 12, fontWeight: .bold), ), ), ), @@ -1093,7 +1094,7 @@ class _ClickableTextState extends State<_ClickableText> { final baseStyle = widget.style ?? const TextStyle(); if (isKeyboard) { - return Text(widget.text, maxLines: 1, overflow: TextOverflow.ellipsis, style: baseStyle); + return Text(widget.text, maxLines: 1, overflow: .ellipsis, style: baseStyle); } return MouseRegion( @@ -1105,7 +1106,7 @@ class _ClickableTextState extends State<_ClickableText> { child: Text( widget.text, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: baseStyle.copyWith( decoration: _isHovered ? TextDecoration.underline : null, decorationColor: baseStyle.color, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 58927a71..1f5b6c18 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -184,11 +185,11 @@ class MediaContextMenuState extends State { /// non-Plex backends — Plex-only flows (Add to Collection, match, /// unmatch, etc.) call this directly. Backend-neutral flows must use /// [_getMediaClientForItem] instead. - PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId); + PlexClient _getClientForItem() => context.getPlexClientWithFallback(serverIdOrNull(_itemServerId)); /// Backend-neutral client for the active item's server. Used by flows /// that work for Jellyfin too (downloads, basic browse). - MediaServerClient _getMediaClientForItem() => context.getMediaClientWithFallback(_itemServerId); + MediaServerClient _getMediaClientForItem() => context.getMediaClientWithFallback(serverIdOrNull(_itemServerId)); void _showContextMenu(BuildContext context) async { if (_isContextMenuOpen) return; @@ -226,7 +227,8 @@ class MediaContextMenuState extends State { // captured at sign-in. final multiServerProvider = Provider.of(context, listen: false); final activeProfile = context.read().active; - final isOwnerOrAdmin = _itemServerId != null && multiServerProvider.serverManager.isOwnerOrAdmin(_itemServerId!); + final isOwnerOrAdmin = + _itemServerId != null && multiServerProvider.serverManager.isOwnerOrAdmin(ServerId(_itemServerId!)); final isAdmin = isAdminActionAllowedForMediaItem( isOwnerOrAdmin: isOwnerOrAdmin, itemBackend: itemBackend, @@ -235,7 +237,7 @@ class MediaContextMenuState extends State { // Backend capabilities gate menu items so we don't expose actions the // active server cannot perform. - final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null; + final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(ServerId(_itemServerId!)) : null; final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false; final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false; final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind); @@ -569,7 +571,7 @@ class MediaContextMenuState extends State { if (isOffline && mediaItem?.serverId != null) { // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); - await offlineWatch.markAsWatched(serverId: mediaItem!.serverId!, itemId: mediaItem.id); + await offlineWatch.markAsWatched(serverId: ServerId(mediaItem!.serverId!), itemId: mediaItem.id); if (context.mounted) { showAppSnackBar(context, t.messages.markedAsWatchedOffline); _notifyRefresh(mediaItem.id); @@ -580,7 +582,7 @@ class MediaContextMenuState extends State { // paths so cross-screen UI updates regardless of backend. await _executeAction(context, () async { final item = mediaItem; - final client = context.tryGetMediaClientForServer(_itemServerId!); + final client = context.tryGetMediaClientForServer(ServerId(_itemServerId!)); if (client != null && item != null) { await client.markWatched(item); unawaited(TrackerCoordinator.instance.markWatched(item, client)); @@ -594,7 +596,7 @@ class MediaContextMenuState extends State { if (isOffline && mediaItem?.serverId != null) { // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); - await offlineWatch.markAsUnwatched(serverId: mediaItem!.serverId!, itemId: mediaItem.id); + await offlineWatch.markAsUnwatched(serverId: ServerId(mediaItem!.serverId!), itemId: mediaItem.id); if (context.mounted) { showAppSnackBar(context, t.messages.markedAsUnwatchedOffline); _notifyRefresh(mediaItem.id); @@ -602,7 +604,7 @@ class MediaContextMenuState extends State { } else { await _executeAction(context, () async { final item = mediaItem; - final client = context.tryGetMediaClientForServer(_itemServerId!); + final client = context.tryGetMediaClientForServer(ServerId(_itemServerId!)); if (client != null && item != null) { await client.markUnwatched(item); unawaited(TrackerCoordinator.instance.markUnwatched(item, client)); @@ -885,7 +887,7 @@ class MediaContextMenuState extends State { Future _handlePlayVersion(BuildContext context) async { final item = _mediaItem!; - final client = context.tryGetMediaClientForServer(_itemServerId); + final client = context.tryGetMediaClientForServer(serverIdOrNull(_itemServerId)); // Same flag the in-player Version & Quality sheet reads — keeps both // surfaces honest about what the active backend can actually do. final canTranscode = client?.capabilities.videoTranscoding ?? false; @@ -1380,7 +1382,7 @@ class MediaContextMenuState extends State { final downloadProvider = Provider.of(context, listen: false); final item = _mediaItem!; // Backend-agnostic resolve so Jellyfin items can be downloaded too. - final client = context.getMediaClientWithFallback(_itemServerId); + final client = context.getMediaClientWithFallback(serverIdOrNull(_itemServerId)); try { final result = await showDownloadOptionsAndQueue( @@ -1453,9 +1455,9 @@ class MediaContextMenuState extends State { final globalKey = _itemGlobalKey(); final serverId = _itemServerId; if (serverId == null) return globalKey; - final client = context.tryGetMediaClientForServer(serverId); + final client = context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) return globalKey; - return context.read().syncRuleKeyForClient(client, _itemId(), serverId: serverId); + return context.read().syncRuleKeyForClient(client, _itemId(), serverId: ServerId(serverId)); } String _itemDisplayTitle() => switch (widget.item) { @@ -1474,12 +1476,12 @@ class MediaContextMenuState extends State { /// Fire-and-forget: if a sync rule exists for the target list, run it now so /// newly-added items download immediately instead of waiting for the next /// cooldown-gated general pass. Fails silently — errors are logged only. - static void _triggerEagerSyncIfRuleExists(BuildContext context, String serverId, String listId) { + static void _triggerEagerSyncIfRuleExists(BuildContext context, ServerId serverId, String listId) { try { final downloadProvider = Provider.of(context, listen: false); final client = Provider.of(context, listen: false).getClientForServer(serverId); final globalKey = client == null - ? buildGlobalKey(serverId, listId) + ? buildGlobalKey(ServerId(serverId), listId) : downloadProvider.syncRuleKeyForClient(client, listId, serverId: serverId); if (!downloadProvider.hasSyncRule(globalKey)) return; final serverManager = Provider.of(context, listen: false).serverManager; @@ -1656,7 +1658,7 @@ class _PlaylistSelectionDialogState extends State<_PlaylistSelectionDialog> { }); } return const Padding( - padding: EdgeInsets.all(16), + padding: .all(16), child: Center(child: CircularProgressIndicator()), ); } @@ -1798,7 +1800,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> content: SizedBox( width: double.maxFinite, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (_collections.length >= 10) ...[ FocusableTextField( @@ -1845,7 +1847,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> }); } return const Padding( - padding: EdgeInsets.all(16), + padding: .all(16), child: Center(child: CircularProgressIndicator()), ); } @@ -1905,21 +1907,16 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> @override Widget build(BuildContext context) { return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: Text(widget.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 1, overflow: .ellipsis), ), Flexible( child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ ...widget.actions.asMap().entries.map((entry) { final index = entry.key; @@ -2044,8 +2041,8 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight), child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: widget.actions.asMap().entries.map((entry) { final index = entry.key; final action = entry.value; diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index b8511f9c..edad90eb 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -38,8 +38,8 @@ class OAuthProxyDialog extends StatelessWidget { return AlertDialog( title: Text(t.trackers.oauthProxy.title(service: serviceName)), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text(t.trackers.oauthProxy.body, style: theme.textTheme.bodyMedium), const SizedBox(height: 16), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index bb68a491..3781ce8c 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -591,7 +591,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr Widget sheetContent; if (showHandle) { sheetContent = Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ // M3 drag handle: 32x4, rounded, with 12dp top / 4dp bottom margin Container( diff --git a/lib/widgets/profile_switching_overlay.dart b/lib/widgets/profile_switching_overlay.dart index 11b7d892..0b2d7e9f 100644 --- a/lib/widgets/profile_switching_overlay.dart +++ b/lib/widgets/profile_switching_overlay.dart @@ -17,7 +17,7 @@ class ProfileSwitchingOverlay extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(24), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const SizedBox(width: 56, height: 56, child: CircularProgressIndicator()), const SizedBox(height: 16), diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index f3ea8159..b77f167e 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -103,7 +103,7 @@ class _RatingBottomSheetState extends State { return ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded), Flexible( @@ -742,15 +742,15 @@ class _RatingRow extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ - Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)), + Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: .w700)), Text( statusText, style: theme.textTheme.bodySmall?.copyWith(color: statusColor), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ), @@ -835,7 +835,7 @@ class _StarRatingControlState extends State<_StarRatingControl> { child: SizedBox( height: 34, child: Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: .end, children: List.generate(5, (i) { final threshold = (i + 1) * 2; final filled = widget.value >= threshold; @@ -938,8 +938,8 @@ class _StepperPill extends StatelessWidget { child: Text( label, maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), + overflow: .ellipsis, + style: theme.textTheme.labelMedium?.copyWith(fontWeight: .w700), ), ), ), diff --git a/lib/widgets/server_activities_button.dart b/lib/widgets/server_activities_button.dart index 1ec43a93..ce0c5169 100644 --- a/lib/widgets/server_activities_button.dart +++ b/lib/widgets/server_activities_button.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -92,7 +93,7 @@ class _ServerActivitiesButtonState extends State { final futures = serverIds.map((serverId) async { // Plex-only: `/activities` API is Plex-specific. - final client = multiServer.getPlexClientForServer(serverId); + final client = multiServer.getPlexClientForServer(ServerId(serverId)); if (client == null) return null; final activities = await client.getActivities(); return _ServerResult(serverId: serverId, serverName: client.serverName ?? serverId, activities: activities); @@ -130,7 +131,7 @@ class _ServerActivitiesButtonState extends State { } catch (_) {} } - Future _cancelActivity(String serverId, String uuid) async { + Future _cancelActivity(ServerId serverId, String uuid) async { final multiServer = Provider.of(context, listen: false); // Plex-only: `/activities` API is Plex-specific. final client = multiServer.getPlexClientForServer(serverId); @@ -178,8 +179,8 @@ class _ServerActivitiesButtonState extends State { child: SizedBox( width: 320, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ _buildPanelHeader(context), Divider(height: 1, color: theme.dividerColor), @@ -196,12 +197,12 @@ class _ServerActivitiesButtonState extends State { Widget _buildPanelHeader(BuildContext context) { final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ AppIcon(Symbols.monitor_heart_rounded, size: 18, color: theme.colorScheme.onSurface), const SizedBox(width: 8), - Text(t.serverTasks.title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text(t.serverTasks.title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: .bold)), ], ), ); @@ -226,7 +227,7 @@ class _ServerActivitiesButtonState extends State { return Padding( padding: const EdgeInsets.all(24), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Icon(Icons.error_outline, color: theme.colorScheme.error), const SizedBox(height: 8), @@ -250,7 +251,7 @@ class _ServerActivitiesButtonState extends State { } return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ for (final result in data.results) if (result.activities.isNotEmpty) ...[ @@ -274,36 +275,36 @@ class _ServerActivitiesButtonState extends State { style: theme.textTheme.labelSmall?.copyWith( color: tokens(context).bg, letterSpacing: 0, - fontWeight: FontWeight.bold, + fontWeight: .bold, ), ), ), ], ), ), - for (final activity in result.activities) _buildActivityTile(context, result.serverId, activity), + for (final activity in result.activities) _buildActivityTile(context, ServerId(result.serverId), activity), ], const SizedBox(height: 8), ], ); } - Widget _buildActivityTile(BuildContext context, String serverId, PlexActivity activity) { + Widget _buildActivityTile(BuildContext context, ServerId serverId, PlexActivity activity) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( activity.title, - style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + style: theme.textTheme.bodySmall?.copyWith(fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (activity.subtitle != null) ...[ const SizedBox(height: 2), @@ -313,7 +314,7 @@ class _ServerActivitiesButtonState extends State { color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], const SizedBox(height: 6), diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index 7609f673..1eef9189 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -13,9 +13,7 @@ class SettingsSectionHeader extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), child: Text( title, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: tokens(context).textMuted, fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: tokens(context).textMuted, fontWeight: .w600), ), ); } @@ -44,7 +42,7 @@ class SegmentedSetting extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Row( children: [ diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 5ecfaffa..b86bcd1e 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; @@ -122,13 +123,13 @@ class NavigationRailItem extends StatelessWidget { ), clipBehavior: Clip.hardEdge, child: UnconstrainedBox( - alignment: Alignment.centerLeft, + alignment: .centerLeft, constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, child: SizedBox( width: SideNavigationRailState.expandedWidth - 24, child: Padding( - padding: EdgeInsets.symmetric(vertical: 12, horizontal: horizontalPadding), + padding: .symmetric(vertical: 12, horizontal: horizontalPadding), child: Row( children: [ AppIcon( @@ -344,16 +345,16 @@ class SideNavigationRailState extends State with MountedSetS _focusTracker.restoreFocus(fallbackKey: _kHome); } - String _serverHeaderFocusKey(_LibraryNavSection section, String serverId) => + String _serverHeaderFocusKey(_LibraryNavSection section, ServerId serverId) => '$_kServerHeaderPrefix:${section.name}:$serverId'; String _libraryItemFocusKey(_LibraryNavSection section, MediaLibrary library) => '$_kLibraryItemPrefix:${section.name}:${library.globalKey}'; - String _serverGroupStateKey(_LibraryNavSection section, String serverId) => '${section.name}:$serverId'; + String _serverGroupStateKey(_LibraryNavSection section, ServerId serverId) => '${section.name}:$serverId'; String _focusKeyForLibraryRow(_LibraryNavRow row) => switch (row) { - _LibraryServerHeaderRow(:final section, :final serverId) => _serverHeaderFocusKey(section, serverId), + _LibraryServerHeaderRow(:final section, :final serverId) => _serverHeaderFocusKey(section, ServerId(serverId)), _LibraryItemRow(:final section, :final library) => _libraryItemFocusKey(section, library), }; @@ -411,7 +412,8 @@ class SideNavigationRailState extends State with MountedSetS ), ); } - if (serverKey.isEmpty || !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, serverKey))) { + if (serverKey.isEmpty || + !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, ServerId(serverKey)))) { for (final lib in bucket) { result.add(_LibraryItemRow(section: section, library: lib)); } @@ -429,9 +431,9 @@ class SideNavigationRailState extends State with MountedSetS return { for (final lib in visibleLibraries) - if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.visible, lib.serverId!), + if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.visible, ServerId(lib.serverId!)), for (final lib in hiddenLibraries) - if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.hidden, lib.serverId!), + if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.hidden, ServerId(lib.serverId!)), }; } @@ -660,7 +662,7 @@ class SideNavigationRailState extends State with MountedSetS SizedBox(height: _getTopPadding(context)), Expanded( child: ListView( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + padding: .symmetric(horizontal: horizontalPadding), clipBehavior: Clip.hardEdge, children: [ if (widget.isOfflineMode && widget.onReconnect != null) ...[ @@ -743,7 +745,7 @@ class SideNavigationRailState extends State with MountedSetS ), if (_showFullscreenToggle) Padding( - padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12), + padding: .fromLTRB(horizontalPadding, 0, horizontalPadding, 12), child: _buildFullscreenItem(isCollapsed: isCollapsed), ), ], @@ -784,7 +786,7 @@ class SideNavigationRailState extends State with MountedSetS fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? t.text : t.textMuted, ), - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, maxLines: 1, ), isSelected: isSelected, @@ -810,8 +812,8 @@ class SideNavigationRailState extends State with MountedSetS ? SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: t.text)) : Text( Translations.of(context).common.reconnect, - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: t.textMuted), - overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted), + overflow: .ellipsis, maxLines: 1, ), isSelected: false, @@ -835,8 +837,8 @@ class SideNavigationRailState extends State with MountedSetS icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, label: Text( isFullscreen ? Translations.of(context).common.exitFullscreen : Translations.of(context).common.fullscreen, - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: t.textMuted), - overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted), + overflow: .ellipsis, maxLines: 1, ), isSelected: false, @@ -865,7 +867,7 @@ class SideNavigationRailState extends State with MountedSetS final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Focus( focusNode: _focusTracker.get(_kLibraries), @@ -906,13 +908,13 @@ class SideNavigationRailState extends State with MountedSetS ), clipBehavior: Clip.hardEdge, child: UnconstrainedBox( - alignment: Alignment.centerLeft, + alignment: .centerLeft, constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, child: SizedBox( width: expandedWidth - 24, child: Padding( - padding: EdgeInsets.symmetric(vertical: 12, horizontal: itemHorizontalPadding), + padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding), child: Row( children: [ AppIcon( @@ -964,14 +966,14 @@ class SideNavigationRailState extends State with MountedSetS curve: Curves.easeOutCubic, builder: (context, value, child) { return ClipRect( - child: Align(alignment: Alignment.topCenter, heightFactor: value, child: child), + child: Align(alignment: .topCenter, heightFactor: value, child: child), ); }, child: ExcludeFocus( excluding: !_librariesExpanded || isCollapsed, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ const SizedBox(height: 4), if (isLoading) @@ -1019,12 +1021,12 @@ class SideNavigationRailState extends State with MountedSetS Widget _buildLibraryGroupedColumn(List<_LibraryNavRow> rows, dynamic t) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: rows.map((row) { return switch (row) { _LibraryServerHeaderRow(:final section, :final serverId, :final serverName) => _buildServerHeader( section, - serverId, + ServerId(serverId), serverName, t, ), @@ -1039,7 +1041,7 @@ class SideNavigationRailState extends State with MountedSetS ); } - Widget _buildServerHeader(_LibraryNavSection section, String serverId, String serverName, dynamic t) { + Widget _buildServerHeader(_LibraryNavSection section, ServerId serverId, String serverName, dynamic t) { // Resolve backend per server so the badge matches the brand. Falls back // to the generic `dns` icon if the client isn't registered yet (rare — // can happen during a profile switch before the manager rehydrates). @@ -1050,7 +1052,7 @@ class SideNavigationRailState extends State with MountedSetS iconSize: 14, leading: backend == null ? null : BackendBadge(backend: backend, size: 14, color: t.textMuted), label: serverName, - labelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.4, color: t.textMuted), + labelStyle: TextStyle(fontSize: 11, fontWeight: .w600, letterSpacing: 0.4, color: t.textMuted), verticalPadding: 6, isExpanded: !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, serverId)), onToggle: () => _toggleServerCollapse(section, serverId), @@ -1058,7 +1060,7 @@ class SideNavigationRailState extends State with MountedSetS ); } - void _toggleServerCollapse(_LibraryNavSection section, String serverId) { + void _toggleServerCollapse(_LibraryNavSection section, ServerId serverId) { final groupKey = _serverGroupStateKey(section, serverId); setState(() { if (!_collapsedServerGroupKeys.add(groupKey)) { @@ -1073,7 +1075,7 @@ class SideNavigationRailState extends State with MountedSetS icon: Symbols.visibility_off_rounded, iconSize: 16, label: Translations.of(context).libraries.hiddenLibrariesCount(count: count), - labelStyle: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: t.textMuted), + labelStyle: TextStyle(fontSize: 12, fontWeight: .w500, color: t.textMuted), verticalPadding: 8, isExpanded: _hiddenLibrariesExpanded, onToggle: () => setState(() => _hiddenLibrariesExpanded = !_hiddenLibrariesExpanded), @@ -1122,19 +1124,19 @@ class SideNavigationRailState extends State with MountedSetS decoration: BoxDecoration(color: isFocused ? t.text.withValues(alpha: 0.08) : null, borderRadius: radius), clipBehavior: Clip.hardEdge, child: UnconstrainedBox( - alignment: Alignment.centerLeft, + alignment: .centerLeft, constrainedAxis: Axis.vertical, clipBehavior: Clip.hardEdge, child: SizedBox( width: expandedWidth - 24, child: Padding( - padding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 17), + padding: .symmetric(vertical: verticalPadding, horizontal: 17), child: Row( children: [ leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted), const SizedBox(width: 11), Expanded( - child: Text(label, style: labelStyle, overflow: TextOverflow.ellipsis), + child: Text(label, style: labelStyle, overflow: .ellipsis), ), AppIcon( isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, @@ -1167,8 +1169,8 @@ class SideNavigationRailState extends State with MountedSetS icon: _getLibraryIcon(library.kind.id), selectedIcon: _getLibraryIcon(library.kind.id), label: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Text( library.title, @@ -1177,13 +1179,13 @@ class SideNavigationRailState extends State with MountedSetS fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? t.text : t.textMuted, ), - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (showServerName) Text( library.serverName!, style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)), - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ), diff --git a/lib/widgets/skeleton_media_card.dart b/lib/widgets/skeleton_media_card.dart index f3e55c3d..4b48f044 100644 --- a/lib/widgets/skeleton_media_card.dart +++ b/lib/widgets/skeleton_media_card.dart @@ -11,9 +11,9 @@ class SkeletonMediaCard extends StatelessWidget { @override Widget build(BuildContext context) { return const Padding( - padding: EdgeInsets.all(8), + padding: .all(8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Expanded( child: ClipRRect( @@ -28,7 +28,7 @@ class SkeletonMediaCard extends StatelessWidget { ), SizedBox(height: 3), FractionallySizedBox( - alignment: Alignment.centerLeft, + alignment: .centerLeft, widthFactor: 0.6, child: SkeletonLoader(borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox(height: 11)), ), diff --git a/lib/widgets/tag_edit_dialog.dart b/lib/widgets/tag_edit_dialog.dart index 08d3ee55..68b3807a 100644 --- a/lib/widgets/tag_edit_dialog.dart +++ b/lib/widgets/tag_edit_dialog.dart @@ -70,7 +70,7 @@ class _TagEditDialogState extends State with ControllerDisposerMi content: SizedBox( width: 400, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ FocusableTextField( controller: _controller, diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index c9e65129..da4ea799 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/ids.dart'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -56,7 +57,7 @@ class TvBrowseRailLayoutMetrics { } class TvBrowseRailLayout { - static const double compactTallPosterScale = 0.80; + static const double compactTallPosterScale = 0.8; static const double compactEpisodeThumbnailScale = compactTallPosterScale; static const double fullCardFocusScale = FocusTheme.fullCardFocusScale; @@ -881,7 +882,7 @@ class TvBrowseRailState extends State { focusNode: _focusNode, onKeyEvent: _handleKeyEvent, child: Align( - alignment: Alignment.bottomCenter, + alignment: .bottomCenter, heightFactor: 1, child: SizedBox( height: totalHeight, @@ -894,7 +895,7 @@ class TvBrowseRailState extends State { backgroundColor: theme.scaffoldBackgroundColor, ), Padding( - padding: EdgeInsets.fromLTRB( + padding: .fromLTRB( horizontalInset, TvBrowseRailLayout.railTopPaddingForScale(scale), 0, @@ -956,7 +957,7 @@ class TvBrowseRailState extends State { controller: _verticalController, physics: const NeverScrollableScrollPhysics(), clipBehavior: Clip.none, - padding: EdgeInsets.only(bottom: bottomPadding), + padding: .only(bottom: bottomPadding), itemExtentBuilder: (index, _) => sectionHeights[index], itemCount: widget.hubs.length, itemBuilder: (context, hubIndex) { @@ -969,7 +970,7 @@ class TvBrowseRailState extends State { key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()), height: sectionHeight, child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), @@ -1012,7 +1013,7 @@ class TvBrowseRailState extends State { height: TvBrowseRailLayout.hubStripHeightForScale(scale), child: ExcludeFocus( child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Row( children: [ AppIcon(widget.iconForHub(hub, hubIndex), fill: 1, size: 20 * scale, color: iconColor), @@ -1021,7 +1022,7 @@ class TvBrowseRailState extends State { child: Text( hub.title, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: titleColor, fontSize: 18 * scale, @@ -1082,7 +1083,7 @@ class TvBrowseRailState extends State { controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, - padding: EdgeInsets.fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), + padding: .fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), itemExtentBuilder: (itemIndex, _) => TvBrowseRailLayout.itemExtentForIndex(hub: hub, index: itemIndex, metrics: metrics, scale: scale), itemCount: totalCount, @@ -1090,9 +1091,9 @@ class TvBrowseRailState extends State { final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex; if (itemIndex == hub.items.length) { return Padding( - padding: EdgeInsets.only(right: metrics.itemGap), + padding: .only(right: metrics.itemGap), child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: _buildViewAllButton( context, isFocused: isFocused, @@ -1150,10 +1151,10 @@ class TvBrowseRailState extends State { ); return Padding( - padding: EdgeInsets.only(right: metrics.itemGap), + padding: .only(right: metrics.itemGap), child: MouseRegion( onEnter: (_) => _setHoveredItem(hub, itemIndex), - child: Align(alignment: Alignment.topLeft, child: focusableCard), + child: Align(alignment: .topLeft, child: focusableCard), ), ); }, @@ -1185,7 +1186,7 @@ class TvBrowseRailState extends State { fit: StackFit.expand, children: [ OptimizedMediaImage( - client: context.tryGetMediaClientWithFallback(item.serverId), + client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), imagePath: item.thumbPath, width: cardWidth, height: imageSize, @@ -1208,31 +1209,26 @@ class TvBrowseRailState extends State { right: 10 * scale, bottom: 9 * scale, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text( item.displayTitle, maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white, - fontSize: 13 * scale, - height: 1.1, - fontWeight: FontWeight.w800, - ), + overflow: .ellipsis, + style: TextStyle(color: Colors.white, fontSize: 13 * scale, height: 1.1, fontWeight: .w800), ), if (characterName != null && characterName.isNotEmpty) ...[ SizedBox(height: 2 * scale), Text( characterName, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: TextStyle( color: Colors.white.withValues(alpha: 0.82), fontSize: 11 * scale, height: 1.1, - fontWeight: FontWeight.w600, + fontWeight: .w600, ), ), ], @@ -1248,15 +1244,15 @@ class TvBrowseRailState extends State { return SizedBox( width: cardWidth, child: Padding( - padding: EdgeInsets.fromLTRB(3 * scale, 3 * scale, 3 * scale, scale), + padding: .fromLTRB(3 * scale, 3 * scale, 3 * scale, scale), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: OptimizedMediaImage( - client: context.tryGetMediaClientWithFallback(item.serverId), + client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), imagePath: item.thumbPath, width: imageSize, height: imageSize, @@ -1269,12 +1265,12 @@ class TvBrowseRailState extends State { Text( item.displayTitle, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: tokens(context).text, fontSize: 13 * scale, height: 1.1, - fontWeight: FontWeight.w700, + fontWeight: .w700, ), ), if (characterName != null && characterName.isNotEmpty) ...[ @@ -1282,7 +1278,7 @@ class TvBrowseRailState extends State { Text( characterName, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: tokens(context).textMuted, fontSize: 11 * scale, @@ -1308,7 +1304,7 @@ class TvBrowseRailState extends State { final height = TvBrowseRailLayout.viewAllPillHeightForScale(scale); final foreground = isFocused ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.78); final background = isFocused - ? theme.colorScheme.primary.withValues(alpha: 0.20) + ? theme.colorScheme.primary.withValues(alpha: 0.2) : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.42); return ClickableCursor( @@ -1323,14 +1319,14 @@ class TvBrowseRailState extends State { curve: Curves.easeOutCubic, width: width, height: height, - padding: EdgeInsets.symmetric(horizontal: (12 * scale).clamp(10, 16).toDouble()), + padding: .symmetric(horizontal: (12 * scale).clamp(10, 16).toDouble()), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(height / 2), boxShadow: isFocused ? [ BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.20), + color: theme.colorScheme.primary.withValues(alpha: 0.2), blurRadius: 18, spreadRadius: 1, ), @@ -1338,17 +1334,17 @@ class TvBrowseRailState extends State { : null, ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ Flexible( child: Text( t.common.viewAll, maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: TextStyle( color: foreground, fontSize: (13 * scale).clamp(12, 16).toDouble(), - fontWeight: FontWeight.w800, + fontWeight: .w800, letterSpacing: 0.1, ), ), diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 9f9b6c90..3c874a3e 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -110,7 +110,7 @@ class _TvColorPickerState extends State with ControllerDisposerMi final currentColor = _currentColor(); return Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Container( height: 64, @@ -311,7 +311,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper children: [ SizedBox( width: 24, - child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), ), const SizedBox(width: 8), _ChannelButton( @@ -322,7 +322,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper const SizedBox(width: 8), Container( constraints: const BoxConstraints(minWidth: 56), - alignment: Alignment.center, + alignment: .center, child: Text('${widget.value}${widget.suffix}', style: theme.textTheme.titleMedium), ), const SizedBox(width: 8), diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 940e7d26..09f62947 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -147,8 +147,8 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< ), ), child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: .min, + mainAxisAlignment: .center, children: [ _SpinnerButton( icon: Symbols.remove_rounded, @@ -160,10 +160,10 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< const SizedBox(width: 16), Container( constraints: const BoxConstraints(minWidth: 60), - alignment: Alignment.center, + alignment: .center, child: Text( widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}', - style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), ), ), const SizedBox(width: 16), diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 54f173b9..57a316a8 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -84,14 +84,14 @@ class TvSpotlightBackground extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) { - return Align(alignment: Alignment.bottomLeft, child: _buildInfo(context, media)); + return Align(alignment: .bottomLeft, child: _buildInfo(context, media)); } return Align( - alignment: Alignment.bottomLeft, + alignment: .bottomLeft, child: FittedBox( fit: BoxFit.scaleDown, - alignment: Alignment.bottomLeft, + alignment: .bottomLeft, child: SizedBox(width: constraints.maxWidth, child: _buildInfo(context, media)), ), ); @@ -188,8 +188,8 @@ class TvSpotlightBackground extends StatelessWidget { final title = media.grandparentTitle ?? media.displayTitle; return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ _buildLogoOrTitle(context, media, title), SizedBox(height: _sectionGap(scale)), @@ -199,7 +199,7 @@ class TvSpotlightBackground extends StatelessWidget { Text( summary, maxLines: compact ? 3 : 4, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.78), fontSize: _summaryFontSize(scale), @@ -211,7 +211,7 @@ class TvSpotlightBackground extends StatelessWidget { Text( media.title ?? '', maxLines: 2, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.72), fontSize: _summaryFontSize(scale), @@ -245,7 +245,7 @@ class TvSpotlightBackground extends StatelessWidget { Image.file( File(localLogoPath), fit: BoxFit.contain, - alignment: Alignment.centerLeft, + alignment: .centerLeft, errorBuilder: (context, error, stackTrace) => _buildTitle(context, title), ), sigma: 10, @@ -273,7 +273,7 @@ class TvSpotlightBackground extends StatelessWidget { imageUrl: imageUrl, cacheManager: PlexImageCacheManager.instance, fit: BoxFit.contain, - alignment: Alignment.centerLeft, + alignment: .centerLeft, memCacheWidth: (logoWidth * dpr).clamp(200, 1000).round(), placeholder: (context, url) => const SizedBox.shrink(), errorBuilder: (context, error, stackTrace) => _buildTitle(context, title), @@ -292,7 +292,7 @@ class TvSpotlightBackground extends StatelessWidget { style: Theme.of(context).textTheme.displaySmall?.copyWith( color: colorScheme.onSurface, fontSize: _titleFontSize(scale), - fontWeight: FontWeight.w800, + fontWeight: .w800, shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 12)], ), ); @@ -316,11 +316,11 @@ class TvSpotlightBackground extends StatelessWidget { return Text( parts.join(' • '), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: TextStyle( color: colorScheme.onSurface, fontSize: _metadataFontSize(scale), - fontWeight: FontWeight.w700, + fontWeight: .w700, letterSpacing: 0.1, ), ); @@ -344,22 +344,22 @@ class TvSpotlightBackground extends StatelessWidget { final scale = _scale(context); final hasProgress = media.hasActiveProgress; final minutesLeft = hasProgress && media.durationMs != null && media.viewOffsetMs != null - ? ((media.durationMs! - media.viewOffsetMs!) / 60000).round() + ? ((media.durationMs! - media.viewOffsetMs!) / 60_000).round() : 0; return GestureDetector( onTap: onPrimaryAction, child: Container( - padding: EdgeInsets.symmetric(horizontal: (compact ? 24 : 30) * scale, vertical: (compact ? 12 : 15) * scale), + padding: .symmetric(horizontal: (compact ? 24 : 30) * scale, vertical: (compact ? 12 : 15) * scale), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(32 * scale)), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(Symbols.play_arrow_rounded, fill: 1, size: (compact ? 24 : 28) * scale, color: Colors.black), SizedBox(width: (compact ? 10 : 12) * scale), Text( hasProgress ? t.discover.minutesLeft(minutes: minutesLeft) : t.common.play, - style: TextStyle(color: Colors.black, fontSize: (compact ? 16 : 18) * scale, fontWeight: FontWeight.w800), + style: TextStyle(color: Colors.black, fontSize: (compact ? 16 : 18) * scale, fontWeight: .w800), ), ], ), diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index 1e4a3235..0714970e 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -27,7 +27,7 @@ Future showTvVirtualKeyboard({ return showDialog( context: context, barrierDismissible: true, - barrierColor: Colors.black.withValues(alpha: 0.10), + barrierColor: Colors.black.withValues(alpha: 0.1), useSafeArea: false, builder: (context) => _TvVirtualKeyboardDialog( controller: controller, @@ -569,8 +569,8 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with return Dialog( key: const Key('tv_virtual_keyboard_dialog'), - alignment: Alignment.bottomCenter, - insetPadding: EdgeInsets.only( + alignment: .bottomCenter, + insetPadding: .only( left: metrics.edgeInset, right: metrics.edgeInset, top: media.padding.top + 48, @@ -584,7 +584,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with child: Container( key: const Key('tv_virtual_keyboard_panel'), constraints: BoxConstraints(maxWidth: metrics.panelWidth), - padding: EdgeInsets.all(metrics.panelPadding), + padding: .all(metrics.panelPadding), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.96), borderRadius: BorderRadius.circular(metrics.panelRadius), @@ -592,8 +592,8 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with child: SizedBox( width: metrics.gridWidth, child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + crossAxisAlignment: .stretch, children: [ _buildPreview(context, text, metrics), SizedBox(height: metrics.previewGap), @@ -656,8 +656,8 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with return Container( height: metrics.previewHeight, - alignment: Alignment.centerLeft, - padding: EdgeInsets.symmetric(horizontal: metrics.keySize * 0.30), + alignment: .centerLeft, + padding: .symmetric(horizontal: metrics.keySize * 0.3), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(metrics.previewRadius), @@ -674,7 +674,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with Widget _buildRow(BuildContext context, int row, _TvKeyboardMetrics metrics) { return Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ for (var column = 0; column < _rows[row].length; column++) ...[ _buildKey(context, _rows[row][column], row, column, metrics), @@ -716,10 +716,10 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with duration: const Duration(milliseconds: 120), width: metrics.keySize, height: metrics.keySize, - alignment: Alignment.center, + alignment: .center, decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(metrics.keyRadius)), child: Padding( - padding: EdgeInsets.symmetric(horizontal: metrics.keySize * 0.04), + padding: .symmetric(horizontal: metrics.keySize * 0.04), child: _buildKeyContent(context, key, foreground, metrics), ), ), @@ -744,7 +744,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with maxLines: 1, style: Theme.of( context, - ).textTheme.titleLarge?.copyWith(color: foreground, fontSize: metrics.keyFontSize, fontWeight: FontWeight.w800), + ).textTheme.titleLarge?.copyWith(color: foreground, fontSize: metrics.keyFontSize, fontWeight: .w800), ), ); } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index edd92b10..9a1ac10c 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -542,7 +542,7 @@ class DesktopVideoControlsState extends State { if (duration.inMilliseconds <= 0) return KeyEventResult.handled; final baseStepMs = widget.seekTimeSmall * 1000; - final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 120000).toInt(); + final stepMs = (baseStepMs * effectiveMultiplier).clamp(500, 120_000).toInt(); final step = Duration(milliseconds: stepMs); final newPosition = isForward ? position + step : position - step; @@ -604,7 +604,7 @@ class DesktopVideoControlsState extends State { ), ), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const Icon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), const SizedBox(height: 4), @@ -655,7 +655,7 @@ class DesktopVideoControlsState extends State { Widget _buildTopBarContent(BuildContext _, double leftPadding) { final topBar = Padding( - padding: EdgeInsets.only(left: leftPadding, right: 16), + padding: .only(left: leftPadding, right: 16), child: Row( children: [ Expanded( @@ -672,7 +672,7 @@ class DesktopVideoControlsState extends State { decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))), child: Text( t.liveTv.live, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 12), ), ), ], @@ -890,13 +890,7 @@ class DesktopVideoControlsState extends State { return Padding( padding: const EdgeInsets.only(left: 8), - child: Text( - text, - style: style, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - ), + child: Text(text, style: style, maxLines: 1, softWrap: false, overflow: .fade), ); }, ); diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 1b1164af..fbbc4b79 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -87,10 +87,10 @@ class TrackSelectionHelper { width: 18, height: 18, decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(4)), - alignment: Alignment.center, + alignment: .center, child: Text( number.toString(), - style: TextStyle(color: colorScheme.onPrimary, fontSize: 11, fontWeight: FontWeight.bold), + style: TextStyle(color: colorScheme.onPrimary, fontSize: 11, fontWeight: .bold), ), ); } diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index a0759411..096d7981 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -250,7 +250,7 @@ class _MobileVideoControlsState extends State with SingleTi ), // Content strip — slides up from below the bottom edge Align( - alignment: Alignment.bottomCenter, + alignment: .bottomCenter, child: FractionalTranslation( translation: Offset(0, 1 - t), child: IgnorePointer( @@ -272,7 +272,7 @@ class _MobileVideoControlsState extends State with SingleTi ), ), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const Icon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), const SizedBox(height: 4), @@ -338,7 +338,7 @@ class _MobileVideoControlsState extends State with SingleTi player: widget.player, builder: (context, isPlaying) { return Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ if (!widget.isLive) ...[ // Previous episode button (greyed out when unavailable) @@ -407,7 +407,7 @@ class _MobileVideoControlsState extends State with SingleTi decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))), child: Text( t.liveTv.live, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 12), ), ), ], diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 68f0aa32..9cc3ff03 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -80,7 +80,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { if (serverId == null) return; try { - final client = context.getPlexClientForServer(serverId); + final client = context.getPlexClientForServer(ServerId(serverId)); final token = client.config.token; if (token == null) return; @@ -187,7 +187,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { if (serverId == null || partId == null || effectiveSubtitleStreamId == null) { throw StateError('No Plex part available for subtitle stream selection'); } - final client = context.getPlexClientForServer(serverId); + final client = context.getPlexClientForServer(ServerId(serverId)); final saved = await client.selectStreams(partId, subtitleStreamID: effectiveSubtitleStreamId, allParts: true); if (!saved) { throw StateError('Failed to select subtitle stream'); diff --git a/lib/widgets/video_controls/parts/playback_extras.dart b/lib/widgets/video_controls/parts/playback_extras.dart index 455c5c2e..6dc2a0c1 100644 --- a/lib/widgets/video_controls/parts/playback_extras.dart +++ b/lib/widgets/video_controls/parts/playback_extras.dart @@ -10,7 +10,7 @@ extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState { final serverId = widget.metadata.serverId; // Read providers before any await — `context` after an async gap is // a lint trigger and can crash if the widget unmounts mid-load. - final client = serverId != null ? context.tryGetMediaClientForServer(serverId) : null; + final client = serverId != null ? context.tryGetMediaClientForServer(ServerId(serverId)) : null; final database = context.read(); try { diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index e6250f2b..3288cd5d 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -165,7 +165,7 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { final serverId = widget.metadata.serverId; if (serverId == null) return false; final manager = context.read().serverManager; - final c = manager.getClient(serverId); + final c = manager.getClient(ServerId(serverId)); return c?.capabilities.externalSubtitleSearch ?? false; } catch (_) { return false; diff --git a/lib/widgets/video_controls/playback_extras_loader.dart b/lib/widgets/video_controls/playback_extras_loader.dart index 89ad7a81..d732305e 100644 --- a/lib/widgets/video_controls/playback_extras_loader.dart +++ b/lib/widgets/video_controls/playback_extras_loader.dart @@ -1,4 +1,5 @@ import '../../database/app_database.dart'; +import '../../media/ids.dart'; import '../../media/media_item.dart'; import '../../media/media_server_client.dart'; import '../../media/media_source_info.dart'; @@ -80,7 +81,7 @@ class VideoControlsPlaybackExtrasLoader { try { final row = await (database.select( database.downloadedMedia, - )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, metadata.id)))).getSingleOrNull(); + )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(ServerId(serverId), metadata.id)))).getSingleOrNull(); return row?.clientScopeId ?? serverId; } catch (_) { return serverId; diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index 73a1a6d2..106d64d1 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -26,7 +26,7 @@ class BaseVideoControlSheet extends StatelessWidget { icon: icon, iconColor: iconColor, onBack: onBack, - titleStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + titleStyle: const TextStyle(fontSize: 18, fontWeight: .bold), showHeaderBorder: false, showHeaderDivider: true, child: child, diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 48b63527..a6512996 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -1,4 +1,5 @@ import 'dart:async' show unawaited; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; @@ -63,7 +64,7 @@ class _ChapterSheetState extends State { /// Get the media client for chapters, or null if unavailable (offline mode). MediaServerClient? _tryGetClientForChapters(BuildContext context) { - return context.tryGetMediaClientForServer(widget.serverId); + return context.tryGetMediaClientForServer(serverIdOrNull(widget.serverId)); } @override @@ -93,7 +94,7 @@ class _ChapterSheetState extends State { final isCurrentChapter = currentChapterIndex == index; final localThumbPath = widget.serverId != null && chapter.thumb != null - ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) + ? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!) : null; return FocusableListTile( diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 4564f53d..44bb6cda 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -1,4 +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'; @@ -71,7 +72,7 @@ class _QueueSheetState extends State { fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), subtitle: Text( _buildSubtitle(item), @@ -80,7 +81,7 @@ class _QueueSheetState extends State { fontSize: 12, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null, onTap: () { @@ -101,7 +102,7 @@ class _QueueSheetState extends State { if (item.thumbPath == null) return null; // Try to get client for thumbnails, may fail in offline mode - final client = context.tryGetMediaClientForServer(item.serverId); + final client = context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)); return MediaSelectorThumbnail( width: _kThumbWidth, diff --git a/lib/widgets/video_controls/sheets/sheet_column_header.dart b/lib/widgets/video_controls/sheets/sheet_column_header.dart index fad81170..d04be962 100644 --- a/lib/widgets/video_controls/sheets/sheet_column_header.dart +++ b/lib/widgets/video_controls/sheets/sheet_column_header.dart @@ -10,7 +10,7 @@ class SheetColumnHeader extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Text( label, style: Theme.of( diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index 3fec5d94..555c5317 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -100,7 +101,7 @@ class _SubtitleSearchSheetState extends State with Controll // Defense-in-depth: searchSubtitles is Plex-only. The UI gates this // sheet on `subtitleSearchSupported` upstream, but if a future caller // reaches us with a Jellyfin server, fail soft instead of throwing. - final neutral = context.tryGetMediaClientForServer(widget.serverId); + final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); final client = neutral is PlexClient ? neutral : null; if (client == null) { if (!mounted) return; @@ -177,7 +178,7 @@ class _SubtitleSearchSheetState extends State with Controll try { // Same Plex-only guard as in [_search]. Don't throw if a Jellyfin // server somehow reaches the download path. - final neutral = context.tryGetMediaClientForServer(widget.serverId); + final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); final client = neutral is PlexClient ? neutral : null; if (client == null) { if (!mounted) return; @@ -251,7 +252,7 @@ class _SubtitleSearchSheetState extends State with Controll child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text(_languageName), const SizedBox(width: 2), @@ -340,17 +341,17 @@ class _SubtitleSearchSheetState extends State with Controll ); } if (trailingChildren.isNotEmpty) { - trailing = Row(mainAxisSize: MainAxisSize.min, spacing: 4, children: trailingChildren); + trailing = Row(mainAxisSize: .min, spacing: 4, children: trailingChildren); } } return FocusableListTile( focusNode: index == 0 ? _firstResultFocusNode : null, - title: Text(result.title ?? result.displayTitle ?? 'Unknown', maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(result.title ?? result.displayTitle ?? 'Unknown', maxLines: 1, overflow: .ellipsis), subtitle: Text( result.displayTitle ?? '', maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), trailing: trailing, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 5a07d71b..7d7642a2 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -156,7 +156,7 @@ class TrackSheet extends StatelessWidget { if (showAudio && showSubtitles) { return Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))), VerticalDivider(width: 1, color: Theme.of(context).dividerColor), diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index 8f48a615..e3b4ad1e 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -75,7 +75,7 @@ class VersionQualityPicker extends StatelessWidget { if (showVersions && showQuality) { return Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Expanded(child: versionColumn), VerticalDivider(width: 1, color: Theme.of(context).dividerColor), @@ -270,7 +270,7 @@ class _SelectionTile extends StatelessWidget { final hasText = trailingText != null && trailingText!.isNotEmpty; final trailing = (hasText || isSelected) ? Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (hasText) Text(trailingText!, style: TextStyle(color: trailingColor)), if (hasText && isSelected) const SizedBox(width: 8), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 48f11eaa..df02e771 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -79,7 +79,7 @@ class _SettingsMenuItem extends StatelessWidget { leading: AppIcon(icon, fill: 1, color: isHighlighted ? Colors.amber : t.textMuted), title: Text(title), trailing: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget, const SizedBox(width: 8), @@ -276,7 +276,7 @@ class _VideoSettingsSheetState extends State { // whenComplete in track_chapter_controls). Cancel it again here. controller .show( - alignment: Alignment.topCenter, + alignment: .topCenter, constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900), initialFocusNode: sliderFocusNode, builder: (_) => _CompactSyncBar( @@ -809,7 +809,7 @@ class _VideoSettingsSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( _formatBackend(entry.key), - style: TextStyle(color: tokens(context).textMuted, fontSize: 12, fontWeight: FontWeight.w600), + style: TextStyle(color: tokens(context).textMuted, fontSize: 12, fontWeight: .w600), ), ), for (final d in entry.value) _buildDeviceTile(d, currentDevice), @@ -874,7 +874,7 @@ class _VideoSettingsSheetState extends State { ? Text(_getShaderSubtitle(preset)!, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)) : null, trailing: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (isSelected) const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.amber), if (isCustom) ...[ @@ -1067,7 +1067,7 @@ class _CompactSyncBarState extends State<_CompactSyncBar> { const SizedBox(width: 16), AppIcon(widget.icon, fill: 1, color: tokens(context).textMuted, size: 20), const SizedBox(width: 8), - Text(widget.title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + Text(widget.title, style: const TextStyle(fontWeight: .w600, fontSize: 14)), Expanded( child: SyncOffsetControl( player: widget.player, @@ -1094,7 +1094,7 @@ class _CompactSyncBarState extends State<_CompactSyncBar> { child: Container( width: 36, height: 36, - alignment: Alignment.center, + alignment: .center, child: AppIcon(Symbols.close_rounded, fill: 1, color: tokens(context).textMuted, size: 22), ), ), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index f01ac5ac..876a699b 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1,6 +1,8 @@ import 'dart:async' show StreamSubscription, Timer, unawaited; import 'dart:io' show Platform; +import '../../media/ids.dart'; + import 'package:flutter/gestures.dart' show PointerCancelEvent, @@ -807,17 +809,13 @@ class _PlexVideoControlsState extends State borderRadius: const BorderRadius.all(Radius.circular(28)), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ const AppIcon(Symbols.lock_rounded, fill: 1, color: Colors.white, size: 20), const SizedBox(width: 8), Text( t.videoControls.longPressToUnlock, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w500), ), ], ), diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 56ca4a16..5995be6c 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -1,4 +1,5 @@ import 'dart:async' show unawaited; +import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -235,7 +236,7 @@ class ContentStripState extends State { return KeyEventResult.ignored; } - MediaServerClient? _tryGetClient(BuildContext context, String? serverId) { + MediaServerClient? _tryGetClient(BuildContext context, ServerId? serverId) { return context.tryGetMediaClientForServer(serverId); } @@ -260,9 +261,9 @@ class ContentStripState extends State { return SafeArea( top: false, child: Padding( - padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 0 : 16), + padding: .symmetric(horizontal: widget.useFocusNavigation ? 0 : 16), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ // Tab bar only shown in touch mode when both tabs exist if (_hasBothTabs && !widget.useFocusNavigation) _buildTabBar(), @@ -272,7 +273,7 @@ class ContentStripState extends State { padding: const EdgeInsets.only(bottom: 4), child: Text( _activeTab == _StripTab.chapters ? t.videoControls.chapters : t.videoControls.queue, - style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w500), + style: const TextStyle(color: Colors.white70, fontSize: 12, fontWeight: .w500), ), ), if (!widget.useFocusNavigation) const SizedBox(height: 8), @@ -288,7 +289,7 @@ class ContentStripState extends State { Widget _buildTabBar() { return Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ _buildTabLabel(t.videoControls.chapters, _StripTab.chapters), const SizedBox(width: 24), @@ -303,7 +304,7 @@ class ContentStripState extends State { child: GestureDetector( onTap: () => setState(() => _activeTab = tab), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text( label, @@ -349,13 +350,13 @@ class ContentStripState extends State { scrollDirection: Axis.horizontal, clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, itemCount: widget.chapters.length, - padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), + padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), itemBuilder: (context, index) { final chapter = widget.chapters[index]; final isCurrent = currentChapterIndex == index; final localThumbPath = widget.serverId != null && chapter.thumb != null - ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) + ? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!) : null; void onTap() => unawaited(_handleChapterTap(chapter.startTime)); @@ -365,7 +366,7 @@ class ContentStripState extends State { isTablet: isTablet, thumbnail: chapter.thumb != null ? OptimizedMediaImage.thumb( - client: _tryGetClient(context, widget.serverId), + client: _tryGetClient(context, serverIdOrNull(widget.serverId)), imagePath: chapter.thumb, localFilePath: localThumbPath, width: thumbWidth, @@ -382,7 +383,7 @@ class ContentStripState extends State { if (widget.useFocusNavigation) { return Align( - alignment: Alignment.topCenter, + alignment: .topCenter, child: FocusableWrapper( focusNode: _chapterFocusNodes[index], onSelect: onTap, @@ -432,12 +433,14 @@ class ContentStripState extends State { scrollDirection: Axis.horizontal, clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, itemCount: items.length, - padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), + padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), itemBuilder: (context, index) { final item = items[index]; final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; - final client = item.serverId != null ? context.tryGetMediaClientForServer(item.serverId) : null; + final client = item.serverId != null + ? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)) + : null; void onTap() => widget.onQueueItemSelected?.call(item); @@ -462,7 +465,7 @@ class ContentStripState extends State { if (widget.useFocusNavigation) { return Align( - alignment: Alignment.topCenter, + alignment: .topCenter, child: FocusableWrapper( focusNode: _queueFocusNodes[index], onSelect: onTap, @@ -516,10 +519,10 @@ class ContentStripState extends State { onTap: onTap, child: Container( width: itemWidth, - margin: EdgeInsets.symmetric(horizontal: 6, vertical: verticalMargin), + margin: .symmetric(horizontal: 6, vertical: verticalMargin), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ MediaSelectorThumbnail( width: itemWidth, @@ -538,7 +541,7 @@ class ContentStripState extends State { fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), Text( subtitle, @@ -548,7 +551,7 @@ class ContentStripState extends State { fontWeight: isCurrent ? FontWeight.w500 : FontWeight.normal, ), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ), diff --git a/lib/widgets/video_controls/widgets/double_tap_feedback.dart b/lib/widgets/video_controls/widgets/double_tap_feedback.dart index e02edfe4..c174fb79 100644 --- a/lib/widgets/video_controls/widgets/double_tap_feedback.dart +++ b/lib/widgets/video_controls/widgets/double_tap_feedback.dart @@ -19,7 +19,7 @@ class DoubleTapFeedback extends StatelessWidget { padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6), shape: BoxShape.circle), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon( isForward ? Symbols.forward_media_rounded : Symbols.replay_rounded, @@ -30,7 +30,7 @@ class DoubleTapFeedback extends StatelessWidget { const SizedBox(height: 4), Text( '$seconds${t.settings.secondsShort}', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold), ), ], ), diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index d7aa6633..5f82e226 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -111,7 +111,7 @@ class _LiveTimelineBarState extends State { _buildSlider(displayPos), const SizedBox(height: 4), Align( - alignment: Alignment.centerLeft, + alignment: .centerLeft, child: Text( _formatEpochTime(context, displayPos), style: const TextStyle(color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]), diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart index 9418f3f9..fe984c9d 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -120,17 +120,17 @@ class _PlayerPerformanceOverlayState extends State { Widget _buildSection(IconData icon, String title, List<_Metric> metrics) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .start, + mainAxisSize: .min, children: [ Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(icon, fill: 1, color: Colors.white70, size: 12), const SizedBox(width: 4), Text( title, - style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600), + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: .w600), ), ], ), @@ -144,19 +144,14 @@ class _PlayerPerformanceOverlayState extends State { return Padding( padding: const EdgeInsets.only(bottom: 2), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text('${metric.label}: ', style: const TextStyle(color: Colors.white60, fontSize: 10)), Flexible( child: Text( metric.value, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w500, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: .w500, fontFamily: 'monospace'), + overflow: .ellipsis, ), ), ], diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart index b1d66cc5..4376f2ae 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -176,7 +176,7 @@ class PerformanceStats { /// Format video bitrate in Mbps. String get videoBitrateFormatted { if (videoBitrate == null || videoBitrate == 0) return 'N/A'; - final mbps = videoBitrate! / 1000000; + final mbps = videoBitrate! / 1_000_000; return '${mbps.toStringAsFixed(1)} Mbps'; } diff --git a/lib/widgets/video_controls/widgets/player_toast_indicator.dart b/lib/widgets/video_controls/widgets/player_toast_indicator.dart index c2a6b79f..248b64db 100644 --- a/lib/widgets/video_controls/widgets/player_toast_indicator.dart +++ b/lib/widgets/video_controls/widgets/player_toast_indicator.dart @@ -14,7 +14,7 @@ class PlayerToastIndicator extends StatelessWidget { @override Widget build(BuildContext context) { return Align( - alignment: Alignment.topCenter, + alignment: .topCenter, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.8), child: Container( @@ -25,7 +25,7 @@ class PlayerToastIndicator extends StatelessWidget { borderRadius: const BorderRadius.all(Radius.circular(20)), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ AppIcon(icon, fill: 1, color: Colors.white, size: 16), const SizedBox(width: 4), @@ -33,8 +33,8 @@ class PlayerToastIndicator extends StatelessWidget { child: Text( text, maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold), + overflow: .ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .bold), ), ), ], diff --git a/lib/widgets/video_controls/widgets/skip_marker_button.dart b/lib/widgets/video_controls/widgets/skip_marker_button.dart index a69c1555..be5666ea 100644 --- a/lib/widgets/video_controls/widgets/skip_marker_button.dart +++ b/lib/widgets/video_controls/widgets/skip_marker_button.dart @@ -89,11 +89,11 @@ class SkipMarkerButton extends StatelessWidget { ], ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text( buttonText, - style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600), + style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: .w600), ), const SizedBox(width: 8), AppIcon(buttonIcon, fill: 1, color: Colors.black, size: 20), diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart index f28b290c..01e2dd48 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -34,7 +34,7 @@ class SleepTimerActiveStatus extends StatelessWidget { children: [ Text( t.videoControls.timerActive, - style: const TextStyle(color: Colors.amber, fontSize: 16, fontWeight: FontWeight.bold), + style: const TextStyle(color: Colors.amber, fontSize: 16, fontWeight: .bold), ), const SizedBox(height: 8), Text( @@ -44,7 +44,7 @@ class SleepTimerActiveStatus extends StatelessWidget { ), const SizedBox(height: 16), Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ if (!isEndOfVideo) ...[ FocusableButton( diff --git a/lib/widgets/video_controls/widgets/sleep_timer_content.dart b/lib/widgets/video_controls/widgets/sleep_timer_content.dart index 9394c270..315deb00 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_content.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_content.dart @@ -55,7 +55,7 @@ class SleepTimerContent extends StatelessWidget { ], Expanded( child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Expanded( child: FocusTraversalGroup( diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 3e017315..bd96980d 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -55,10 +55,10 @@ class SyncOffsetControl extends StatefulWidget { class _SyncOffsetControlState extends State { // Range constants - static const double _sliderMin = -60000; // ±60s for slider - static const double _sliderMax = 60000; - static const double _absoluteMin = -60000; // ±60s absolute limit - static const double _absoluteMax = 60000; + static const double _sliderMin = -60_000; // ±60s for slider + static const double _sliderMax = 60_000; + static const double _absoluteMin = -60_000; // ±60s absolute limit + static const double _absoluteMax = 60_000; static const double _tapStep = 100; // 100ms per tap static const double _longPressStep = 1000; // 1s per long-press tick static const int _sliderDivisions = 1200; // 100ms steps for ±60s range @@ -275,7 +275,7 @@ class _SyncOffsetControlState extends State { width: 80, child: Text( formatSyncOffset(_currentOffset), - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + style: const TextStyle(fontSize: 16, fontWeight: .bold), textAlign: TextAlign.center, ), ), @@ -291,7 +291,7 @@ class _SyncOffsetControlState extends State { child: Container( width: 36, height: 36, - alignment: Alignment.center, + alignment: .center, child: AppIcon( Symbols.restart_alt_rounded, fill: 1, @@ -313,10 +313,10 @@ class _SyncOffsetControlState extends State { return Padding( padding: const EdgeInsets.all(24), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: .center, children: [ // Current offset display - Text(formatSyncOffset(_currentOffset), style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold)), + Text(formatSyncOffset(_currentOffset), style: const TextStyle(fontSize: 48, fontWeight: .bold)), const SizedBox(height: 8), Text(_getDescriptionText(), style: TextStyle(color: tokens(context).textMuted, fontSize: 16)), const SizedBox(height: 48), diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index 130f5216..9ddc23a8 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -233,7 +233,7 @@ class _TimelineSliderState extends State { Widget buildSlider(Widget? tooltip) { return Stack( clipBehavior: Clip.none, - alignment: Alignment.center, + alignment: .center, children: [ // Buffer range + segmented background track (with chapter gaps) Positioned.fill( @@ -257,7 +257,7 @@ class _TimelineSliderState extends State { data: SliderTheme.of(context).copyWith( trackHeight: 8, trackGap: 0, - padding: EdgeInsets.zero, + padding: .zero, overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), tickMarkShape: SliderTickMarkShape.noTickMark, thumbSize: WidgetStatePropertyAll( @@ -379,7 +379,7 @@ class _ScrubFrameView extends StatelessWidget { child: OverflowBox( maxWidth: sheetW, maxHeight: sheetH, - alignment: Alignment.topLeft, + alignment: .topLeft, child: Transform.translate( offset: Offset(-f.tileColumn * tileW, -f.tileRow * tileH), child: Image( diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index d682a304..c2da317b 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -460,7 +460,7 @@ class TrackChapterControls extends StatelessWidget { } return IntrinsicHeight( - child: Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: buttons), + child: Row(mainAxisSize: .min, crossAxisAlignment: .stretch, children: buttons), ); }, ); diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index c2c0aad6..e6d4e2e8 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -54,7 +54,7 @@ class VideoControlsHeader extends StatelessWidget { selector: (_, p) => p.isInSession, builder: (context, inSession, child) { if (!inSession) return const SizedBox.shrink(); - return const Padding(padding: EdgeInsets.only(right: 8), child: WatchTogetherSessionIndicator()); + return const Padding(padding: .only(right: 8), child: WatchTogetherSessionIndicator()); }, ), ?trailing, @@ -75,9 +75,9 @@ class VideoControlsHeader extends StatelessWidget { return Text( toBulletedString(parts), - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: .w500), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ); } @@ -95,20 +95,20 @@ class VideoControlsHeader extends StatelessWidget { } return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( metadata.grandparentTitle ?? metadata.title!, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: .bold), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), if (secondLineParts.isNotEmpty) Text( toBulletedString(secondLineParts), style: const TextStyle(color: Colors.white70, fontSize: 14), maxLines: 1, - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, ), ], ); diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 15bbf1a0..b636cbaf 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -122,7 +122,7 @@ class VideoTimelineBar extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, children: [_buildTimestamp(position), _buildRemainingTimestamp(remaining)], ), ), diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index 02c66ead..698b8487 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -156,7 +156,7 @@ class _VolumeControlState extends State { ); return Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ if (widget.focusNode != null) FocusableWrapper( @@ -207,7 +207,7 @@ class _VolumeControlState extends State { child: SizedBox( width: 100, child: Stack( - alignment: Alignment.centerLeft, + alignment: .centerLeft, children: [ if (showMarker) Positioned( @@ -225,7 +225,7 @@ class _VolumeControlState extends State { data: SliderTheme.of(context).copyWith( trackHeight: 8, trackGap: 0, - padding: EdgeInsets.zero, + padding: .zero, overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), tickMarkShape: SliderTickMarkShape.noTickMark, ), diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 4de905eb..0c5d8b3e 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:plezy/media/ids.dart'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; @@ -436,7 +437,7 @@ class _AppDatabaseTestSuite { group('OfflineWatchProgress', () { test('upsertProgressAction inserts a new progress row', () async { await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '42', viewOffset: 5000, duration: 10000, @@ -456,14 +457,14 @@ class _AppDatabaseTestSuite { test('upsertProgressAction merges into the existing progress row', () async { await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '42', viewOffset: 1000, duration: 10000, shouldMarkWatched: false, ); await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '42', viewOffset: 9500, duration: 10000, @@ -478,7 +479,7 @@ class _AppDatabaseTestSuite { test('upsertProgressAction keeps scoped Jellyfin users separate', () async { await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), clientScopeId: 'srv/user-a', ratingKey: '42', viewOffset: 1000, @@ -486,7 +487,7 @@ class _AppDatabaseTestSuite { shouldMarkWatched: false, ); await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), clientScopeId: 'srv/user-b', ratingKey: '42', viewOffset: 9000, @@ -505,14 +506,18 @@ class _AppDatabaseTestSuite { test('insertWatchAction (watched) clears prior progress + insert single row', () async { // Existing progress row for the same item await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '42', viewOffset: 5000, duration: 10000, shouldMarkWatched: false, ); - await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.id); + await db.insertWatchAction( + serverId: ServerId('srv'), + ratingKey: '42', + actionType: OfflineActionType.watched.id, + ); final rows = await db.select(db.offlineWatchProgress).get(); expect(rows, hasLength(1)); @@ -522,7 +527,7 @@ class _AppDatabaseTestSuite { test('insertWatchAction clears only matching clientScopeId conflicts', () async { await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), clientScopeId: 'srv/user-a', ratingKey: '42', viewOffset: 1000, @@ -530,7 +535,7 @@ class _AppDatabaseTestSuite { shouldMarkWatched: false, ); await db.upsertProgressAction( - serverId: 'srv', + serverId: ServerId('srv'), clientScopeId: 'srv/user-b', ratingKey: '42', viewOffset: 2000, @@ -539,7 +544,7 @@ class _AppDatabaseTestSuite { ); await db.insertWatchAction( - serverId: 'srv', + serverId: ServerId('srv'), clientScopeId: 'srv/user-a', ratingKey: '42', actionType: OfflineActionType.watched.id, @@ -588,10 +593,10 @@ class _AppDatabaseTestSuite { }); test('adoptLegacyOfflineWatchActionsForProfile claims null-profile rows', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id); await db.insertWatchAction( profileId: 'profile-existing', - serverId: 's', + serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.watched.id, ); @@ -603,14 +608,14 @@ class _AppDatabaseTestSuite { }); test('getPendingWatchActionsForServer filters by serverId', () async { - await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.id); - await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.id); - await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.id); + await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('b'), ratingKey: '2', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '3', actionType: OfflineActionType.unwatched.id); - final aRows = await db.getPendingWatchActionsForServer('a'); + final aRows = await db.getPendingWatchActionsForServer(ServerId('a')); expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'}); - final bRows = await db.getPendingWatchActionsForServer('b'); + final bRows = await db.getPendingWatchActionsForServer(ServerId('b')); expect(bRows.map((r) => r.ratingKey).toSet(), {'2'}); }); @@ -774,7 +779,7 @@ class _AppDatabaseTestSuite { }); test('updateSyncAttempt increments syncAttempts and stores lastError', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id); final inserted = (await db.select(db.offlineWatchProgress).get()).single; await db.updateSyncAttempt(inserted.id, 'boom'); @@ -794,8 +799,8 @@ class _AppDatabaseTestSuite { }); test('deleteWatchAction removes only the matching row', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); - await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.watched.id); final rows = await db.select(db.offlineWatchProgress).get(); expect(rows, hasLength(2)); @@ -806,14 +811,14 @@ class _AppDatabaseTestSuite { test('getPendingSyncCount counts every row', () async { expect(await db.getPendingSyncCount(), 0); - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); - await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id); expect(await db.getPendingSyncCount(), 2); }); test('clearAllWatchActions empties the table', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); - await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id); await db.clearAllWatchActions(); expect(await db.select(db.offlineWatchProgress).get(), isEmpty); @@ -829,7 +834,7 @@ class _AppDatabaseTestSuite { group('SyncRules', () { test('insertSyncRule + getSyncRules round-trip with defaults', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -854,14 +859,14 @@ class _AppDatabaseTestSuite { // [globalKey] so re-creating a rule for the same target updates the // existing row rather than throwing. await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', episodeCount: 5, ); await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'season', @@ -879,7 +884,7 @@ class _AppDatabaseTestSuite { test('insertSyncRule allows the same server item for different profiles', () async { await db.insertSyncRule( profileId: 'profile-a', - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'profile-a|srv:10', targetType: 'show', @@ -887,7 +892,7 @@ class _AppDatabaseTestSuite { ); await db.insertSyncRule( profileId: 'profile-b', - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'profile-b|srv:10', targetType: 'show', @@ -902,7 +907,7 @@ class _AppDatabaseTestSuite { test('insertSyncRule preserves enabled + lastExecutedAt across upserts', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -913,7 +918,7 @@ class _AppDatabaseTestSuite { final firstRun = (await db.getSyncRule('srv:10'))!; await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -927,7 +932,7 @@ class _AppDatabaseTestSuite { test('getSyncRule returns the matching rule or null', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -939,7 +944,7 @@ class _AppDatabaseTestSuite { test('updateSyncRuleCount mutates only the count', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -954,7 +959,7 @@ class _AppDatabaseTestSuite { test('updateSyncRuleFilter mutates the filter', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -968,7 +973,7 @@ class _AppDatabaseTestSuite { test('updateSyncRuleEnabled toggles enabled', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -983,7 +988,7 @@ class _AppDatabaseTestSuite { test('updateSyncRuleLastExecuted writes a timestamp', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', @@ -1001,14 +1006,14 @@ class _AppDatabaseTestSuite { test('deleteSyncRule removes the matching row', () async { await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '10', globalKey: 'srv:10', targetType: 'show', episodeCount: 5, ); await db.insertSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '11', globalKey: 'srv:11', targetType: 'show', diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 6f1f8a1e..9e6126e2 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; @@ -23,7 +24,7 @@ void main() { group('insertDownload', () { test('inserts a movie row with defaults', () async { await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '100', globalKey: 'srv:100', type: 'movie', @@ -45,7 +46,7 @@ void main() { test('inserts an episode with parent and grandparent keys', () async { await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'ep1', globalKey: 'srv:ep1', type: 'episode', @@ -63,7 +64,7 @@ void main() { test('insertDownload uses InsertMode.insertOrReplace (re-insert overwrites)', () async { await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '100', globalKey: 'srv:100', type: 'movie', @@ -74,7 +75,7 @@ void main() { // Re-insert with the same globalKey — should replace, resetting progress to default 0. await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '100', globalKey: 'srv:100', type: 'movie', @@ -140,14 +141,14 @@ void main() { test('getNextQueueItem only returns items whose media is queued', () async { // Two items in queue; one's media is still queued, the other is downloading. await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '1', globalKey: 'srv:1', type: 'movie', status: DownloadStatus.queued.index, ); await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '2', globalKey: 'srv:2', type: 'movie', @@ -166,21 +167,21 @@ void main() { test('getNextQueueItem orders by priority desc, then addedAt asc', () async { // All have queued status await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '1', globalKey: 'srv:1', type: 'movie', status: DownloadStatus.queued.index, ); await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '2', globalKey: 'srv:2', type: 'movie', status: DownloadStatus.queued.index, ); await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '3', globalKey: 'srv:3', type: 'movie', @@ -212,7 +213,7 @@ void main() { group('update helpers', () { Future seed({String key = 'srv:100'}) async { await db.insertDownload( - serverId: key.split(':').first, + serverId: ServerId(key.split(':').first), ratingKey: key.split(':').last, globalKey: key, type: 'movie', @@ -308,7 +309,7 @@ void main() { group('lookup helpers', () { Future seedTree() async { await db.insertDownload( - serverId: 'srvA', + serverId: ServerId('srvA'), ratingKey: 'ep1', globalKey: 'srvA:ep1', type: 'episode', @@ -317,7 +318,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'srvA', + serverId: ServerId('srvA'), ratingKey: 'ep2', globalKey: 'srvA:ep2', type: 'episode', @@ -326,7 +327,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'srvA', + serverId: ServerId('srvA'), ratingKey: 'ep3', globalKey: 'srvA:ep3', type: 'episode', @@ -335,7 +336,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'srvB', + serverId: ServerId('srvB'), ratingKey: 'movie1', globalKey: 'srvB:movie1', type: 'movie', @@ -367,7 +368,7 @@ void main() { test('getEpisodesBySeason can filter by server and client scope', () async { await db.insertDownload( - serverId: 'jf', + serverId: ServerId('jf'), clientScopeId: 'jf/user-a', ratingKey: 'ep-a', globalKey: 'jf:ep-a', @@ -377,7 +378,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'jf', + serverId: ServerId('jf'), clientScopeId: 'jf/user-b', ratingKey: 'ep-b', globalKey: 'jf:ep-b', @@ -387,7 +388,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'other', + serverId: ServerId('other'), ratingKey: 'ep-other', globalKey: 'other:ep-other', type: 'episode', @@ -396,7 +397,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'other', + serverId: ServerId('other'), clientScopeId: 'other/user-a', ratingKey: 'ep-other-scoped', globalKey: 'other:ep-other-scoped', @@ -406,8 +407,8 @@ void main() { status: DownloadStatus.completed.index, ); - final userA = await db.getEpisodesBySeason('season1', serverId: 'jf', clientScopeId: 'jf/user-a'); - final unscoped = await db.getEpisodesBySeason('season1', serverId: 'other', filterClientScope: true); + final userA = await db.getEpisodesBySeason('season1', serverId: ServerId('jf'), clientScopeId: 'jf/user-a'); + final unscoped = await db.getEpisodesBySeason('season1', serverId: ServerId('other'), filterClientScope: true); expect(userA.map((e) => e.ratingKey), ['ep-a']); expect(unscoped.map((e) => e.ratingKey), ['ep-other']); @@ -424,7 +425,7 @@ void main() { test('getEpisodesByShow can filter by server and client scope', () async { await db.insertDownload( - serverId: 'jf', + serverId: ServerId('jf'), clientScopeId: 'jf/user-a', ratingKey: 'ep-a', globalKey: 'jf:ep-a', @@ -434,7 +435,7 @@ void main() { status: DownloadStatus.completed.index, ); await db.insertDownload( - serverId: 'jf', + serverId: ServerId('jf'), clientScopeId: 'jf/user-b', ratingKey: 'ep-b', globalKey: 'jf:ep-b', @@ -444,7 +445,7 @@ void main() { status: DownloadStatus.completed.index, ); - final userB = await db.getEpisodesByShow('show1', serverId: 'jf', clientScopeId: 'jf/user-b'); + final userB = await db.getEpisodesByShow('show1', serverId: ServerId('jf'), clientScopeId: 'jf/user-b'); expect(userB.map((e) => e.ratingKey), ['ep-b']); }); @@ -452,13 +453,13 @@ void main() { test('getDownloadsByServerId filters by serverId', () async { await seedTree(); - final a = await db.getDownloadsByServerId('srvA'); + final a = await db.getDownloadsByServerId(ServerId('srvA')); expect(a.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2', 'ep3'}); - final b = await db.getDownloadsByServerId('srvB'); + final b = await db.getDownloadsByServerId(ServerId('srvB')); expect(b.map((e) => e.ratingKey).toSet(), {'movie1'}); - expect(await db.getDownloadsByServerId('srvZ'), isEmpty); + expect(await db.getDownloadsByServerId(ServerId('srvZ')), isEmpty); }); }); @@ -513,7 +514,7 @@ void main() { group('deleteDownload', () { test('removes the row from downloadedMedia AND its queue entry', () async { await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '100', globalKey: 'srv:100', type: 'movie', @@ -521,7 +522,7 @@ void main() { ); await db.addToQueue(mediaGlobalKey: 'srv:100'); await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '200', globalKey: 'srv:200', type: 'movie', diff --git a/test/mixins/deletion_aware_test.dart b/test/mixins/deletion_aware_test.dart index 879a3f5a..ace3689d 100644 --- a/test/mixins/deletion_aware_test.dart +++ b/test/mixins/deletion_aware_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/mixins/deletion_aware.dart'; import 'package:plezy/utils/deletion_notifier.dart'; @@ -50,7 +51,7 @@ class _ProbeState extends State<_Probe> with DeletionAware { } DeletionEvent _ev({ - required String serverId, + required ServerId serverId, required String itemId, List parentChain = const [], String mediaType = 'movie', @@ -66,7 +67,7 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); @@ -77,7 +78,7 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '999')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '999')); await _settle(tester); expect(state.events, isEmpty); @@ -88,7 +89,7 @@ void main() { await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'show123'})); DeletionNotifier().notify( - _ev(serverId: 's1', itemId: 'season789', parentChain: const ['show123'], mediaType: 'season'), + _ev(serverId: ServerId('s1'), itemId: 'season789', parentChain: const ['show123'], mediaType: 'season'), ); await _settle(tester); @@ -100,11 +101,11 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, serverIdOverride: 's1', itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's2', itemId: '42')); + DeletionNotifier().notify(_ev(serverId: ServerId('s2'), itemId: '42')); await _settle(tester); expect(state.events, isEmpty); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); }); @@ -115,11 +116,11 @@ void main() { _Probe(onState: (s) => state = s, globalKeysOverride: const {'s1:99'}, itemIdsOverride: const {'5'}), ); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '5')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '5')); await _settle(tester); expect(state.events, isEmpty); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '99')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '99')); await _settle(tester); expect(state.events, hasLength(1)); expect(state.events.first.itemId, '99'); @@ -129,7 +130,7 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {})); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '1')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '1')); await _settle(tester); expect(state.events, isEmpty); @@ -139,13 +140,13 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); await tester.pumpWidget(const SizedBox.shrink()); - DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); + DeletionNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await tester.pump(Duration.zero); expect(state.events, hasLength(1)); diff --git a/test/mixins/event_aware_test.dart b/test/mixins/event_aware_test.dart index dd3501e1..800e351d 100644 --- a/test/mixins/event_aware_test.dart +++ b/test/mixins/event_aware_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/mixins/event_aware.dart'; @@ -10,7 +11,7 @@ class _FakeEvent with HierarchicalEventMixin { _FakeEvent({required this.serverId, required this.itemId, this.parentChain = const []}); @override - final String serverId; + final ServerId serverId; @override final String itemId; @@ -49,7 +50,7 @@ void main() { onEvent: received.add, ); - final ev = _FakeEvent(serverId: 's1', itemId: '42'); + final ev = _FakeEvent(serverId: ServerId('s1'), itemId: '42'); notifier.notify(ev); await _settle(); @@ -68,13 +69,13 @@ void main() { onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '42')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '42')); await _settle(); expect(received, isEmpty); // Once mounted, future events flow. mounted = true; - final ev = _FakeEvent(serverId: 's1', itemId: '99'); + final ev = _FakeEvent(serverId: ServerId('s1'), itemId: '99'); notifier.notify(ev); await _settle(); expect(received, [ev]); @@ -92,8 +93,8 @@ void main() { onEvent: received.add, ); - final keep = _FakeEvent(serverId: 's1', itemId: '1'); - final drop = _FakeEvent(serverId: 's2', itemId: '1'); + final keep = _FakeEvent(serverId: ServerId('s1'), itemId: '1'); + final drop = _FakeEvent(serverId: ServerId('s2'), itemId: '1'); notifier.notify(drop); notifier.notify(keep); await _settle(); @@ -103,7 +104,7 @@ void main() { }); test('globalKeys filter delivers events matching any global key', () async { - final keys = {buildGlobalKey('s1', '42'), buildGlobalKey('s1', '7')}; + final keys = {buildGlobalKey(ServerId('s1'), '42'), buildGlobalKey(ServerId('s1'), '7')}; final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, @@ -113,8 +114,8 @@ void main() { onEvent: received.add, ); - final hit = _FakeEvent(serverId: 's1', itemId: '42'); - final miss = _FakeEvent(serverId: 's1', itemId: '9999'); + final hit = _FakeEvent(serverId: ServerId('s1'), itemId: '42'); + final miss = _FakeEvent(serverId: ServerId('s1'), itemId: '9999'); notifier.notify(hit); notifier.notify(miss); await _settle(); @@ -126,7 +127,7 @@ void main() { test('globalKeys filter takes precedence over itemIds', () async { // Even though itemIds would match '5', globalKeys path returns early // and short-circuits the itemIds check. - final globalKeys = {buildGlobalKey('s1', '99')}; + final globalKeys = {buildGlobalKey(ServerId('s1'), '99')}; final itemIds = {'5'}; final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, @@ -138,12 +139,12 @@ void main() { ); // itemId 5 matches the itemIds set but not the globalKeys set. - notifier.notify(_FakeEvent(serverId: 's1', itemId: '5')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '5')); await _settle(); expect(received, isEmpty); // Now an event matching the globalKeys set comes through. - final hit = _FakeEvent(serverId: 's1', itemId: '99'); + final hit = _FakeEvent(serverId: ServerId('s1'), itemId: '99'); notifier.notify(hit); await _settle(); expect(received, [hit]); @@ -161,8 +162,8 @@ void main() { onEvent: received.add, ); - final a = _FakeEvent(serverId: 's1', itemId: '1'); - final b = _FakeEvent(serverId: 's2', itemId: '2'); + final a = _FakeEvent(serverId: ServerId('s1'), itemId: '1'); + final b = _FakeEvent(serverId: ServerId('s2'), itemId: '2'); notifier.notify(a); notifier.notify(b); await _settle(); @@ -181,8 +182,8 @@ void main() { onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); - notifier.notify(_FakeEvent(serverId: 's2', itemId: '2')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1')); + notifier.notify(_FakeEvent(serverId: ServerId('s2'), itemId: '2')); await _settle(); expect(received, isEmpty); @@ -199,8 +200,8 @@ void main() { onEvent: received.add, ); - final hit = _FakeEvent(serverId: 's1', itemId: '42'); - final miss = _FakeEvent(serverId: 's1', itemId: '99'); + final hit = _FakeEvent(serverId: ServerId('s1'), itemId: '42'); + final miss = _FakeEvent(serverId: ServerId('s1'), itemId: '99'); notifier.notify(hit); notifier.notify(miss); await _settle(); @@ -221,7 +222,7 @@ void main() { onEvent: received.add, ); - final episode = _FakeEvent(serverId: 's1', itemId: 'episode456', parentChain: ['season789', 'show123']); + final episode = _FakeEvent(serverId: ServerId('s1'), itemId: 'episode456', parentChain: ['season789', 'show123']); notifier.notify(episode); await _settle(); @@ -240,15 +241,15 @@ void main() { onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '2')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '2')); await _settle(); expect(received.map((e) => e.itemId).toList(), ['1']); // Change the filter set; the next event should be evaluated against it. ids = {'2'}; - notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '2')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '2')); await _settle(); expect(received.map((e) => e.itemId).toList(), ['1', '2']); @@ -265,12 +266,12 @@ void main() { onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '1')); await _settle(); expect(received, hasLength(1)); await sub.cancel(); - notifier.notify(_FakeEvent(serverId: 's1', itemId: '2')); + notifier.notify(_FakeEvent(serverId: ServerId('s1'), itemId: '2')); await _settle(); expect(received, hasLength(1)); }); diff --git a/test/mixins/library_tab_state_test.dart b/test/mixins/library_tab_state_test.dart index 9bab555c..64d9782b 100644 --- a/test/mixins/library_tab_state_test.dart +++ b/test/mixins/library_tab_state_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; @@ -51,7 +52,7 @@ class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> { } } -MediaLibrary _lib({String? serverId, String key = '1'}) => +MediaLibrary _lib({ServerId? serverId, String key = '1'}) => MediaLibrary(id: key, backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie, serverId: serverId); void main() { @@ -60,7 +61,7 @@ void main() { group('LibraryTabStateMixin', () { testWidgets('library getter returns the host state\'s library', (tester) async { late _ProbeState state; - final library = _lib(serverId: 'srv-A', key: 'lib-1'); + final library = _lib(serverId: ServerId('srv-A'), key: 'lib-1'); await tester.pumpWidget(_Probe(library: library, onState: (s, _) => state = s)); await tester.pump(); @@ -85,7 +86,7 @@ void main() { ChangeNotifierProvider.value( value: provider, child: _Probe( - library: _lib(serverId: 'srv-missing'), + library: _lib(serverId: ServerId('srv-missing')), onState: (s, c) { state = s; ctx = c; diff --git a/test/mixins/server_bound_media_mixin_test.dart b/test/mixins/server_bound_media_mixin_test.dart index f86de891..507a3db6 100644 --- a/test/mixins/server_bound_media_mixin_test.dart +++ b/test/mixins/server_bound_media_mixin_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -36,7 +37,7 @@ class _ProbeState extends State<_Probe> with ServerBoundMediaMixin<_Probe> { } } -MediaItem _meta({String? serverId, String ratingKey = 'rk1'}) => +MediaItem _meta({ServerId? serverId, String ratingKey = 'rk1'}) => MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId); void main() { @@ -47,7 +48,7 @@ void main() { late _ProbeState state; await tester.pumpWidget( _Probe( - metadata: _meta(serverId: 'srv-A'), + metadata: _meta(serverId: ServerId('srv-A')), offline: false, onState: (s, _) => state = s, ), @@ -68,7 +69,7 @@ void main() { late _ProbeState offState; await tester.pumpWidget( _Probe( - metadata: _meta(serverId: 's1'), + metadata: _meta(serverId: ServerId('s1')), offline: false, onState: (s, _) => offState = s, ), @@ -78,7 +79,7 @@ void main() { await tester.pumpWidget( _Probe( - metadata: _meta(serverId: 's1'), + metadata: _meta(serverId: ServerId('s1')), offline: true, onState: (s, _) => onState = s, ), @@ -91,7 +92,7 @@ void main() { late _ProbeState state; await tester.pumpWidget( _Probe( - metadata: _meta(serverId: 'srv-A'), + metadata: _meta(serverId: ServerId('srv-A')), offline: false, onState: (s, _) => state = s, ), @@ -106,7 +107,7 @@ void main() { late _ProbeState state; await tester.pumpWidget( _Probe( - metadata: _meta(serverId: 'srv-A'), + metadata: _meta(serverId: ServerId('srv-A')), offline: false, onState: (s, _) => state = s, ), @@ -114,7 +115,7 @@ void main() { await tester.pump(); // Explicit serverId takes precedence over the metadata-bound one. - expect(state.toServerBoundGlobalKey('rk-1', serverId: 'srv-B'), 'srv-B:rk-1'); + expect(state.toServerBoundGlobalKey('rk-1', serverId: ServerId('srv-B')), 'srv-B:rk-1'); }); testWidgets('toServerBoundGlobalKey falls back to empty serverId when metadata has none', (tester) async { @@ -131,7 +132,7 @@ void main() { late BuildContext ctx; await tester.pumpWidget( _Probe( - metadata: _meta(serverId: 'srv-A'), + metadata: _meta(serverId: ServerId('srv-A')), offline: true, onState: (s, c) { state = s; diff --git a/test/mixins/watch_state_aware_test.dart b/test/mixins/watch_state_aware_test.dart index d010e39e..55144054 100644 --- a/test/mixins/watch_state_aware_test.dart +++ b/test/mixins/watch_state_aware_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/mixins/watch_state_aware.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; @@ -52,7 +53,7 @@ class _ProbeState extends State<_Probe> with WatchStateAware { } WatchStateEvent _ev({ - required String serverId, + required ServerId serverId, required String itemId, List parentChain = const [], WatchStateChangeType type = WatchStateChangeType.watched, @@ -70,7 +71,7 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - final hit = _ev(serverId: 's1', itemId: '42'); + final hit = _ev(serverId: ServerId('s1'), itemId: '42'); WatchStateNotifier().notify(hit); await _settle(tester); @@ -82,7 +83,7 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '999')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '999')); await _settle(tester); expect(state.events, isEmpty); @@ -94,7 +95,7 @@ void main() { // Episode whose parent chain contains the show this screen tracks. WatchStateNotifier().notify( - _ev(serverId: 's1', itemId: 'episode456', parentChain: const ['season789', 'show123']), + _ev(serverId: ServerId('s1'), itemId: 'episode456', parentChain: const ['season789', 'show123']), ); await _settle(tester); @@ -106,11 +107,11 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, serverIdOverride: 's1', itemIdsOverride: const {'42'})); - WatchStateNotifier().notify(_ev(serverId: 's2', itemId: '42')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s2'), itemId: '42')); await _settle(tester); expect(state.events, isEmpty); - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); }); @@ -122,11 +123,11 @@ void main() { ); // itemId 5 matches the itemIds set, but globalKeys is the active filter. - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '5')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '5')); await _settle(tester); expect(state.events, isEmpty); - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '99')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '99')); await _settle(tester); expect(state.events, hasLength(1)); expect(state.events.first.itemId, '99'); @@ -136,8 +137,8 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {})); - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '1')); - WatchStateNotifier().notify(_ev(serverId: 's2', itemId: '2')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '1')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s2'), itemId: '2')); await _settle(tester); expect(state.events, isEmpty); @@ -147,14 +148,14 @@ void main() { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); // Replace the tree to dispose the probe. await tester.pumpWidget(const SizedBox.shrink()); - WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42')); + WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await tester.pump(Duration.zero); // No second delivery — subscription cancelled. diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 1c232c6b..5e7576bc 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'dart:convert'; import 'package:drift/native.dart'; @@ -471,7 +472,7 @@ class _FailingPlexMultiServerManager extends MultiServerManager { }) async { refreshCalls++; for (final server in connection.servers) { - updateServerStatus(server.clientIdentifier, false); + updateServerStatus(ServerId(server.clientIdentifier), false); } return const {}; } @@ -490,7 +491,7 @@ class _BlockingMixedMultiServerManager extends MultiServerManager { if (!plexStarted.isCompleted) plexStarted.complete(); await releasePlex.future; for (final server in connection.servers) { - updateServerStatus(server.clientIdentifier, false); + updateServerStatus(ServerId(server.clientIdentifier), false); } return const {}; } @@ -498,7 +499,7 @@ class _BlockingMixedMultiServerManager extends MultiServerManager { @override Future addJellyfinConnection(JellyfinConnection connection) async { if (!jellyfinStarted.isCompleted) jellyfinStarted.complete(); - updateServerStatus(connection.serverMachineId, true); + updateServerStatus(ServerId(connection.serverMachineId), true); return true; } } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 9578ca60..b46f884d 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -36,7 +37,7 @@ class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient { _ScopedTestClient({required this.serverId, required this.scopedServerId}); @override - final String serverId; + final ServerId serverId; @override final String scopedServerId; @@ -129,7 +130,7 @@ void main() { test('falls back to media index when caller has no source id', () async { const globalKey = 'srv:movie-1'; await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'movie-1', globalKey: globalKey, type: 'movie', @@ -158,8 +159,8 @@ void main() { var notified = 0; p.addListener(() => notified++); - await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); - final ruleKey = p.syncRuleKeyFor('srv', '10'); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5); + final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10'); expect(p.hasSyncRule(ruleKey), isTrue); final rule = p.getSyncRule(ruleKey); @@ -184,8 +185,8 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); - final ruleKey = p.syncRuleKeyFor('srv', '10'); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5); + final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10'); var notified = 0; p.addListener(() => notified++); @@ -202,8 +203,8 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'collection', episodeCount: 0); - final ruleKey = p.syncRuleKeyFor('srv', '10'); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'collection', episodeCount: 0); + final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10'); var notified = 0; p.addListener(() => notified++); @@ -219,8 +220,8 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); - final ruleKey = p.syncRuleKeyFor('srv', '10'); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5); + final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10'); expect(p.getSyncRule(ruleKey)!.enabled, isTrue); await p.setSyncRuleEnabled(ruleKey, false); @@ -237,10 +238,10 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); - await p.createSyncRule(serverId: 'srv', ratingKey: '11', targetType: 'show', episodeCount: 5); - final ruleKey10 = p.syncRuleKeyFor('srv', '10'); - final ruleKey11 = p.syncRuleKeyFor('srv', '11'); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '11', targetType: 'show', episodeCount: 5); + final ruleKey10 = p.syncRuleKeyFor(ServerId('srv'), '10'); + final ruleKey11 = p.syncRuleKeyFor(ServerId('srv'), '11'); expect(p.syncRules, hasLength(2)); var notified = 0; @@ -267,10 +268,10 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.collection, title: 'My Collection', - serverId: 'srv', + serverId: ServerId('srv'), ); await p.createSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '20', targetType: 'collection', episodeCount: 0, @@ -278,7 +279,7 @@ void main() { ); expect(p.getMetadata('srv:20'), isNotNull, reason: 'targetMetadata should be stashed'); - await p.deleteSyncRule(p.syncRuleKeyFor('srv', '20')); + await p.deleteSyncRule(p.syncRuleKeyFor(ServerId('srv'), '20')); expect(p.getMetadata('srv:20'), isNull, reason: 'orphan metadata should be released'); p.dispose(); @@ -293,10 +294,10 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.show, title: 'A Show', - serverId: 'srv', + serverId: ServerId('srv'), ); await p.createSyncRule( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '30', targetType: 'show', episodeCount: 5, @@ -309,7 +310,7 @@ void main() { downloads: {'srv:30': const DownloadProgress(globalKey: 'srv:30', status: DownloadStatus.queued)}, ); - await p.deleteSyncRule(p.syncRuleKeyFor('srv', '30')); + await p.deleteSyncRule(p.syncRuleKeyFor(ServerId('srv'), '30')); expect(p.getMetadata('srv:30'), isNotNull, reason: 'metadata is still in use by the download'); p.dispose(); @@ -322,7 +323,7 @@ void main() { final keys = p.syncRuleKeysForWatchEvent( WatchStateEvent( itemId: 'episode-1', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), cacheServerId: 'jf-machine/user-a', changeType: WatchStateChangeType.watched, parentChain: const ['season-1', 'show-1'], @@ -348,7 +349,7 @@ void main() { // Pre-seed the database with a rule before the provider exists. await db.insertSyncRule( profileId: 'test-profile', - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '99', globalKey: 'test-profile|srv:99', targetType: 'show', @@ -371,7 +372,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Owned Movie', - serverId: 'srv', + serverId: ServerId('srv'), ); test('queueDownload is a no-op when downloads are unsupported', () async { @@ -383,7 +384,7 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: unsupportedManager, database: db); await p.ensureInitialized(); - final queued = await p.queueDownload(movie, _ScopedTestClient(serverId: 'srv', scopedServerId: 'srv')); + final queued = await p.queueDownload(movie, _ScopedTestClient(serverId: ServerId('srv'), scopedServerId: 'srv')); expect(queued, 0); expect(p.downloads, isEmpty); @@ -478,7 +479,7 @@ void main() { test('deleteDownload is a no-op for unowned physical rows', () async { await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '1', globalKey: 'srv:1', type: 'movie', @@ -503,7 +504,7 @@ void main() { test('cancelDownload is a no-op for unowned physical rows', () async { await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: '1', globalKey: 'srv:1', type: 'movie', @@ -539,7 +540,7 @@ void main() { }, metadata: { 'srv:1': movie, - 'other:2': movie.copyWith(id: '2', serverId: 'other'), + 'other:2': movie.copyWith(id: '2', serverId: ServerId('other')), }, ); @@ -577,8 +578,8 @@ void main() { } Future putPinnedItem(String scopeId, String userId, String itemId, Map data) async { - await JellyfinApiCache.instance.put(scopeId, '/Users/$userId/Items/$itemId', data); - await JellyfinApiCache.instance.pinForOffline(scopeId, itemId); + await JellyfinApiCache.instance.put(ServerId(scopeId), '/Users/$userId/Items/$itemId', data); + await JellyfinApiCache.instance.pinForOffline(ServerId(scopeId), itemId); } test('loads parent metadata from the downloaded Jellyfin user scope', () async { @@ -619,7 +620,7 @@ void main() { }); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', globalKey: 'jf-machine:ep-1', @@ -665,7 +666,7 @@ void main() { 'UserData': {'PlayCount': 1, 'Played': true}, }); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', globalKey: 'jf-machine:ep-1', @@ -677,7 +678,7 @@ void main() { await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1'); downloadManager.setClientResolver((serverId, {clientScopeId}) { if (serverId == 'jf-machine') { - return _ScopedTestClient(serverId: 'jf-machine', scopedServerId: 'jf-machine/user-b'); + return _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b'); } return null; }); @@ -709,7 +710,7 @@ void main() { 'UserData': {'PlayCount': 0}, }); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', globalKey: 'jf-machine:ep-1', @@ -721,14 +722,14 @@ void main() { await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1'); await db.insertWatchAction( profileId: 'test-profile', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'ep-1', actionType: 'watched', ); downloadManager.setClientResolver((serverId, {clientScopeId}) { if (serverId == 'jf-machine') { - return _ScopedTestClient(serverId: 'jf-machine', scopedServerId: 'jf-machine/user-b'); + return _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-b'); } return null; }); @@ -766,7 +767,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie', - serverId: 'srv', + serverId: ServerId('srv'), durationMs: 100000, viewOffsetMs: 12000, viewCount: 0, @@ -792,7 +793,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie', - serverId: 'srv', + serverId: ServerId('srv'), durationMs: 100000, viewOffsetMs: 0, viewCount: 1, @@ -833,7 +834,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Ep 42', - serverId: 'srv', + serverId: ServerId('srv'), ), }, artwork: {key: const DownloadedArtwork(thumbPath: '/art/42.jpg')}, @@ -908,7 +909,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.season, title: 'Season 7', - serverId: 'srv', + serverId: ServerId('srv'), ); expect(p.getMetadata('srv:7'), isNull); @@ -931,7 +932,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.season, title: 'Original Title', - serverId: 'srv', + serverId: ServerId('srv'), ); p.debugSeedState(metadata: {'srv:7': preexisting}); @@ -940,7 +941,7 @@ void main() { backend: MediaBackend.plex, kind: MediaKind.season, title: 'New Title', - serverId: 'srv', + serverId: ServerId('srv'), ); await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA())); diff --git a/test/providers/libraries_provider_test.dart b/test/providers/libraries_provider_test.dart index 354c3b38..a4e7a936 100644 --- a/test/providers/libraries_provider_test.dart +++ b/test/providers/libraries_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; @@ -12,7 +13,7 @@ import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; -MediaLibrary _lib(String key, {String type = 'movie', String? serverId, String title = 'L'}) => MediaLibrary( +MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary( id: key, backend: MediaBackend.plex, title: title, @@ -20,7 +21,7 @@ MediaLibrary _lib(String key, {String type = 'movie', String? serverId, String t serverId: serverId, ); -MediaLibrary _serverLib(String serverId, String id, String title) => +MediaLibrary _serverLib(ServerId serverId, String id, String title) => MediaLibrary(id: id, backend: MediaBackend.plex, title: title, kind: MediaKind.movie, serverId: serverId); /// Minimal [MediaServerClient] returning canned libraries; only the surface the @@ -31,7 +32,7 @@ class _FakeClient implements MediaServerClient { _FakeClient({required this.serverId, this.libraries = const [], this.gate}); @override - final String serverId; + final ServerId serverId; @override final String serverName = 'Server'; @@ -109,9 +110,9 @@ void main() { p.addListener(() => notified++); final libs = [ - _lib('1', serverId: 'srv', title: 'A'), - _lib('2', serverId: 'srv', title: 'B'), - _lib('3', serverId: 'srv', title: 'C'), + _lib('1', serverId: ServerId('srv'), title: 'A'), + _lib('2', serverId: ServerId('srv'), title: 'B'), + _lib('3', serverId: ServerId('srv'), title: 'C'), ]; await p.updateLibraryOrder(libs); @@ -128,14 +129,14 @@ void main() { test('libraries getter returns an unmodifiable list', () async { final p = LibrariesProvider(); - await p.updateLibraryOrder([_lib('1', serverId: 'srv')]); + await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]); expect(() => p.libraries.add(_lib('mutated')), throwsUnsupportedError); p.dispose(); }); test('clear resets state to initial and notifies', () async { final p = LibrariesProvider(); - await p.updateLibraryOrder([_lib('1', serverId: 'srv'), _lib('2', serverId: 'srv')]); + await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv')), _lib('2', serverId: ServerId('srv'))]); expect(p.libraries, hasLength(2)); var notified = 0; @@ -157,14 +158,14 @@ void main() { // Post-dispose clear / updateLibraryOrder must not throw — the provider // uses `safeNotifyListeners` which swallows post-dispose firings. p.clear(); - await p.updateLibraryOrder([_lib('1', serverId: 'srv')]); + await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]); }); }); group('LibrariesProvider.syncToOnlineServers', () { test('loads when a server first comes online', () async { final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -180,7 +181,7 @@ void main() { test('does not reload when the online set is unchanged', () async { final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -198,14 +199,14 @@ void main() { // slow server reconnecting after timing out) was never picked up because // the load was one-shot. final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); expect(p.libraries.map((l) => l.title), ['Movies A']); - final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]); + final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientB); await p.syncToOnlineServers({'A', 'B'}); @@ -219,7 +220,7 @@ void main() { test('a background reload over existing data never surfaces a loading state', () async { final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -232,7 +233,7 @@ void main() { final sawLoading = []; p.addListener(() => sawLoading.add(p.isLoading)); - final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]); + final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientB); await p.syncToOnlineServers({'A', 'B'}); @@ -250,8 +251,8 @@ void main() { // failed server out of _loadedServerIds so it reloads instead of staying // missing until a profile re-switch/restart. final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); - final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]) + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); + final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]) ..error = Exception('transient'); manager.debugRegisterClientForTesting(clientA); manager.debugRegisterClientForTesting(clientB); @@ -274,8 +275,8 @@ void main() { test('does not reload when the online set shrinks', () async { final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); - final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); + final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientA); manager.debugRegisterClientForTesting(clientB); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -295,7 +296,7 @@ void main() { test('a zero-library server is marked loaded and does not retrigger', () async { final manager = MultiServerManager(); - final clientC = _FakeClient(serverId: 'C', libraries: const []); + final clientC = _FakeClient(serverId: ServerId('C'), libraries: const []); manager.debugRegisterClientForTesting(clientC); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -317,7 +318,11 @@ void main() { test('a server appearing mid-load is still picked up', () async { final manager = MultiServerManager(); final gate = Completer(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')], gate: gate.future); + final clientA = _FakeClient( + serverId: ServerId('A'), + libraries: [_serverLib(ServerId('A'), '1', 'Movies A')], + gate: gate.future, + ); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -325,7 +330,7 @@ void main() { final inFlight = p.syncToOnlineServers({'A'}); // B comes online before the first load completes. - final clientB = _FakeClient(serverId: 'B', libraries: [_serverLib('B', '1', 'Shows B')]); + final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientB); unawaited(p.syncToOnlineServers({'A', 'B'})); // queued behind the in-flight pass @@ -341,7 +346,7 @@ void main() { test('clear() resets tracking so the next sync reloads', () async { final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); @@ -360,7 +365,7 @@ void main() { test('is a no-op for an empty set or before initialize', () async { final manager = MultiServerManager(); - final clientA = _FakeClient(serverId: 'A', libraries: [_serverLib('A', '1', 'Movies A')]); + final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); // Empty set on an initialized provider. diff --git a/test/providers/multi_server_provider_test.dart b/test/providers/multi_server_provider_test.dart index 92eb855f..97d43ed6 100644 --- a/test/providers/multi_server_provider_test.dart +++ b/test/providers/multi_server_provider_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.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'; @@ -39,8 +40,8 @@ void main() { test('isServerOnline / getClientForServer return defaults for unknown ids', () { final p = MultiServerProvider(manager, aggregation); - expect(p.isServerOnline('nope'), isFalse); - expect(p.getClientForServer('nope'), isNull); + expect(p.isServerOnline(ServerId('nope')), isFalse); + expect(p.getClientForServer(ServerId('nope')), isNull); p.dispose(); }); @@ -73,7 +74,7 @@ void main() { p.addListener(() => notified++); // Push a status change through the manager's public API. - manager.updateServerStatus('srv-1', true); + manager.updateServerStatus(ServerId('srv-1'), true); // Give the broadcast stream microtask time to deliver. await Future.delayed(Duration.zero); @@ -87,7 +88,7 @@ void main() { final calls = >[]; p.onOnlineServersChanged = calls.add; - manager.updateServerStatus('srv-1', true); + manager.updateServerStatus(ServerId('srv-1'), true); await Future.delayed(Duration.zero); expect(calls, isNotEmpty); expect(calls.last, {'srv-1'}); @@ -95,7 +96,7 @@ void main() { // A server that is online in the manager but outside the active profile's // visibility filter must not appear in the payload. p.setVisibleServerIds({'srv-1'}); - manager.updateServerStatus('srv-2', true); + manager.updateServerStatus(ServerId('srv-2'), true); await Future.delayed(Duration.zero); expect(calls.last, {'srv-1'}, reason: 'srv-2 is online but filtered out'); @@ -146,15 +147,15 @@ void main() { p.addListener(() => notified++); // No prior filter — first add seeds it as a one-element set. - p.addToVisibleServerIds('srv-1'); + p.addToVisibleServerIds(ServerId('srv-1')); expect(notified, 1); // Build up incrementally. - p.addToVisibleServerIds('srv-2'); + p.addToVisibleServerIds(ServerId('srv-2')); expect(notified, 2); // Idempotent on already-present ids. - p.addToVisibleServerIds('srv-1'); + p.addToVisibleServerIds(ServerId('srv-1')); expect(notified, 2); p.dispose(); @@ -167,16 +168,16 @@ void main() { // status). The serverIds list requires actual server registration // which goes through addPlexAccount/addJellyfinConnection — beyond // what this unit test needs to cover. - manager.updateServerStatus('srv-1', true); - manager.updateServerStatus('srv-2', true); - manager.updateServerStatus('srv-3', false); + manager.updateServerStatus(ServerId('srv-1'), true); + manager.updateServerStatus(ServerId('srv-2'), true); + manager.updateServerStatus(ServerId('srv-3'), false); // No filter — every online id passes through. expect(p.onlineServerIds, containsAll({'srv-1', 'srv-2'})); p.setVisibleServerIds({'srv-1'}); expect(p.onlineServerIds, ['srv-1']); - expect(p.isServerOnline('srv-2'), isFalse, reason: 'filtered out even when manager reports online'); + expect(p.isServerOnline(ServerId('srv-2')), isFalse, reason: 'filtered out even when manager reports online'); // Empty filter blocks everything — covers the "no connections" path // for a freshly-created profile that hasn't borrowed anything yet. @@ -221,16 +222,16 @@ void main() { p.setVisibleServerIds({'srv-1'}); p.setExpectedVisibleServerIds({'srv-1', 'srv-2'}); - manager.updateServerStatus('srv-1', true); + manager.updateServerStatus(ServerId('srv-1'), true); await Future.delayed(Duration.zero); expect(p.onlineServerIds, ['srv-1']); - manager.updateServerStatus('srv-2', true); + manager.updateServerStatus(ServerId('srv-2'), true); await Future.delayed(Duration.zero); expect(p.onlineServerIds, containsAllInOrder(['srv-1', 'srv-2'])); - expect(p.isServerOnline('srv-2'), isTrue); + expect(p.isServerOnline(ServerId('srv-2')), isTrue); expect(onlineCalls.last, {'srv-1', 'srv-2'}); p.dispose(); @@ -244,7 +245,7 @@ void main() { p.addListener(() => notifyCount++); // Sanity: subscription works pre-dispose. - manager.updateServerStatus('a', true); + manager.updateServerStatus(ServerId('a'), true); await Future.delayed(Duration.zero); expect(notifyCount, greaterThanOrEqualTo(1)); diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart index afb00ccf..85535d6e 100644 --- a/test/providers/offline_mode_provider_test.dart +++ b/test/providers/offline_mode_provider_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; @@ -40,7 +41,7 @@ void main() { test('reads online server IDs from the manager at construction', () { final manager = MultiServerManager(); - manager.updateServerStatus('srv-1', true); + manager.updateServerStatus(ServerId('srv-1'), true); final p = OfflineModeProvider(manager); expect(p.hasServerConnection, isTrue); @@ -58,8 +59,8 @@ void main() { // fresh-cold-start manager), so we stay optimistic until the // provider's own listener catches an emission. final manager = MultiServerManager(); - manager.updateServerStatus('srv-1', false); - manager.updateServerStatus('srv-2', false); + manager.updateServerStatus(ServerId('srv-1'), false); + manager.updateServerStatus(ServerId('srv-2'), false); final p = OfflineModeProvider(manager); expect(p.hasServerConnection, isFalse); @@ -93,7 +94,7 @@ void main() { test('OfflineModeSource interface contract: isOffline is exposed', () { final manager = MultiServerManager(); - manager.updateServerStatus('srv', true); + manager.updateServerStatus(ServerId('srv'), true); final p = OfflineModeProvider(manager); // The provider implements OfflineModeSource — its isOffline getter is the @@ -110,7 +111,7 @@ void main() { // hasServerConnection reflects the manager's state and isOffline // is correctly false (network up + server up). final manager = MultiServerManager(); - manager.updateServerStatus('srv', true); + manager.updateServerStatus(ServerId('srv'), true); final p = OfflineModeProvider(manager); expect(p.hasServerConnection, isTrue); @@ -131,7 +132,7 @@ void main() { final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); - manager.debugMarkAuthErrorForTesting('jf-machine'); + manager.debugMarkAuthErrorForTesting(ServerId('jf-machine')); await Future.delayed(Duration.zero); expect(multi.authErrorServerIds, contains('jf-machine')); @@ -147,7 +148,7 @@ void main() { final multi = MultiServerProvider(manager, DataAggregationService(manager)); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); - manager.updateServerStatus('plex-server', false); + manager.updateServerStatus(ServerId('plex-server'), false); await Future.delayed(Duration.zero); expect(p.isOffline, isFalse); diff --git a/test/providers/offline_watch_provider_test.dart b/test/providers/offline_watch_provider_test.dart index 6e13c1ad..228cb34e 100644 --- a/test/providers/offline_watch_provider_test.dart +++ b/test/providers/offline_watch_provider_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/providers/download_provider.dart'; @@ -67,7 +68,12 @@ void main() { test('getViewOffset returns null for local progress that crossed watched threshold', () async { final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); - await syncService.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000); + await syncService.queueProgressUpdate( + serverId: ServerId('srv'), + itemId: '42', + viewOffset: 95000, + duration: 100000, + ); expect(await p.isWatched('srv:42'), isTrue); expect(await p.getViewOffset('srv:42'), isNull); @@ -95,7 +101,7 @@ void main() { // queueMarkWatched on the sync service notifies its listeners; the // provider's internal listener forwards via safeNotifyListeners. - await syncService.queueMarkWatched(serverId: 'srv', itemId: '42'); + await syncService.queueMarkWatched(serverId: ServerId('srv'), itemId: '42'); expect(notified, greaterThanOrEqualTo(1)); p.dispose(); @@ -107,7 +113,7 @@ void main() { var notified = 0; p.addListener(() => notified++); - await p.markAsWatched(serverId: 'srv', itemId: '50'); + await p.markAsWatched(serverId: ServerId('srv'), itemId: '50'); // The local watch status now reads as true via the sync service. expect(await p.isWatched('srv:50'), isTrue); @@ -121,7 +127,7 @@ void main() { test('markAsUnwatched queues an offline action and notifies', () async { final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); - await p.markAsUnwatched(serverId: 'srv', itemId: '60'); + await p.markAsUnwatched(serverId: ServerId('srv'), itemId: '60'); expect(await p.isWatched('srv:60'), isFalse); p.dispose(); @@ -134,7 +140,7 @@ void main() { p.addListener(() => notified++); // Sanity: listener is registered - await syncService.queueMarkWatched(serverId: 'srv', itemId: '70'); + await syncService.queueMarkWatched(serverId: ServerId('srv'), itemId: '70'); final preDisposeNotifies = notified; expect(preDisposeNotifies, greaterThanOrEqualTo(1)); @@ -143,7 +149,7 @@ void main() { // After dispose, sync service notifications should not call our // listener (provider unsubscribed). Mutating the sync service post- // dispose must not throw on the provider side. - await syncService.queueMarkUnwatched(serverId: 'srv', itemId: '70'); + await syncService.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '70'); expect(notified, preDisposeNotifies); }); }); diff --git a/test/providers/watch_state_overlay_provider_test.dart b/test/providers/watch_state_overlay_provider_test.dart index e305ef89..a698724c 100644 --- a/test/providers/watch_state_overlay_provider_test.dart +++ b/test/providers/watch_state_overlay_provider_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/providers/watch_state_overlay_provider.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; @@ -17,7 +18,7 @@ WatchStateEvent _event({ }) { return WatchStateEvent( itemId: itemId, - serverId: serverId, + serverId: ServerId(serverId), cacheServerId: cacheServerId, changeType: changeType, parentChain: const [], diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 0ab888e2..848ffb18 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -225,7 +226,7 @@ class _FakeMediaServerClient implements MediaServerClient { _FakeMediaServerClient({required this.hubs}); @override - String get serverId => 'server_1'; + ServerId get serverId => ServerId('server_1'); @override String? get serverName => 'Server'; diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index a6ca4291..227e26c0 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -73,7 +74,7 @@ JellyfinClient _jellyfinClient(JellyfinConnection connection) { ); } -MediaItem _show(String serverId, String ratingKey, String title) { +MediaItem _show(ServerId serverId, String ratingKey, String title) { return MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.show, title: title, serverId: serverId); } @@ -116,7 +117,7 @@ void main() { await db.close(); }); - Future insertRule(String serverId, String ratingKey) { + Future insertRule(ServerId serverId, String ratingKey) { return downloadProvider.createSyncRule( serverId: serverId, ratingKey: ratingKey, @@ -128,10 +129,10 @@ void main() { Future pumpScreen(WidgetTester tester, {bool keyboardMode = false}) async { downloadProvider.debugSeedState( metadata: { - 'plex-srv:show-1': _show('plex-srv', 'show-1', 'Plex Show'), - 'jf-machine:show-2': _show('jf-machine', 'show-2', 'Jellyfin Show'), - 'auth-jf:show-3': _show('auth-jf', 'show-3', 'Auth Show'), - 'unknown-srv:show-4': _show('unknown-srv', 'show-4', 'Unknown Show'), + 'plex-srv:show-1': _show(ServerId('plex-srv'), 'show-1', 'Plex Show'), + 'jf-machine:show-2': _show(ServerId('jf-machine'), 'show-2', 'Jellyfin Show'), + 'auth-jf:show-3': _show(ServerId('auth-jf'), 'show-3', 'Auth Show'), + 'unknown-srv:show-4': _show(ServerId('unknown-srv'), 'show-4', 'Unknown Show'), }, ); @@ -196,13 +197,13 @@ void main() { final authClient = _jellyfinClient(authJellyfin); addTearDown(authClient.close); serverManager.debugRegisterJellyfinClientForTesting(authClient, online: false); - serverManager.debugMarkAuthErrorForTesting('auth-jf'); + serverManager.debugMarkAuthErrorForTesting(ServerId('auth-jf')); multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); - await insertRule('plex-srv', 'show-1'); - await insertRule('jf-machine', 'show-2'); - await insertRule('auth-jf', 'show-3'); - await insertRule('unknown-srv', 'show-4'); + await insertRule(ServerId('plex-srv'), 'show-1'); + await insertRule(ServerId('jf-machine'), 'show-2'); + await insertRule(ServerId('auth-jf'), 'show-3'); + await insertRule(ServerId('unknown-srv'), 'show-4'); await pumpScreen(tester); @@ -218,7 +219,7 @@ void main() { testWidgets('removes orphaned sync rules from the sync rules screen', (tester) async { multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); - await insertRule('orphan-srv', '76672'); + await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester); @@ -241,7 +242,7 @@ void main() { testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async { multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); - await insertRule('orphan-srv', '76672'); + await insertRule(ServerId('orphan-srv'), '76672'); FocusManager.instance.primaryFocus?.unfocus(); await pumpScreen(tester); @@ -252,7 +253,7 @@ void main() { testWidgets('keyboard navigation reaches and toggles the sync rule switch', (tester) async { multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); - await insertRule('orphan-srv', '76672'); + await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester, keyboardMode: true); @@ -270,7 +271,7 @@ void main() { testWidgets('setting sync rule count to zero removes the rule', (tester) async { multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); - await insertRule('orphan-srv', '76672'); + await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester, keyboardMode: true); await tester.sendKeyEvent(LogicalKeyboardKey.enter); diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 487689dd..0f4adc13 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -429,7 +430,7 @@ class _FakeMediaServerClient implements MediaServerClient { }); @override - String get serverId => 'server_1'; + ServerId get serverId => ServerId('server_1'); @override String? get serverName => 'Server'; diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 272d0b65..320e6b4c 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; @@ -208,7 +209,7 @@ class _PagedPlaylistClient implements MediaServerClient { _PagedPlaylistClient(this.items); @override - String get serverId => 'server_1'; + ServerId get serverId => ServerId('server_1'); @override String? get serverName => 'Server'; diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index b9925b33..c2c65989 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -73,7 +74,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'plex-1', + serverId: ServerId('plex-1'), serverName: 'Plex', httpClient: MockClient((req) async { plexRequests.add(req.url); @@ -131,7 +132,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'plex-1', + serverId: ServerId('plex-1'), serverName: 'Plex', httpClient: MockClient((req) async { captured.add(req.url); @@ -175,7 +176,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'plex-1', + serverId: ServerId('plex-1'), serverName: 'Plex', httpClient: MockClient((req) async { if (req.url.path == '/hubs') { @@ -251,7 +252,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'plex-1', + serverId: ServerId('plex-1'), serverName: 'Plex', httpClient: MockClient((req) async { if (req.url.path == '/hubs') { @@ -426,7 +427,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'plex-1', + serverId: ServerId('plex-1'), serverName: 'Plex', promotedHubKey: '/hubs/promoted', httpClient: MockClient((req) async { diff --git a/test/services/download_artwork_service_test.dart b/test/services/download_artwork_service_test.dart index 97d78209..746c43f8 100644 --- a/test/services/download_artwork_service_test.dart +++ b/test/services/download_artwork_service_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'dart:convert'; import 'dart:io'; @@ -119,7 +120,7 @@ void main() { const tokenized = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; const sanitized = 'https://jf/Items/1/Images/Logo?tag=abc'; - expect(await service.localPath('srv', tokenized), await service.localPath('srv', sanitized)); + expect(await service.localPath(ServerId('srv'), tokenized), await service.localPath(ServerId('srv'), sanitized)); }); test('downloadFile rejects non-success responses without leaving final files', () async { @@ -146,16 +147,16 @@ void main() { ); const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; - final filePath = await service.localPath('srv', rawPath); + final filePath = await service.localPath(ServerId('srv'), rawPath); await File(filePath).writeAsString('not an image'); await service.downloadSingleArtwork( - 'srv', + ServerId('srv'), DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'), ); expect(await File(filePath).readAsBytes(), body); - expect(await service.existsUsable('srv', rawPath), isTrue); + expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue); }); test('downloadSingleArtwork serializes duplicate writes to the same local file', () async { @@ -170,15 +171,15 @@ void main() { const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; final spec = DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'); - final first = service.downloadSingleArtwork('srv', spec); + final first = service.downloadSingleArtwork(ServerId('srv'), spec); await Future.delayed(Duration.zero); - final second = service.downloadSingleArtwork('srv', spec); + final second = service.downloadSingleArtwork(ServerId('srv'), spec); await Future.delayed(Duration.zero); httpClient.release.complete(); await Future.wait([first, second]); expect(httpClient.sends, 1); - expect(await service.existsUsable('srv', rawPath), isTrue); + expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue); }); } diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index f74c1832..e2ad3e58 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; @@ -83,7 +84,7 @@ void main() { .into(db.downloadedMedia) .insert( DownloadedMediaCompanion.insert( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: const Value('jf-machine/user-a'), ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -103,10 +104,13 @@ void main() { final manager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance) ..setClientResolver((serverId, {clientScopeId}) { - return _ScopedJellyfinClient(serverId: serverId, scopedServerId: clientScopeId ?? 'jf-machine/user-b'); + return _ScopedJellyfinClient( + serverId: ServerId(serverId), + scopedServerId: clientScopeId ?? 'jf-machine/user-b', + ); }); - final item = await manager.lookupMetadata('jf-machine', 'item-1', preferActiveScope: true); + final item = await manager.lookupMetadata(ServerId('jf-machine'), 'item-1', preferActiveScope: true); expect(item?.title, 'Cached for User A'); expect(item?.serverId, 'jf-machine'); @@ -118,7 +122,7 @@ void main() { JellyfinApiCache.initialize(db); addTearDown(db.close); - await PlexApiCache.instance.put('srv-1', '/library/metadata/show-1', { + await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/show-1', { 'MediaContainer': { 'Metadata': [ {'ratingKey': 'show-1', 'type': 'show', 'title': 'The Show', 'year': 2008}, @@ -132,7 +136,7 @@ void main() { id: 'ep-1', backend: MediaBackend.plex, kind: MediaKind.episode, - serverId: 'srv-1', + serverId: ServerId('srv-1'), title: 'Episode from 2010', year: 2010, grandparentId: 'show-1', @@ -151,25 +155,25 @@ void main() { JellyfinApiCache.initialize(db); addTearDown(db.close); - await JellyfinApiCache.instance.put('jf-machine/user-a', '/Users/user-a/Items/item-1', { + await JellyfinApiCache.instance.put(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1', { 'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', }); - await JellyfinApiCache.instance.put('jf-machine/user-a', '/MediaSegments/item-1', { + await JellyfinApiCache.instance.put(ServerId('jf-machine/user-a'), '/MediaSegments/item-1', { 'Items': [ {'Type': 'Intro', 'StartTicks': 10000000, 'EndTicks': 20000000}, ], }); - await JellyfinApiCache.instance.pinForOffline('jf-machine/user-a', 'item-1'); + await JellyfinApiCache.instance.pinForOffline(ServerId('jf-machine/user-a'), 'item-1'); - expect(await JellyfinApiCache.instance.isPinned('jf-machine/user-a', '/MediaSegments/item-1'), isTrue); + expect(await JellyfinApiCache.instance.isPinned(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isTrue); - await JellyfinApiCache.instance.deleteForItem('jf-machine/user-a', 'item-1'); + await JellyfinApiCache.instance.deleteForItem(ServerId('jf-machine/user-a'), 'item-1'); - expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/Users/user-a/Items/item-1'), isNull); - expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/MediaSegments/item-1'), isNull); + expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1'), isNull); + expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isNull); }); test('artwork repair fetches full parent metadata and backfills thumb path', () async { @@ -196,7 +200,7 @@ void main() { .into(db.downloadedMedia) .insert( DownloadedMediaCompanion.insert( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'ep-1', globalKey: 'srv:ep-1', type: 'episode', @@ -205,7 +209,7 @@ void main() { status: DownloadStatus.completed.index, ), ); - await PlexApiCache.instance.put('srv', '/library/metadata/ep-1', { + await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/ep-1', { 'MediaContainer': { 'Metadata': [ { @@ -222,7 +226,7 @@ void main() { ], }, }); - await PlexApiCache.instance.put('srv', '/library/metadata/show-1', { + await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/show-1', { 'MediaContainer': { 'Metadata': [ {'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'thumb': '/show-thumb'}, @@ -231,13 +235,13 @@ void main() { }); final client = _ArtworkRepairClient( - serverId: 'srv', + serverId: ServerId('srv'), items: { 'show-1': MediaItem( id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, - serverId: 'srv', + serverId: ServerId('srv'), title: 'Show', thumbPath: '/show-thumb', clearLogoPath: '/show-logo', @@ -256,7 +260,7 @@ void main() { expect(client.fetchCounts['show-1'], isNotNull); expect(client.fetchCounts['show-1']!, greaterThan(0)); - final logoPath = DownloadArtworkService.localPathSync(storage, 'srv', '/show-logo'); + final logoPath = DownloadArtworkService.localPathSync(storage, ServerId('srv'), '/show-logo'); expect(logoPath, isNotNull); expect(File(logoPath!).existsSync(), isTrue); final row = await db.getDownloadedMedia('srv:ep-1'); @@ -270,7 +274,7 @@ void main() { addTearDown(db.close); const globalKey = 'srv:item-1'; await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'item-1', globalKey: globalKey, type: 'movie', @@ -305,7 +309,7 @@ void main() { addTearDown(db.close); const globalKey = 'srv:item-1'; await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'item-1', globalKey: globalKey, type: 'movie', @@ -333,7 +337,7 @@ void main() { addTearDown(db.close); const globalKey = 'srv:item-1'; await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'item-1', globalKey: globalKey, type: 'movie', @@ -365,7 +369,7 @@ void main() { addTearDown(db.close); const globalKey = 'srv:item-1'; await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'item-1', globalKey: globalKey, type: 'movie', @@ -398,7 +402,7 @@ void main() { addTearDown(db.close); const globalKey = 'srv:item-1'; await db.insertDownload( - serverId: 'srv', + serverId: ServerId('srv'), ratingKey: 'item-1', globalKey: globalKey, type: 'movie', @@ -443,7 +447,7 @@ MediaItem _movie({String? thumbPath}) { id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), thumbPath: thumbPath, ); } @@ -452,7 +456,7 @@ class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClien _ScopedJellyfinClient({required this.serverId, required this.scopedServerId}); @override - final String serverId; + final ServerId serverId; @override final String scopedServerId; @@ -504,7 +508,7 @@ class _ArtworkRepairClient implements MediaServerClient { _ArtworkRepairClient({required this.serverId, required this.items}); @override - final String serverId; + final ServerId serverId; final Map items; final fetchCounts = {}; diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index 8b71bd7f..f6f317ed 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -234,14 +235,14 @@ void main() { final dss = DownloadStorageService.instance; // Before initialize() the sync getter is null. expect(dss.artworkDirectoryPath, isNull); - expect(dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'), isNull); + expect(dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/1/thumb'), isNull); final settings = await SettingsService.getInstance(); await dss.initialize(settings); - final p1 = dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'); - final p2 = dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'); - final p3 = dss.getArtworkPathSync('srv', '/library/metadata/2/thumb'); + final p1 = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/1/thumb'); + final p2 = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/1/thumb'); + final p3 = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/2/thumb'); expect(p1, isNotNull); // Same input → same path (MD5 of `serverId:thumbPath`). expect(p1, p2); @@ -254,8 +255,8 @@ void main() { final dss = DownloadStorageService.instance; await dss.initialize(settings); - final asyncPath = await dss.getArtworkPathFromThumb('srv', '/library/metadata/9/thumb'); - final syncPath = dss.getArtworkPathSync('srv', '/library/metadata/9/thumb'); + final asyncPath = await dss.getArtworkPathFromThumb(ServerId('srv'), '/library/metadata/9/thumb'); + final syncPath = dss.getArtworkPathSync(ServerId('srv'), '/library/metadata/9/thumb'); expect(asyncPath, syncPath); }); @@ -264,11 +265,11 @@ void main() { final dss = DownloadStorageService.instance; await dss.initialize(settings); - expect(await dss.artworkExists('srv', '/thumb/1'), isFalse); + expect(await dss.artworkExists(ServerId('srv'), '/thumb/1'), isFalse); - final filePath = await dss.getArtworkPathFromThumb('srv', '/thumb/1'); + final filePath = await dss.getArtworkPathFromThumb(ServerId('srv'), '/thumb/1'); await File(filePath).writeAsString('fake-artwork'); - expect(await dss.artworkExists('srv', '/thumb/1'), isTrue); + expect(await dss.artworkExists(ServerId('srv'), '/thumb/1'), isTrue); }); }); @@ -521,7 +522,7 @@ void main() { final dss = DownloadStorageService.instance; await dss.initialize(settings); - final dir = await dss.getMediaDirectory('srv-1', '42'); + final dir = await dss.getMediaDirectory(ServerId('srv-1'), '42'); expect(dir.existsSync(), isTrue); final downloads = await dss.getDownloadsDirectory(); expect(dir.path, p.join(downloads.path, 'srv-1', '42')); diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index d0481b8a..2857bd6f 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -32,7 +33,7 @@ import 'package:provider/provider.dart'; MediaItem _meta(String id, {String? title}) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); -MediaItem _jfEpisode(String id, {required String seriesId, String serverId = 'srv-jf'}) => MediaItem( +MediaItem _jfEpisode(String id, {required String seriesId, ServerId serverId = const ServerId('srv-jf')}) => MediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.episode, diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index d6eb2be0..7833c562 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/media_backend.dart'; @@ -18,7 +19,7 @@ class _RecordingClient implements MediaServerClient { final stopped = <({int positionMs, int? durationMs})>[]; @override - String get serverId => 'srv'; + ServerId get serverId => ServerId('srv'); @override MediaBackend get backend => MediaBackend.plex; diff --git a/test/services/jellyfin_api_cache_test.dart b/test/services/jellyfin_api_cache_test.dart index abe0f910..be9f0d1d 100644 --- a/test/services/jellyfin_api_cache_test.dart +++ b/test/services/jellyfin_api_cache_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; @@ -63,7 +64,7 @@ void main() { // `serverId:/Users/{userId}/Items/{itemId}` — mirror the shape exactly so // we exercise the same lookup pattern. Future putItemRow({ - required String serverId, + required ServerId serverId, required String userId, required String itemId, Map? data, @@ -91,13 +92,13 @@ void main() { const userId = 'jf-user'; await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin'); await putItemRow( - serverId: machineId, + serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: jellyfinItem(id: 'item-1', name: 'A Movie'), ); - final meta = await cache.getMetadata(machineId, 'item-1'); + final meta = await cache.getMetadata(ServerId(machineId), 'item-1'); expect(meta, isNotNull, reason: 'cache lookup must succeed despite id-format mismatch'); expect(meta!.title, 'A Movie'); expect(meta.serverId, machineId); @@ -106,8 +107,8 @@ void main() { test('returns null when the connection row is missing', () async { // Cache row exists but no Connections row → lookup can't resolve serverName. - await putItemRow(serverId: 'orphan', userId: 'u', itemId: 'item-1'); - expect(await cache.getMetadata('orphan', 'item-1'), isNull); + await putItemRow(serverId: ServerId('orphan'), userId: 'u', itemId: 'item-1'); + expect(await cache.getMetadata(ServerId('orphan'), 'item-1'), isNull); }); test('absolutizes image paths against the connection baseUrl + accessToken', () async { @@ -118,7 +119,7 @@ void main() { const userId = 'jf-user'; await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin'); await putItemRow( - serverId: machineId, + serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: { @@ -129,7 +130,7 @@ void main() { }, ); - final meta = await cache.getMetadata(machineId, 'item-1'); + final meta = await cache.getMetadata(ServerId(machineId), 'item-1'); expect(meta, isNotNull); expect(meta!.thumbPath, 'http://example.lan/Items/item-1/Images/Primary?tag=tag-abc&api_key=token'); expect(meta.clearLogoPath, 'http://example.lan/Items/item-1/Images/Logo?tag=tag-logo&api_key=token'); @@ -145,7 +146,7 @@ void main() { accessToken: await CredentialVault.protect('secret-token'), ); await putItemRow( - serverId: machineId, + serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: { @@ -156,7 +157,7 @@ void main() { }, ); - final meta = await cache.getMetadata(machineId, 'item-1'); + final meta = await cache.getMetadata(ServerId(machineId), 'item-1'); expect(meta, isNotNull); expect(meta!.thumbPath, contains('api_key=secret-token')); expect(meta.thumbPath, isNot(contains('enc:v1:'))); @@ -167,7 +168,7 @@ void main() { await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); await putItemRow( - serverId: '$machineId/user-a', + serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1', data: { @@ -176,7 +177,7 @@ void main() { }, ); await putItemRow( - serverId: '$machineId/user-b', + serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1', data: { @@ -185,8 +186,8 @@ void main() { }, ); - final a = await cache.getMetadata('$machineId/user-a', 'item-1'); - final b = await cache.getMetadata('$machineId/user-b', 'item-1'); + final a = await cache.getMetadata(ServerId('$machineId/user-a'), 'item-1'); + final b = await cache.getMetadata(ServerId('$machineId/user-b'), 'item-1'); expect(a, isNotNull); expect(b, isNotNull); @@ -207,10 +208,10 @@ void main() { await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); - await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-1', pinned: true); - await putItemRow(serverId: machineId, userId: 'user-b', itemId: 'item-2', pinned: true); + await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1', pinned: true); + await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-2', pinned: true); // Unpinned row is filtered out. - await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-3'); + await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-3'); final pinned = await cache.getAllPinnedMetadata(); expect(pinned.keys.toSet(), {'$machineId:item-1', '$machineId:item-2'}); @@ -218,7 +219,7 @@ void main() { }); test('skips pinned rows whose serverId has no matching connection', () async { - await putItemRow(serverId: 'orphan-machine', userId: 'u', itemId: 'lost', pinned: true); + await putItemRow(serverId: ServerId('orphan-machine'), userId: 'u', itemId: 'lost', pinned: true); expect(await cache.getAllPinnedMetadata(), isEmpty); }); @@ -227,7 +228,7 @@ void main() { await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); await putItemRow( - serverId: '$machineId/user-a', + serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1', data: { @@ -237,7 +238,7 @@ void main() { pinned: true, ); await putItemRow( - serverId: '$machineId/user-b', + serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1', data: { @@ -259,11 +260,11 @@ void main() { group('pinForOffline', () { test('pins by user-segment wildcard so a single call covers any user', () async { const machineId = 'jf-machine'; - await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-1'); - await putItemRow(serverId: machineId, userId: 'user-b', itemId: 'item-1'); - await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-2'); + await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1'); + await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-1'); + await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-2'); - await cache.pinForOffline(machineId, 'item-1'); + await cache.pinForOffline(ServerId(machineId), 'item-1'); // Both per-user rows for item-1 get pinned, item-2 stays unpinned. final rows = await db.select(db.apiCache).get(); @@ -273,10 +274,10 @@ void main() { test('pins only the requested compound Jellyfin user scope', () async { const machineId = 'jf-machine'; - await putItemRow(serverId: '$machineId/user-a', userId: 'user-a', itemId: 'item-1'); - await putItemRow(serverId: '$machineId/user-b', userId: 'user-b', itemId: 'item-1'); + await putItemRow(serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1'); + await putItemRow(serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1'); - await cache.pinForOffline('$machineId/user-a', 'item-1'); + await cache.pinForOffline(ServerId('$machineId/user-a'), 'item-1'); final rows = await db.select(db.apiCache).get(); final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet(); @@ -288,7 +289,7 @@ void main() { test('mutates only the requested compound Jellyfin user scope', () async { const machineId = 'jf-machine'; await putItemRow( - serverId: '$machineId/user-a', + serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1', data: { @@ -297,7 +298,7 @@ void main() { }, ); await putItemRow( - serverId: '$machineId/user-b', + serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1', data: { @@ -306,7 +307,7 @@ void main() { }, ); - await cache.applyWatchState(serverId: '$machineId/user-a', itemId: 'item-1', isWatched: true); + await cache.applyWatchState(serverId: ServerId('$machineId/user-a'), itemId: 'item-1', isWatched: true); final rows = await db.select(db.apiCache).get(); final byKey = {for (final row in rows) row.cacheKey: jsonDecode(row.data) as Map}; diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 405e76cb..45956e40 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.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_stream.dart'; @@ -43,7 +44,12 @@ void main() { 'BackdropImageTags': ['backtag'], }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, serverName: 'Home', absolutizer: null)!; + final item = JellyfinMappers.mediaItem( + json, + serverId: ServerId(_serverId), + serverName: 'Home', + absolutizer: null, + )!; expect(item.id, 'abc123'); expect(item.backend, MediaBackend.jellyfin); @@ -88,7 +94,7 @@ void main() { 'UserData': {'PlayCount': 1, 'Played': false}, }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; expect(item.viewCount, 0); expect(item.isWatched, isFalse); @@ -97,12 +103,12 @@ void main() { test('maps generic Jellyfin video types to playable clips', () { final video = JellyfinMappers.mediaItem( {'Id': 'home-video', 'Name': 'Home Video', 'Type': 'Video'}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; final musicVideo = JellyfinMappers.mediaItem( {'Id': 'music-video', 'Name': 'Music Video', 'Type': 'MusicVideo'}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; @@ -128,7 +134,7 @@ void main() { 'UserData': {'UnplayedItemCount': 0}, }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; expect(item.kind, MediaKind.episode); expect(item.index, 1); @@ -154,7 +160,7 @@ void main() { 'SeasonName': 'Season 1', }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; expect(item.parentThumbPath, '/Items/season-1/Images/Primary'); expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary'); @@ -175,7 +181,7 @@ void main() { 'UserData': {'UnplayedItemCount': 4}, }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; expect(item.leafCount, 12); expect(item.viewedLeafCount, 8); @@ -198,7 +204,7 @@ void main() { {'Type': 'Actor', 'Name': 'Actor', 'Id': 'person/id #1?x', 'PrimaryImageTag': 'person/tag ?x'}, ], }, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; @@ -222,7 +228,7 @@ void main() { 'UserData': {'UnplayedItemCount': 7}, }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; expect(item.leafCount, 50); expect(item.viewedLeafCount, 43); @@ -273,7 +279,7 @@ void main() { ], }; - final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!; expect(item.mediaVersions, isNotNull); final v = item.mediaVersions!.single; expect(v.id, 'src-1'); @@ -323,7 +329,7 @@ void main() { 'Id': 'view-${entry.key}', 'Name': 'Library', 'CollectionType': entry.key, - }, serverId: _serverId)!; + }, serverId: ServerId(_serverId))!; expect(lib.kind, entry.value, reason: 'CollectionType ${entry.key}'); expect(lib.backend, MediaBackend.jellyfin); } @@ -334,7 +340,7 @@ void main() { 'Id': 'view-x', 'Name': 'Mixed', 'CollectionType': 'mixed', - }, serverId: _serverId)!; + }, serverId: ServerId(_serverId))!; expect(lib.kind, MediaKind.unknown); }); }); @@ -347,7 +353,7 @@ void main() { test('minimal payload (just Id + Type) yields a MediaItem with sane defaults', () { final item = JellyfinMappers.mediaItem( {'Id': 'bare-1', 'Type': 'Movie'}, - serverId: _serverId, + serverId: ServerId(_serverId), serverName: 'Home', absolutizer: null, )!; @@ -364,7 +370,7 @@ void main() { test('missing UserData leaves watch state nullable without throwing', () { final item = JellyfinMappers.mediaItem( {'Id': 'i', 'Type': 'Movie', 'Name': 'X'}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; // Either 0 or null is acceptable as long as we don't crash. @@ -376,7 +382,7 @@ void main() { test('null People array does not crash', () { final item = JellyfinMappers.mediaItem( {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'People': null}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; expect(item.directors, anyOf(isNull, isEmpty)); @@ -387,7 +393,7 @@ void main() { test('null Genres / Studios / ProductionLocations degrade gracefully', () { final item = JellyfinMappers.mediaItem( {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'Genres': null, 'Studios': null, 'ProductionLocations': null}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; expect(item.genres, anyOf(isNull, isEmpty)); @@ -398,7 +404,7 @@ void main() { test('malformed RunTimeTicks does not throw — duration left null', () { final item = JellyfinMappers.mediaItem( {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'RunTimeTicks': 'not-a-number'}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; expect(item.durationMs, isNull); @@ -407,7 +413,7 @@ void main() { test('null MediaSources does not crash', () { final item = JellyfinMappers.mediaItem( {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'MediaSources': null}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; expect(item.mediaVersions, anyOf(isNull, isEmpty)); @@ -417,7 +423,7 @@ void main() { group('JellyfinMappers.mediaItem missing-Id rejection', () { test('returns null when Id is absent', () { expect( - JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: _serverId, absolutizer: null), + JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: ServerId(_serverId), absolutizer: null), isNull, ); }); @@ -426,7 +432,7 @@ void main() { expect( JellyfinMappers.mediaItem( {'Id': '', 'Type': 'Movie', 'Name': 'emptyId'}, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, ), isNull, @@ -443,7 +449,7 @@ void main() { {'Id': 'src-ok', 'Container': 'mp4', 'Bitrate': 4000000, 'MediaStreams': []}, ], }, - serverId: _serverId, + serverId: ServerId(_serverId), absolutizer: null, )!; expect(item.mediaVersions!.length, 1); @@ -453,7 +459,10 @@ void main() { group('JellyfinMappers.library missing-Id rejection', () { test('returns null when Id is absent', () { - expect(JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: _serverId), isNull); + expect( + JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: ServerId(_serverId)), + isNull, + ); }); }); } diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index f8550b80..fb40dcbe 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; @@ -81,7 +82,7 @@ class _RecordingJellyfinClient implements JellyfinClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem( +MediaItem _ep(String id, {ServerId? serverId = const ServerId('srv-jf')}) => MediaItem( id: id, backend: MediaBackend.jellyfin, kind: MediaKind.episode, @@ -89,13 +90,13 @@ MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem( serverId: serverId, ); -MediaItem _movie(String id, {String? serverId = 'srv-jf'}) => +MediaItem _movie(String id, {ServerId? serverId = const ServerId('srv-jf')}) => MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, title: 'Movie $id', serverId: serverId); -MediaItem _clip(String id, {String? serverId = 'srv-jf'}) => +MediaItem _clip(String id, {ServerId? serverId = const ServerId('srv-jf')}) => MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.clip, title: 'Video $id', serverId: serverId); -MediaItem _track(String id, {String? serverId = 'srv-jf'}) => +MediaItem _track(String id, {ServerId? serverId = const ServerId('srv-jf')}) => MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.track, title: 'Track $id', serverId: serverId); void main() { diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 7ccdf362..8e08f53d 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -77,9 +78,9 @@ void main() { final m = MultiServerManager(); addTearDown(m.dispose); - expect(m.getClient('nope'), isNull); - expect(m.getPlexServer('nope'), isNull); - expect(m.isServerOnline('nope'), isFalse); + expect(m.getClient(ServerId('nope')), isNull); + expect(m.getPlexServer(ServerId('nope')), isNull); + expect(m.isServerOnline(ServerId('nope')), isFalse); }); test('plexServers map is unmodifiable', () { @@ -106,9 +107,9 @@ void main() { addTearDown(sub.cancel); // Pre-seed status (mirrors what addServer would do post-connect). - m.updateServerStatus('srv-1', true); - m.updateServerStatus('srv-2', false); - m.updateServerStatus('srv-1', false); // change + m.updateServerStatus(ServerId('srv-1'), true); + m.updateServerStatus(ServerId('srv-2'), false); + m.updateServerStatus(ServerId('srv-1'), false); // change // Let the broadcast stream events drain. await Future.delayed(Duration.zero); @@ -127,9 +128,9 @@ void main() { final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); - m.updateServerStatus('srv-1', true); - m.updateServerStatus('srv-1', true); // same value: no-op - m.updateServerStatus('srv-1', true); + m.updateServerStatus(ServerId('srv-1'), true); + m.updateServerStatus(ServerId('srv-1'), true); // same value: no-op + m.updateServerStatus(ServerId('srv-1'), true); await Future.delayed(Duration.zero); expect(emitted, hasLength(1)); @@ -140,14 +141,14 @@ void main() { final m = MultiServerManager(); addTearDown(m.dispose); - m.updateServerStatus('a', true); - m.updateServerStatus('b', false); - m.updateServerStatus('c', true); + m.updateServerStatus(ServerId('a'), true); + m.updateServerStatus(ServerId('b'), false); + m.updateServerStatus(ServerId('c'), true); expect(m.onlineServerIds.toSet(), {'a', 'c'}); expect(m.offlineServerIds.toSet(), {'b'}); - expect(m.isServerOnline('a'), isTrue); - expect(m.isServerOnline('b'), isFalse); + expect(m.isServerOnline(ServerId('a')), isTrue); + expect(m.isServerOnline(ServerId('b')), isFalse); }); }); @@ -168,12 +169,12 @@ void main() { product: 'Plezy', version: '1.0.0', ), - serverId: 'server-1', + serverId: ServerId('server-1'), serverName: 'Plex', httpClient: MockClient((_) async => http.Response('{}', 200)), ); m.debugRegisterClientForTesting(client, online: true); - m.debugMarkAuthErrorForTesting('server-1'); + m.debugMarkAuthErrorForTesting(ServerId('server-1')); final bound = await m.refreshTokensForProfile( PlexAccountConnection( @@ -254,8 +255,8 @@ void main() { await m.checkServerHealth(); - expect(m.isServerOnline('jf-machine'), isTrue); - expect(m.isOwnerOrAdmin('jf-machine'), isTrue); + expect(m.isServerOnline(ServerId('jf-machine')), isTrue); + expect(m.isOwnerOrAdmin(ServerId('jf-machine')), isTrue); }); test('ignores stale admin-status persistence from a replaced Jellyfin client', () async { @@ -315,8 +316,8 @@ void main() { allowResponse.complete(); await healthFuture; - expect(m.getClient('jf-machine'), same(userB)); - expect(m.isServerOnline('jf-machine'), isTrue); + expect(m.getClient(ServerId('jf-machine')), same(userB)); + expect(m.isServerOnline(ServerId('jf-machine')), isTrue); expect(m.authErrorServerIds, isNot(contains('jf-machine'))); }); }); @@ -330,14 +331,14 @@ void main() { final m = MultiServerManager(); addTearDown(m.dispose); - m.updateServerStatus('srv-1', true); - m.updateServerStatus('srv-2', true); + m.updateServerStatus(ServerId('srv-1'), true); + m.updateServerStatus(ServerId('srv-2'), true); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); - m.removeServer('srv-1'); + m.removeServer(ServerId('srv-1')); await Future.delayed(Duration.zero); expect(m.serverIds, isNot(contains('srv-1'))); @@ -353,7 +354,7 @@ void main() { final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); - m.removeServer('never-added'); + m.removeServer(ServerId('never-added')); await Future.delayed(Duration.zero); // Doesn't throw; state stays empty; one snapshot fires. @@ -372,9 +373,9 @@ void main() { expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNotNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNotNull); - m.removeServer('jf-machine'); + m.removeServer(ServerId('jf-machine')); - expect(m.getClient('jf-machine'), isNull); + expect(m.getClient(ServerId('jf-machine')), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull); }); @@ -389,8 +390,8 @@ void main() { final m = MultiServerManager(); addTearDown(m.dispose); - m.updateServerStatus('a', true); - m.updateServerStatus('b', false); + m.updateServerStatus(ServerId('a'), true); + m.updateServerStatus(ServerId('b'), false); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); @@ -414,7 +415,7 @@ void main() { m.disconnectAll(); - expect(m.getClient('jf-machine'), isNull); + expect(m.getClient(ServerId('jf-machine')), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull); }); diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 846f537f..03aed3d2 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -68,7 +69,7 @@ class _RecordingMediaClient implements MediaServerClient { _RecordingMediaClient({required this.serverId, required this.backend}); @override - final String serverId; + final ServerId serverId; @override final MediaBackend backend; @@ -200,7 +201,7 @@ void main() { }); // No SettingsService initialized, no client registered → default 90/100. - expect(svc.getWatchedThreshold('unknown-server'), 0.9); + expect(svc.getWatchedThreshold(ServerId('unknown-server')), 0.9); }); }); @@ -220,7 +221,7 @@ void main() { var notifications = 0; svc.addListener(() => notifications++); - await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42'); expect(await svc.getPendingSyncCount(), 1); // ChangeNotifier emission was synchronous in the queue helper. @@ -242,7 +243,7 @@ void main() { await db.close(); }); - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '42'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '42'); final action = await db.getLatestWatchAction('srv:42'); expect(action, isNotNull); @@ -257,13 +258,13 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42'); expect(await svc.getPendingSyncCount(), 1); // The DB layer's insertWatchAction deletes any prior entries for the // same globalKey before inserting — so flipping watched/unwatched keeps // a single row. - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '42'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '42'); expect(await svc.getPendingSyncCount(), 1); final action = await db.getLatestWatchAction('srv:42'); @@ -278,9 +279,9 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); - await svc.queueMarkWatched(serverId: 'srv', itemId: '2'); - await svc.queueMarkUnwatched(serverId: 'other', itemId: '1'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '2'); + await svc.queueMarkUnwatched(serverId: ServerId('other'), itemId: '1'); expect(await svc.getPendingSyncCount(), 3); @@ -299,7 +300,7 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42'); await svc.syncPendingItems(); @@ -317,7 +318,7 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42'); var action = await db.getLatestWatchAction('srv:42'); for (var i = 0; i < OfflineWatchSyncService.maxSyncAttempts; i++) { await db.updateSyncAttempt(action!.id, 'server error'); @@ -340,9 +341,9 @@ void main() { await db.close(); }); - final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex); + final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.plex); mgr.debugRegisterClientForTesting(client); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: 100000); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: 100000); final queued = await db.getLatestWatchAction('srv:42'); await svc.syncPendingItems(); @@ -365,9 +366,9 @@ void main() { await db.close(); }); - final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex); + final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.plex); mgr.debugRegisterClientForTesting(client); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: null); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50000, duration: null); await svc.syncPendingItems(); @@ -389,9 +390,9 @@ void main() { await db.close(); }); - final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex); + final client = _RecordingMediaClient(serverId: ServerId('srv'), backend: MediaBackend.plex); mgr.debugRegisterClientForTesting(client); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 95000, duration: 100000); final queued = await db.getLatestWatchAction('srv:42'); await svc.syncPendingItems(); @@ -423,7 +424,7 @@ void main() { }); // 50% progress → below default 0.9 threshold. - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50, duration: 100); final action = await db.getLatestWatchAction('srv:42'); expect(action, isNotNull); @@ -441,7 +442,7 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: null); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 50, duration: null); final action = await db.getLatestWatchAction('srv:42'); expect(action, isNotNull); @@ -459,7 +460,7 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 95, duration: 100); final action = await db.getLatestWatchAction('srv:42'); expect(action!.shouldMarkWatched, isTrue); @@ -473,8 +474,8 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 10, duration: 100); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 20, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 10, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '42', viewOffset: 20, duration: 100); // upsertProgressAction merges by globalKey — only ONE row. expect(await svc.getPendingSyncCount(), 1); @@ -505,7 +506,7 @@ void main() { mgr.dispose(); await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); expect(await svc.getLocalWatchStatus('srv:1'), isTrue); }); @@ -516,7 +517,7 @@ void main() { mgr.dispose(); await db.close(); }); - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '1'); expect(await svc.getLocalWatchStatus('srv:1'), isFalse); }); @@ -529,11 +530,11 @@ void main() { }); // Below threshold is resume-only; it must not override stale watched metadata. - await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 50, duration: 100); expect(await svc.getLocalWatchStatus('srv:1'), isNull); // Above threshold → shouldMarkWatched=true → status=true. - await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '2', viewOffset: 99, duration: 100); expect(await svc.getLocalWatchStatus('srv:2'), isTrue); }); }); @@ -561,10 +562,10 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); expect(await svc.getLocalViewOffset('srv:1'), isNull); - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2'); expect(await svc.getLocalViewOffset('srv:2'), isNull); }); @@ -576,7 +577,7 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 12345, duration: 60000); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 12345, duration: 60000); expect(await svc.getLocalViewOffset('srv:1'), 12345); }); @@ -588,13 +589,13 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 5000, duration: 10000); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 5000, duration: 10000); expect(await svc.getLocalViewOffset('srv:1'), 5000); // Manual "watched" wipes the progress row (insertWatchAction deletes // by globalKey first), so getLocalViewOffset reads the new row whose // actionType != 'progress' → null. - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); expect(await svc.getLocalViewOffset('srv:1'), isNull); }); }); @@ -614,9 +615,9 @@ void main() { expect(await svc.getPendingSyncCount(), 0); - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '3', viewOffset: 50, duration: 100); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2'); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '3', viewOffset: 50, duration: 100); expect(await svc.getPendingSyncCount(), 3); }); @@ -628,8 +629,8 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 10, duration: 100); - await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 20, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 10, duration: 100); + await svc.queueProgressUpdate(serverId: ServerId('srv'), itemId: '1', viewOffset: 20, duration: 100); expect(await svc.getPendingSyncCount(), 1); }); }); @@ -657,10 +658,10 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2'); await svc.queueProgressUpdate( - serverId: 'srv', + serverId: ServerId('srv'), itemId: '3', viewOffset: 99, duration: 100, // above threshold @@ -691,7 +692,7 @@ void main() { mgr.debugRegisterJellyfinClientForTesting(activeUserB); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -699,14 +700,14 @@ void main() { status: 3, ); await db.insertWatchAction( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', actionType: OfflineActionType.unwatched.id, ); await Future.delayed(const Duration(milliseconds: 2)); await db.insertWatchAction( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'item-1', actionType: OfflineActionType.watched.id, @@ -725,14 +726,14 @@ void main() { }); svc.setActiveProfileId('profile-a'); - await svc.queueMarkWatched(serverId: 'plex-machine', itemId: 'item-1'); + await svc.queueMarkWatched(serverId: ServerId('plex-machine'), itemId: 'item-1'); expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isTrue); expect(await svc.getPendingSyncCount(), 1); svc.setActiveProfileId('profile-b'); expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isNull); expect(await svc.getPendingSyncCount(), 0); - await svc.queueMarkUnwatched(serverId: 'plex-machine', itemId: 'item-1'); + await svc.queueMarkUnwatched(serverId: ServerId('plex-machine'), itemId: 'item-1'); expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isFalse); expect(await svc.getPendingSyncCount(), 1); @@ -752,7 +753,7 @@ void main() { }); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -760,7 +761,7 @@ void main() { status: 3, ); - await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1'); + await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1'); final queued = await db.getPendingWatchActions(); expect(queued.single.clientScopeId, 'jf-machine/user-a'); @@ -782,7 +783,7 @@ void main() { mgr.debugRegisterJellyfinClientForTesting(activeUserB); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -790,7 +791,7 @@ void main() { status: 3, ); await db.upsertProgressAction( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', viewOffset: 5000, @@ -799,7 +800,7 @@ void main() { ); await Future.delayed(const Duration(milliseconds: 2)); await db.upsertProgressAction( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'item-1', viewOffset: 90000, @@ -822,7 +823,7 @@ void main() { }); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -837,7 +838,7 @@ void main() { addTearDown(activeUserB.close); mgr.debugRegisterJellyfinClientForTesting(activeUserB); - final returnedScope = await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1'); + final returnedScope = await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1'); final queued = await db.getPendingWatchActions(); expect(returnedScope, 'jf-machine/user-b'); @@ -876,7 +877,7 @@ void main() { addTearDown(userB.close); mgr.debugRegisterJellyfinClientForTesting(userA); - await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1'); + await svc.queueMarkWatched(serverId: ServerId('jf-machine'), itemId: 'item-1'); final queued = await db.getPendingWatchActions(); expect(queued.single.clientScopeId, 'jf-machine/user-a'); @@ -915,7 +916,11 @@ void main() { addTearDown(client.close); mgr.debugRegisterJellyfinClientForTesting(client); - await db.insertWatchAction(serverId: 'jf-machine', ratingKey: 'item-1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction( + serverId: ServerId('jf-machine'), + ratingKey: 'item-1', + actionType: OfflineActionType.watched.id, + ); await svc.syncPendingItems(); @@ -955,14 +960,18 @@ void main() { addTearDown(userB.close); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', type: 'movie', status: 3, ); - await db.insertWatchAction(serverId: 'jf-machine', ratingKey: 'item-1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction( + serverId: ServerId('jf-machine'), + ratingKey: 'item-1', + actionType: OfflineActionType.watched.id, + ); mgr.debugRegisterJellyfinClientForTesting(userA); mgr.debugRegisterJellyfinClientForTesting(userB); @@ -1009,7 +1018,7 @@ void main() { addTearDown(sub.cancel); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -1058,7 +1067,7 @@ void main() { addTearDown(sub.cancel); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -1119,7 +1128,7 @@ void main() { addTearDown(userB.close); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', globalKey: 'jf-machine:ep-1', @@ -1157,7 +1166,7 @@ void main() { addTearDown(userB.close); await db.insertDownload( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', globalKey: 'jf-machine:item-1', @@ -1187,8 +1196,8 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); - await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); + await svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '1'); + await svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '2'); expect(await svc.getPendingSyncCount(), 2); var notifications = 0; diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index af2f95dd..6e8db88a 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'dart:io'; import 'package:drift/drift.dart'; @@ -78,11 +79,21 @@ void main() { }); test('pure-offline playback loads cached Plex media source info without a client', () async { - await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1'); - await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope()); + await _insertDownloaded( + db, + serverId: ServerId('srv-1'), + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1', + ); + await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), selectedMediaIndex: 0, preferOffline: true, ); @@ -93,12 +104,22 @@ void main() { }); test('preferOffline uses cache without calling live client when local file exists', () async { - await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1'); - await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope()); - final client = _FailingPlaybackClient(serverId: 'srv-1'); + await _insertDownloaded( + db, + serverId: ServerId('srv-1'), + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1', + ); + await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); + final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), selectedMediaIndex: 0, preferOffline: true, ); @@ -113,19 +134,24 @@ void main() { test('pure-offline playback uses cached Plex media source for selected version', () async { await _insertDownloaded( db, - serverId: 'srv-1', + serverId: ServerId('srv-1'), ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1-v2', mediaIndex: 1, ); await PlexApiCache.instance.put( - 'srv-1', + ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope(includeSecondVersion: true), ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), selectedMediaIndex: 1, preferOffline: true, ); @@ -137,7 +163,7 @@ void main() { test('offline path falls back to media index when caller has no source id', () async { await _insertDownloaded( db, - serverId: 'srv-1', + serverId: ServerId('srv-1'), ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1-v1', mediaIndex: 0, @@ -146,14 +172,17 @@ void main() { final service = PlaybackInitializationService(database: db); - expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 1), null); - expect(await service.getOfflineVideoPath('srv-1', 'movie-1', mediaIndex: 0), 'content://offline/movie-1-v1'); + expect(await service.getOfflineVideoPath(ServerId('srv-1'), 'movie-1', mediaIndex: 1), null); + expect( + await service.getOfflineVideoPath(ServerId('srv-1'), 'movie-1', mediaIndex: 0), + 'content://offline/movie-1-v1', + ); }); test('pure-offline Jellyfin cache works without a connection row', () async { await _insertDownloaded( db, - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'item-1', videoFilePath: 'content://offline/jf-item-1', @@ -169,7 +198,12 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'jf-machine'), + metadata: MediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: ServerId('jf-machine'), + ), selectedMediaIndex: 0, preferOffline: true, ); @@ -180,14 +214,24 @@ void main() { }); test('SAF offline playback discovers app-managed sidecar subtitles', () async { - await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1'); - final subtitlePath = await DownloadStorageService.instance.getSubtitlePath('srv-1', 'movie-1', 2, 'srt'); + await _insertDownloaded( + db, + serverId: ServerId('srv-1'), + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1', + ); + final subtitlePath = await DownloadStorageService.instance.getSubtitlePath(ServerId('srv-1'), 'movie-1', 2, 'srt'); final subtitleFile = File(subtitlePath); await subtitleFile.parent.create(recursive: true); await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + metadata: MediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), selectedMediaIndex: 0, preferOffline: true, ); @@ -198,7 +242,7 @@ void main() { }); test('cache-only playback extras fills missing Plex marker types from chapters', () async { - await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope()); + await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( backend: MediaBackend.plex, @@ -211,7 +255,11 @@ void main() { }); test('Plex extras parser skips malformed entries and keeps valid ones', () async { - await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope(malformedExtras: true)); + await PlexApiCache.instance.put( + ServerId('srv-1'), + '/library/metadata/movie-1', + _plexMetadataEnvelope(malformedExtras: true), + ); final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( backend: MediaBackend.plex, @@ -255,7 +303,7 @@ void main() { }); test('cache-only Jellyfin playback extras uses chapter fallback patterns', () async { - await JellyfinApiCache.instance.put('srv-1/user-1', '/Users/user-1/Items/item-1', { + await JellyfinApiCache.instance.put(ServerId('srv-1/user-1'), '/Users/user-1/Items/item-1', { 'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', @@ -278,13 +326,13 @@ void main() { }); test('cache-only Jellyfin playback extras uses cached native media segments', () async { - await JellyfinApiCache.instance.put('srv-1/user-1', '/Users/user-1/Items/item-1', { + await JellyfinApiCache.instance.put(ServerId('srv-1/user-1'), '/Users/user-1/Items/item-1', { 'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': [], }); - await JellyfinApiCache.instance.put('srv-1/user-1', '/MediaSegments/item-1', { + await JellyfinApiCache.instance.put(ServerId('srv-1/user-1'), '/MediaSegments/item-1', { 'Items': [ {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, @@ -307,7 +355,7 @@ class _FailingPlaybackClient implements MediaServerClient { _FailingPlaybackClient({required this.serverId}); @override - final String serverId; + final ServerId serverId; int playbackInitializationCalls = 0; @@ -323,7 +371,7 @@ class _FailingPlaybackClient implements MediaServerClient { Future _insertDownloaded( AppDatabase db, { - required String serverId, + required ServerId serverId, String? clientScopeId, required String ratingKey, required String videoFilePath, diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index fbb87d6f..90f31108 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -263,13 +264,14 @@ class _DelayedStartClient extends _FakePlexClient { } } -MediaItem _meta({String ratingKey = '42', String? serverId = 'srv', String? type = 'movie'}) => MediaItem( - id: ratingKey, - backend: MediaBackend.plex, - kind: MediaKind.fromString(type), - title: 'Test Item', - serverId: serverId, -); +MediaItem _meta({String ratingKey = '42', ServerId? serverId = const ServerId('srv'), String? type = 'movie'}) => + MediaItem( + id: ratingKey, + backend: MediaBackend.plex, + kind: MediaKind.fromString(type), + title: 'Test Item', + serverId: serverId, + ); void main() { setUp(resetSharedPreferencesForTest); @@ -747,7 +749,7 @@ void main() { final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 60)); final tracker = PlaybackProgressTracker( client: null, - metadata: _meta(ratingKey: '42', serverId: 'srv'), + metadata: _meta(ratingKey: '42', serverId: ServerId('srv')), player: player, isOffline: true, offlineWatchService: svc, @@ -798,7 +800,7 @@ void main() { final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100)); final tracker = PlaybackProgressTracker( client: client, - metadata: _meta(ratingKey: '42', serverId: 'srv'), + metadata: _meta(ratingKey: '42', serverId: ServerId('srv')), player: player, isOffline: false, offlineWatchService: svc, @@ -825,7 +827,7 @@ void main() { final player = _FakePlayer(position: const Duration(seconds: 30), duration: const Duration(seconds: 100)); final tracker = PlaybackProgressTracker( client: client, - metadata: _meta(ratingKey: '42', serverId: 'srv'), + metadata: _meta(ratingKey: '42', serverId: ServerId('srv')), player: player, isOffline: false, ); @@ -851,7 +853,7 @@ void main() { final player = _FakePlayer(position: Duration.zero, duration: const Duration(seconds: 100)); final tracker = PlaybackProgressTracker( client: client, - metadata: _meta(ratingKey: 'no-watch', serverId: 'srv'), + metadata: _meta(ratingKey: 'no-watch', serverId: ServerId('srv')), player: player, isOffline: false, ); @@ -875,7 +877,7 @@ void main() { final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100)); final tracker = PlaybackProgressTracker( client: client, - metadata: _meta(ratingKey: 'scrobbler', serverId: 'srv'), + metadata: _meta(ratingKey: 'scrobbler', serverId: ServerId('srv')), player: player, isOffline: false, ); diff --git a/test/services/playback_source_resolver_test.dart b/test/services/playback_source_resolver_test.dart index 87a75b7b..5570ea9e 100644 --- a/test/services/playback_source_resolver_test.dart +++ b/test/services/playback_source_resolver_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/media_backend.dart'; @@ -21,7 +22,7 @@ class _PlaybackClient implements MediaServerClient { final PlaybackInitializationResult result; @override - String get serverId => 'srv'; + ServerId get serverId => ServerId('srv'); @override MediaBackend get backend => clientBackend; diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index 56d8c3a3..9d200c96 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; @@ -61,27 +62,27 @@ void main() { group('get / put', () { test('miss returns null for an unknown key', () async { - expect(await cache.get('srv', '/library/metadata/1'), isNull); + expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull); }); test('put + get round-trip preserves the JSON map', () async { final payload = mediaContainer(ratingKey: '1', title: 'Hello'); - await cache.put('srv', '/library/metadata/1', payload); + await cache.put(ServerId('srv'), '/library/metadata/1', payload); - final hit = await cache.get('srv', '/library/metadata/1'); + final hit = await cache.get(ServerId('srv'), '/library/metadata/1'); expect(hit, isNotNull); expect(hit, equals(payload)); }); test('put on existing key overwrites prior data (insertOnConflictUpdate)', () async { - await cache.put('srv', '/library/metadata/1', { + await cache.put(ServerId('srv'), '/library/metadata/1', { 'MediaContainer': { 'Metadata': [ {'title': 'first'}, ], }, }); - await cache.put('srv', '/library/metadata/1', { + await cache.put(ServerId('srv'), '/library/metadata/1', { 'MediaContainer': { 'Metadata': [ {'title': 'second'}, @@ -89,22 +90,22 @@ void main() { }, }); - final hit = await cache.get('srv', '/library/metadata/1'); + final hit = await cache.get(ServerId('srv'), '/library/metadata/1'); expect(((hit!['MediaContainer'] as Map)['Metadata'] as List).first['title'], 'second'); }); test('keys are namespaced by serverId — same endpoint on different servers is isolated', () async { - await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: 'A')); - await cache.put('srv-b', '/library/metadata/1', mediaContainer(ratingKey: 'B')); + await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: 'A')); + await cache.put(ServerId('srv-b'), '/library/metadata/1', mediaContainer(ratingKey: 'B')); - final a = await cache.get('srv-a', '/library/metadata/1'); - final b = await cache.get('srv-b', '/library/metadata/1'); + final a = await cache.get(ServerId('srv-a'), '/library/metadata/1'); + final b = await cache.get(ServerId('srv-b'), '/library/metadata/1'); expect(((a!['MediaContainer'] as Map)['Metadata'] as List).first['ratingKey'], 'A'); expect(((b!['MediaContainer'] as Map)['Metadata'] as List).first['ratingKey'], 'B'); }); test('put writes a fresh cachedAt timestamp on overwrite', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); final firstRow = await (db.select( db.apiCache, )..where((t) => t.cacheKey.equals('srv:/library/metadata/1'))).getSingle(); @@ -112,7 +113,7 @@ void main() { // Wait one tick so DateTime.now() advances. await Future.delayed(const Duration(milliseconds: 5)); - await cache.put('srv', '/library/metadata/1', mediaContainer(title: 'Updated')); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(title: 'Updated')); final secondRow = await (db.select( db.apiCache, )..where((t) => t.cacheKey.equals('srv:/library/metadata/1'))).getSingle(); @@ -127,33 +128,33 @@ void main() { group('deletion', () { test('deleteForServer wipes only the targeted serverId', () async { - await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1')); - await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2')); - await cache.put('srv-b', '/library/metadata/1', mediaContainer(ratingKey: '1')); + await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1')); + await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2')); + await cache.put(ServerId('srv-b'), '/library/metadata/1', mediaContainer(ratingKey: '1')); - await cache.deleteForServer('srv-a'); + await cache.deleteForServer(ServerId('srv-a')); - expect(await cache.get('srv-a', '/library/metadata/1'), isNull); - expect(await cache.get('srv-a', '/library/metadata/2'), isNull); - expect(await cache.get('srv-b', '/library/metadata/1'), isNotNull); + expect(await cache.get(ServerId('srv-a'), '/library/metadata/1'), isNull); + expect(await cache.get(ServerId('srv-a'), '/library/metadata/2'), isNull); + expect(await cache.get(ServerId('srv-b'), '/library/metadata/1'), isNotNull); }); test('deleteForItem removes both metadata and children endpoints', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer()); - await cache.put('srv', '/library/metadata/1/children', mediaContainer()); - await cache.put('srv', '/library/metadata/2', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/1/children', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/2', mediaContainer()); - await cache.deleteForItem('srv', '1'); + await cache.deleteForItem(ServerId('srv'), '1'); - expect(await cache.get('srv', '/library/metadata/1'), isNull); - expect(await cache.get('srv', '/library/metadata/1/children'), isNull); + expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull); + expect(await cache.get(ServerId('srv'), '/library/metadata/1/children'), isNull); // Unrelated item not affected. - expect(await cache.get('srv', '/library/metadata/2'), isNotNull); + expect(await cache.get(ServerId('srv'), '/library/metadata/2'), isNotNull); }); test('clearAll wipes every row across servers', () async { - await cache.put('srv-a', '/library/metadata/1', mediaContainer()); - await cache.put('srv-b', '/library/metadata/2', mediaContainer()); + await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer()); + await cache.put(ServerId('srv-b'), '/library/metadata/2', mediaContainer()); await cache.clearAll(); @@ -161,14 +162,14 @@ void main() { }); test('clearVolatile preserves pinned offline metadata', () async { - await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1')); - await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2')); - await cache.pinForOffline('srv-a', '1'); + await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1')); + await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2')); + await cache.pinForOffline(ServerId('srv-a'), '1'); await cache.clearVolatile(); - expect(await cache.get('srv-a', '/library/metadata/1'), isNotNull); - expect(await cache.get('srv-a', '/library/metadata/2'), isNull); + expect(await cache.get(ServerId('srv-a'), '/library/metadata/1'), isNotNull); + expect(await cache.get(ServerId('srv-a'), '/library/metadata/2'), isNull); }); }); @@ -178,43 +179,43 @@ void main() { group('pinning', () { test('isPinned defaults to false for a freshly cached item', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer()); - expect(await cache.isPinnedRatingKey('srv', '1'), isFalse); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); + expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isFalse); }); test('isPinned returns false when the item is not cached at all', () async { - expect(await cache.isPinnedRatingKey('srv', 'missing'), isFalse); + expect(await cache.isPinnedRatingKey(ServerId('srv'), 'missing'), isFalse); }); test('pinForOffline marks the row as pinned', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer()); - await cache.pinForOffline('srv', '1'); - expect(await cache.isPinnedRatingKey('srv', '1'), isTrue); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); + await cache.pinForOffline(ServerId('srv'), '1'); + expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isTrue); }); test('unpinForOffline reverts the pin', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer()); - await cache.pinForOffline('srv', '1'); - await cache.unpinForOffline('srv', '1'); - expect(await cache.isPinnedRatingKey('srv', '1'), isFalse); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); + await cache.pinForOffline(ServerId('srv'), '1'); + await cache.unpinForOffline(ServerId('srv'), '1'); + expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isFalse); }); test('pinForOffline on missing row is a no-op (no insert, no throw)', () async { - await cache.pinForOffline('srv', 'missing'); - expect(await cache.isPinnedRatingKey('srv', 'missing'), isFalse); + await cache.pinForOffline(ServerId('srv'), 'missing'); + expect(await cache.isPinnedRatingKey(ServerId('srv'), 'missing'), isFalse); }); test('getPinnedKeys extracts ratingKeys from pinned rows for the server', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer()); - await cache.put('srv', '/library/metadata/2', mediaContainer()); - await cache.put('srv', '/library/metadata/3', mediaContainer()); - await cache.put('other', '/library/metadata/4', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/2', mediaContainer()); + await cache.put(ServerId('srv'), '/library/metadata/3', mediaContainer()); + await cache.put(ServerId('other'), '/library/metadata/4', mediaContainer()); - await cache.pinForOffline('srv', '1'); - await cache.pinForOffline('srv', '3'); - await cache.pinForOffline('other', '4'); + await cache.pinForOffline(ServerId('srv'), '1'); + await cache.pinForOffline(ServerId('srv'), '3'); + await cache.pinForOffline(ServerId('other'), '4'); - final keys = await cache.getPinnedKeys('srv'); + final keys = await cache.getPinnedKeys(ServerId('srv')); expect(keys, equals({'1', '3'})); }); @@ -228,15 +229,15 @@ void main() { const ApiCacheCompanion(pinned: Value(true)), ); - expect(await cache.getPinnedKeys('srv'), isEmpty); + expect(await cache.getPinnedKeys(ServerId('srv')), isEmpty); }); test('getPinnedKeys handles alphanumeric ratingKeys', () async { // Plex sometimes uses alphanumeric ratingKeys (e.g. for online-content). - await cache.put('srv', '/library/metadata/abc-123', mediaContainer(ratingKey: 'abc-123')); - await cache.pinForOffline('srv', 'abc-123'); + await cache.put(ServerId('srv'), '/library/metadata/abc-123', mediaContainer(ratingKey: 'abc-123')); + await cache.pinForOffline(ServerId('srv'), 'abc-123'); - final keys = await cache.getPinnedKeys('srv'); + final keys = await cache.getPinnedKeys(ServerId('srv')); expect(keys, equals({'abc-123'})); }); }); @@ -247,20 +248,20 @@ void main() { group('metadata extraction', () { test('getMetadata returns null when the key is not cached', () async { - expect(await cache.getMetadata('srv', 'missing'), isNull); + expect(await cache.getMetadata(ServerId('srv'), 'missing'), isNull); }); test('getMetadata returns null when cached payload has no Metadata array', () async { - await cache.put('srv', '/library/metadata/empty', { + await cache.put(ServerId('srv'), '/library/metadata/empty', { 'MediaContainer': {'size': 0}, }); - expect(await cache.getMetadata('srv', 'empty'), isNull); + expect(await cache.getMetadata(ServerId('srv'), 'empty'), isNull); }); test('getMetadata parses MediaContainer.Metadata[0] and tags it with serverId', () async { - await cache.put('srv', '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello')); + await cache.put(ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello')); - final meta = await cache.getMetadata('srv', '42'); + final meta = await cache.getMetadata(ServerId('srv'), '42'); expect(meta, isNotNull); expect(meta!.id, '42'); expect(meta.title, 'Hello'); @@ -269,12 +270,12 @@ void main() { test('getMetadata preserves hoisted MediaContainer library fields', () async { await cache.put( - 'srv', + ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: '7', librarySectionTitle: 'Movies'), ); - final meta = await cache.getMetadata('srv', '42'); + final meta = await cache.getMetadata(ServerId('srv'), '42'); expect(meta, isNotNull); expect(meta!.libraryId, '7'); @@ -282,21 +283,21 @@ void main() { }); test('getAllPinnedMetadata returns an empty map when nothing is pinned', () async { - await cache.put('srv', '/library/metadata/1', mediaContainer(ratingKey: '1')); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(ratingKey: '1')); // No pin yet. expect(await cache.getAllPinnedMetadata(), isEmpty); }); test('getAllPinnedMetadata aggregates pinned items across servers, keyed by globalKey', () async { - await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1', title: 'A1')); - await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2', title: 'A2')); - await cache.put('srv-b', '/library/metadata/9', mediaContainer(ratingKey: '9', title: 'B9')); + await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1', title: 'A1')); + await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2', title: 'A2')); + await cache.put(ServerId('srv-b'), '/library/metadata/9', mediaContainer(ratingKey: '9', title: 'B9')); // One unpinned row to verify it's filtered out. - await cache.put('srv-b', '/library/metadata/10', mediaContainer(ratingKey: '10', title: 'B10')); + await cache.put(ServerId('srv-b'), '/library/metadata/10', mediaContainer(ratingKey: '10', title: 'B10')); - await cache.pinForOffline('srv-a', '1'); - await cache.pinForOffline('srv-a', '2'); - await cache.pinForOffline('srv-b', '9'); + await cache.pinForOffline(ServerId('srv-a'), '1'); + await cache.pinForOffline(ServerId('srv-a'), '2'); + await cache.pinForOffline(ServerId('srv-b'), '9'); final result = await cache.getAllPinnedMetadata(); expect(result.keys.toSet(), {'srv-a:1', 'srv-a:2', 'srv-b:9'}); @@ -308,11 +309,11 @@ void main() { test('getAllPinnedMetadata preserves hoisted MediaContainer library fields', () async { await cache.put( - 'srv', + ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: 7, librarySectionTitle: 'Movies'), ); - await cache.pinForOffline('srv', '42'); + await cache.pinForOffline(ServerId('srv'), '42'); final result = await cache.getAllPinnedMetadata(); @@ -331,8 +332,8 @@ void main() { pinned: const Value(true), ), ); - await cache.put('srv', '/library/metadata/1', mediaContainer(ratingKey: '1')); - await cache.pinForOffline('srv', '1'); + await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(ratingKey: '1')); + await cache.pinForOffline(ServerId('srv'), '1'); final result = await cache.getAllPinnedMetadata(); expect(result.keys.toSet(), {'srv:1'}); @@ -350,8 +351,8 @@ void main() { ), ); // Good pinned row. - await cache.put('srv', '/library/metadata/good', mediaContainer(ratingKey: 'good', title: 'OK')); - await cache.pinForOffline('srv', 'good'); + await cache.put(ServerId('srv'), '/library/metadata/good', mediaContainer(ratingKey: 'good', title: 'OK')); + await cache.pinForOffline(ServerId('srv'), 'good'); final result = await cache.getAllPinnedMetadata(); expect(result.keys, contains('srv:good')); diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index 4eaf9470..90392d92 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:plezy/media/ids.dart'; import 'dart:convert'; import 'package:drift/native.dart'; @@ -46,7 +47,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, ); @@ -81,7 +82,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, prioritizedEndpoints: const [primary, fallback], @@ -113,7 +114,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, prioritizedEndpoints: const [primary, fallback], @@ -146,7 +147,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, seedTranscoderVideoSupport: true, @@ -178,7 +179,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, seedTranscoderVideoSupport: true, @@ -209,7 +210,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, ); @@ -244,7 +245,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), serverName: 'Server', httpClient: httpClient, prioritizedEndpoints: const [primary, fallback], diff --git a/test/services/plex_library_details_test.dart b/test/services/plex_library_details_test.dart index 40a83a6d..c5e87975 100644 --- a/test/services/plex_library_details_test.dart +++ b/test/services/plex_library_details_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -31,7 +32,7 @@ void main() { product: 'Plezy', version: '1', ), - serverId: 'server-id', + serverId: ServerId('server-id'), httpClient: MockClient(handler), ); } diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart index b783f554..70088215 100644 --- a/test/services/plex_live_tv_support_test.dart +++ b/test/services/plex_live_tv_support_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:drift/native.dart'; @@ -39,7 +40,7 @@ void main() { version: '1', machineIdentifier: 'machine-1', ), - serverId: 'machine-1', + serverId: ServerId('machine-1'), httpClient: MockClient(handler), epgProviders: epgProviders, ); diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 88bf919c..40090703 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/plex_mappers.dart'; @@ -60,7 +61,7 @@ void main() { ], }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId, serverName: _serverName); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId), serverName: _serverName); expect(item.id, '12345'); expect(item.backend, MediaBackend.plex); @@ -132,7 +133,7 @@ void main() { 'year': 2008, }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.kind, MediaKind.show); expect(item.leafCount, 62); expect(item.viewedLeafCount, 62); @@ -150,7 +151,7 @@ void main() { 'flattenSeasons': '1', }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.raw, containsPair('key', '/library/metadata/500')); expect(item.raw, containsPair('skipChildren', true)); @@ -170,7 +171,7 @@ void main() { 'viewedLeafCount': 3, }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.kind, MediaKind.season); expect(item.index, 1); expect(item.parentId, '500'); @@ -199,7 +200,7 @@ void main() { 'viewOffset': 1410000, }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.kind, MediaKind.episode); expect(item.index, 1); expect(item.parentIndex, 1); @@ -228,7 +229,7 @@ void main() { 'leafCount': 13, }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.kind, MediaKind.album); expect(item.title, 'Random Access Memories'); expect(item.parentId, '699'); @@ -251,7 +252,7 @@ void main() { 'duration': 369000, }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.kind, MediaKind.track); expect(item.title, 'Get Lucky'); expect(item.index, 8); @@ -285,7 +286,7 @@ void main() { ], }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.mediaVersions, isNotNull); final v = item.mediaVersions!.single; expect(v.id, '1'); @@ -310,7 +311,7 @@ void main() { ], }; - final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId)); expect(item.clearLogoPath, '/library/metadata/12345/clearLogo'); expect(item.backgroundSquarePath, '/library/metadata/12345/squareBg'); }); @@ -329,7 +330,7 @@ void main() { 'hidden': 0, }; - final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId, serverName: _serverName); + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), serverName: _serverName); expect(lib.id, '1'); expect(lib.backend, MediaBackend.plex); expect(lib.title, 'Movies'); @@ -345,13 +346,13 @@ void main() { test('shared library is marked isShared', () { final json = {'key': 'shared', 'title': 'Shared with you', 'type': 'movie'}; - final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId, isShared: true); + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), isShared: true); expect(lib.isShared, isTrue); }); test('hidden=1 maps to true', () { final json = {'key': '2', 'title': 'Hidden', 'type': 'show', 'hidden': 1}; - final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId); + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId)); expect(lib.hidden, isTrue); }); @@ -384,7 +385,7 @@ void main() { // PlexLibraryDto.fromJson would throw TypeError when Plex omitted // either field. Confirms graceful degradation. final json = {'key': '99'}; - final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId); + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId)); expect(lib.id, '99'); expect(lib.title, ''); expect(lib.kind, MediaKind.unknown); @@ -406,7 +407,7 @@ void main() { ], }; - final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId, serverName: _serverName); + final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId), serverName: _serverName); expect(hub.id, '/hubs/movie.recentlyAdded'); expect(hub.identifier, 'movie.recentlyAdded.1'); expect(hub.title, 'Recently Added Movies'); @@ -436,7 +437,7 @@ void main() { ], }; - final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId); + final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId)); expect(hub.items.length, 2); expect(hub.items[0].kind, MediaKind.show); expect(hub.items[1].kind, MediaKind.unknown); @@ -455,7 +456,7 @@ void main() { ], }; - final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId); + final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId)); expect(hub.items.length, 2); expect(hub.items[0].kind, MediaKind.movie); expect(hub.items[1].kind, MediaKind.show); @@ -482,7 +483,7 @@ void main() { 'thumb': '/playlists/999/thumb', }; - final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId, serverName: _serverName); + final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId), serverName: _serverName); expect(p.id, '999'); expect(p.backend, MediaBackend.plex); expect(p.title, 'Date Night'); @@ -511,7 +512,7 @@ void main() { 'playlistType': 'audio', }; - final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId); + final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId)); expect(p.smart, isTrue); expect(p.playlistType, 'audio'); }); @@ -521,7 +522,7 @@ void main() { // PlexPlaylistDto.fromJson would throw TypeError when Plex omitted // optional fields. Confirms graceful degradation. final json = {'ratingKey': '777', 'title': 'Bare', 'summary': null}; - final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId); + final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId)); expect(p.id, '777'); expect(p.title, 'Bare'); expect(p.smart, isFalse); @@ -562,7 +563,7 @@ void main() { group('PlexMappers DTO direct entry points', () { test('mediaItem (DTO) preserves data identical to JSON path', () { final json = {'ratingKey': '1', 'type': 'movie', 'title': 'Test', 'year': 2024}; - final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: _serverId); + final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: ServerId(_serverId)); final item = PlexMappers.mediaItem(dto); expect(item.id, '1'); expect(item.title, 'Test'); diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 13935dd6..49f33b0c 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -37,7 +38,7 @@ void main() { product: 'Plezy', version: '1', ), - serverId: 'server-id', + serverId: ServerId('server-id'), httpClient: MockClient(handler), ); } @@ -166,7 +167,7 @@ void main() { test('latest server metadata overwrites cached playback media fields', () async { final cache = PlexApiCache.instance; - await cache.put('server-id', '/library/metadata/42', { + await cache.put(ServerId('server-id'), '/library/metadata/42', { 'MediaContainer': { 'Metadata': [ { @@ -194,7 +195,7 @@ void main() { }, }); - await cache.put('server-id', '/library/metadata/42', { + await cache.put(ServerId('server-id'), '/library/metadata/42', { 'MediaContainer': { 'Metadata': [ { @@ -214,7 +215,7 @@ void main() { }, }); - final cached = await cache.get('server-id', '/library/metadata/42'); + final cached = await cache.get(ServerId('server-id'), '/library/metadata/42'); final metadata = (cached!['MediaContainer'] as Map)['Metadata'] as List; final item = metadata.single as Map; final media = item['Media'] as List; @@ -228,7 +229,7 @@ void main() { }); test('network failure falls back to lean cached playback metadata', () async { - await PlexApiCache.instance.put('server-id', '/library/metadata/42', { + await PlexApiCache.instance.put(ServerId('server-id'), '/library/metadata/42', { 'MediaContainer': { 'Metadata': [ { diff --git a/test/services/plex_search_test.dart b/test/services/plex_search_test.dart index 72b79e37..f35ab547 100644 --- a/test/services/plex_search_test.dart +++ b/test/services/plex_search_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -32,7 +33,7 @@ void main() { product: 'Plezy', version: 'test', ), - serverId: 'plex-1', + serverId: ServerId('plex-1'), serverName: 'Plex', httpClient: MockClient(handler), ); diff --git a/test/services/plex_transcoder_capability_test.dart b/test/services/plex_transcoder_capability_test.dart index 6dcdcc12..bf894fd4 100644 --- a/test/services/plex_transcoder_capability_test.dart +++ b/test/services/plex_transcoder_capability_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'dart:io'; import 'package:drift/native.dart'; @@ -84,7 +85,7 @@ PlexClient _makeClient(Map rootContainer) { product: 'Plezy', version: 'test', ), - serverId: 'server-id', + serverId: ServerId('server-id'), httpClient: MockClient((request) async { expect(request.url.path, '/'); return http.Response( diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart index a2074911..99265f5f 100644 --- a/test/services/storage_service_test.dart +++ b/test/services/storage_service_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; @@ -78,21 +79,21 @@ void main() { group('ServerEndpoint', () { test('round-trip per server id', () async { final s = await StorageService.getInstance(); - await s.saveServerEndpoint('srv-1', 'http://192.0.2.1:32400'); - await s.saveServerEndpoint('srv-2', 'http://198.51.100.5:32400'); + await s.saveServerEndpoint(ServerId('srv-1'), 'http://192.0.2.1:32400'); + await s.saveServerEndpoint(ServerId('srv-2'), 'http://198.51.100.5:32400'); - expect(s.getServerEndpoint('srv-1'), 'http://192.0.2.1:32400'); - expect(s.getServerEndpoint('srv-2'), 'http://198.51.100.5:32400'); - expect(s.getServerEndpoint('missing'), isNull); + expect(s.getServerEndpoint(ServerId('srv-1')), 'http://192.0.2.1:32400'); + expect(s.getServerEndpoint(ServerId('srv-2')), 'http://198.51.100.5:32400'); + expect(s.getServerEndpoint(ServerId('missing')), isNull); }); test('clearServerEndpoint removes only the targeted id', () async { final s = await StorageService.getInstance(); - await s.saveServerEndpoint('srv-1', 'http://example.test'); - await s.saveServerEndpoint('srv-2', 'http://other.test'); - await s.clearServerEndpoint('srv-1'); - expect(s.getServerEndpoint('srv-1'), isNull); - expect(s.getServerEndpoint('srv-2'), 'http://other.test'); + await s.saveServerEndpoint(ServerId('srv-1'), 'http://example.test'); + await s.saveServerEndpoint(ServerId('srv-2'), 'http://other.test'); + await s.clearServerEndpoint(ServerId('srv-1')); + expect(s.getServerEndpoint(ServerId('srv-1')), isNull); + expect(s.getServerEndpoint(ServerId('srv-2')), 'http://other.test'); }); }); @@ -123,16 +124,16 @@ void main() { // Write legacy values directly — the setters are gone. await s.prefs.setString('servers_list', '[{"x":1}]'); await s.prefs.setString('server_order', json.encode(['a', 'b'])); - await s.saveServerEndpoint('a', 'http://foo.test'); - await s.saveServerEndpoint('b', 'http://bar.test'); + await s.saveServerEndpoint(ServerId('a'), 'http://foo.test'); + await s.saveServerEndpoint(ServerId('b'), 'http://bar.test'); await s.clearMultiServerData(); // ignore: deprecated_member_use_from_same_package expect(s.getServersListJson(), isNull); expect(s.prefs.getString('server_order'), isNull); - expect(s.getServerEndpoint('a'), isNull); - expect(s.getServerEndpoint('b'), isNull); + expect(s.getServerEndpoint(ServerId('a')), isNull); + expect(s.getServerEndpoint(ServerId('b')), isNull); }); }); @@ -404,7 +405,7 @@ void main() { await s.prefs.setString('client_identifier', 'client-x'); await s.prefs.setString('servers_list', '[{"x":1}]'); await s.prefs.setString('server_order', json.encode(['a'])); - await s.saveServerEndpoint('a', 'http://foo.test'); + await s.saveServerEndpoint(ServerId('a'), 'http://foo.test'); // Library prefs and unrelated counters: write WITHOUT an active profile id // so they land on the legacy unscoped key. @@ -427,7 +428,7 @@ void main() { // ignore: deprecated_member_use_from_same_package expect(s.getServersListJson(), isNull); expect(s.prefs.getString('server_order'), isNull); - expect(s.getServerEndpoint('a'), isNull); + expect(s.getServerEndpoint(ServerId('a')), isNull); // Library prefs and unrelated state untouched (no scope active, so // the scoped read falls through to the same legacy key it was written to). diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 279a2805..d1dab46c 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -78,21 +79,21 @@ void main() { await db.insertSyncRule( profileId: 'profile-a', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); await db.insertWatchAction( - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); await db.insertWatchAction( profileId: 'profile-b', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, @@ -154,7 +155,7 @@ void main() { await db.insertSyncRule( profileId: 'profile-a', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', @@ -162,7 +163,7 @@ void main() { ); await db.insertWatchAction( profileId: 'profile-a', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, @@ -209,7 +210,7 @@ void main() { await db.insertSyncRule( profileId: 'profile-a', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', @@ -266,7 +267,7 @@ void main() { await db.insertSyncRule( profileId: 'profile-b', - serverId: 'jf-machine', + serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-b|jf-machine:show-1', targetType: 'show', @@ -316,7 +317,7 @@ void main() { await db.insertSyncRule( profileId: 'profile-a', - serverId: 'plex-machine', + serverId: ServerId('plex-machine'), ratingKey: 'collection-1', globalKey: ruleKey, targetType: 'collection', @@ -350,7 +351,7 @@ class _CollectionPagingClient implements MediaServerClient { final collectionPageCalls = <({int? start, int? size})>[]; @override - String get serverId => 'plex-machine'; + ServerId get serverId => ServerId('plex-machine'); @override String? get serverName => 'Plex'; diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index a5af62b2..8bfba5e8 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -22,7 +23,7 @@ import 'package:plezy/utils/external_ids.dart'; class _FakeMediaServerClient implements MediaServerClient { @override - final String serverId; + final ServerId serverId; @override String? get serverName => null; @@ -35,7 +36,7 @@ class _FakeMediaServerClient implements MediaServerClient { final double watchedThreshold; _FakeMediaServerClient({ - this.serverId = 'server-1', + this.serverId = const ServerId('server-1'), required this.externalIdsByItem, required this.descendantsByParent, this.watchedThreshold = 0.9, @@ -91,7 +92,7 @@ MediaItem _season() => MediaItem( backend: MediaBackend.plex, kind: MediaKind.season, title: 'Season 1', - serverId: 'server-1', + serverId: ServerId('server-1'), libraryId: 'lib-1', index: 1, parentId: 'show-1', @@ -102,7 +103,7 @@ MediaItem _episode(int number, {int season = 1}) => MediaItem( backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Episode $number', - serverId: 'server-1', + serverId: ServerId('server-1'), libraryId: 'lib-1', parentIndex: season, index: number, @@ -113,7 +114,7 @@ MediaItem _show() => MediaItem( backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show 1', - serverId: 'server-1', + serverId: ServerId('server-1'), libraryId: 'lib-1', ); @@ -122,7 +123,7 @@ MediaItem _movie() => MediaItem( backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1', - serverId: 'server-1', + serverId: ServerId('server-1'), libraryId: 'lib-1', ); @@ -490,17 +491,21 @@ void main() { ); final firstClient = _FakeMediaServerClient( - serverId: 'server-a', + serverId: ServerId('server-a'), externalIdsByItem: {'show-a': const ExternalIds(tvdb: 111)}, descendantsByParent: const {}, ); final secondClient = _FakeMediaServerClient( - serverId: 'server-b', + serverId: ServerId('server-b'), externalIdsByItem: {'show-b': const ExternalIds(tvdb: 222)}, descendantsByParent: const {}, ); - final firstEpisode = _episode(1).copyWith(id: 'episode-a', serverId: 'server-a', grandparentId: 'show-a'); - final secondEpisode = _episode(1).copyWith(id: 'episode-b', serverId: 'server-b', grandparentId: 'show-b'); + final firstEpisode = _episode( + 1, + ).copyWith(id: 'episode-a', serverId: ServerId('server-a'), grandparentId: 'show-a'); + final secondEpisode = _episode( + 1, + ).copyWith(id: 'episode-b', serverId: ServerId('server-b'), grandparentId: 'show-b'); await coordinator.startPlayback(firstEpisode, firstClient); await coordinator.startPlayback(secondEpisode, secondClient); diff --git a/test/services/watch_state_resolver_test.dart b/test/services/watch_state_resolver_test.dart index cfc9cdcb..c0eb0b18 100644 --- a/test/services/watch_state_resolver_test.dart +++ b/test/services/watch_state_resolver_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/services/watch_state_resolver.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; @@ -52,7 +53,7 @@ void main() { final snapshot = WatchStateResolver.fromEvent( WatchStateEvent( itemId: 'item-1', - serverId: 'srv', + serverId: ServerId('srv'), changeType: WatchStateChangeType.progressUpdate, parentChain: const [], mediaType: 'movie', @@ -70,7 +71,7 @@ void main() { final snapshot = WatchStateResolver.fromEvent( WatchStateEvent( itemId: 'item-1', - serverId: 'srv', + serverId: ServerId('srv'), changeType: WatchStateChangeType.removedFromContinueWatching, parentChain: const [], mediaType: 'movie', diff --git a/test/utils/global_key_utils_test.dart b/test/utils/global_key_utils_test.dart index 3881b0ab..7997d354 100644 --- a/test/utils/global_key_utils_test.dart +++ b/test/utils/global_key_utils_test.dart @@ -1,16 +1,17 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/utils/global_key_utils.dart'; void main() { group('buildGlobalKey', () { test('joins with a colon', () { - expect(buildGlobalKey('server', '123'), 'server:123'); + expect(buildGlobalKey(ServerId('server'), '123'), 'server:123'); }); test('passes through empty components', () { - expect(buildGlobalKey('', '123'), ':123'); - expect(buildGlobalKey('server', ''), 'server:'); - expect(buildGlobalKey('', ''), ':'); + expect(buildGlobalKey(ServerId(''), '123'), ':123'); + expect(buildGlobalKey(ServerId('server'), ''), 'server:'); + expect(buildGlobalKey(ServerId(''), ''), ':'); }); }); @@ -51,7 +52,7 @@ void main() { test('round-trip build → parse returns original components', () { for (final pair in const [('s1', '42'), ('serverXYZ', '/library/metadata/123'), ('', 'abc'), ('s', '')]) { - final built = buildGlobalKey(pair.$1, pair.$2); + final built = buildGlobalKey(ServerId(pair.$1), pair.$2); final parsed = parseGlobalKey(built); expect(parsed, isNotNull); expect(parsed!.serverId, pair.$1); diff --git a/test/utils/live_tv_grouping_test.dart b/test/utils/live_tv_grouping_test.dart index 7bf0acea..cc13af2e 100644 --- a/test/utils/live_tv_grouping_test.dart +++ b/test/utils/live_tv_grouping_test.dart @@ -1,10 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/models/livetv_channel.dart'; import 'package:plezy/utils/live_tv_grouping.dart'; LiveTvChannel _channel({ required String key, - required String serverId, + required ServerId serverId, required String serverName, required String dvrKey, required String favoriteSource, @@ -24,7 +25,7 @@ void main() { test('groups channels by Live TV source while preserving first source appearance', () { final firstHome = _channel( key: '101', - serverId: 'home', + serverId: ServerId('home'), serverName: 'Home Plex', dvrKey: 'dvr-a', favoriteSource: 'server://home/provider-a', @@ -32,7 +33,7 @@ void main() { ); final cabin = _channel( key: '101', - serverId: 'cabin', + serverId: ServerId('cabin'), serverName: 'Cabin Plex', dvrKey: 'dvr-a', favoriteSource: 'server://cabin/provider-b', @@ -40,7 +41,7 @@ void main() { ); final secondHome = _channel( key: '102', - serverId: 'home', + serverId: ServerId('home'), serverName: 'Home Plex', dvrKey: 'dvr-a', favoriteSource: 'server://home/provider-a', @@ -58,7 +59,7 @@ void main() { final channels = [ _channel( key: '101', - serverId: 'home', + serverId: ServerId('home'), serverName: 'Home Plex', dvrKey: 'dvr-a', favoriteSource: 'server://home/provider-a', @@ -66,7 +67,7 @@ void main() { ), _channel( key: '101', - serverId: 'home', + serverId: ServerId('home'), serverName: 'Home Plex', dvrKey: 'dvr-b', favoriteSource: 'server://home/provider-a', diff --git a/test/utils/media_hub_ordering_test.dart b/test/utils/media_hub_ordering_test.dart index 6002fd5d..3fb8ba7f 100644 --- a/test/utils/media_hub_ordering_test.dart +++ b/test/utils/media_hub_ordering_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; @@ -6,7 +7,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/utils/media_hub_ordering.dart'; -MediaLibrary _library(String id, {String serverId = 'server'}) { +MediaLibrary _library(String id, {ServerId serverId = const ServerId('server')}) { return MediaLibrary( id: id, backend: MediaBackend.plex, @@ -16,11 +17,16 @@ MediaLibrary _library(String id, {String serverId = 'server'}) { ); } -MediaItem _item(String id, {String? libraryId, String? serverId = 'server'}) { +MediaItem _item(String id, {String? libraryId, ServerId? serverId = const ServerId('server')}) { return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, libraryId: libraryId, serverId: serverId); } -MediaHub _hub(String id, {String? libraryId, String? serverId = 'server', List items = const []}) { +MediaHub _hub( + String id, { + String? libraryId, + ServerId? serverId = const ServerId('server'), + List items = const [], +}) { return MediaHub(id: id, title: id, type: 'movie', libraryId: libraryId, serverId: serverId, items: items); } diff --git a/test/watch_together/watch_together_provider_test.dart b/test/watch_together/watch_together_provider_test.dart index cef6b33d..ddbae5c5 100644 --- a/test/watch_together/watch_together_provider_test.dart +++ b/test/watch_together/watch_together_provider_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/watch_together/models/watch_session.dart'; import 'package:plezy/watch_together/providers/watch_together_provider.dart'; @@ -74,7 +75,7 @@ void main() { var notified = 0; p.addListener(() => notified++); // Without a session, setCurrentMedia logs a warning and bails — no notify. - p.setCurrentMedia(ratingKey: 'rk1', serverId: 's1', mediaTitle: 't1'); + p.setCurrentMedia(ratingKey: 'rk1', serverId: ServerId('s1'), mediaTitle: 't1'); expect(notified, 0); expect(p.currentMediaRatingKey, isNull); p.dispose(); @@ -93,7 +94,7 @@ void main() { test('markCurrentPlaybackHandled does not throw on a fresh provider', () { final p = WatchTogetherProvider(); - expect(() => p.markCurrentPlaybackHandled(ratingKey: 'rk1', serverId: 's1'), returnsNormally); + expect(() => p.markCurrentPlaybackHandled(ratingKey: 'rk1', serverId: ServerId('s1')), returnsNormally); p.dispose(); }); diff --git a/test/widgets/download_tree_view_test.dart b/test/widgets/download_tree_view_test.dart index 64d5e712..157c012d 100644 --- a/test/widgets/download_tree_view_test.dart +++ b/test/widgets/download_tree_view_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; @@ -30,7 +31,7 @@ DownloadTreeNode _showNode({required String key, required List MediaItem _episodeMeta({ required String id, - required String? serverId, + required ServerId? serverId, required String? grandparentId, required String? parentId, }) => MediaItem( @@ -49,7 +50,9 @@ void main() { final ep = _episodeNode('plex1:ep100'); final season = _seasonNode(key: 'show42:season7', children: [ep]); final show = _showNode(key: 'show42', children: [season]); - final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')}; + final metadata = { + 'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '7'), + }; expect(resolveDownloadContainerGlobalKey(show, metadata), 'plex1:42'); }); @@ -57,7 +60,9 @@ void main() { test('season node: builds globalKey from leaf serverId + parentId', () { final ep = _episodeNode('plex1:ep100'); final season = _seasonNode(key: 'show42:season7', children: [ep]); - final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')}; + final metadata = { + 'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '7'), + }; expect(resolveDownloadContainerGlobalKey(season, metadata), 'plex1:7'); }); @@ -70,7 +75,9 @@ void main() { type: DownloadNodeType.movie, status: DownloadStatus.completed, ); - final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')}; + final metadata = { + 'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '7'), + }; expect(resolveDownloadContainerGlobalKey(ep, metadata), isNull); expect(resolveDownloadContainerGlobalKey(movie, metadata), isNull); @@ -97,14 +104,18 @@ void main() { test('show node with leaf missing grandparentId returns null', () { final ep = _episodeNode('plex1:ep100'); final show = _showNode(key: 'show42', children: [ep]); - final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: null, parentId: '7')}; + final metadata = { + 'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: null, parentId: '7'), + }; expect(resolveDownloadContainerGlobalKey(show, metadata), isNull); }); test('season node with leaf missing parentId returns null', () { final ep = _episodeNode('plex1:ep100'); final season = _seasonNode(key: 'show42:season7', children: [ep]); - final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: null)}; + final metadata = { + 'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: null), + }; expect(resolveDownloadContainerGlobalKey(season, metadata), isNull); }); @@ -115,8 +126,8 @@ void main() { final s2 = _seasonNode(key: 'show42:season2', children: [ep2]); final show = _showNode(key: 'show42', children: [s1, s2]); final metadata = { - 'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '1'), - 'plex1:ep200': _episodeMeta(id: '200', serverId: 'plex1', grandparentId: '42', parentId: '2'), + 'plex1:ep100': _episodeMeta(id: '100', serverId: ServerId('plex1'), grandparentId: '42', parentId: '1'), + 'plex1:ep200': _episodeMeta(id: '200', serverId: ServerId('plex1'), grandparentId: '42', parentId: '2'), }; expect(resolveDownloadContainerGlobalKey(show, metadata), 'plex1:42'); diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 90c5a173..8d15609c 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -1,4 +1,5 @@ import 'dart:ui' show PointerDeviceKind; +import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -40,7 +41,7 @@ const _testTokens = MonoTokens( MediaLibrary _library({ required String id, required String title, - required String serverId, + required ServerId serverId, required String serverName, }) { return MediaLibrary( @@ -374,19 +375,19 @@ void main() { final visibleServerALibrary = _library( id: '1', title: 'Visible Server A', - serverId: 'server-a', + serverId: ServerId('server-a'), serverName: 'Server A', ); final hiddenServerALibrary = _library( id: '2', title: 'Hidden Server A', - serverId: 'server-a', + serverId: ServerId('server-a'), serverName: 'Server A', ); final visibleServerBLibrary = _library( id: '1', title: 'Visible Server B', - serverId: 'server-b', + serverId: ServerId('server-b'), serverName: 'Server B', );