refactor: deduplicate shared patterns, replace hardcoded colors
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
|
||||
/// Extension methods on AppDatabase for download operations
|
||||
extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// Insert a new download into the database
|
||||
Future<void> insertDownload({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String type,
|
||||
String? parentRatingKey,
|
||||
String? grandparentRatingKey,
|
||||
required int status,
|
||||
}) async {
|
||||
await into(downloadedMedia).insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
type: type,
|
||||
parentRatingKey: Value(parentRatingKey),
|
||||
grandparentRatingKey: Value(grandparentRatingKey),
|
||||
status: status,
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Add item to download queue
|
||||
Future<void> addToQueue({
|
||||
required String mediaGlobalKey,
|
||||
int priority = 0,
|
||||
bool downloadSubtitles = true,
|
||||
bool downloadArtwork = true,
|
||||
}) async {
|
||||
await into(downloadQueue).insert(
|
||||
DownloadQueueCompanion.insert(
|
||||
mediaGlobalKey: mediaGlobalKey,
|
||||
priority: Value(priority),
|
||||
addedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
downloadSubtitles: Value(downloadSubtitles),
|
||||
downloadArtwork: Value(downloadArtwork),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get next item from queue (highest priority, oldest first)
|
||||
/// Only returns items that are not paused
|
||||
Future<DownloadQueueItem?> getNextQueueItem() async {
|
||||
// Join with downloadedMedia to check status and filter out paused items
|
||||
final query = select(
|
||||
downloadQueue,
|
||||
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
||||
|
||||
query
|
||||
..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
|
||||
..orderBy([
|
||||
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: downloadQueue.addedAt),
|
||||
])
|
||||
..limit(1);
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.readTable(downloadQueue);
|
||||
}
|
||||
|
||||
/// Update download status
|
||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
||||
}
|
||||
|
||||
/// Update download progress
|
||||
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
progress: Value(progress),
|
||||
downloadedBytes: Value(downloadedBytes),
|
||||
totalBytes: Value(totalBytes),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update video file path
|
||||
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
videoFilePath: Value(filePath),
|
||||
downloadedAt: Value(DateTime.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update artwork paths
|
||||
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||
}
|
||||
|
||||
/// Update download error and increment retry count
|
||||
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
||||
// Get current retry count to increment it
|
||||
final existing = await getDownloadedMedia(globalKey);
|
||||
final currentCount = existing?.retryCount ?? 0;
|
||||
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear download error and reset retry count (for retry)
|
||||
Future<void> clearDownloadError(String globalKey) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from queue
|
||||
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||
}
|
||||
|
||||
/// Get downloaded media item
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Delete a download
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a season
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(String seasonKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a show
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(String showKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
|
||||
}
|
||||
|
||||
/// Get all downloaded items for a specific server
|
||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||
}
|
||||
|
||||
/// Update the background_downloader task ID for a download
|
||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
||||
}
|
||||
|
||||
/// Get the background_downloader task ID for a download
|
||||
Future<String?> getBgTaskId(String globalKey) async {
|
||||
final item = await getDownloadedMedia(globalKey);
|
||||
return item?.bgTaskId;
|
||||
}
|
||||
}
|
||||
@@ -63,36 +63,47 @@ extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
|
||||
}
|
||||
|
||||
/// Global helper to suppress the next SELECT key-up event.
|
||||
class SelectKeyUpSuppressor {
|
||||
static bool _suppressSelectUntilKeyUp = false;
|
||||
/// Base class for suppressing key-up events after a key category triggers an
|
||||
/// action (e.g. opening a sheet). While suppressed, all events for the matched
|
||||
/// key category are consumed; suppression auto-clears on [KeyUpEvent].
|
||||
class _KeyUpSuppressor {
|
||||
final bool Function(LogicalKeyboardKey) _keyMatcher;
|
||||
|
||||
static void suppressSelectUntilKeyUp() {
|
||||
_suppressSelectUntilKeyUp = true;
|
||||
}
|
||||
_KeyUpSuppressor(this._keyMatcher);
|
||||
|
||||
static void clearSuppression() {
|
||||
_suppressSelectUntilKeyUp = false;
|
||||
}
|
||||
bool _suppressed = false;
|
||||
|
||||
static bool consumeIfSuppressed(KeyEvent event) {
|
||||
if (!_suppressSelectUntilKeyUp) return false;
|
||||
if (event.logicalKey.isSelectKey) {
|
||||
if (event is KeyUpEvent) {
|
||||
_suppressSelectUntilKeyUp = false;
|
||||
}
|
||||
void suppress() => _suppressed = true;
|
||||
|
||||
void clearSuppression() => _suppressed = false;
|
||||
|
||||
/// Returns `true` (consumed) when the event belongs to the matched key
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent].
|
||||
bool consumeIfSuppressed(KeyEvent event) {
|
||||
if (!_suppressed) return false;
|
||||
if (_keyMatcher(event.logicalKey)) {
|
||||
if (event is KeyUpEvent) _suppressed = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global helper to suppress the next SELECT key-up event.
|
||||
class SelectKeyUpSuppressor {
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isSelectKey);
|
||||
|
||||
static void suppressSelectUntilKeyUp() => _instance.suppress();
|
||||
static void clearSuppression() => _instance.clearSuppression();
|
||||
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
|
||||
}
|
||||
|
||||
/// Global helper to suppress the next BACK key-up event.
|
||||
///
|
||||
/// Use this when a modal (bottom sheet, dialog) closes to prevent
|
||||
/// the BACK key-up from propagating to the underlying screen.
|
||||
class BackKeyUpSuppressor {
|
||||
static bool _suppressBackUntilKeyUp = false;
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey);
|
||||
static bool _closedViaBackKey = false;
|
||||
|
||||
/// Mark that a modal is being closed via back key press.
|
||||
@@ -109,26 +120,17 @@ class BackKeyUpSuppressor {
|
||||
_closedViaBackKey = false;
|
||||
return;
|
||||
}
|
||||
_suppressBackUntilKeyUp = true;
|
||||
_instance.suppress();
|
||||
}
|
||||
|
||||
/// Clear any pending suppression. Call when opening a new modal
|
||||
/// to ensure stale suppression from previous closes doesn't affect it.
|
||||
static void clearSuppression() {
|
||||
_suppressBackUntilKeyUp = false;
|
||||
_instance.clearSuppression();
|
||||
_closedViaBackKey = false;
|
||||
}
|
||||
|
||||
static bool consumeIfSuppressed(KeyEvent event) {
|
||||
if (!_suppressBackUntilKeyUp) return false;
|
||||
if (event.logicalKey.isBackKey) {
|
||||
if (event is KeyUpEvent) {
|
||||
_suppressBackUntilKeyUp = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
|
||||
}
|
||||
|
||||
/// Tracks whether a back key is currently physically pressed.
|
||||
|
||||
+2
-4
@@ -155,7 +155,7 @@ void main() async {
|
||||
);
|
||||
}
|
||||
|
||||
Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint hint) {
|
||||
Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
|
||||
if (breadcrumb == null) return null;
|
||||
|
||||
final message = breadcrumb.message;
|
||||
@@ -168,7 +168,7 @@ Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint hint) {
|
||||
);
|
||||
}
|
||||
|
||||
FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint hint) {
|
||||
FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
|
||||
// Drop event if user opted out of crash reporting
|
||||
final instance = SettingsService.instanceOrNull;
|
||||
if (instance != null && !instance.getCrashReporting()) return null;
|
||||
@@ -361,14 +361,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
create: (context) => OfflineWatchProvider(
|
||||
syncService: _offlineWatchSyncService,
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
apiCache: PlexApiCache.instance,
|
||||
),
|
||||
update: (_, syncService, downloadProvider, previous) {
|
||||
return previous ??
|
||||
OfflineWatchProvider(
|
||||
syncService: syncService,
|
||||
downloadProvider: downloadProvider,
|
||||
apiCache: PlexApiCache.instance,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/track_label_builder.dart' show buildTrackLabel;
|
||||
|
||||
class PlexMediaInfo {
|
||||
final String videoUrl;
|
||||
@@ -23,9 +24,9 @@ class PlexMediaInfo {
|
||||
static PlexMediaInfo? fromMetadataJson(Map<String, dynamic> metadata) {
|
||||
final media = metadata['Media'] as List<dynamic>?;
|
||||
if (media == null || media.isEmpty) return null;
|
||||
final parts = media[0]['Part'] as List<dynamic>?;
|
||||
final parts = media.first['Part'] as List<dynamic>?;
|
||||
if (parts == null || parts.isEmpty) return null;
|
||||
final streams = parts[0]['Stream'] as List<dynamic>?;
|
||||
final streams = parts.first['Stream'] as List<dynamic>?;
|
||||
|
||||
final audioTracks = <PlexAudioTrack>[];
|
||||
final subtitleTracks = <PlexSubtitleTrack>[];
|
||||
@@ -75,27 +76,11 @@ class PlexMediaInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a track label from parts with the standard `' · '` joiner pattern.
|
||||
/// Mixin for building track labels with a consistent pattern.
|
||||
///
|
||||
/// Shared by both Plex track models and MPV track label utilities.
|
||||
/// If [title] is non-empty it is added first, then [language], then [extraParts].
|
||||
/// Falls back to `'$fallbackPrefix ${index + 1}'` when no parts are available.
|
||||
String buildTrackLabel({
|
||||
String? title,
|
||||
String? language,
|
||||
List<String> extraParts = const [],
|
||||
required int index,
|
||||
String fallbackPrefix = 'Track',
|
||||
}) {
|
||||
final parts = <String>[];
|
||||
if (title != null && title.isNotEmpty) parts.add(title);
|
||||
if (language != null && language.isNotEmpty) parts.add(language);
|
||||
parts.addAll(extraParts);
|
||||
return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' · ');
|
||||
}
|
||||
|
||||
/// Mixin for building track labels with a consistent pattern
|
||||
mixin TrackLabelBuilder {
|
||||
/// Used by [PlexAudioTrack] and [PlexSubtitleTrack] to provide a [buildLabel]
|
||||
/// method that delegates to the shared [buildTrackLabel] function.
|
||||
mixin _TrackLabelMixin {
|
||||
int get id;
|
||||
int? get index;
|
||||
String? get displayTitle;
|
||||
@@ -112,7 +97,7 @@ mixin TrackLabelBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
class PlexAudioTrack with TrackLabelBuilder {
|
||||
class PlexAudioTrack with _TrackLabelMixin {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
@@ -147,7 +132,7 @@ class PlexAudioTrack with TrackLabelBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
class PlexSubtitleTrack with TrackLabelBuilder {
|
||||
class PlexSubtitleTrack with _TrackLabelMixin {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
|
||||
@@ -309,10 +309,12 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
appLogger.d('CompanionRemote: Attempting reconnect...');
|
||||
// Clean up old peer service without triggering intentional disconnect
|
||||
_cleanupSubscriptions();
|
||||
await _peerService?.disconnect();
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
try {
|
||||
await _peerService?.disconnect();
|
||||
} finally {
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
}
|
||||
|
||||
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'dart:io';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:plezy/utils/content_utils.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/download_manager_service.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/plex_api_cache.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -118,7 +118,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Load total episode counts from SharedPreferences
|
||||
// Load total episode counts from StorageService
|
||||
await _loadTotalEpisodeCounts();
|
||||
|
||||
appLogger.i(
|
||||
@@ -131,32 +131,24 @@ class DownloadProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load total episode counts from SharedPreferences
|
||||
/// Load total episode counts from StorageService
|
||||
Future<void> _loadTotalEpisodeCounts() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keys = prefs.getKeys().where((k) => k.startsWith('episode_count_'));
|
||||
final storage = await StorageService.getInstance();
|
||||
final counts = storage.loadAllEpisodeCounts();
|
||||
_totalEpisodeCounts.addAll(counts);
|
||||
|
||||
for (final key in keys) {
|
||||
final globalKey = key.replaceFirst('episode_count_', '');
|
||||
final count = prefs.getInt(key);
|
||||
if (count != null) {
|
||||
_totalEpisodeCounts[globalKey] = count;
|
||||
appLogger.d('📂 Loaded episode count from SharedPrefs: $globalKey = $count');
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.i('📚 Loaded ${_totalEpisodeCounts.length} episode counts from SharedPreferences');
|
||||
appLogger.i('Loaded ${_totalEpisodeCounts.length} episode counts from StorageService');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to load episode counts', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist total episode count to SharedPreferences
|
||||
/// Persist total episode count to StorageService
|
||||
Future<void> _persistTotalEpisodeCount(String globalKey, int count) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('episode_count_$globalKey', count);
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveTotalEpisodeCount(globalKey, count);
|
||||
appLogger.d('Persisted episode count for $globalKey: $count');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to persist episode count for $globalKey', error: e);
|
||||
@@ -409,7 +401,7 @@ class DownloadProvider extends ChangeNotifier {
|
||||
countSource = 'metadata.leafCount';
|
||||
} else if (storedCount != null && storedCount > 0) {
|
||||
totalEpisodes = storedCount;
|
||||
countSource = 'stored count (SharedPreferences)';
|
||||
countSource = 'stored count (StorageService)';
|
||||
} else {
|
||||
totalEpisodes = downloadedCount;
|
||||
countSource = 'downloaded episodes (fallback)';
|
||||
@@ -874,10 +866,10 @@ class DownloadProvider extends ChangeNotifier {
|
||||
final meta = _metadata[globalKey];
|
||||
if (meta?.type == 'show' || meta?.type == 'season') {
|
||||
final removedCount = _totalEpisodeCounts.remove(globalKey);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('episode_count_$globalKey');
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.removeEpisodeCount(globalKey);
|
||||
appLogger.i(
|
||||
'🗑️ Removed episode count for $globalKey\n'
|
||||
'Removed episode count for $globalKey\n'
|
||||
' - Removed count value: $removedCount\n'
|
||||
' - Metadata type: ${meta?.type}\n'
|
||||
' - Metadata title: ${meta?.title}\n'
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/plex_api_cache.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import 'download_provider.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
@@ -16,16 +15,12 @@ import '../utils/global_key_utils.dart';
|
||||
class OfflineWatchProvider extends ChangeNotifier {
|
||||
final OfflineWatchSyncService _syncService;
|
||||
final DownloadProvider _downloadProvider;
|
||||
// ignore: unused_field - reserved for future cached metadata lookup
|
||||
final PlexApiCache _apiCache;
|
||||
|
||||
OfflineWatchProvider({
|
||||
required OfflineWatchSyncService syncService,
|
||||
required DownloadProvider downloadProvider,
|
||||
required PlexApiCache apiCache,
|
||||
}) : _syncService = syncService,
|
||||
_downloadProvider = downloadProvider,
|
||||
_apiCache = apiCache {
|
||||
_downloadProvider = downloadProvider {
|
||||
// Listen to sync service changes to update UI
|
||||
_syncService.addListener(_onSyncServiceChanged);
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.keyboard, size: 64, color: Colors.blue),
|
||||
Icon(Icons.keyboard, size: 64, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.companionRemote.pairing.pairWithDesktop,
|
||||
|
||||
@@ -15,12 +15,13 @@ import '../widgets/media_grid_delegate.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import 'libraries/sort_bottom_sheet.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
|
||||
/// Screen to display full content of a recommendation hub
|
||||
class HubDetailScreen extends StatefulWidget {
|
||||
@@ -32,7 +33,8 @@ class HubDetailScreen extends StatefulWidget {
|
||||
State<HubDetailScreen> createState() => _HubDetailScreenState();
|
||||
}
|
||||
|
||||
class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, GridFocusNodeMixin {
|
||||
class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin {
|
||||
PlexClient get client => _getClientForHub();
|
||||
|
||||
List<PlexMetadata> _items = [];
|
||||
@@ -43,14 +45,42 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item');
|
||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
bool _isAppBarFocused = false;
|
||||
bool _backHandledByKeyEvent = false;
|
||||
|
||||
/// Key for getting a context below OverlaySheetHost
|
||||
final GlobalKey _overlayChildKey = GlobalKey();
|
||||
|
||||
@override
|
||||
bool get hasItems => _filteredItems.isNotEmpty;
|
||||
|
||||
@override
|
||||
List<FocusableAction> getAppBarActions() {
|
||||
return [
|
||||
FocusableAction(
|
||||
icon: Symbols.swap_vert_rounded,
|
||||
tooltip: t.libraries.sort,
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Override to add bounds check for filtered items (sorting can change item order)
|
||||
@override
|
||||
void navigateToGrid() {
|
||||
if (!hasItems) return;
|
||||
|
||||
final targetIndex =
|
||||
shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0;
|
||||
|
||||
setState(() {
|
||||
isAppBarFocused = false;
|
||||
});
|
||||
|
||||
if (targetIndex == 0) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the correct PlexClient for this hub's server
|
||||
PlexClient _getClientForHub() {
|
||||
return context.getClientForServer(widget.hub.serverId!);
|
||||
@@ -69,43 +99,15 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
|
||||
// Load sorts based on the library type
|
||||
_loadSorts();
|
||||
// Auto-focus first grid item in keyboard mode after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (InputModeTracker.isKeyboardMode(context) && _filteredItems.isNotEmpty) {
|
||||
_firstItemFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
autoFocusFirstItemAfterLoad();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstItemFocusNode.dispose();
|
||||
disposeGridFocusNodes();
|
||||
disposeFocusResources();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _focusGrid() {
|
||||
if (_filteredItems.isEmpty) return;
|
||||
final targetIndex =
|
||||
shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0;
|
||||
if (targetIndex == 0) {
|
||||
_firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToAppBar() {
|
||||
setState(() => _isAppBarFocused = true);
|
||||
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
|
||||
}
|
||||
|
||||
void _handleBackFromContent() {
|
||||
_backHandledByKeyEvent = true;
|
||||
_navigateToAppBar();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadSorts() async {
|
||||
try {
|
||||
final client = _getClientForHub();
|
||||
@@ -279,40 +281,27 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !isKeyboardMode || _isAppBarFocused,
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop || _backHandledByKeyEvent) {
|
||||
_backHandledByKeyEvent = false;
|
||||
return;
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (didPop) return;
|
||||
final shouldPop = handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
_navigateToAppBar();
|
||||
},
|
||||
child: OverlaySheetHost(
|
||||
child: Scaffold(
|
||||
key: _overlayChildKey,
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
clipBehavior: Clip.none,
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(widget.hub.title),
|
||||
pinned: true,
|
||||
actions: [
|
||||
FocusableActionBar(
|
||||
key: _actionBarKey,
|
||||
onNavigateDown: _focusGrid,
|
||||
onBack: () => Navigator.pop(context),
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.swap_vert_rounded,
|
||||
tooltip: t.libraries.sort,
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
actions: buildFocusableAppBarActions(),
|
||||
),
|
||||
if (_errorMessage != null)
|
||||
SliverFillRemaining(
|
||||
@@ -372,7 +361,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
|
||||
(context, index) {
|
||||
final item = _filteredItems[index];
|
||||
final focusNode = index == 0
|
||||
? _firstItemFocusNode
|
||||
? firstItemFocusNode
|
||||
: getGridItemFocusNode(index, prefix: 'hub_detail_item');
|
||||
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
||||
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
|
||||
@@ -381,9 +370,9 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
|
||||
focusNode: focusNode,
|
||||
item: item,
|
||||
onRefresh: _handleItemRefresh,
|
||||
onNavigateUp: isFirstRow ? _navigateToAppBar : null,
|
||||
onNavigateUp: isFirstRow ? navigateToAppBar : null,
|
||||
onNavigateLeft: isFirstColumn ? () {} : null,
|
||||
onBack: _handleBackFromContent,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
mixedHubContext: isMixedHub,
|
||||
);
|
||||
|
||||
@@ -1270,14 +1270,14 @@ class _SkeletonCard extends StatelessWidget {
|
||||
// Poster area — matches the Expanded poster in _buildGridCard
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: const SkeletonLoader(child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Title bar
|
||||
SkeletonLoader(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: const SizedBox(height: 13, width: double.infinity),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
@@ -1286,7 +1286,7 @@ class _SkeletonCard extends StatelessWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: 0.6,
|
||||
child: SkeletonLoader(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: const SizedBox(height: 11),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -110,6 +110,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
String _toGlobalKey(String ratingKey, {String? serverId}) =>
|
||||
buildGlobalKey(serverId ?? widget.metadata.serverId ?? '', ratingKey);
|
||||
|
||||
/// Calls [setState] only if the widget is still mounted.
|
||||
void _setStateIfMounted(VoidCallback fn) {
|
||||
if (mounted) setState(fn);
|
||||
}
|
||||
|
||||
// WatchStateAware: watch the show/movie and all season ratingKeys
|
||||
@override
|
||||
Set<String>? get watchedRatingKeys {
|
||||
@@ -229,8 +234,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
final metadata = result['metadata'] as PlexMetadata?;
|
||||
final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?;
|
||||
|
||||
if (metadata != null && mounted) {
|
||||
setState(() {
|
||||
if (metadata != null) {
|
||||
_setStateIfMounted(() {
|
||||
_fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName);
|
||||
_onDeckEpisode = onDeckEpisode?.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
@@ -242,13 +247,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
// Refresh seasons for updated watched counts (also without loader)
|
||||
if (widget.metadata.isShow) {
|
||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_seasons = seasons
|
||||
.map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
_setStateIfMounted(() {
|
||||
_seasons = seasons
|
||||
.map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - data will refresh on next navigation
|
||||
@@ -1047,14 +1050,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
final seasonsWithServerId = seasons
|
||||
.map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
|
||||
.toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_setStateIfMounted(() {
|
||||
_seasons = seasonsWithServerId;
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_setStateIfMounted(() {
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
} finally {
|
||||
@@ -1131,11 +1132,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
.map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
|
||||
.toList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_extras = extrasWithServerId;
|
||||
});
|
||||
}
|
||||
_setStateIfMounted(() {
|
||||
_extras = extrasWithServerId;
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently fail - extras section won't appear if fetch fails
|
||||
}
|
||||
@@ -1604,8 +1603,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
final offlineWatchProvider = context.read<OfflineWatchProvider>();
|
||||
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey);
|
||||
|
||||
if (nextEpisode != null && mounted) {
|
||||
setState(() {
|
||||
if (nextEpisode != null) {
|
||||
_setStateIfMounted(() {
|
||||
_onDeckEpisode = nextEpisode;
|
||||
});
|
||||
appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}');
|
||||
@@ -1645,8 +1644,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
|
||||
// Single setState to minimize rebuilds - scroll position is preserved by controller
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_setStateIfMounted(() {
|
||||
_fullMetadata = metadataWithServerId;
|
||||
if (updatedSeasons != null) {
|
||||
_seasons = updatedSeasons;
|
||||
|
||||
@@ -542,11 +542,11 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]),
|
||||
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal),
|
||||
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -64,6 +64,11 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
String _toGlobalKey(String ratingKey, {String? serverId}) =>
|
||||
buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey);
|
||||
|
||||
/// Calls [setState] only if the widget is still mounted.
|
||||
void _setStateIfMounted(VoidCallback fn) {
|
||||
if (mounted) setState(fn);
|
||||
}
|
||||
|
||||
// WatchStateAware: watch all episode ratingKeys
|
||||
@override
|
||||
Set<String>? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet();
|
||||
@@ -160,14 +165,12 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
// Episodes are automatically tagged with server info by PlexClient
|
||||
final episodes = await _client!.getChildren(widget.season.ratingKey);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_setStateIfMounted(() {
|
||||
_episodes = episodes;
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
@@ -573,14 +576,14 @@ class _EpisodeCardState extends State<_EpisodeCard> {
|
||||
value: 1.0,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
getMutedColor(Colors.blue).withValues(alpha: 0.3),
|
||||
getMutedColor(Theme.of(context).colorScheme.primary).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
// Progress circle
|
||||
CircularProgressIndicator(
|
||||
value: progress?.progressPercent,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(getMutedColor(Colors.blue)),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(getMutedColor(Theme.of(context).colorScheme.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/utils/http_client.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
@@ -130,7 +131,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await Dio().post(
|
||||
final response = await createHttpClient().post(
|
||||
'https://ice.plezy.app/logs',
|
||||
data: logText,
|
||||
options: Options(contentType: 'text/plain'),
|
||||
|
||||
@@ -57,7 +57,7 @@ import '../utils/platform_detector.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/track_label_builder.dart' as tlb;
|
||||
import '../utils/track_label_builder.dart';
|
||||
import '../utils/plex_url_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
@@ -434,10 +434,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// exhausts the process address space on memory-constrained devices.
|
||||
final heapMB = await PlayerAndroid.getHeapSize();
|
||||
if (heapMB > 0) {
|
||||
final autoBackMB = heapMB <= 256 ? 16 : (heapMB <= 512 ? 32 : 48);
|
||||
int autoBackMB;
|
||||
if (heapMB <= 256) {
|
||||
autoBackMB = 16;
|
||||
} else if (heapMB <= 512) {
|
||||
autoBackMB = 32;
|
||||
} else {
|
||||
autoBackMB = 48;
|
||||
}
|
||||
if (bufferSizeMB == 0) {
|
||||
// Auto mode: cap both forward and back buffer based on heap
|
||||
final autoForwardMB = heapMB <= 256 ? 32 : (heapMB <= 512 ? 64 : 100);
|
||||
int autoForwardMB;
|
||||
if (heapMB <= 256) {
|
||||
autoForwardMB = 32;
|
||||
} else if (heapMB <= 512) {
|
||||
autoForwardMB = 64;
|
||||
} else {
|
||||
autoForwardMB = 100;
|
||||
}
|
||||
await player!.setProperty('demuxer-max-bytes', '${autoForwardMB * 1024 * 1024}');
|
||||
await player!.setProperty('demuxer-max-back-bytes', '${autoBackMB * 1024 * 1024}');
|
||||
} else {
|
||||
@@ -1279,6 +1293,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final presetId = settings.getGlobalShaderPreset();
|
||||
final preset = ShaderPreset.fromId(presetId) ?? ShaderPreset.none;
|
||||
await _shaderService!.applyPreset(preset);
|
||||
if (!mounted) return;
|
||||
context.read<ShaderProvider>().setCurrentPreset(preset);
|
||||
} catch (e) {
|
||||
appLogger.d('Could not apply shader preset', error: e);
|
||||
@@ -1578,7 +1593,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (mounted) {
|
||||
final label = next.id == 'no'
|
||||
? 'Subtitles: Off'
|
||||
: 'Subtitles: ${tlb.TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}';
|
||||
: 'Subtitles: ${TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}';
|
||||
showAppSnackBar(context, label, duration: const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
@@ -1597,7 +1612,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
if (mounted) {
|
||||
final label =
|
||||
'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
|
||||
'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
|
||||
showAppSnackBar(context, label, duration: const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Error types that can occur in peer services.
|
||||
///
|
||||
/// This is a superset covering both Watch Together relay errors and
|
||||
/// Companion Remote direct-connection errors.
|
||||
enum PeerErrorType {
|
||||
connectionFailed,
|
||||
peerDisconnected,
|
||||
dataChannelError,
|
||||
serverError,
|
||||
timeout,
|
||||
invalidSession,
|
||||
authFailed,
|
||||
networkError,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Represents an error in a peer service.
|
||||
class PeerError {
|
||||
final PeerErrorType type;
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
|
||||
const PeerError({required this.type, required this.message, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => 'PeerError($type): $message';
|
||||
}
|
||||
|
||||
/// Mixin that provides WebSocket keepalive ping/pong timer management.
|
||||
///
|
||||
/// Subclasses must implement [sendPing] to send the actual ping message
|
||||
/// over their specific transport. The mixin manages periodic pings and
|
||||
/// an optional pong timeout that closes the connection if no data arrives.
|
||||
mixin KeepaliveMixin {
|
||||
Timer? _pingTimer;
|
||||
Timer? _pongTimer;
|
||||
|
||||
/// How often to send a keepalive ping.
|
||||
Duration get pingInterval;
|
||||
|
||||
/// How long to wait for any incoming data before considering the
|
||||
/// connection dead. Set to [Duration.zero] to disable pong timeout.
|
||||
Duration get pongTimeout;
|
||||
|
||||
/// Send a keepalive ping over the transport.
|
||||
void sendPing();
|
||||
|
||||
/// Called when the pong timeout fires (no data received within [pongTimeout]).
|
||||
/// Override to close the underlying socket/channel.
|
||||
void onPongTimeout();
|
||||
|
||||
/// Start the periodic ping timer and arm the pong timeout.
|
||||
void startKeepalive() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = Timer.periodic(pingInterval, (_) => sendPing());
|
||||
resetPongTimer();
|
||||
}
|
||||
|
||||
/// Reset the pong timeout (call on every incoming message).
|
||||
void resetPongTimer() {
|
||||
if (pongTimeout == Duration.zero) return;
|
||||
_pongTimer?.cancel();
|
||||
_pongTimer = Timer(pongTimeout, onPongTimeout);
|
||||
}
|
||||
|
||||
/// Stop all keepalive timers.
|
||||
void stopKeepalive() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
_pongTimer?.cancel();
|
||||
_pongTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -8,31 +8,17 @@ import 'package:web_socket_channel/io.dart';
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../base_peer_service.dart';
|
||||
|
||||
enum RemotePeerErrorType {
|
||||
connectionFailed,
|
||||
peerDisconnected,
|
||||
dataChannelError,
|
||||
serverError,
|
||||
timeout,
|
||||
invalidSession,
|
||||
authFailed,
|
||||
networkError,
|
||||
unknown,
|
||||
}
|
||||
// Re-export so callers that import from here get the types.
|
||||
export '../base_peer_service.dart' show PeerError, PeerErrorType;
|
||||
|
||||
class RemotePeerError {
|
||||
final RemotePeerErrorType type;
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
/// Backward-compatible aliases so existing callers that reference the
|
||||
/// Remote-specific names keep compiling.
|
||||
typedef RemotePeerErrorType = PeerErrorType;
|
||||
typedef RemotePeerError = PeerError;
|
||||
|
||||
const RemotePeerError({required this.type, required this.message, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => 'RemotePeerError($type): $message';
|
||||
}
|
||||
|
||||
class CompanionRemotePeerService {
|
||||
class CompanionRemotePeerService with KeepaliveMixin {
|
||||
// Server-side (host) fields
|
||||
HttpServer? _server;
|
||||
WebSocket? _clientSocket;
|
||||
@@ -52,7 +38,11 @@ class CompanionRemotePeerService {
|
||||
final _errorController = StreamController<RemotePeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<RemoteSessionStatus>.broadcast();
|
||||
|
||||
Timer? _pingTimer;
|
||||
// Keepalive (via KeepaliveMixin)
|
||||
@override
|
||||
Duration get pingInterval => const Duration(seconds: 5);
|
||||
@override
|
||||
Duration get pongTimeout => Duration.zero; // No pong timeout; host just replies inline
|
||||
|
||||
// Auth rate limiting
|
||||
int _failedAuthAttempts = 0;
|
||||
@@ -291,7 +281,7 @@ class CompanionRemotePeerService {
|
||||
_clientSocket = null;
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
stopKeepalive();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
@@ -328,6 +318,7 @@ class CompanionRemotePeerService {
|
||||
_connectionStateController.add(RemoteSessionStatus.connecting);
|
||||
|
||||
_channel = IOWebSocketChannel.connect(Uri.parse(url));
|
||||
await _channel!.ready;
|
||||
|
||||
// Send authentication message
|
||||
final authMessage = jsonEncode({
|
||||
@@ -361,7 +352,7 @@ class CompanionRemotePeerService {
|
||||
sendDeviceInfo(deviceName, platform);
|
||||
|
||||
// Start ping timer
|
||||
_startPingTimer();
|
||||
startKeepalive();
|
||||
} else if (messageType == 'authFailed') {
|
||||
final message = json['message'] as String? ?? 'Authentication failed';
|
||||
appLogger.w('CompanionRemote: $message');
|
||||
@@ -395,7 +386,7 @@ class CompanionRemotePeerService {
|
||||
appLogger.d('CompanionRemote: Connection closed');
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
stopKeepalive();
|
||||
// Reconnection is handled by CompanionRemoteProvider
|
||||
},
|
||||
onError: (error) {
|
||||
@@ -432,7 +423,9 @@ class CompanionRemotePeerService {
|
||||
onTimeout: () async {
|
||||
// Clean up channel on timeout
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
try {
|
||||
await _channel!.sink.close();
|
||||
} catch (_) {}
|
||||
_channel = null;
|
||||
}
|
||||
throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session');
|
||||
@@ -528,18 +521,16 @@ class CompanionRemotePeerService {
|
||||
}
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_stopPingTimer();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (isConnected) {
|
||||
sendCommand(const RemoteCommand(type: RemoteCommandType.ping));
|
||||
}
|
||||
});
|
||||
@override
|
||||
void sendPing() {
|
||||
if (isConnected) {
|
||||
sendCommand(const RemoteCommand(type: RemoteCommandType.ping));
|
||||
}
|
||||
}
|
||||
|
||||
void _stopPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
@override
|
||||
void onPongTimeout() {
|
||||
// Not used — pong timeout is disabled for companion remote.
|
||||
}
|
||||
|
||||
bool _shouldSendAck(RemoteCommand command) {
|
||||
@@ -594,15 +585,19 @@ class CompanionRemotePeerService {
|
||||
Future<void> disconnect() async {
|
||||
appLogger.d('CompanionRemote: Disconnecting');
|
||||
|
||||
_stopPingTimer();
|
||||
stopKeepalive();
|
||||
|
||||
if (_clientSocket != null) {
|
||||
await _clientSocket!.close();
|
||||
try {
|
||||
await _clientSocket!.close();
|
||||
} catch (_) {}
|
||||
_clientSocket = null;
|
||||
}
|
||||
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
try {
|
||||
await _channel!.sink.close();
|
||||
} catch (_) {}
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:dart_discord_presence/dart_discord_presence.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/http_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'plex_client.dart';
|
||||
import 'settings_service.dart';
|
||||
@@ -299,7 +300,7 @@ class DiscordRPCService {
|
||||
if (imageUrl.isEmpty) return null;
|
||||
|
||||
// Fetch image data
|
||||
final dio = Dio();
|
||||
final dio = createHttpClient();
|
||||
final imageResponse = await dio.get<List<int>>(
|
||||
imageUrl,
|
||||
options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 10)),
|
||||
|
||||
@@ -3,10 +3,11 @@ import 'dart:io';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:plezy/utils/content_utils.dart';
|
||||
import 'package:plezy/utils/http_client.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../database/download_operations.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'saf_storage_service.dart';
|
||||
import '../models/download_models.dart';
|
||||
@@ -20,170 +21,6 @@ import '../utils/codec_utils.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/plex_cache_parser.dart';
|
||||
|
||||
/// Extension methods on AppDatabase for download operations
|
||||
|
||||
extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// Insert a new download into the database
|
||||
Future<void> insertDownload({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String type,
|
||||
String? parentRatingKey,
|
||||
String? grandparentRatingKey,
|
||||
required int status,
|
||||
}) async {
|
||||
await into(downloadedMedia).insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
type: type,
|
||||
parentRatingKey: Value(parentRatingKey),
|
||||
grandparentRatingKey: Value(grandparentRatingKey),
|
||||
status: status,
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Add item to download queue
|
||||
Future<void> addToQueue({
|
||||
required String mediaGlobalKey,
|
||||
int priority = 0,
|
||||
bool downloadSubtitles = true,
|
||||
bool downloadArtwork = true,
|
||||
}) async {
|
||||
await into(downloadQueue).insert(
|
||||
DownloadQueueCompanion.insert(
|
||||
mediaGlobalKey: mediaGlobalKey,
|
||||
priority: Value(priority),
|
||||
addedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
downloadSubtitles: Value(downloadSubtitles),
|
||||
downloadArtwork: Value(downloadArtwork),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get next item from queue (highest priority, oldest first)
|
||||
/// Only returns items that are not paused
|
||||
Future<DownloadQueueItem?> getNextQueueItem() async {
|
||||
// Join with downloadedMedia to check status and filter out paused items
|
||||
final query = select(
|
||||
downloadQueue,
|
||||
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
||||
|
||||
query
|
||||
..where(downloadedMedia.status.equals(DownloadStatus.queued.index))
|
||||
..orderBy([
|
||||
OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: downloadQueue.addedAt),
|
||||
])
|
||||
..limit(1);
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.readTable(downloadQueue);
|
||||
}
|
||||
|
||||
/// Update download status
|
||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
||||
}
|
||||
|
||||
/// Update download progress
|
||||
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
progress: Value(progress),
|
||||
downloadedBytes: Value(downloadedBytes),
|
||||
totalBytes: Value(totalBytes),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update video file path
|
||||
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
videoFilePath: Value(filePath),
|
||||
downloadedAt: Value(DateTime.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update artwork paths
|
||||
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||
}
|
||||
|
||||
/// Update download error and increment retry count
|
||||
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
||||
// Get current retry count to increment it
|
||||
final existing = await getDownloadedMedia(globalKey);
|
||||
final currentCount = existing?.retryCount ?? 0;
|
||||
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear download error and reset retry count (for retry)
|
||||
Future<void> clearDownloadError(String globalKey) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from queue
|
||||
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||
}
|
||||
|
||||
/// Get downloaded media item
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Delete a download
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a season
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(String seasonKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a show
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(String showKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
|
||||
}
|
||||
|
||||
/// Get all downloaded items for a specific server
|
||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||
}
|
||||
|
||||
/// Update the background_downloader task ID for a download
|
||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
||||
}
|
||||
|
||||
/// Get the background_downloader task ID for a download
|
||||
Future<String?> getBgTaskId(String globalKey) async {
|
||||
final item = await getDownloadedMedia(globalKey);
|
||||
return item?.bgTaskId;
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for a download that's been enqueued with background_downloader.
|
||||
/// Carries metadata needed between enqueue and completion callback.
|
||||
class _DownloadContext {
|
||||
@@ -284,7 +121,7 @@ class DownloadManagerService {
|
||||
DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, Dio? dio})
|
||||
: _database = database,
|
||||
_storageService = storageService,
|
||||
_dio = dio ?? Dio();
|
||||
_dio = dio ?? createHttpClient();
|
||||
|
||||
/// Initialize background_downloader with callbacks, notifications, and concurrency config.
|
||||
Future<void> _initializeFileDownloader() async {
|
||||
|
||||
@@ -14,6 +14,13 @@ class InAppReviewService {
|
||||
|
||||
final InAppReview _inAppReview = InAppReview.instance;
|
||||
|
||||
// Cached SharedPreferences instance (lazy-initialized)
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<SharedPreferences> _getPrefs() async {
|
||||
return _prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
// SharedPreferences keys
|
||||
static const String _keyQualifyingSessionsCount = 'review_qualifying_sessions_count';
|
||||
static const String _keyLastPromptTime = 'review_last_prompt_time';
|
||||
@@ -62,20 +69,20 @@ class InAppReviewService {
|
||||
|
||||
/// Increment the qualifying sessions counter
|
||||
Future<void> _incrementQualifyingSessions() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
final currentCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0;
|
||||
await prefs.setInt(_keyQualifyingSessionsCount, currentCount + 1);
|
||||
}
|
||||
|
||||
/// Get the current qualifying sessions count
|
||||
Future<int> _getQualifyingSessionsCount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getInt(_keyQualifyingSessionsCount) ?? 0;
|
||||
}
|
||||
|
||||
/// Check if we should request a review based on session count and cooldown
|
||||
Future<bool> _shouldRequestReview() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
|
||||
// Check session count
|
||||
final sessionCount = await _getQualifyingSessionsCount();
|
||||
@@ -127,7 +134,7 @@ class InAppReviewService {
|
||||
|
||||
/// Record that the review prompt was shown
|
||||
Future<void> _recordPromptShown() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLastPromptTime, DateTime.now().toIso8601String());
|
||||
// Reset session count so user needs to use app more before next prompt
|
||||
await prefs.setInt(_keyQualifyingSessionsCount, 0);
|
||||
@@ -135,7 +142,7 @@ class InAppReviewService {
|
||||
|
||||
/// Get debug info about the current state (for development/testing)
|
||||
Future<Map<String, dynamic>> getDebugInfo() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
final sessionCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0;
|
||||
final lastPromptString = prefs.getString(_keyLastPromptTime);
|
||||
final isAvailable = await _inAppReview.isAvailable();
|
||||
@@ -153,7 +160,7 @@ class InAppReviewService {
|
||||
|
||||
/// Reset all stored data (for testing purposes)
|
||||
Future<void> reset() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyQualifyingSessionsCount);
|
||||
await prefs.remove(_keyLastPromptTime);
|
||||
_sessionStartTime = null;
|
||||
|
||||
@@ -961,7 +961,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
await prefs.setString(_keyMpvConfigText, text);
|
||||
}
|
||||
|
||||
/// Parse raw config text into a Map<String, String> (skip blanks and # comments)
|
||||
/// Parse raw config text into a `Map<String, String>` (skip blanks and # comments)
|
||||
static Map<String, String> parseMpvConfigText(String text) {
|
||||
final result = <String, String>{};
|
||||
for (final line in text.split('\n')) {
|
||||
|
||||
@@ -284,6 +284,41 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
await prefs.remove(_keyServerOrder);
|
||||
}
|
||||
|
||||
// Episode Count Persistence (for partial download detection)
|
||||
|
||||
static const String _prefixEpisodeCount = 'episode_count_';
|
||||
|
||||
/// Save the total episode count for a show/season
|
||||
Future<void> saveTotalEpisodeCount(String globalKey, int count) async {
|
||||
await prefs.setInt('$_prefixEpisodeCount$globalKey', count);
|
||||
}
|
||||
|
||||
/// Get the total episode count for a show/season
|
||||
int? getTotalEpisodeCount(String globalKey) {
|
||||
return prefs.getInt('$_prefixEpisodeCount$globalKey');
|
||||
}
|
||||
|
||||
/// Load all persisted episode counts
|
||||
Map<String, int> loadAllEpisodeCounts() {
|
||||
final counts = <String, int>{};
|
||||
final keys = prefs.getKeys().where((k) => k.startsWith(_prefixEpisodeCount));
|
||||
|
||||
for (final key in keys) {
|
||||
final globalKey = key.replaceFirst(_prefixEpisodeCount, '');
|
||||
final count = prefs.getInt(key);
|
||||
if (count != null) {
|
||||
counts[globalKey] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Remove the episode count for a specific show/season
|
||||
Future<void> removeEpisodeCount(String globalKey) async {
|
||||
await prefs.remove('$_prefixEpisodeCount$globalKey');
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/// Helper to read and decode JSON `List<String>` from preferences
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:auto_updater/auto_updater.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:plezy/utils/http_client.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Service to check for new versions on GitHub
|
||||
@@ -153,7 +154,7 @@ class UpdateService {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentVersion = packageInfo.version;
|
||||
|
||||
final dio = Dio();
|
||||
final dio = createHttpClient();
|
||||
final response = await dio.get(
|
||||
'https://api.github.com/repos/$_githubRepo/releases/latest',
|
||||
options: Options(headers: {'Accept': 'application/vnd.github+json'}),
|
||||
|
||||
@@ -5,33 +5,27 @@ import 'platform_detector.dart';
|
||||
|
||||
/// Utility class for calculating consistent grid sizes across the app
|
||||
class GridSizeCalculator {
|
||||
/// Screen width breakpoint for tablet devices
|
||||
static const double tabletBreakpoint = ScreenBreakpoints.tablet;
|
||||
|
||||
/// Screen width breakpoint for desktop devices
|
||||
static const double desktopBreakpoint = ScreenBreakpoints.desktop;
|
||||
|
||||
/// Calculates the maximum cross-axis extent for grid items based on screen size and density
|
||||
static double getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isTV = PlatformDetector.isTV();
|
||||
final isDesktop = screenWidth > desktopBreakpoint;
|
||||
final isTablet = screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint;
|
||||
final isDesktopOrLarger = ScreenBreakpoints.isDesktopOrLarger(screenWidth);
|
||||
final isTablet = ScreenBreakpoints.isTablet(screenWidth);
|
||||
|
||||
switch (density) {
|
||||
case LibraryDensity.comfortable:
|
||||
if (isTV) return GridLayoutConstants.comfortableTV;
|
||||
if (isDesktop) return GridLayoutConstants.comfortableDesktop;
|
||||
if (isDesktopOrLarger) return GridLayoutConstants.comfortableDesktop;
|
||||
if (isTablet) return GridLayoutConstants.comfortableTablet;
|
||||
return GridLayoutConstants.comfortableMobile;
|
||||
case LibraryDensity.compact:
|
||||
if (isTV) return GridLayoutConstants.compactTV;
|
||||
if (isDesktop) return GridLayoutConstants.compactDesktop;
|
||||
if (isDesktopOrLarger) return GridLayoutConstants.compactDesktop;
|
||||
if (isTablet) return GridLayoutConstants.compactTablet;
|
||||
return GridLayoutConstants.compactMobile;
|
||||
case LibraryDensity.normal:
|
||||
if (isTV) return GridLayoutConstants.normalTV;
|
||||
if (isDesktop) return GridLayoutConstants.normalDesktop;
|
||||
if (isDesktopOrLarger) return GridLayoutConstants.normalDesktop;
|
||||
if (isTablet) return GridLayoutConstants.normalTablet;
|
||||
return GridLayoutConstants.normalMobile;
|
||||
}
|
||||
@@ -106,22 +100,6 @@ class GridSizeCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the current screen is a desktop-sized screen
|
||||
static bool isDesktop(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width > desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// Returns whether the current screen is a tablet-sized screen
|
||||
static bool isTablet(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
return screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// Returns whether the current screen is a mobile-sized screen
|
||||
static bool isMobile(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width <= tabletBreakpoint;
|
||||
}
|
||||
|
||||
/// Calculates the number of columns for a given available width.
|
||||
///
|
||||
/// Uses the same formula as Flutter's SliverGridDelegateWithMaxCrossAxisExtent:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Creates a plain [Dio] instance with sensible default timeouts for ad-hoc
|
||||
/// HTTP requests that don't go through [PlexClient].
|
||||
///
|
||||
/// Use this instead of bare `Dio()` so every call site gets consistent timeout
|
||||
/// behaviour without duplicating configuration.
|
||||
Dio createHttpClient({
|
||||
Duration connectTimeout = const Duration(seconds: 10),
|
||||
Duration receiveTimeout = const Duration(seconds: 30),
|
||||
}) {
|
||||
return Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: connectTimeout,
|
||||
receiveTimeout: receiveTimeout,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
import 'codec_utils.dart';
|
||||
import '../models/plex_media_info.dart' show buildTrackLabel;
|
||||
|
||||
/// Builds a track label from parts with the standard `' · '` joiner pattern.
|
||||
///
|
||||
/// Shared by both Plex track models and MPV track label utilities.
|
||||
/// If [title] is non-empty it is added first, then [language], then [extraParts].
|
||||
/// Falls back to `'$fallbackPrefix ${index + 1}'` when no parts are available.
|
||||
String buildTrackLabel({
|
||||
String? title,
|
||||
String? language,
|
||||
List<String> extraParts = const [],
|
||||
required int index,
|
||||
String fallbackPrefix = 'Track',
|
||||
}) {
|
||||
final parts = <String>[];
|
||||
if (title != null && title.isNotEmpty) parts.add(title);
|
||||
if (language != null && language.isNotEmpty) parts.add(language);
|
||||
parts.addAll(extraParts);
|
||||
return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' · ');
|
||||
}
|
||||
|
||||
/// Utility for building track labels for audio and subtitle tracks.
|
||||
///
|
||||
/// Delegates to the shared [buildTrackLabel] function so Plex-model and
|
||||
/// MPV-player label logic stays consistent.
|
||||
class TrackLabelBuilder {
|
||||
TrackLabelBuilder._();
|
||||
|
||||
|
||||
@@ -4,23 +4,12 @@ import 'dart:convert';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../services/base_peer_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models/sync_message.dart';
|
||||
|
||||
/// Error types that can occur in the peer service
|
||||
enum PeerErrorType { connectionFailed, peerDisconnected, dataChannelError, serverError, timeout, unknown }
|
||||
|
||||
/// Represents an error in the peer service
|
||||
class PeerError {
|
||||
final PeerErrorType type;
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
|
||||
const PeerError({required this.type, required this.message, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => 'PeerError($type): $message';
|
||||
}
|
||||
// Re-export so existing callers that import from here keep working.
|
||||
export '../../services/base_peer_service.dart' show PeerError, PeerErrorType;
|
||||
|
||||
/// Service for managing Watch Together connections via a WebSocket relay
|
||||
///
|
||||
@@ -29,7 +18,7 @@ class PeerError {
|
||||
/// - Joining sessions (as guest)
|
||||
/// - Sending/receiving sync messages through the relay server
|
||||
/// - Reconnection on WebSocket drops
|
||||
class WatchTogetherPeerService {
|
||||
class WatchTogetherPeerService with KeepaliveMixin {
|
||||
static const String _baseUrl = 'https://ice.plezy.app';
|
||||
static String get healthUrl => '$_baseUrl/health';
|
||||
static const String _relayUrl = 'wss://ice.plezy.app/relay';
|
||||
@@ -53,11 +42,11 @@ class WatchTogetherPeerService {
|
||||
static const int _maxReconnectAttempts = 3;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Keepalive
|
||||
Timer? _pingTimer;
|
||||
Timer? _pongTimer;
|
||||
static const Duration _pingInterval = Duration(seconds: 15);
|
||||
static const Duration _pongTimeout = Duration(seconds: 30);
|
||||
// Keepalive (via KeepaliveMixin)
|
||||
@override
|
||||
Duration get pingInterval => const Duration(seconds: 15);
|
||||
@override
|
||||
Duration get pongTimeout => const Duration(seconds: 30);
|
||||
|
||||
/// Stream of peer IDs when a new peer connects
|
||||
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||||
@@ -111,7 +100,7 @@ class WatchTogetherPeerService {
|
||||
_channelSubscription?.cancel();
|
||||
_channelSubscription = channel.stream.listen(
|
||||
(data) {
|
||||
_resetPongTimer();
|
||||
resetPongTimer();
|
||||
_handleServerMessage(data as String, setupCompleter: setupCompleter);
|
||||
},
|
||||
onError: (error) {
|
||||
@@ -203,7 +192,7 @@ class WatchTogetherPeerService {
|
||||
}
|
||||
|
||||
case 'pong':
|
||||
// Handled by _resetPongTimer already
|
||||
// Handled by resetPongTimer() already
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -214,30 +203,15 @@ class WatchTogetherPeerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the keepalive ping timer.
|
||||
void _startPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = Timer.periodic(_pingInterval, (_) {
|
||||
_sendRaw({'type': 'ping'});
|
||||
});
|
||||
_resetPongTimer();
|
||||
}
|
||||
@override
|
||||
void sendPing() => _sendRaw({'type': 'ping'});
|
||||
|
||||
/// Reset the pong timeout timer (called on every incoming message).
|
||||
void _resetPongTimer() {
|
||||
_pongTimer?.cancel();
|
||||
_pongTimer = Timer(_pongTimeout, () {
|
||||
appLogger.w('WatchTogether: Pong timeout — closing WebSocket');
|
||||
@override
|
||||
void onPongTimeout() {
|
||||
appLogger.w('WatchTogether: Pong timeout — closing WebSocket');
|
||||
try {
|
||||
_channel?.sink.close();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop keepalive timers.
|
||||
void _stopTimers() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
_pongTimer?.cancel();
|
||||
_pongTimer = null;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Send a raw JSON map to the relay.
|
||||
@@ -251,7 +225,7 @@ class WatchTogetherPeerService {
|
||||
|
||||
/// Handle the WebSocket being closed unexpectedly — attempt reconnection.
|
||||
void _handleWebSocketClosed() {
|
||||
_stopTimers();
|
||||
stopKeepalive();
|
||||
_channelSubscription?.cancel();
|
||||
_channelSubscription = null;
|
||||
_channel = null;
|
||||
@@ -295,7 +269,7 @@ class WatchTogetherPeerService {
|
||||
|
||||
final completer = Completer<void>();
|
||||
_listenToChannel(channel, setupCompleter: completer);
|
||||
_startPingTimer();
|
||||
startKeepalive();
|
||||
|
||||
// Re-send create or join
|
||||
if (_isHost) {
|
||||
@@ -332,7 +306,7 @@ class WatchTogetherPeerService {
|
||||
|
||||
final completer = Completer<void>();
|
||||
_listenToChannel(channel, setupCompleter: completer);
|
||||
_startPingTimer();
|
||||
startKeepalive();
|
||||
|
||||
_sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId});
|
||||
|
||||
@@ -369,7 +343,7 @@ class WatchTogetherPeerService {
|
||||
|
||||
final completer = Completer<void>();
|
||||
_listenToChannel(channel, setupCompleter: completer);
|
||||
_startPingTimer();
|
||||
startKeepalive();
|
||||
|
||||
_sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId});
|
||||
|
||||
@@ -408,12 +382,14 @@ class WatchTogetherPeerService {
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_stopTimers();
|
||||
stopKeepalive();
|
||||
|
||||
_channelSubscription?.cancel();
|
||||
_channelSubscription = null;
|
||||
|
||||
await _channel?.sink.close();
|
||||
try {
|
||||
await _channel?.sink.close();
|
||||
} catch (_) {}
|
||||
_channel = null;
|
||||
|
||||
_connectedPeers.clear();
|
||||
|
||||
@@ -28,11 +28,8 @@ class WatchTogetherSyncManager {
|
||||
bool _isRemoteAction = false; // Flag to prevent echo
|
||||
bool _isSyncing = false; // Flag for UI indicator during sync
|
||||
|
||||
// Stream subscriptions
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<bool>? _bufferingSubscription;
|
||||
StreamSubscription<double>? _rateSubscription;
|
||||
StreamSubscription<SyncMessage>? _messageSubscription;
|
||||
// Stream subscriptions (cancelled together in detachPlayer)
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
|
||||
// Position sync timer (host broadcasts position periodically)
|
||||
Timer? _positionSyncTimer;
|
||||
@@ -171,16 +168,11 @@ class WatchTogetherSyncManager {
|
||||
_hasClockOffset = false;
|
||||
_pendingPingTimestamp = null;
|
||||
|
||||
_playingSubscription?.cancel();
|
||||
_bufferingSubscription?.cancel();
|
||||
_rateSubscription?.cancel();
|
||||
_messageSubscription?.cancel();
|
||||
for (final subscription in _subscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
_subscriptions.clear();
|
||||
_positionSyncTimer?.cancel();
|
||||
|
||||
_playingSubscription = null;
|
||||
_bufferingSubscription = null;
|
||||
_rateSubscription = null;
|
||||
_messageSubscription = null;
|
||||
_positionSyncTimer = null;
|
||||
|
||||
_player = null;
|
||||
@@ -190,7 +182,7 @@ class WatchTogetherSyncManager {
|
||||
/// Set up subscriptions to player streams
|
||||
void _setupPlayerSubscriptions() {
|
||||
// Listen to playing state changes
|
||||
_playingSubscription = _player!.streams.playing.listen((isPlaying) async {
|
||||
_subscriptions.add(_player!.streams.playing.listen((isPlaying) async {
|
||||
if (_isRemoteAction) return;
|
||||
if (isPlaying == _lastKnownPlaying) return;
|
||||
_lastKnownPlaying = isPlaying;
|
||||
@@ -212,10 +204,10 @@ class WatchTogetherSyncManager {
|
||||
|
||||
if (!isPlaying) _deferredPlay = false;
|
||||
_broadcastPlayPause(isPlaying);
|
||||
});
|
||||
}));
|
||||
|
||||
// Listen to buffering state changes
|
||||
_bufferingSubscription = _player!.streams.buffering.listen((isBuffering) async {
|
||||
_subscriptions.add(_player!.streams.buffering.listen((isBuffering) async {
|
||||
if (_isRemoteAction) return;
|
||||
|
||||
// Announce ready when we stop buffering for the first time (video loaded)
|
||||
@@ -232,10 +224,10 @@ class WatchTogetherSyncManager {
|
||||
|
||||
// Broadcast for UI (peer buffering indicators) — no playback control
|
||||
_peerService.broadcast(SyncMessage.buffering(isBuffering, peerId: _peerService.myPeerId));
|
||||
});
|
||||
}));
|
||||
|
||||
// Listen to rate changes
|
||||
_rateSubscription = _player!.streams.rate.listen((rate) {
|
||||
_subscriptions.add(_player!.streams.rate.listen((rate) {
|
||||
if (_isRemoteAction) return;
|
||||
|
||||
if (rate != _lastKnownRate) {
|
||||
@@ -244,12 +236,12 @@ class WatchTogetherSyncManager {
|
||||
_peerService.broadcast(SyncMessage.rate(rate, peerId: _peerService.myPeerId));
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set up subscription to incoming sync messages
|
||||
void _setupMessageSubscription() {
|
||||
_messageSubscription = _peerService.onMessageReceived.listen(_handleMessage);
|
||||
_subscriptions.add(_peerService.onMessageReceived.listen(_handleMessage));
|
||||
}
|
||||
|
||||
/// Start periodic position sync (host only)
|
||||
@@ -537,74 +529,54 @@ class WatchTogetherSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply remote play command
|
||||
Future<void> _applyRemotePlay({Duration? position}) async {
|
||||
/// Shared helper for applying remote actions with proper guarding
|
||||
Future<void> _applyRemoteAction(Future<void> Function() action) async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await action();
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote action', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply remote play command
|
||||
Future<void> _applyRemotePlay({Duration? position}) async {
|
||||
appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}');
|
||||
await _applyRemoteAction(() async {
|
||||
if (position != null) {
|
||||
await _player!.seek(position);
|
||||
}
|
||||
await _player!.play();
|
||||
_lastKnownPlaying = true;
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote PLAY', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply remote pause command
|
||||
Future<void> _applyRemotePause() async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote PAUSE');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _applyRemoteAction(() async {
|
||||
await _player!.pause();
|
||||
_lastKnownPlaying = false;
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote PAUSE', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply remote seek command
|
||||
Future<void> _applyRemoteSeek(Duration position) async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote SEEK to ${position.inSeconds}s');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _player!.seek(position);
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote SEEK', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
await _applyRemoteAction(() => _player!.seek(position));
|
||||
}
|
||||
|
||||
/// Apply remote rate change
|
||||
Future<void> _applyRemoteRate(double rate) async {
|
||||
if (_player == null) return;
|
||||
|
||||
appLogger.d('WatchTogether: Applying remote RATE: $rate');
|
||||
_isRemoteAction = true;
|
||||
try {
|
||||
await _applyRemoteAction(() async {
|
||||
await _player!.setRate(rate);
|
||||
_lastKnownRate = rate;
|
||||
} on StateError catch (e) {
|
||||
appLogger.w('WatchTogether: Player disposed during remote RATE', error: e);
|
||||
detachPlayer();
|
||||
} finally {
|
||||
_isRemoteAction = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Check and correct position drift
|
||||
|
||||
@@ -129,9 +129,7 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
||||
height: 400,
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: (_artworkList == null || _artworkList!.isEmpty)
|
||||
? Center(child: Text(t.metadataEdit.noArtworkAvailable))
|
||||
: _buildGrid(),
|
||||
: _buildArtworkContent(),
|
||||
),
|
||||
actions: [
|
||||
if (_isApplying)
|
||||
@@ -167,6 +165,13 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtworkContent() {
|
||||
if (_artworkList == null || _artworkList!.isEmpty) {
|
||||
return Center(child: Text(t.metadataEdit.noArtworkAvailable));
|
||||
}
|
||||
return _buildGrid();
|
||||
}
|
||||
|
||||
Widget _buildGrid() {
|
||||
final crossAxisCount = _isPosters ? 3 : 2;
|
||||
final aspectRatio = _isPosters ? 2.0 / 3.0 : 16.0 / 9.0;
|
||||
|
||||
@@ -182,6 +182,7 @@ class FocusableRadioListTile<T> extends StatelessWidget {
|
||||
subtitle: subtitle,
|
||||
secondary: secondary,
|
||||
value: value,
|
||||
// groupValue and onChanged provided by RadioGroup ancestor
|
||||
dense: dense,
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
|
||||
@@ -170,11 +170,14 @@ class MediaCardState extends State<MediaCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final viewMode = widget.forceListMode
|
||||
? ViewMode.list
|
||||
: widget.forceGridMode
|
||||
? ViewMode.grid
|
||||
: settingsProvider.viewMode;
|
||||
final ViewMode viewMode;
|
||||
if (widget.forceListMode) {
|
||||
viewMode = ViewMode.list;
|
||||
} else if (widget.forceGridMode) {
|
||||
viewMode = ViewMode.grid;
|
||||
} else {
|
||||
viewMode = settingsProvider.viewMode;
|
||||
}
|
||||
|
||||
final semanticLabel = _buildSemanticLabel();
|
||||
final localPosterPath = _getLocalPosterPath(context);
|
||||
|
||||
@@ -549,15 +549,13 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// M3 drag handle: 32x4, rounded, with 12dp top / 4dp bottom margin
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 4),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(2)),
|
||||
),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(2)),
|
||||
),
|
||||
),
|
||||
Flexible(child: content),
|
||||
|
||||
@@ -41,30 +41,33 @@ class TrackSelectionHelper {
|
||||
}
|
||||
|
||||
/// Build the "Off" list tile for track selection
|
||||
static Widget buildOffTile<T>({required bool isSelected, required VoidCallback onTap, FocusNode? focusNode}) {
|
||||
return _buildSelectableTile(label: 'Off', isSelected: isSelected, onTap: onTap, focusNode: focusNode);
|
||||
static Widget buildOffTile<T>({required BuildContext context, required bool isSelected, required VoidCallback onTap, FocusNode? focusNode}) {
|
||||
return _buildSelectableTile(context: context, label: 'Off', isSelected: isSelected, onTap: onTap, focusNode: focusNode);
|
||||
}
|
||||
|
||||
/// Build a track selection list tile
|
||||
static Widget buildTrackTile<T>({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
return _buildSelectableTile(label: label, isSelected: isSelected, onTap: onTap, focusNode: focusNode);
|
||||
return _buildSelectableTile(context: context, label: label, isSelected: isSelected, onTap: onTap, focusNode: focusNode);
|
||||
}
|
||||
|
||||
static Widget _buildSelectableTile({
|
||||
required BuildContext context,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
return FocusableListTile(
|
||||
focusNode: focusNode,
|
||||
title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : null)),
|
||||
trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null,
|
||||
title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)),
|
||||
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primaryColor) : null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,9 +111,9 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
if (isCurrentChapter)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -124,19 +124,19 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
title: Text(
|
||||
chapter.label,
|
||||
style: TextStyle(
|
||||
color: isCurrentChapter ? Colors.blue : null,
|
||||
color: isCurrentChapter ? Theme.of(context).colorScheme.primary : null,
|
||||
fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDurationTimestamp(chapter.startTime),
|
||||
style: TextStyle(
|
||||
color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted,
|
||||
color: isCurrentChapter ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: isCurrentChapter
|
||||
? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue)
|
||||
? AppIcon(Symbols.play_circle_rounded, fill: 1, color: Theme.of(context).colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
widget.player.seek(chapter.startTime);
|
||||
|
||||
@@ -42,12 +42,13 @@ class QueueSheet extends StatelessWidget {
|
||||
final item = items[index];
|
||||
final isCurrent = item.playQueueItemID == currentItemID;
|
||||
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
return FocusableListTile(
|
||||
leading: _buildThumbnail(context, item, isCurrent),
|
||||
title: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
color: isCurrent ? Colors.blue : null,
|
||||
color: isCurrent ? primaryColor : null,
|
||||
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -56,13 +57,13 @@ class QueueSheet extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
_buildSubtitle(item),
|
||||
style: TextStyle(
|
||||
color: isCurrent ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted,
|
||||
color: isCurrent ? primaryColor.withValues(alpha: 0.7) : tokens(context).textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: isCurrent ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) : null,
|
||||
trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null,
|
||||
onTap: () {
|
||||
onItemSelected(item);
|
||||
OverlaySheetController.of(context).close();
|
||||
@@ -103,9 +104,9 @@ class QueueSheet extends StatelessWidget {
|
||||
if (isCurrent)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4)),
|
||||
border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -155,6 +155,7 @@ class _AudioColumn extends StatelessWidget {
|
||||
index: index,
|
||||
);
|
||||
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
|
||||
context: context,
|
||||
label: label,
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () {
|
||||
@@ -200,6 +201,7 @@ class _SubtitleColumn extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
|
||||
context: context,
|
||||
isSelected: isOffSelected,
|
||||
onTap: () {
|
||||
player.selectSubtitleTrack(SubtitleTrack.off);
|
||||
@@ -217,6 +219,7 @@ class _SubtitleColumn extends StatelessWidget {
|
||||
index: index - 1,
|
||||
);
|
||||
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>(
|
||||
context: context,
|
||||
label: label,
|
||||
isSelected: !isOffSelected && track.id == selectedSub.id,
|
||||
onTap: () {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../models/plex_media_version.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import '../helpers/track_selection_helper.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting video version
|
||||
@@ -35,9 +34,10 @@ class _VersionSheetState extends State<VersionSheet> {
|
||||
final version = widget.availableVersions[index];
|
||||
final isSelected = index == widget.selectedMediaIndex;
|
||||
|
||||
return FocusableListTile(
|
||||
title: Text(version.displayLabel, style: TextStyle(color: isSelected ? Colors.blue : null)),
|
||||
trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null,
|
||||
return TrackSelectionHelper.buildTrackTile(
|
||||
context: context,
|
||||
label: version.displayLabel,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
OverlaySheetController.of(context).close();
|
||||
widget.onVersionSelected(index);
|
||||
|
||||
@@ -524,9 +524,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
||||
final label = speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x';
|
||||
|
||||
return ListTile(
|
||||
title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : null)),
|
||||
trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null,
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return FocusableListTile(
|
||||
title: Text(label, style: TextStyle(color: isSelected ? primary : null)),
|
||||
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
|
||||
onTap: () async {
|
||||
widget.player.setRate(speed);
|
||||
// Save as default playback speed
|
||||
@@ -641,9 +642,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final isSelected = device.name == currentDevice.name;
|
||||
final label = device.description.isEmpty ? device.name : device.description;
|
||||
|
||||
return ListTile(
|
||||
title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : null)),
|
||||
trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null,
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return FocusableListTile(
|
||||
title: Text(label, style: TextStyle(color: isSelected ? primary : null)),
|
||||
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
|
||||
onTap: () {
|
||||
widget.player.setAudioDevice(device);
|
||||
OverlaySheetController.of(context).close();
|
||||
|
||||
@@ -2183,7 +2183,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
flex: (_autoSkipProgress * 100).round(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -91,11 +91,11 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_hasBothTabs) _buildTabBar(context),
|
||||
if (_hasBothTabs) _buildTabBar(),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 106,
|
||||
child: _activeTab == _StripTab.chapters ? _buildChapterStrip(context) : _buildQueueStrip(context),
|
||||
child: _activeTab == _StripTab.chapters ? _buildChapterStrip() : _buildQueueStrip(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -103,18 +103,18 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar(BuildContext context) {
|
||||
Widget _buildTabBar() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildTabLabel(context, t.videoControls.chapters, _StripTab.chapters),
|
||||
_buildTabLabel(t.videoControls.chapters, _StripTab.chapters),
|
||||
const SizedBox(width: 24),
|
||||
_buildTabLabel(context, t.videoControls.queue, _StripTab.queue),
|
||||
_buildTabLabel(t.videoControls.queue, _StripTab.queue),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabLabel(BuildContext context, String label, _StripTab tab) {
|
||||
Widget _buildTabLabel(String label, _StripTab tab) {
|
||||
final isActive = _activeTab == tab;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _activeTab = tab),
|
||||
@@ -133,14 +133,14 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
Container(
|
||||
height: 2,
|
||||
width: 40,
|
||||
color: isActive ? Colors.blue : Colors.transparent,
|
||||
color: isActive ? Theme.of(context).colorScheme.primary : Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChapterStrip(BuildContext context) {
|
||||
Widget _buildChapterStrip() {
|
||||
return StreamBuilder<Duration>(
|
||||
stream: widget.player.streams.position,
|
||||
initialData: widget.player.state.position,
|
||||
@@ -192,7 +192,7 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
width: 120,
|
||||
height: 68,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) =>
|
||||
errorWidget: (_, _, _) =>
|
||||
const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34),
|
||||
)
|
||||
: null,
|
||||
@@ -206,7 +206,7 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueStrip(BuildContext context) {
|
||||
Widget _buildQueueStrip() {
|
||||
return Consumer<PlaybackStateProvider>(
|
||||
builder: (context, playbackState, _) {
|
||||
final items = playbackState.loadedItems;
|
||||
@@ -246,7 +246,7 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
width: 120,
|
||||
height: 68,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) =>
|
||||
errorWidget: (_, _, _) =>
|
||||
const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34),
|
||||
)
|
||||
: null,
|
||||
@@ -304,9 +304,9 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
if (isCurrent)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -318,7 +318,7 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isCurrent ? Colors.blue : Colors.white,
|
||||
color: isCurrent ? Theme.of(context).colorScheme.primary : Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
@@ -329,7 +329,7 @@ class _ContentStripState extends State<ContentStrip> {
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: isCurrent ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted,
|
||||
color: isCurrent ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted,
|
||||
fontSize: 10,
|
||||
),
|
||||
maxLines: 1,
|
||||
|
||||
+1
-1
@@ -298,7 +298,7 @@ class PerformanceStatsService {
|
||||
decoderFrameDropCount: _parseInt(results[14]),
|
||||
cacheDuration: _parseDouble(results[15]),
|
||||
// Video-dependent properties
|
||||
displayFps: _parseDouble(videoResults?[0]),
|
||||
displayFps: _parseDouble(videoResults?.first),
|
||||
pixelformat: videoResults?[1],
|
||||
hwPixelformat: videoResults?[2],
|
||||
colormatrix: videoResults?[3],
|
||||
|
||||
@@ -236,7 +236,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
min: _sliderMin,
|
||||
max: _sliderMax,
|
||||
divisions: _sliderDivisions,
|
||||
activeColor: Colors.blue,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -328,7 +328,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
min: _sliderMin,
|
||||
max: _sliderMax,
|
||||
divisions: _sliderDivisions,
|
||||
activeColor: Colors.blue,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
|
||||
Reference in New Issue
Block a user