refactor: type server identifiers
This commit is contained in:
@@ -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<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId, {String? profileId}) {
|
||||
Future<List<OfflineWatchProgressItem>> 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<void> 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<void> 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<void> 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();
|
||||
|
||||
@@ -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<void> insertDownload({
|
||||
required String serverId,
|
||||
required ServerId serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
@@ -209,14 +210,14 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
|
||||
Future<List<DownloadedMediaItem>> 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<List<DownloadedMediaItem>> 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<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(ServerId serverId) {
|
||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||
}
|
||||
|
||||
Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, String? serverId) {
|
||||
Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, ServerId? serverId) {
|
||||
return serverId == null ? const Constant(true) : column.equals(serverId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
+9
-8
@@ -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<MainApp> 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<SetupScreen> 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<SetupScreen> 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),
|
||||
|
||||
@@ -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);
|
||||
@@ -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: `[]`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> _putCacheResponse(String cacheKey, dynamic data) async {
|
||||
if (data is Map<String, dynamic>) {
|
||||
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}');
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -383,7 +383,7 @@ String? _jellyfinDate(String value, Object? originalIso) {
|
||||
|
||||
String _imageContentType(List<int> 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<int> 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<int> 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';
|
||||
|
||||
@@ -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<T extends StatefulWidget> on State<T> {
|
||||
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(() {
|
||||
|
||||
@@ -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<T extends StatefulWidget> on State<T> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<String>{});
|
||||
multiServerProvider.setVisibleServerIds(<String>{});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<DownloadProgress> 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<void> _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<MediaItem?> lookupOfflineMetadata(String serverId, String itemId) =>
|
||||
Future<MediaItem?> 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<String, SyncRuleItem> 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 = <String>{};
|
||||
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<void> 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<void> 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
|
||||
|
||||
@@ -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 = <LiveTvServerInfo>[];
|
||||
|
||||
for (final serverId in onlineServerIds) {
|
||||
final genericClient = _serverManager.getClient(serverId);
|
||||
final genericClient = _serverManager.getClient(ServerId(serverId));
|
||||
if (genericClient == null) continue;
|
||||
|
||||
try {
|
||||
|
||||
@@ -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<void> markAsWatched({required String serverId, required String itemId}) async {
|
||||
Future<void> 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<void> markAsUnwatched({required String serverId, required String itemId}) async {
|
||||
Future<void> markAsUnwatched({required ServerId serverId, required String itemId}) async {
|
||||
final cacheServerId = await _syncService.queueMarkUnwatched(serverId: serverId, itemId: itemId);
|
||||
_emitWatchStateChange(
|
||||
serverId: serverId,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ActorMediaScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
MediaServerClient get _mediaClient => context.getMediaClientForServer(widget.serverId);
|
||||
MediaServerClient get _mediaClient => context.getMediaClientForServer(ServerId(widget.serverId));
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
||||
@@ -132,7 +133,7 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
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<ActorMediaScreen>
|
||||
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<ActorMediaScreen>
|
||||
widget.characterName!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
],
|
||||
if (totalSize > 0) ...[
|
||||
|
||||
@@ -212,8 +212,8 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
||||
|
||||
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<PlexPinAuthFlow> {
|
||||
|
||||
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<PlexPinAuthFlow> {
|
||||
|
||||
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<PlexPinAuthFlow> {
|
||||
|
||||
Widget _buildBrowserWaiting(ThemeData theme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -214,18 +214,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
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<AuthScreen> {
|
||||
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<AuthScreen> {
|
||||
)
|
||||
: 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<AuthScreen> {
|
||||
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<AuthScreen> {
|
||||
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<AuthScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<T extends StatefulWidget> extends State
|
||||
if (serverId == null) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
return context.getMediaClientWithFallback(serverId);
|
||||
return context.getMediaClientWithFallback(ServerId(serverId));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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<CollectionD
|
||||
|
||||
String _collectionSyncRuleKey() {
|
||||
final serverId = widget.collection.serverId ?? mediaClient.serverId;
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(mediaClient, widget.collection.id, serverId: serverId);
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||
mediaClient,
|
||||
widget.collection.id,
|
||||
serverId: ServerId(serverId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
|
||||
@@ -64,7 +64,7 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
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<MobileRemoteScreen> {
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
),
|
||||
),
|
||||
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<DiscoverScreen>
|
||||
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<WatchTogetherProvider, CompanionRemoteProvider>(
|
||||
@@ -1292,11 +1291,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
left: -26,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
// Pause/Play button
|
||||
ClickableCursor(
|
||||
@@ -1736,7 +1731,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
? 200
|
||||
: 0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
padding: .symmetric(
|
||||
horizontal: isTv
|
||||
? TvLayoutConstants.horizontalInset
|
||||
: isLargeScreen
|
||||
@@ -1935,7 +1930,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
: 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<DiscoverScreen>
|
||||
borderRadius: BorderRadius.all(Radius.circular(isTv ? 4 : 3)),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
alignment: .centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -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<DownloadsScreen>
|
||||
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<DownloadsScreen>
|
||||
// (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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<HubDetailScreen>
|
||||
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 <MediaSort>[] : await client.fetchSortOptions(sectionId);
|
||||
|
||||
appLogger.d('Loaded ${sorts.length} sorts');
|
||||
@@ -272,7 +273,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
try {
|
||||
final loader = widget.loadItems;
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
|
||||
final List<MediaItem> items;
|
||||
int totalCount;
|
||||
int loadedCount;
|
||||
@@ -397,7 +398,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
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<HubDetailScreen>
|
||||
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<HubDetailScreen>
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -241,7 +241,7 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
|
||||
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<AlphaJumpBar> {
|
||||
width: markerSize,
|
||||
height: markerSize,
|
||||
decoration: decoration,
|
||||
alignment: Alignment.center,
|
||||
alignment: .center,
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -187,7 +187,7 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> 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<AlphaScrollHandle> 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<FiltersBottomSheet> {
|
||||
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<FiltersBottomSheet> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<FolderTreeView> {
|
||||
});
|
||||
|
||||
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<FolderTreeView> {
|
||||
});
|
||||
|
||||
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<FolderTreeView> {
|
||||
|
||||
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<FolderTreeView> {
|
||||
|
||||
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,
|
||||
|
||||
@@ -817,9 +817,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
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<LibrariesScreen>
|
||||
|
||||
PopupMenuItem<String> _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<LibrariesScreen>
|
||||
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<LibrariesScreen>
|
||||
// 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<LibrariesScreen>
|
||||
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),
|
||||
|
||||
@@ -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<MediaSort>;
|
||||
return LoadedFiltersAndSorts(filters: filterResult.filters, sorts: sorts, cachedValues: filterResult.cachedValues);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -101,7 +101,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: t.libraries.sortBy,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<MediaItem, LibraryBrows
|
||||
@override
|
||||
String? get itemServerId => 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<MediaItem, LibraryBrows
|
||||
for (final item in loadedItems.values) {
|
||||
final serverId = item.serverId ?? widget.library.serverId;
|
||||
if (serverId == null) return null;
|
||||
keys.add(_toGlobalKey(item.id, serverId: serverId));
|
||||
keys.add(_toGlobalKey(item.id, serverId: ServerId(serverId)));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -131,7 +132,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
for (final item in loadedItems.values) {
|
||||
final serverId = item.serverId ?? widget.library.serverId;
|
||||
if (serverId == null) return null;
|
||||
keys.add(_toGlobalKey(item.id, serverId: serverId));
|
||||
keys.add(_toGlobalKey(item.id, serverId: ServerId(serverId)));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -279,7 +280,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// only constructed when the library's backend is Plex — the bang is safe.
|
||||
plexClientProvider: () {
|
||||
final manager = context.read<MultiServerProvider>().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<MediaItem, LibraryBrows
|
||||
final type = widget.library.kind.id.toLowerCase();
|
||||
// Folder browsing is gated by backend capability: Plex uses its section
|
||||
// folder API, while Jellyfin uses direct non-recursive Items queries.
|
||||
final canFolder = context.tryGetMediaClientForServer(widget.library.serverId)?.capabilities.folderGrouping ?? false;
|
||||
final canFolder =
|
||||
context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId))?.capabilities.folderGrouping ??
|
||||
false;
|
||||
if (type == 'show') {
|
||||
return ['shows', 'seasons', 'episodes', if (canFolder) 'folders'];
|
||||
} else if (type == 'movie') {
|
||||
@@ -742,7 +745,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
.show<String>(
|
||||
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<MediaItem, LibraryBrows
|
||||
t.libraries.groupings.title,
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: options.map((grouping) {
|
||||
final isSelected = _selectedGrouping == grouping;
|
||||
return FocusableListTile(
|
||||
@@ -1434,9 +1437,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
alignment: .centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
// Grouping chip
|
||||
FocusableFilterChip(
|
||||
@@ -1592,7 +1595,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (viewMode == ViewMode.list) {
|
||||
// In list view, all items are in a single column (first column)
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
|
||||
padding: .fromLTRB(8, topPadding, rightPadding, 8),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, _) {
|
||||
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio);
|
||||
@@ -1621,7 +1624,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final effectiveMaxExtent = useWideRatio ? baseMaxExtent * 1.8 : baseMaxExtent;
|
||||
final hasAlphaBarReservation = rightPadding > 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);
|
||||
|
||||
@@ -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<MediaHub, LibraryR
|
||||
for (final item in hub.items) {
|
||||
final serverId = item.serverId ?? widget.library.serverId;
|
||||
if (serverId == null) return null;
|
||||
keys.add(buildGlobalKey(serverId, item.id));
|
||||
if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!));
|
||||
if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!));
|
||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
@@ -177,7 +178,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
|
||||
// Backend-aware fetch: Plex hits /hubs/sections, Jellyfin synthesises
|
||||
// Continue Watching + Next Up + Recently Added.
|
||||
final client = context.tryGetMediaClientForServer(widget.library.serverId);
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
|
||||
final hubs = client == null
|
||||
? <MediaHub>[]
|
||||
: List.of(
|
||||
@@ -300,7 +301,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final theme = Theme.of(context);
|
||||
final svc = SettingsService.instanceOrNull!;
|
||||
final client = context.tryGetMediaClientForServer(spotlight?.serverId ?? widget.library.serverId);
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId ?? widget.library.serverId));
|
||||
final scale = TvLayoutConstants.scaleForSize(size);
|
||||
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
|
||||
context,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../media/ids.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/livetv_channel.dart';
|
||||
@@ -51,7 +52,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
|
||||
required String posterServerId,
|
||||
}) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(posterServerId);
|
||||
final client = multiServer.getClientForServer(ServerId(posterServerId));
|
||||
String? posterUrl;
|
||||
if (posterThumb != null && client != null) {
|
||||
posterUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
|
||||
@@ -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<LiveTvScreen>
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final futures = <Future<void>>[];
|
||||
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<LiveTvScreen>
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final futures = <Future<void>>[];
|
||||
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<LiveTvScreen>
|
||||
/// 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<LiveTvScreen>
|
||||
|
||||
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<LiveTvScreen>
|
||||
final fetchedStores = <String>{};
|
||||
final seenFavorites = <String>{};
|
||||
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<LiveTvScreen>
|
||||
}
|
||||
final writtenStores = <String>{};
|
||||
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<LiveTvScreen>
|
||||
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<LiveTvScreen>
|
||||
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()),
|
||||
|
||||
@@ -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<LiveTvShowScheduleScreen>
|
||||
|
||||
Future<void> _loadSchedule() async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
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<LiveTvShowScheduleScreen>
|
||||
/// schedule screen is opened with a single [serverId], so no per-program
|
||||
/// lookup is needed.
|
||||
bool get _canRecord {
|
||||
final client = context.read<MultiServerProvider>().getClientForServer(widget.serverId);
|
||||
final client = context.read<MultiServerProvider>().getClientForServer(ServerId(widget.serverId));
|
||||
return client != null && client.capabilities.liveTvDvr;
|
||||
}
|
||||
|
||||
Future<void> _onRecordShow() async {
|
||||
final client = context.read<MultiServerProvider>().getClientForServer(widget.serverId);
|
||||
final client = context.read<MultiServerProvider>().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) ...[
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ReorderFavoritesSheet> {
|
||||
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<ReorderFavoritesSheet> {
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
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<ReorderFavoritesSheet> {
|
||||
key: key,
|
||||
tileColor: tileColor,
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
@@ -311,7 +312,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
title: Text(displayName, maxLines: 1, overflow: .ellipsis),
|
||||
subtitle: channelNumber != null
|
||||
? Text(
|
||||
channelNumber,
|
||||
|
||||
@@ -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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> with MountedSetStateMixin {
|
||||
|
||||
Future<void> _addScheduledRecordingKeysForServer({
|
||||
required MediaServerClient client,
|
||||
required String serverId,
|
||||
required ServerId serverId,
|
||||
required Set<String> 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<GuideTab> 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<GuideTab> with MountedSetStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
void _addRecordingKeysForGrab(MediaGrabOperation grab, {required String serverId, required Set<String> keys}) {
|
||||
void _addRecordingKeysForGrab(MediaGrabOperation grab, {required ServerId serverId, required Set<String> keys}) {
|
||||
if (!_isActiveScheduledGrab(grab)) return;
|
||||
final program = grab.program;
|
||||
if (program == null) return;
|
||||
@@ -367,7 +368,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
|
||||
final keys = <String>{};
|
||||
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<GuideTab> 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<GuideTab> 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<GuideTab> with MountedSetStateMixin {
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
_timeNavFocusWrap(
|
||||
index: 1,
|
||||
@@ -1013,7 +1014,7 @@ class GuideTabState extends State<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> with MountedSetStateMixin {
|
||||
|
||||
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> 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<GuideTab> with MountedSetStateMixin {
|
||||
|
||||
void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
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,
|
||||
|
||||
@@ -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<RecordingsTab> {
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
|
||||
if (!queriedServers.add(serverInfo.serverId)) continue;
|
||||
try {
|
||||
// Plex-only: Live TV hubs API is Plex-specific.
|
||||
final client = multiServer.getPlexClientForServer(serverInfo.serverId);
|
||||
final client = multiServer.getPlexClientForServer(ServerId(serverInfo.serverId));
|
||||
if (client == null) continue;
|
||||
|
||||
final hubs = await client.getLiveTvHubs();
|
||||
@@ -432,13 +433,13 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> 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),
|
||||
|
||||
@@ -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<MainScreen>
|
||||
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<MainScreen>
|
||||
}
|
||||
|
||||
/// Navigate to media when host switches content in Watch Together session
|
||||
Future<void> _navigateToWatchTogetherMedia(String ratingKey, String serverId) async {
|
||||
Future<void> _navigateToWatchTogetherMedia(String ratingKey, ServerId serverId) async {
|
||||
if (!mounted) return; // Check before any context usage
|
||||
|
||||
try {
|
||||
@@ -1659,7 +1660,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
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<MainScreen>
|
||||
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<MainScreen>
|
||||
t.common.reconnect,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: .w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<void> 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 = <FocusableAction>[
|
||||
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 = <FocusableAction>[
|
||||
playAction,
|
||||
if (downloadAction != null) downloadAction,
|
||||
watchedAction,
|
||||
if (moreActionsAction != null) moreActionsAction,
|
||||
];
|
||||
final medium = <FocusableAction>[playAction, ?downloadAction, watchedAction, ?moreActionsAction];
|
||||
if (!maxWidth.isFinite || estimatedRowWidth(medium) <= maxWidth) return medium;
|
||||
|
||||
final compact = <FocusableAction>[playAction, watchedAction, if (moreActionsAction != null) moreActionsAction];
|
||||
final compact = <FocusableAction>[playAction, watchedAction, ?moreActionsAction];
|
||||
if (estimatedRowWidth(compact) <= maxWidth) return compact;
|
||||
|
||||
return [playAction, if (moreActionsAction != null) moreActionsAction];
|
||||
return [playAction, ?moreActionsAction];
|
||||
}
|
||||
|
||||
Widget actionBar(List<FocusableAction> actions) {
|
||||
@@ -292,9 +287,9 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
// Offline mode: queue action for later sync
|
||||
final offlineWatch = context.read<OfflineWatchProvider>();
|
||||
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) {
|
||||
|
||||
@@ -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<MediaDetailScreen>
|
||||
final serverId = serverBoundServerId;
|
||||
if (serverId == null) return null;
|
||||
|
||||
final keys = <String>{toServerBoundGlobalKey(_metadata.id, serverId: serverId)};
|
||||
final keys = <String>{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<MediaDetailScreen>
|
||||
final serverId = serverBoundServerId;
|
||||
if (serverId == null) return null;
|
||||
|
||||
final keys = <String>{toServerBoundGlobalKey(_metadata.id, serverId: serverId)};
|
||||
final keys = <String>{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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
/// 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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
|
||||
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<MediaDetailScreen>
|
||||
|
||||
String? _offlineArtworkLocalPath(BuildContext context, String? artworkPath) {
|
||||
if (!widget.isOffline || _metadata.serverId == null) return null;
|
||||
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(_metadata.serverId!, artworkPath);
|
||||
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(ServerId(_metadata.serverId!), artworkPath);
|
||||
if (localPath == null || !File(localPath).existsSync()) return null;
|
||||
return localPath;
|
||||
}
|
||||
@@ -1189,7 +1190,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
// Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.)
|
||||
if (widget.isOffline) {
|
||||
final cachedMetadata = await context.read<DownloadProvider>().lookupOfflineMetadata(
|
||||
_metadata.serverId ?? '',
|
||||
ServerId(_metadata.serverId ?? ''),
|
||||
_metadata.id,
|
||||
);
|
||||
if (!mounted) return;
|
||||
@@ -1412,7 +1413,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
});
|
||||
|
||||
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<MediaDetailScreen>
|
||||
: Future.value(<String, dynamic>{});
|
||||
|
||||
final results = await Future.wait([seasonsFuture, prefsFuture]);
|
||||
final seasons = results[0] as List<MediaItem>;
|
||||
final seasons = results.first as List<MediaItem>;
|
||||
final prefs = results[1] as Map<String, dynamic>;
|
||||
|
||||
// Preserve serverId for each season.
|
||||
@@ -1522,7 +1523,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
// 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 <MediaItem>[]);
|
||||
return;
|
||||
@@ -1730,7 +1731,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
_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<MediaDetailScreen>
|
||||
List<MediaItem> seasons,
|
||||
Set<String> seasonIdsToWarm,
|
||||
MediaServerClient client,
|
||||
String serverId,
|
||||
ServerId serverId,
|
||||
int generation,
|
||||
) async {
|
||||
final episodesBySeasonId = <String, List<MediaItem>>{};
|
||||
@@ -1922,7 +1923,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
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<MediaDetailScreen>
|
||||
}
|
||||
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
String? localPosterPath;
|
||||
if (widget.isOffline && episode.serverId != null) {
|
||||
final artworkRef = context.read<DownloadProvider>().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<MediaDetailScreen>
|
||||
});
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
_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<MediaDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
List<MediaItem> _enrichPlayableEpisodes(List<MediaItem> episodes, String serverId) {
|
||||
List<MediaItem> _enrichPlayableEpisodes(List<MediaItem> 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<MediaDetailScreen>
|
||||
|
||||
Future<void> _fetchRemainingEpisodes(
|
||||
MediaServerClient client,
|
||||
String serverId,
|
||||
ServerId serverId,
|
||||
int generation,
|
||||
int startOffset,
|
||||
int totalCount,
|
||||
@@ -2677,7 +2680,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
// 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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
],
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
context,
|
||||
title,
|
||||
fontSize: titleFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: .bold,
|
||||
shadowBlur: 8,
|
||||
),
|
||||
),
|
||||
@@ -3809,7 +3806,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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<MediaDetailScreen>
|
||||
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)),
|
||||
|
||||
@@ -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<MetadataEditScreen> {
|
||||
|
||||
Future<void> _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<MetadataEditScreen> {
|
||||
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<MetadataEditScreen> {
|
||||
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<MetadataEditScreen> {
|
||||
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<ArtworkPickerDialog> {
|
||||
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(
|
||||
|
||||
@@ -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<PlaylistDetai
|
||||
|
||||
String _playlistSyncRuleKey() {
|
||||
final serverId = widget.playlist.serverId ?? mediaClient.serverId;
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(mediaClient, widget.playlist.id, serverId: serverId);
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||
mediaClient,
|
||||
widget.playlist.id,
|
||||
serverId: ServerId(serverId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _managePlaylistSyncRule() =>
|
||||
@@ -701,12 +706,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
|
||||
if (widget.playlist.smart)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
AppIcon(
|
||||
Symbols.auto_awesome_rounded,
|
||||
@@ -717,11 +722,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.primary, fontWeight: .normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -827,7 +828,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -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';
|
||||
@@ -113,7 +114,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> 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<PlaylistItemCard> 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<PlaylistItemCard> 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<PlaylistItemCard> 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,
|
||||
|
||||
@@ -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<PlexMatchScreen> 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<PlexMatchScreen> 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<PlexMatchScreen> with ControllerDispos
|
||||
|
||||
Widget _buildSearchForm(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -231,9 +232,9 @@ class _PlexMatchScreenState extends State<PlexMatchScreen> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
profileRegistry.list(),
|
||||
StorageService.getInstance(),
|
||||
]);
|
||||
final allPcs = results[0] as List<ProfileConnection>;
|
||||
final allPcs = results.first as List<ProfileConnection>;
|
||||
final allConns = results[1] as List<Connection>;
|
||||
final localProfiles = results[2] as List<Profile>;
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -92,8 +92,8 @@ class _PinEntryDialogState extends State<PinEntryDialog> 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<PinEntryDialog> 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<PinEntryDialog> 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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -279,7 +279,7 @@ class _ConnectionsList extends StatelessWidget {
|
||||
final pcs = snapshot.data ?? const <ProfileConnection>[];
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
padding: .symmetric(vertical: 20),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -442,11 +442,11 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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),
|
||||
),
|
||||
|
||||
@@ -163,18 +163,18 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> 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,
|
||||
|
||||
@@ -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<bool> persistAndBindConnection({
|
||||
|
||||
final mp = context.read<MultiServerProvider>();
|
||||
if (visibleServerId != null) {
|
||||
mp.addToVisibleServerIds(visibleServerId);
|
||||
mp.addToVisibleServerIds(ServerId(visibleServerId));
|
||||
}
|
||||
unawaited(context.read<LibrariesProvider>().loadLibraries());
|
||||
return true;
|
||||
|
||||
@@ -84,12 +84,12 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
title: Text(t.connections.editJellyfinTitle),
|
||||
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,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.connections.editJellyfinIntro(serverName: widget.connection.serverName),
|
||||
|
||||
@@ -97,7 +97,7 @@ class _PlayerTile extends StatelessWidget {
|
||||
leading: leading,
|
||||
title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (isCustom)
|
||||
IconButton(
|
||||
@@ -183,7 +183,7 @@ class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> {
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
FocusableTextField(
|
||||
controller: _nameController,
|
||||
|
||||
@@ -40,13 +40,10 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
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<HotKeyRecorderWidget> {
|
||||
_recordedHotKey = null;
|
||||
});
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
padding: .zero,
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||
tooltip: t.hotkeys.clearShortcut,
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -159,7 +159,7 @@ class _LogsScreenState extends State<LogsScreen> 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<LogsScreen> 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));
|
||||
|
||||
@@ -194,13 +194,13 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> 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(
|
||||
|
||||
@@ -255,7 +255,7 @@ class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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<SettingsScreen> 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),
|
||||
|
||||
@@ -112,7 +112,7 @@ Future<T?> showSelectionDialog<T>({
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PlaybackStateProvider>();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> _handlePlayerMediaSwitch(String ratingKey, String serverId, String title) async {
|
||||
Future<void> _handlePlayerMediaSwitch(String ratingKey, ServerId serverId, String title) async {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.d('WatchTogether: Guest handling media switch to $title');
|
||||
|
||||
@@ -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<PlaybackStateProvider>(
|
||||
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),
|
||||
|
||||
@@ -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<VideoPlayerScreen> with WidgetsBindin
|
||||
MediaServerClient? _getMediaServerClient(BuildContext context) {
|
||||
final id = _currentMetadata.serverId;
|
||||
if (id == null) return null;
|
||||
return context.read<MultiServerProvider>().serverManager.getClient(id);
|
||||
return context.read<MultiServerProvider>().serverManager.getClient(ServerId(id));
|
||||
}
|
||||
|
||||
MediaServerClient? _getOnlineMediaServerClient(BuildContext context) {
|
||||
final id = _currentMetadata.serverId;
|
||||
if (id == null) return null;
|
||||
final manager = context.read<MultiServerProvider>().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;
|
||||
|
||||
+17
-16
@@ -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<Map<String, dynamic>?> get(String serverId, String endpoint) async {
|
||||
Future<Map<String, dynamic>?> 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<void> put(String serverId, String endpoint, Map<String, dynamic> data) async {
|
||||
Future<void> put(ServerId serverId, String endpoint, Map<String, dynamic> data) async {
|
||||
final key = _buildKey(serverId, endpoint);
|
||||
final encoded = await tryIsolateRun(() => jsonEncode(data));
|
||||
await _db
|
||||
@@ -85,26 +86,26 @@ abstract class ApiCache {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteForServer(String serverId) async {
|
||||
Future<void> 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<void> pin(String serverId, String endpoint) async {
|
||||
Future<void> 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<void> unpin(String serverId, String endpoint) async {
|
||||
Future<void> 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<bool> isPinned(String serverId, String endpoint) async {
|
||||
Future<bool> 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<Set<String>> extractPinnedIds(String serverId, RegExp keyPattern) async {
|
||||
Future<Set<String>> 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<List<({String serverId, String id, String data})>> listPinnedRowsByPattern(RegExp keyPattern) async {
|
||||
Future<List<({ServerId serverId, String id, String data})>> 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<MediaItem?> getMetadata(String serverId, String itemId);
|
||||
Future<MediaItem?> 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<void> pinForOffline(String serverId, String itemId);
|
||||
Future<void> pinForOffline(ServerId serverId, String itemId);
|
||||
|
||||
/// Delete cached metadata for [itemId] (used when removing a download).
|
||||
Future<void> deleteForItem(String serverId, String itemId);
|
||||
Future<void> 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<void> 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<Map<String, MediaItem>> getAllPinnedMetadata();
|
||||
}
|
||||
|
||||
@@ -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<MediaSourceInfo?> _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<PlaybackExtras?> _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<Map<String, dynamic>?> _plexMetadata(String serverId, String itemId) async {
|
||||
static Future<Map<String, dynamic>?> _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);
|
||||
|
||||
@@ -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 = <String>{};
|
||||
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 <MediaHub>[];
|
||||
@@ -334,7 +335,7 @@ class DataAggregationService {
|
||||
}
|
||||
|
||||
/// Filter hidden-library items and drop empty hubs.
|
||||
List<MediaHub> _postProcessHubs(List<MediaHub> hubs, {required String serverId, Set<String>? hiddenLibraryKeys}) {
|
||||
List<MediaHub> _postProcessHubs(List<MediaHub> hubs, {required ServerId serverId, Set<String>? 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;
|
||||
|
||||
@@ -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<String> localPath(String serverId, String pathOrUrl) {
|
||||
Future<String> localPath(ServerId serverId, String pathOrUrl) {
|
||||
return storageService.getArtworkPathFromThumb(serverId, normalizeKey(pathOrUrl));
|
||||
}
|
||||
|
||||
Future<bool> existsUsable(String serverId, String pathOrUrl) async {
|
||||
Future<bool> existsUsable(ServerId serverId, String pathOrUrl) async {
|
||||
final file = File(await localPath(serverId, pathOrUrl));
|
||||
return isUsableArtworkFile(file);
|
||||
}
|
||||
|
||||
Future<bool> hasMissingArtwork(String serverId, Iterable<DownloadArtworkSpec> specs) async {
|
||||
Future<bool> hasMissingArtwork(ServerId serverId, Iterable<DownloadArtworkSpec> specs) async {
|
||||
for (final spec in specs) {
|
||||
if (!await existsUsable(serverId, spec.localKey)) return true;
|
||||
}
|
||||
@@ -53,10 +54,10 @@ class DownloadArtworkService {
|
||||
Future<void> 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<void> ensureArtworkSpecs(String serverId, Iterable<DownloadArtworkSpec> specs) async {
|
||||
Future<void> ensureArtworkSpecs(ServerId serverId, Iterable<DownloadArtworkSpec> 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<void> downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async {
|
||||
Future<void> 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<void> _downloadSingleArtworkToPath(String serverId, DownloadArtworkSpec spec, String filePath) async {
|
||||
Future<void> _downloadSingleArtworkToPath(ServerId serverId, DownloadArtworkSpec spec, String filePath) async {
|
||||
try {
|
||||
if (await existsUsable(serverId, spec.localKey)) {
|
||||
appLogger.d('Artwork already exists: ${spec.localKey}');
|
||||
|
||||
@@ -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<Task?> Function(String taskId);
|
||||
typedef _NativeResumeTask = Future<bool> 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<Map<String, MediaItem>> 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<MediaItem?> lookupMetadata(String serverId, String itemId, {bool preferActiveScope = false}) async {
|
||||
final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId));
|
||||
Future<MediaItem?> 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<String> _metadataScopeCandidates(
|
||||
String serverId, {
|
||||
ServerId serverId, {
|
||||
String? downloadedClientScopeId,
|
||||
required bool preferActiveScope,
|
||||
}) {
|
||||
final candidates = <String>[
|
||||
if (preferActiveScope) ?activeClientScopeIdForServer(serverId),
|
||||
if (preferActiveScope) ?activeClientScopeIdForServer(ServerId(serverId)),
|
||||
?downloadedClientScopeId,
|
||||
?_getClient(serverId, clientScopeId: downloadedClientScopeId)?.cacheServerId,
|
||||
?_getClient(ServerId(serverId), clientScopeId: downloadedClientScopeId)?.cacheServerId,
|
||||
serverId,
|
||||
];
|
||||
return <String>{
|
||||
@@ -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<MediaItem?> fetchAndPinMetadata(String serverId, String itemId, {bool preferActiveScope = false}) async {
|
||||
final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId));
|
||||
Future<MediaItem?> 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<MediaBackend?> _backendForServer(String serverId) async {
|
||||
Future<MediaBackend?> _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<MediaItem?> _lookupMetadata(String serverId, String itemId, {String? clientScopeId}) async {
|
||||
Future<MediaItem?> _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<void> _deleteForItemByServer(String serverId, String itemId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _repairParentArtwork(
|
||||
String serverId,
|
||||
ServerId serverId,
|
||||
String? ratingKey,
|
||||
MediaServerClient client,
|
||||
Set<String> 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<int?> _fetchShowYear(String serverId, String? grandparentRatingKey, {String? clientScopeId}) async {
|
||||
Future<int?> _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<int?> _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<void> _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<void> _downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async {
|
||||
Future<void> _downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async {
|
||||
await _artworkService.downloadSingleArtwork(serverId, spec);
|
||||
}
|
||||
|
||||
@@ -1900,14 +1905,14 @@ class DownloadManagerService {
|
||||
Future<void> 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<void> _downloadChapterThumbnails(String serverId, String ratingKey, MediaServerClient client) async {
|
||||
Future<void> _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<int> _getTotalItemsToDelete(MediaItem metadata, String serverId, {String? clientScopeId}) async {
|
||||
Future<int> _getTotalItemsToDelete(MediaItem metadata, ServerId serverId, {String? clientScopeId}) async {
|
||||
switch (metadata.kind) {
|
||||
case MediaKind.episode:
|
||||
case MediaKind.movie:
|
||||
@@ -2223,9 +2238,9 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteMediaFilesWithMetadata(String serverId, String ratingKey, {String? clientScopeId}) async {
|
||||
Future<void> _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<List<String>> _getChapterThumbPaths(String serverId, String ratingKey, {String? clientScopeId}) async {
|
||||
Future<List<String>> _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<void> _deleteChapterThumbnails(String serverId, String ratingKey, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _deleteEpisodeFiles(MediaItem episode, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _deleteSeasonFiles(MediaItem season, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _deleteEpisodesInCollection({
|
||||
required List<DownloadedMediaItem> 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<void> _deleteShowFiles(MediaItem show, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _deleteMovieFiles(MediaItem movie, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _deleteMovieFilesSaf(MediaItem movie, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _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<void> _deleteSeasonFilesSaf(MediaItem season, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _deleteShowFilesSaf(MediaItem show, String serverId, {String? clientScopeId}) async {
|
||||
Future<void> _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<void> _ensureDbFileDeleted(String serverId, String ratingKey) async {
|
||||
Future<void> _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;
|
||||
|
||||
|
||||
@@ -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<String> getArtworkPathFromThumb(String serverId, String thumbPath) async {
|
||||
Future<String> getArtworkPathFromThumb(ServerId serverId, String thumbPath) async {
|
||||
final artworkDir = await getArtworkDirectory();
|
||||
final hash = _hashArtworkPath(serverId, thumbPath);
|
||||
return path.join(artworkDir.path, '$hash.jpg');
|
||||
}
|
||||
|
||||
Future<bool> artworkExists(String serverId, String thumbPath) async {
|
||||
Future<bool> 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<Directory> getMediaDirectory(String serverId, String ratingKey) async {
|
||||
Future<Directory> getMediaDirectory(ServerId serverId, String ratingKey) async {
|
||||
final baseDir = await getDownloadsDirectory();
|
||||
return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey)));
|
||||
}
|
||||
|
||||
Future<String> getVideoFilePath(String serverId, String ratingKey, String extension) async {
|
||||
Future<String> getVideoFilePath(ServerId serverId, String ratingKey, String extension) async {
|
||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||
return path.join(mediaDir.path, 'video.$extension');
|
||||
}
|
||||
|
||||
Future<Directory> getSubtitlesDirectory(String serverId, String ratingKey) async {
|
||||
Future<Directory> 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<String> getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async {
|
||||
Future<String> getSubtitlePath(ServerId serverId, String ratingKey, int trackId, String extension) async {
|
||||
final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey);
|
||||
return path.join(subtitlesDir.path, '$trackId.$extension');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> deleteForItem(String serverId, String itemId) async {
|
||||
Future<void> 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<void> pinForOffline(String serverId, String itemId) async {
|
||||
Future<void> pinForOffline(ServerId serverId, String itemId) async {
|
||||
final endpoint = mediaSegmentsEndpoint(itemId);
|
||||
await Future.wait([pinByKeyPattern(_itemPattern(serverId, itemId)), pin(serverId, endpoint)]);
|
||||
}
|
||||
|
||||
Future<void> unpinForOffline(String serverId, String itemId) async {
|
||||
Future<void> 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<bool> isPinnedItemId(String serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId));
|
||||
Future<bool> isPinnedItemId(ServerId serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId));
|
||||
|
||||
Future<Set<String>> getPinnedItemIds(String serverId) => extractPinnedIds(serverId, _itemKeyPattern);
|
||||
Future<Set<String>> 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<MediaItem?> getMetadata(String serverId, String itemId) async {
|
||||
Future<MediaItem?> 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<String, dynamic>;
|
||||
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<void> 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<String, dynamic>)
|
||||
: <String, dynamic>{};
|
||||
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<String, dynamic>;
|
||||
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
|
||||
|
||||
@@ -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<MediaItem>().toList();
|
||||
|
||||
@override
|
||||
String get serverId => connection.serverMachineId;
|
||||
ServerId get serverId => ServerId(connection.serverMachineId);
|
||||
|
||||
@override
|
||||
String get scopedServerId => connection.id;
|
||||
|
||||
@@ -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<String, dynamic>) return _mapItem(cached);
|
||||
return null;
|
||||
}
|
||||
@@ -420,7 +420,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) 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<String, dynamic>) 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<String, dynamic>) {
|
||||
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<List<MediaItem>> _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,
|
||||
),
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -13,7 +13,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
});
|
||||
Future<Map<String, dynamic>?> getPlaybackInfo(
|
||||
String itemId, {
|
||||
int? maxStreamingBitrate = 100000000,
|
||||
int? maxStreamingBitrate = 100_000_000,
|
||||
String? mediaSourceId,
|
||||
String? liveStreamId,
|
||||
int? startTimeTicks,
|
||||
|
||||
@@ -78,7 +78,7 @@ mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin {
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user