refactor: deduplicate shared patterns, replace hardcoded colors

This commit is contained in:
edde746
2026-03-03 22:43:00 +01:00
parent 8f3d52142d
commit cd2ab9d9cd
42 changed files with 685 additions and 612 deletions
+167
View File
@@ -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;
}
}
+30 -28
View File
@@ -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
View File
@@ -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,
);
},
),
+9 -24
View File
@@ -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
+3 -1
View File
@@ -309,10 +309,12 @@ class CompanionRemoteProvider with ChangeNotifier {
appLogger.d('CompanionRemote: Attempting reconnect...');
// Clean up old peer service without triggering intentional disconnect
_cleanupSubscriptions();
try {
await _peerService?.disconnect();
} finally {
_peerService = CompanionRemotePeerService();
_setupPeerServiceListeners();
}
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
+14 -22
View File
@@ -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'
+1 -6
View File
@@ -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,
+50 -61
View File
@@ -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),
),
),
+14 -16
View File
@@ -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,14 +247,12 @@ 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(() {
_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(() {
_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),
),
],
),
+9 -6
View File
@@ -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)),
),
],
),
+2 -1
View File
@@ -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'),
+20 -5
View File
@@ -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));
}
}
+75
View File
@@ -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) {
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), (_) {
@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) {
try {
await _clientSocket!.close();
} catch (_) {}
_clientSocket = null;
}
if (_channel != null) {
try {
await _channel!.sink.close();
} catch (_) {}
_channel = null;
}
+2 -1
View File
@@ -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 -166
View File
@@ -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 {
+13 -6
View File
@@ -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;
+1 -1
View File
@@ -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')) {
+35
View File
@@ -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
+2 -1
View File
@@ -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 -27
View File
@@ -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:
+18
View File
@@ -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,
),
);
}
+19 -4
View File
@@ -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, () {
@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;
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
+8 -3
View File
@@ -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;
+1
View File
@@ -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,
+8 -5
View File
@@ -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);
+2 -4
View File
@@ -549,17 +549,15 @@ 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(
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,
@@ -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(() {