refactor: simplify & deduplicate

This commit is contained in:
edde746
2025-12-14 21:03:11 +01:00
parent 00d6f920f0
commit a2a5a5ae55
57 changed files with 1636 additions and 1810 deletions
+7
View File
@@ -1,5 +1,12 @@
import 'package:flutter/services.dart';
/// Extension on KeyEvent for common event type checks.
extension KeyEventActionable on KeyEvent {
/// Whether this event should trigger an action (KeyDownEvent or KeyRepeatEvent).
/// Use this to filter out KeyUpEvents early in key handlers.
bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent;
}
/// Shared sets for keyboard key categories.
final _dpadDirectionKeys = {
LogicalKeyboardKey.arrowUp,
+1 -1
View File
@@ -108,7 +108,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
KeyEvent event,
ChipKeyCallbacks callbacks,
) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
+2 -2
View File
@@ -304,8 +304,8 @@ class _FocusableWrapperState extends State<FocusableWrapper>
}
}
// Ignore key repeat events for other keys
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
// Ignore key up events for other keys
if (!event.isActionable) {
return KeyEventResult.ignored;
}
+4 -17
View File
@@ -1,3 +1,4 @@
import '../utils/byte_formatter.dart';
import 'download_status.dart';
class DownloadProgress {
@@ -28,23 +29,9 @@ class DownloadProgress {
double get progressPercent => progress / 100.0;
String get speedFormatted {
if (speed < 1024) return '${speed.toStringAsFixed(0)} B/s';
if (speed < 1024 * 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s';
return '${(speed / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
String get downloadedFormatted => _formatBytes(downloadedBytes);
String get totalFormatted => _formatBytes(totalBytes);
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
String get speedFormatted => ByteFormatter.formatSpeed(speed);
String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes);
String get totalFormatted => ByteFormatter.formatBytes(totalBytes);
Duration? get estimatedTimeRemaining {
if (speed <= 0 || totalBytes <= 0) return null;
+4 -25
View File
@@ -1,3 +1,5 @@
import '../utils/byte_formatter.dart';
class PlexFileInfo {
// Media level properties
final String? container;
@@ -61,20 +63,7 @@ class PlexFileInfo {
/// Format file size in human-readable format (GB, MB, KB, bytes)
String get fileSizeFormatted {
if (fileSize == null) return 'Unknown';
const kb = 1024;
const mb = kb * 1024;
const gb = mb * 1024;
if (fileSize! >= gb) {
return '${(fileSize! / gb).toStringAsFixed(2)} GB';
} else if (fileSize! >= mb) {
return '${(fileSize! / mb).toStringAsFixed(2)} MB';
} else if (fileSize! >= kb) {
return '${(fileSize! / kb).toStringAsFixed(2)} KB';
} else {
return '$fileSize bytes';
}
return ByteFormatter.formatBytes(fileSize!, decimals: 2);
}
/// Format duration in HH:MM:SS or MM:SS format
@@ -96,17 +85,7 @@ class PlexFileInfo {
/// Format bitrate in Mbps or Kbps
String get bitrateFormatted {
if (bitrate == null) return 'Unknown';
const kbps = 1000;
const mbps = kbps * 1000;
if (bitrate! >= mbps) {
return '${(bitrate! / mbps).toStringAsFixed(2)} Mbps';
} else if (bitrate! >= kbps) {
return '${(bitrate! / kbps).toStringAsFixed(2)} Kbps';
} else {
return '$bitrate bps';
}
return ByteFormatter.formatBitrateBps(bitrate!);
}
/// Format resolution as widthxheight
+4 -30
View File
@@ -1,3 +1,5 @@
import '../utils/codec_utils.dart';
class PlexMediaInfo {
final String videoUrl;
final List<PlexAudioTrack> audioTracks;
@@ -67,7 +69,7 @@ class PlexAudioTrack with TrackLabelBuilder {
String get label {
final additionalParts = <String>[];
if (codec != null) additionalParts.add(codec!.toUpperCase());
if (codec != null) additionalParts.add(CodecUtils.formatAudioCodec(codec!));
if (channels != null) additionalParts.add('${channels!}ch');
return buildLabel(additionalParts);
}
@@ -118,39 +120,11 @@ class PlexSubtitleTrack with TrackLabelBuilder {
if (!isExternal) return null;
// Determine file extension based on codec
final ext = _getExtensionFromCodec(codec);
final ext = CodecUtils.getSubtitleExtension(codec);
// Construct URL with authentication token
return '$baseUrl$key.$ext?X-Plex-Token=$token';
}
/// Maps Plex subtitle codec names to file extensions
String _getExtensionFromCodec(String? codec) {
if (codec == null) return 'srt';
switch (codec.toLowerCase()) {
case 'subrip':
case 'srt':
return 'srt';
case 'ass':
return 'ass';
case 'ssa':
return 'ssa';
case 'webvtt':
case 'vtt':
return 'vtt';
case 'mov_text':
return 'srt';
case 'pgs':
case 'hdmv_pgs_subtitle':
return 'sup';
case 'dvd_subtitle':
case 'dvdsub':
return 'sub';
default:
return 'srt'; // Default to SRT for unknown codecs
}
}
}
class PlexChapter {
+5 -3
View File
@@ -1,3 +1,6 @@
import '../utils/byte_formatter.dart';
import '../utils/codec_utils.dart';
class PlexMediaVersion {
final int id;
final String? videoResolution;
@@ -52,7 +55,7 @@ class PlexMediaVersion {
// Add codec
if (videoCodec != null && videoCodec!.isNotEmpty) {
parts.add(videoCodec!.toUpperCase());
parts.add(CodecUtils.formatVideoCodec(videoCodec!));
}
// Add container
@@ -65,8 +68,7 @@ class PlexMediaVersion {
// Add bitrate in parentheses
if (bitrate != null && bitrate! > 0) {
final bitrateInMbps = (bitrate! / 1000).toStringAsFixed(1);
label += ' ($bitrateInMbps Mbps)';
label += ' (${ByteFormatter.formatBitrate(bitrate!)})';
}
return label;
+15 -12
View File
@@ -1,17 +1,20 @@
import '../utils/content_type_helper.dart';
import 'plex_metadata.dart';
/// Extension on PlexMetadata for type checking convenience methods
extension PlexMetadataType on PlexMetadata {
bool get isShow => type.toLowerCase() == 'show';
bool get isMovie => type.toLowerCase() == 'movie';
bool get isSeason => type.toLowerCase() == 'season';
bool get isEpisode => type.toLowerCase() == 'episode';
bool get isArtist => type.toLowerCase() == 'artist';
bool get isAlbum => type.toLowerCase() == 'album';
bool get isTrack => type.toLowerCase() == 'track';
bool get isCollection => type.toLowerCase() == 'collection';
bool get isPlaylist => type.toLowerCase() == 'playlist';
bool get isClip => type.toLowerCase() == 'clip';
bool get isMusicContent => isArtist || isAlbum || isTrack;
bool get isVideoContent => isShow || isMovie || isSeason || isEpisode;
String get _lowerType => type.toLowerCase();
bool get isShow => _lowerType == ContentTypes.show;
bool get isMovie => _lowerType == ContentTypes.movie;
bool get isSeason => _lowerType == ContentTypes.season;
bool get isEpisode => _lowerType == ContentTypes.episode;
bool get isArtist => _lowerType == ContentTypes.artist;
bool get isAlbum => _lowerType == ContentTypes.album;
bool get isTrack => _lowerType == ContentTypes.track;
bool get isCollection => _lowerType == ContentTypes.collection;
bool get isPlaylist => _lowerType == ContentTypes.playlist;
bool get isClip => _lowerType == ContentTypes.clip;
bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType);
bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType);
}
+69 -174
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:plezy/models/plex_metadata_extensions.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/download_status.dart';
import '../models/download_progress.dart';
@@ -12,6 +13,7 @@ import '../services/download_storage_service.dart';
import '../services/plex_api_cache.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../utils/plex_cache_parser.dart';
/// Holds Plex thumb path reference for downloaded artwork.
/// The actual file path is computed from the hash of serverId + thumb path.
@@ -104,18 +106,16 @@ class DownloadProvider extends ChangeNotifier {
item.serverId,
'/library/metadata/${item.ratingKey}',
);
if (cached != null) {
final metadataList = cached['MediaContainer']?['Metadata'] as List?;
if (metadataList != null && metadataList.isNotEmpty) {
final metadata = PlexMetadata.fromJson(
metadataList[0],
).copyWith(serverId: item.serverId);
_metadata[item.globalKey] = metadata;
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
if (firstMetadata != null) {
final metadata = PlexMetadata.fromJson(
firstMetadata,
).copyWith(serverId: item.serverId);
_metadata[item.globalKey] = metadata;
// For episodes, also load parent (show and season) metadata
if (metadata.type == 'episode') {
await _loadParentMetadataFromCache(metadata, apiCache);
}
// For episodes, also load parent (show and season) metadata
if (metadata.isEpisode) {
await _loadParentMetadataFromCache(metadata, apiCache);
}
}
}
@@ -186,19 +186,17 @@ class DownloadProvider extends ChangeNotifier {
serverId,
'/library/metadata/$showRatingKey',
);
if (cached != null) {
final metadataList = cached['MediaContainer']?['Metadata'] as List?;
if (metadataList != null && metadataList.isNotEmpty) {
final showMetadata = PlexMetadata.fromJson(
metadataList[0],
).copyWith(serverId: serverId);
_metadata[showGlobalKey] = showMetadata;
// Store artwork reference for offline display
if (showMetadata.thumb != null) {
_artworkPaths[showGlobalKey] = DownloadedArtwork(
thumbPath: showMetadata.thumb,
);
}
final showJson = PlexCacheParser.extractFirstMetadata(cached);
if (showJson != null) {
final showMetadata = PlexMetadata.fromJson(
showJson,
).copyWith(serverId: serverId);
_metadata[showGlobalKey] = showMetadata;
// Store artwork reference for offline display
if (showMetadata.thumb != null) {
_artworkPaths[showGlobalKey] = DownloadedArtwork(
thumbPath: showMetadata.thumb,
);
}
}
}
@@ -213,19 +211,17 @@ class DownloadProvider extends ChangeNotifier {
serverId,
'/library/metadata/$seasonRatingKey',
);
if (cached != null) {
final metadataList = cached['MediaContainer']?['Metadata'] as List?;
if (metadataList != null && metadataList.isNotEmpty) {
final seasonMetadata = PlexMetadata.fromJson(
metadataList[0],
).copyWith(serverId: serverId);
_metadata[seasonGlobalKey] = seasonMetadata;
// Store artwork reference for offline display
if (seasonMetadata.thumb != null) {
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(
thumbPath: seasonMetadata.thumb,
);
}
final seasonJson = PlexCacheParser.extractFirstMetadata(cached);
if (seasonJson != null) {
final seasonMetadata = PlexMetadata.fromJson(
seasonJson,
).copyWith(serverId: serverId);
_metadata[seasonGlobalKey] = seasonMetadata;
// Store artwork reference for offline display
if (seasonMetadata.thumb != null) {
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(
thumbPath: seasonMetadata.thumb,
);
}
}
}
@@ -408,118 +404,11 @@ class DownloadProvider extends ChangeNotifier {
String serverId,
String showRatingKey,
) {
final globalKey = '$serverId:$showRatingKey';
final episodes = _getEpisodeDownloadsForShow(showRatingKey);
// DIAGNOSTIC: Check all sources of episode count
final showMeta = _metadata[globalKey];
final metadataLeafCount = showMeta?.leafCount;
final storedCount = _totalEpisodeCounts[globalKey];
final downloadedCount = episodes.length;
appLogger.d(
'📊 Episode count sources for show $showRatingKey:\n'
' - Metadata leafCount: $metadataLeafCount\n'
' - Stored count: $storedCount\n'
' - Downloaded episodes: $downloadedCount\n'
' - Show metadata exists: ${showMeta != null}\n'
' - Show type: ${showMeta?.type}\n'
' - Show title: ${showMeta?.title}',
);
// Get total episode count - FIXED: Use metadata.leafCount as primary source
int totalEpisodes;
String countSource;
if (metadataLeafCount != null && metadataLeafCount > 0) {
totalEpisodes = metadataLeafCount;
countSource = 'metadata.leafCount';
} else if (storedCount != null && storedCount > 0) {
totalEpisodes = storedCount;
countSource = 'stored count (SharedPreferences)';
} else {
totalEpisodes = downloadedCount;
countSource = 'downloaded episodes (fallback)';
}
appLogger.d(
'✅ Using totalEpisodes=$totalEpisodes from [$countSource] for show $showRatingKey',
);
// If we have stored count but no downloads, check if it's a valid partial state
if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) {
// No episodes downloaded yet, but we have a count stored
// This means user hasn't downloaded anything or deleted all episodes
appLogger.d(
'⚠️ No valid downloads for show $showRatingKey, returning null',
);
return null;
}
// Calculate aggregate statistics
int completedCount = 0;
int downloadingCount = 0;
int queuedCount = 0;
int failedCount = 0;
for (final ep in episodes) {
switch (ep.status) {
case DownloadStatus.completed:
completedCount++;
break;
case DownloadStatus.downloading:
downloadingCount++;
break;
case DownloadStatus.queued:
queuedCount++;
break;
case DownloadStatus.failed:
failedCount++;
break;
default:
break;
}
}
// Determine overall status
final DownloadStatus overallStatus;
if (completedCount == totalEpisodes) {
// All episodes fully downloaded
overallStatus = DownloadStatus.completed;
} else if (completedCount > 0 &&
downloadingCount == 0 &&
queuedCount == 0 &&
completedCount < totalEpisodes) {
// Some episodes downloaded, but not all, and nothing actively downloading
overallStatus = DownloadStatus.partial;
} else if (downloadingCount > 0) {
overallStatus = DownloadStatus.downloading;
} else if (queuedCount > 0) {
overallStatus = DownloadStatus.queued;
} else if (failedCount > 0) {
overallStatus = DownloadStatus.failed;
} else {
return null;
}
// Calculate overall progress percentage based on TOTAL episodes
final int overallProgress = totalEpisodes > 0
? ((completedCount * 100) / totalEpisodes).round()
: 0;
appLogger.d(
'Aggregate progress for show $showRatingKey: $overallProgress% '
'($completedCount completed, $downloadingCount downloading, '
'$queuedCount queued of $totalEpisodes total) - Status: $overallStatus',
);
return DownloadProgress(
globalKey: globalKey,
status: overallStatus,
progress: overallProgress,
downloadedBytes: 0, // Not tracked at show level
totalBytes: 0,
currentFile: '$completedCount/$totalEpisodes episodes',
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: showRatingKey,
episodes: _getEpisodeDownloadsForShow(showRatingKey),
entityType: 'show',
);
}
@@ -529,26 +418,40 @@ class DownloadProvider extends ChangeNotifier {
String serverId,
String seasonRatingKey,
) {
final globalKey = '$serverId:$seasonRatingKey';
final episodes = _getEpisodeDownloadsForSeason(seasonRatingKey);
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: seasonRatingKey,
episodes: _getEpisodeDownloadsForSeason(seasonRatingKey),
entityType: 'season',
);
}
/// Shared helper to calculate aggregate download progress for shows/seasons
DownloadProgress? _calculateAggregateProgress({
required String serverId,
required String ratingKey,
required List<DownloadProgress> episodes,
required String entityType,
}) {
final globalKey = '$serverId:$ratingKey';
// DIAGNOSTIC: Check all sources of episode count
final seasonMeta = _metadata[globalKey];
final metadataLeafCount = seasonMeta?.leafCount;
final meta = _metadata[globalKey];
final metadataLeafCount = meta?.leafCount;
final storedCount = _totalEpisodeCounts[globalKey];
final downloadedCount = episodes.length;
appLogger.d(
'📊 Episode count sources for season $seasonRatingKey:\n'
'📊 Episode count sources for $entityType $ratingKey:\n'
' - Metadata leafCount: $metadataLeafCount\n'
' - Stored count: $storedCount\n'
' - Downloaded episodes: $downloadedCount\n'
' - Season metadata exists: ${seasonMeta != null}\n'
' - Season type: ${seasonMeta?.type}\n'
' - Season title: ${seasonMeta?.title}',
' - Metadata exists: ${meta != null}\n'
' - Type: ${meta?.type}\n'
' - Title: ${meta?.title}',
);
// Get total episode count - FIXED: Use metadata.leafCount as primary source
// Get total episode count - Use metadata.leafCount as primary source
int totalEpisodes;
String countSource;
@@ -564,13 +467,13 @@ class DownloadProvider extends ChangeNotifier {
}
appLogger.d(
'✅ Using totalEpisodes=$totalEpisodes from [$countSource] for season $seasonRatingKey',
'✅ Using totalEpisodes=$totalEpisodes from [$countSource] for $entityType $ratingKey',
);
// If we have stored count but no downloads, check if it's a valid partial state
if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) {
appLogger.d(
'⚠️ No valid downloads for season $seasonRatingKey, returning null',
'⚠️ No valid downloads for $entityType $ratingKey, returning null',
);
return null;
}
@@ -585,16 +488,12 @@ class DownloadProvider extends ChangeNotifier {
switch (ep.status) {
case DownloadStatus.completed:
completedCount++;
break;
case DownloadStatus.downloading:
downloadingCount++;
break;
case DownloadStatus.queued:
queuedCount++;
break;
case DownloadStatus.failed:
failedCount++;
break;
default:
break;
}
@@ -603,13 +502,11 @@ class DownloadProvider extends ChangeNotifier {
// Determine overall status
final DownloadStatus overallStatus;
if (completedCount == totalEpisodes) {
// All episodes fully downloaded
overallStatus = DownloadStatus.completed;
} else if (completedCount > 0 &&
downloadingCount == 0 &&
queuedCount == 0 &&
completedCount < totalEpisodes) {
// Some episodes downloaded, but not all, and nothing actively downloading
overallStatus = DownloadStatus.partial;
} else if (downloadingCount > 0) {
overallStatus = DownloadStatus.downloading;
@@ -627,7 +524,7 @@ class DownloadProvider extends ChangeNotifier {
: 0;
appLogger.d(
'Aggregate progress for season $seasonRatingKey: $overallProgress% '
'Aggregate progress for $entityType $ratingKey: $overallProgress% '
'($completedCount completed, $downloadingCount downloading, '
'$queuedCount queued of $totalEpisodes total) - Status: $overallStatus',
);
@@ -1248,13 +1145,11 @@ class DownloadProvider extends ChangeNotifier {
'/library/metadata/$ratingKey',
);
if (cached != null) {
final metadataList = cached['MediaContainer']?['Metadata'] as List?;
if (metadataList != null && metadataList.isNotEmpty) {
final metadata = PlexMetadata.fromJson(metadataList.first);
_metadata[globalKey] = metadata.copyWith(serverId: serverId);
updatedCount++;
}
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
if (firstMetadata != null) {
final metadata = PlexMetadata.fromJson(firstMetadata);
_metadata[globalKey] = metadata.copyWith(serverId: serverId);
updatedCount++;
}
} catch (e) {
appLogger.d('Failed to refresh metadata for $globalKey: $e');
+13 -12
View File
@@ -8,23 +8,26 @@ class HiddenLibrariesProvider extends ChangeNotifier {
late StorageService _storageService;
Set<String> _hiddenLibraryKeys = {};
bool _isInitialized = false;
Future<void>? _initFuture;
/// Get an unmodifiable copy of hidden library keys
Set<String> get hiddenLibraryKeys {
if (!_isInitialized) _initialize();
return Set.unmodifiable(_hiddenLibraryKeys);
HiddenLibrariesProvider() {
// Start initialization eagerly to reduce race conditions
_initFuture = _initialize();
}
/// Ensures the provider is initialized. Call this before accessing hidden
/// libraries in contexts where you need the actual persisted values.
Future<void> ensureInitialized() => _initFuture ?? _initialize();
/// Check if the provider has completed initialization
bool get isInitialized => _isInitialized;
HiddenLibrariesProvider() {
// Don't initialize immediately if lazy-loaded
// _initialize() will be called when first accessed
}
/// Get an unmodifiable copy of hidden library keys
Set<String> get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys);
/// Initialize the provider by loading hidden libraries from storage
Future<void> _initialize() async {
if (_isInitialized) return;
_storageService = await StorageService.getInstance();
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
_isInitialized = true;
@@ -54,10 +57,8 @@ class HiddenLibrariesProvider extends ChangeNotifier {
}
/// Check if a specific library is hidden
bool isLibraryHidden(String libraryKey) {
if (!_isInitialized) _initialize();
return _hiddenLibraryKeys.contains(libraryKey);
}
bool isLibraryHidden(String libraryKey) =>
_hiddenLibraryKeys.contains(libraryKey);
/// Refresh hidden libraries from storage
/// Useful if storage was modified outside the provider
+14 -18
View File
@@ -8,12 +8,17 @@ class SettingsProvider extends ChangeNotifier {
bool _useSeasonPoster = false;
bool _showHeroSection = true;
bool _isInitialized = false;
Future<void>? _initFuture;
SettingsProvider() {
// Don't initialize immediately if lazy-loaded
// _initializeSettings() will be called when first accessed
// Start initialization eagerly to reduce race conditions
_initFuture = _initializeSettings();
}
/// Ensures the provider is initialized. Call this before accessing settings
/// in contexts where you need the actual persisted values.
Future<void> ensureInitialized() => _initFuture ?? _initializeSettings();
Future<void> _initializeSettings() async {
if (_isInitialized) return;
@@ -26,25 +31,16 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}
LibraryDensity get libraryDensity {
if (!_isInitialized) _initializeSettings();
return _libraryDensity;
}
/// Whether the provider has completed initialization
bool get isInitialized => _isInitialized;
ViewMode get viewMode {
if (!_isInitialized) _initializeSettings();
return _viewMode;
}
LibraryDensity get libraryDensity => _libraryDensity;
bool get useSeasonPoster {
if (!_isInitialized) _initializeSettings();
return _useSeasonPoster;
}
ViewMode get viewMode => _viewMode;
bool get showHeroSection {
if (!_isInitialized) _initializeSettings();
return _showHeroSection;
}
bool get useSeasonPoster => _useSeasonPoster;
bool get showHeroSection => _showHeroSection;
Future<void> setLibraryDensity(LibraryDensity density) async {
if (!_isInitialized) await _initializeSettings();
+2 -1
View File
@@ -10,6 +10,7 @@ import '../services/server_registry.dart';
import '../providers/multi_server_provider.dart';
import '../providers/plex_client_provider.dart';
import '../i18n/strings.g.dart';
import '../theme/theme_helper.dart';
import '../utils/app_logger.dart';
import 'main_screen.dart';
@@ -507,7 +508,7 @@ class _AuthScreenState extends State<AuthScreen> {
const SizedBox(height: 24),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
child: QrImageView(
data: _qrAuthUrl!,
size: qrSize,
+9 -37
View File
@@ -12,6 +12,8 @@ import '../utils/app_logger.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../i18n/strings.g.dart';
import 'libraries/error_state_widget.dart';
import 'libraries/empty_state_widget.dart';
/// Abstract base class for screens displaying media lists (collections/playlists)
/// Provides common state management and playback functionality
@@ -141,25 +143,10 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
if (errorMessage != null) {
return [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppIcon(
Symbols.error_outline_rounded,
fill: 1,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: loadItems,
child: Text(t.common.retry),
),
],
),
child: ErrorStateWidget(
message: errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: loadItems,
),
),
];
@@ -174,26 +161,11 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
}
if (items.isEmpty) {
final icon = emptyIcon;
return [
SliverFillRemaining(
child: Center(
child: icon != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(icon, fill: 1, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
emptyMessage,
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
)
: Text(emptyMessage),
child: EmptyStateWidget(
message: emptyMessage,
icon: emptyIcon,
),
),
];
+17 -28
View File
@@ -4,6 +4,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../services/plex_client.dart';
import '../utils/plex_image_helper.dart';
@@ -28,8 +29,9 @@ import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/layout_constants.dart';
import '../focus/dpad_navigator.dart';
import '../theme/theme_helper.dart';
import 'auth_screen.dart';
import 'libraries/error_state_widget.dart';
class DiscoverScreen extends StatefulWidget {
final VoidCallback? onBecameVisible;
@@ -197,7 +199,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Handle key events for the hero section
KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -222,7 +224,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (key.isLeftKey) {
if (_currentHeroIndex > 0) {
_heroController.previousPage(
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
curve: Curves.easeInOut,
);
}
@@ -233,7 +235,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (key.isRightKey) {
if (_currentHeroIndex < _onDeck.length - 1) {
_heroController.nextPage(
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
curve: Curves.easeInOut,
);
}
@@ -253,7 +255,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Handle key events for the refresh button in app bar
KeyEventResult _handleRefreshKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -287,7 +289,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Handle key events for the user button in app bar
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -901,25 +903,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppIcon(
Symbols.error_outline_rounded,
fill: 1,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadContent,
child: Text(t.common.retry),
),
],
),
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadContent,
),
),
if (!_isLoading && _errorMessage == null) ...[
@@ -1007,7 +994,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(
tokens(context).radiusSm,
),
),
);
},
@@ -1132,7 +1121,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
((maxWidth - dotSize) *
_indicatorAnimationController.value);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(
horizontal: 4,
@@ -1164,7 +1153,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
} else {
// Static indicator for inactive pages
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: dotSize,
+6 -31
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../models/plex_metadata.dart';
@@ -14,6 +13,7 @@ import '../../widgets/focusable_media_card.dart';
import '../../widgets/media_grid_delegate.dart';
import '../../widgets/download_tree_view.dart';
import '../main_screen.dart';
import '../libraries/empty_state_widget.dart';
import '../../i18n/strings.g.dart';
class DownloadsScreen extends StatefulWidget {
@@ -341,36 +341,11 @@ class _DownloadsGridContent extends StatelessWidget {
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(
Symbols.download_rounded,
fill: 1,
size: 80,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 24),
Text(
t.downloads.noDownloads,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
t.downloads.noDownloadsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
return EmptyStateWidget(
message: t.downloads.noDownloads,
subtitle: t.downloads.noDownloadsDescription,
icon: Symbols.download_rounded,
iconSize: 80,
);
}
}
+5 -19
View File
@@ -10,6 +10,7 @@ import '../utils/app_logger.dart';
import '../widgets/media_grid_sliver.dart';
import '../widgets/focused_scroll_scaffold.dart';
import 'libraries/sort_bottom_sheet.dart';
import 'libraries/error_state_widget.dart';
import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
@@ -256,25 +257,10 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
slivers: [
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppIcon(
Symbols.error_outline_rounded,
fill: 1,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadMoreItems,
child: Text(t.common.retry),
),
],
),
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadMoreItems,
),
)
else if (_filteredItems.isEmpty && _isLoading)
@@ -7,9 +7,15 @@ class EmptyStateWidget extends StatelessWidget {
/// The message to display
final String message;
/// Optional subtitle/description below the message
final String? subtitle;
/// Optional icon to display above the message
final IconData? icon;
/// Optional size for the icon
final double iconSize;
/// Optional callback for action button
final VoidCallback? onAction;
@@ -19,7 +25,9 @@ class EmptyStateWidget extends StatelessWidget {
const EmptyStateWidget({
super.key,
required this.message,
this.subtitle,
this.icon,
this.iconSize = 64,
this.onAction,
this.actionLabel,
});
@@ -28,7 +36,9 @@ class EmptyStateWidget extends StatelessWidget {
Widget build(BuildContext context) {
return StateMessageWidget(
message: message,
subtitle: subtitle,
icon: icon,
iconSize: iconSize,
onAction: onAction,
actionLabel: actionLabel,
actionIcon: Symbols.add_rounded,
+12 -65
View File
@@ -27,6 +27,8 @@ import '../../mixins/refreshable.dart';
import '../../mixins/item_updatable.dart';
import '../../i18n/strings.g.dart';
import '../../utils/error_message_utils.dart';
import 'error_state_widget.dart';
import 'empty_state_widget.dart';
import 'tabs/library_browse_tab.dart';
import 'tabs/library_recommended_tab.dart';
import 'tabs/library_collections_tab.dart';
@@ -957,7 +959,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
child: Row(
children: [
AppIcon(
_getLibraryIcon(library.type),
ContentTypeHelper.getLibraryIcon(library.type),
fill: 1,
size: 20,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
@@ -1088,7 +1090,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(_getLibraryIcon(selectedLibrary.type), fill: 1, size: 20),
AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.type), fill: 1, size: 20),
const SizedBox(width: 8),
if (_hasMultipleServers && selectedLibrary.serverName != null)
Column(
@@ -1164,43 +1166,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
)
else if (_errorMessage != null && visibleLibraries.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppIcon(
Symbols.error_outline_rounded,
fill: 1,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadLibraries,
child: Text(t.common.retry),
),
],
),
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadLibraries,
),
)
else if (visibleLibraries.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppIcon(
Symbols.video_library_rounded,
fill: 1,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(t.libraries.noLibrariesFound),
],
),
child: EmptyStateWidget(
message: t.libraries.noLibrariesFound,
icon: Symbols.video_library_rounded,
),
)
else ...[
@@ -1286,20 +1262,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
}
IconData _getLibraryIcon(String type) {
switch (type.toLowerCase()) {
case 'movie':
return Symbols.movie_rounded;
case 'show':
return Symbols.tv_rounded;
case 'artist':
return Symbols.music_note_rounded;
case 'photo':
return Symbols.photo_rounded;
default:
return Symbols.folder_rounded;
}
}
}
class _LibraryManagementSheet extends StatefulWidget {
@@ -1533,21 +1495,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
}
}
IconData _getLibraryIcon(String type) {
switch (type.toLowerCase()) {
case 'movie':
return Symbols.movie_rounded;
case 'show':
return Symbols.tv_rounded;
case 'artist':
return Symbols.music_note_rounded;
case 'photo':
return Symbols.photo_rounded;
default:
return Symbols.folder_rounded;
}
}
/// Get set of library names that appear more than once (not globally unique)
Set<String> _getNonUniqueLibraryNames() {
final nameCounts = <String, int>{};
@@ -1703,7 +1650,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
),
),
const SizedBox(width: 8),
AppIcon(_getLibraryIcon(library.type), fill: 1),
AppIcon(ContentTypeHelper.getLibraryIcon(library.type), fill: 1),
],
),
title: Text(library.title),
@@ -5,18 +5,27 @@ import 'package:material_symbols_icons/symbols.dart';
/// Base widget for displaying state messages (empty, error, etc.)
/// Provides a consistent UI pattern for showing icons, messages, and actions
class StateMessageWidget extends StatelessWidget {
/// The message to display
/// The main message/title to display
final String message;
/// Optional subtitle/description below the message
final String? subtitle;
/// Optional icon to display above the message
final IconData? icon;
/// Optional size for the icon (default: 64)
final double iconSize;
/// Optional color for the icon
final Color? iconColor;
/// Optional color for the message text
final Color? textColor;
/// Optional color for the subtitle text
final Color? subtitleColor;
/// Optional callback for action button
final VoidCallback? onAction;
@@ -29,9 +38,12 @@ class StateMessageWidget extends StatelessWidget {
const StateMessageWidget({
super.key,
required this.message,
this.subtitle,
this.icon,
this.iconSize = 64,
this.iconColor,
this.textColor,
this.subtitleColor,
this.onAction,
this.actionLabel,
this.actionIcon,
@@ -39,6 +51,7 @@ class StateMessageWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
@@ -49,26 +62,34 @@ class StateMessageWidget extends StatelessWidget {
AppIcon(
icon,
fill: 1,
size: 64,
size: iconSize,
color:
iconColor ??
Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
],
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
style: theme.textTheme.titleLarge?.copyWith(
color:
textColor ??
Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color:
subtitleColor ??
theme.colorScheme.onSurfaceVariant,
),
),
],
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
+17 -48
View File
@@ -30,6 +30,7 @@ import '../utils/desktop_window_padding.dart';
import '../widgets/horizontal_scroll_with_arrows.dart';
import '../widgets/media_card.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/placeholder_container.dart';
import 'season_detail_screen.dart';
class MediaDetailScreen extends StatefulWidget {
@@ -146,18 +147,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
appLogger.d(
'Playing on deck episode: ${_onDeckEpisode!.title}',
);
await navigateToVideoPlayer(
await navigateToVideoPlayerWithRefresh(
context,
metadata: _onDeckEpisode!,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
appLogger.d(
'Returned from playback, refreshing metadata',
);
// Refresh metadata when returning from video player
if (!widget.isOffline) {
_loadFullMetadata();
}
} else {
// No on deck episode, fetch first episode of first season
await _playFirstEpisode();
@@ -165,18 +160,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} else {
appLogger.d('Playing: ${metadata.title}');
// For movies or episodes, play directly
await navigateToVideoPlayer(
await navigateToVideoPlayerWithRefresh(
context,
metadata: metadata,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
appLogger.d(
'Returned from playback, refreshing metadata',
);
// Refresh metadata when returning from video player
if (!widget.isOffline) {
_loadFullMetadata();
}
}
},
icon: AppIcon(
@@ -1115,16 +1104,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
);
if (mounted) {
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
await navigateToVideoPlayer(
await navigateToVideoPlayerWithRefresh(
context,
metadata: episodeWithServerId,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
appLogger.d('Returned from playback, refreshing metadata');
// Refresh metadata when returning from video player (skip if offline)
if (!widget.isOffline) {
_loadFullMetadata();
}
}
} catch (e) {
if (mounted) {
@@ -1298,19 +1283,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
const PlaceholderContainer(),
);
}
// Offline but no local file - show placeholder
return Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
);
return const PlaceholderContainer();
}
// Online - use network image
@@ -1330,24 +1307,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
return CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
placeholder: (context, url) =>
const PlaceholderContainer(),
errorWidget: (context, url, error) =>
const PlaceholderContainer(),
);
},
)
: Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
: const PlaceholderContainer(),
),
// Gradient overlay
@@ -1631,7 +1598,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(
tokens(context).radiusSm,
),
child: actor.thumb != null
? CachedNetworkImage(
imageUrl: actor.thumb!,
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../i18n/strings.g.dart';
import '../../models/plex_home_user.dart';
import '../../theme/theme_helper.dart';
import 'user_avatar_widget.dart';
enum UserAttribute { admin, restricted, protected }
@@ -36,7 +37,7 @@ class ProfileListTile extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
child: Text(
t.userStatus.current,
+5 -28
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../models/plex_home_user.dart';
@@ -8,6 +7,7 @@ import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import 'profile_list_tile.dart';
import '../../widgets/desktop_app_bar.dart';
import '../libraries/empty_state_widget.dart';
import '../../i18n/strings.g.dart';
class ProfileSwitchScreen extends StatelessWidget {
@@ -53,33 +53,10 @@ class ProfileSwitchScreen extends StatelessWidget {
}
if (users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(
Symbols.person_off_rounded,
fill: 1,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No profiles available',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Contact your Plex administrator to add profiles',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
return const EmptyStateWidget(
message: 'No profiles available',
subtitle: 'Contact your Plex administrator to add profiles',
icon: Symbols.person_off_rounded,
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../models/plex_home_user.dart';
import '../../theme/theme_helper.dart';
import '../../i18n/strings.g.dart';
class UserAvatarWidget extends StatelessWidget {
@@ -92,7 +93,7 @@ class UserAvatarWidget extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
),
child: Text(
text,
+18 -50
View File
@@ -14,6 +14,8 @@ import '../utils/sliver_adaptive_media_builder.dart';
import '../utils/snackbar_helper.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_card.dart';
import '../utils/focus_utils.dart';
import 'libraries/state_message_widget.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -41,9 +43,7 @@ class _SearchScreenState extends State<SearchScreen>
);
_searchController.addListener(_onSearchChanged);
// Focus the search input when the screen is shown
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
FocusUtils.requestFocusAfterBuild(this, _searchFocusNode);
}
@override
@@ -132,9 +132,7 @@ class _SearchScreenState extends State<SearchScreen>
/// Focus the search input field
@override
void focusSearchInput() {
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
FocusUtils.requestFocusAfterBuild(this, _searchFocusNode);
}
// Public method to fully reload all content (for profile switches)
@@ -214,16 +212,22 @@ class _SearchScreenState extends State<SearchScreen>
child: Center(child: CircularProgressIndicator()),
)
else if (!_hasSearched)
_SearchEmptyState(
icon: Symbols.search_rounded,
title: t.search.searchYourMedia,
subtitle: t.search.enterTitleActorOrKeyword,
SliverFillRemaining(
child: StateMessageWidget(
message: t.search.searchYourMedia,
subtitle: t.search.enterTitleActorOrKeyword,
icon: Symbols.search_rounded,
iconSize: 80,
),
)
else if (_searchResults.isEmpty)
_SearchEmptyState(
icon: Symbols.search_off_rounded,
title: t.messages.noResultsFound,
subtitle: t.search.tryDifferentTerm,
SliverFillRemaining(
child: StateMessageWidget(
message: t.messages.noResultsFound,
subtitle: t.search.tryDifferentTerm,
icon: Symbols.search_off_rounded,
iconSize: 80,
),
)
else
Consumer<SettingsProvider>(
@@ -253,39 +257,3 @@ class _SearchScreenState extends State<SearchScreen>
);
}
}
/// Empty state widget for search screen with icon, title, and subtitle.
class _SearchEmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
const _SearchEmptyState({
required this.icon,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(icon, fill: 1, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
Text(subtitle, style: TextStyle(color: Colors.grey.shade600)),
],
),
),
);
}
}
+17 -30
View File
@@ -17,6 +17,7 @@ import '../utils/video_player_navigation.dart';
import '../utils/duration_formatter.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/placeholder_container.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
@@ -193,15 +194,12 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
autofocus:
index == 0 && InputModeTracker.isKeyboardMode(context),
onTap: () async {
await navigateToVideoPlayer(
await navigateToVideoPlayerWithRefresh(
context,
metadata: episode,
isOffline: widget.isOffline,
onRefresh: _loadEpisodes,
);
// Refresh episodes when returning from video player (skip if offline)
if (!widget.isOffline) {
_loadEpisodes();
}
},
onRefresh: widget.isOffline ? null : updateItem,
);
@@ -316,11 +314,8 @@ class _EpisodeCard extends StatelessWidget {
File(localPosterPath!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const AppIcon(
const PlaceholderContainer(
child: AppIcon(
Symbols.movie_rounded,
fill: 1,
size: 32,
@@ -333,27 +328,19 @@ class _EpisodeCard extends StatelessWidget {
imagePath: episode.thumb,
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const AppIcon(
Symbols.movie_rounded,
fill: 1,
size: 32,
),
),
placeholder: (context, url) =>
const PlaceholderContainer(),
errorWidget: (context, url, error) =>
const PlaceholderContainer(
child: AppIcon(
Symbols.movie_rounded,
fill: 1,
size: 32,
),
),
)
: Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const AppIcon(
: const PlaceholderContainer(
child: AppIcon(
Symbols.movie_rounded,
fill: 1,
size: 32,
+144 -304
View File
@@ -23,6 +23,19 @@ import 'about_screen.dart';
import 'logs_screen.dart';
import 'subtitle_styling_screen.dart';
/// Helper class for option selection dialog items
class _DialogOption<T> {
final T value;
final String title;
final String? subtitle;
const _DialogOption({
required this.value,
required this.title,
this.subtitle,
});
}
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -803,8 +816,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showSeekTimeSmallDialog() {
final controller = TextEditingController(text: _seekTimeSmall.toString());
/// Generic numeric input dialog to avoid duplication across settings
void _showNumericInputDialog({
required String title,
required String labelText,
required String suffixText,
required int min,
required int max,
required int currentValue,
required Future<void> Function(int value) onSave,
}) {
final controller = TextEditingController(text: currentValue.toString());
String? errorText;
showDialog(
@@ -813,15 +835,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(t.settings.smallSkipDuration),
title: Text(title),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.settings.secondsLabel,
hintText: t.settings.durationHint(min: 1, max: 120),
labelText: labelText,
hintText: t.settings.durationHint(min: min, max: max),
errorText: errorText,
suffixText: t.settings.secondsShort,
suffixText: suffixText,
),
autofocus: true,
onChanged: (value) {
@@ -829,11 +851,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
setDialogState(() {
if (parsed == null) {
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 1 || parsed > 120) {
} else if (parsed < min || parsed > max) {
errorText = t.settings.validationErrorDuration(
min: 1,
max: 120,
unit: t.settings.secondsLabel.toLowerCase(),
min: min,
max: max,
unit: labelText.toLowerCase(),
);
} else {
errorText = null;
@@ -849,13 +871,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
TextButton(
onPressed: () async {
final parsed = int.tryParse(controller.text);
if (parsed != null && parsed >= 1 && parsed <= 120) {
setState(() {
_seekTimeSmall = parsed;
_settingsService.setSeekTimeSmall(parsed);
});
// Reload keyboard shortcuts service to use new settings
await _keyboardService?.refreshFromStorage();
if (parsed != null && parsed >= min && parsed <= max) {
await onSave(parsed);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
@@ -871,204 +888,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showSeekTimeSmallDialog() {
_showNumericInputDialog(
title: t.settings.smallSkipDuration,
labelText: t.settings.secondsLabel,
suffixText: t.settings.secondsShort,
min: 1,
max: 120,
currentValue: _seekTimeSmall,
onSave: (value) async {
setState(() {
_seekTimeSmall = value;
_settingsService.setSeekTimeSmall(value);
});
await _keyboardService?.refreshFromStorage();
},
);
}
void _showSeekTimeLargeDialog() {
final controller = TextEditingController(text: _seekTimeLarge.toString());
String? errorText;
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(t.settings.largeSkipDuration),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.settings.secondsLabel,
hintText: t.settings.durationHint(min: 1, max: 120),
errorText: errorText,
suffixText: t.settings.secondsShort,
),
autofocus: true,
onChanged: (value) {
final parsed = int.tryParse(value);
setDialogState(() {
if (parsed == null) {
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 1 || parsed > 120) {
errorText = t.settings.validationErrorDuration(
min: 1,
max: 120,
unit: t.settings.secondsLabel.toLowerCase(),
);
} else {
errorText = null;
}
});
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
final parsed = int.tryParse(controller.text);
if (parsed != null && parsed >= 1 && parsed <= 120) {
setState(() {
_seekTimeLarge = parsed;
_settingsService.setSeekTimeLarge(parsed);
});
// Reload keyboard shortcuts service to use new settings
await _keyboardService?.refreshFromStorage();
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
}
},
child: Text(t.common.save),
),
],
);
},
);
_showNumericInputDialog(
title: t.settings.largeSkipDuration,
labelText: t.settings.secondsLabel,
suffixText: t.settings.secondsShort,
min: 1,
max: 120,
currentValue: _seekTimeLarge,
onSave: (value) async {
setState(() {
_seekTimeLarge = value;
_settingsService.setSeekTimeLarge(value);
});
await _keyboardService?.refreshFromStorage();
},
);
}
void _showSleepTimerDurationDialog() {
final controller = TextEditingController(
text: _sleepTimerDuration.toString(),
);
String? errorText;
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(t.settings.defaultSleepTimer),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.settings.minutesLabel,
hintText: t.settings.durationHint(min: 5, max: 240),
errorText: errorText,
suffixText: t.settings.minutesShort,
),
autofocus: true,
onChanged: (value) {
final parsed = int.tryParse(value);
setDialogState(() {
if (parsed == null) {
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 5 || parsed > 240) {
errorText = t.settings.validationErrorDuration(
min: 5,
max: 240,
unit: t.settings.minutesLabel.toLowerCase(),
);
} else {
errorText = null;
}
});
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
final parsed = int.tryParse(controller.text);
if (parsed != null && parsed >= 5 && parsed <= 240) {
setState(() {
_sleepTimerDuration = parsed;
});
await _settingsService.setSleepTimerDuration(parsed);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
}
},
child: Text(t.common.save),
),
],
);
},
);
_showNumericInputDialog(
title: t.settings.defaultSleepTimer,
labelText: t.settings.minutesLabel,
suffixText: t.settings.minutesShort,
min: 5,
max: 240,
currentValue: _sleepTimerDuration,
onSave: (value) async {
setState(() => _sleepTimerDuration = value);
await _settingsService.setSleepTimerDuration(value);
},
);
}
void _showAutoSkipDelayDialog() {
final controller = TextEditingController(text: _autoSkipDelay.toString());
String? errorText;
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(t.settings.autoSkipDelay),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.settings.secondsLabel,
hintText: t.settings.durationHint(min: 1, max: 30),
errorText: errorText,
suffixText: t.settings.secondsShort,
),
autofocus: true,
onChanged: (value) {
final parsed = int.tryParse(value);
setDialogState(() {
if (parsed == null) {
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 1 || parsed > 30) {
errorText = t.settings.validationErrorDuration(
min: 1,
max: 30,
unit: t.settings.secondsLabel.toLowerCase(),
);
} else {
errorText = null;
}
});
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
final parsed = int.tryParse(controller.text);
if (parsed != null && parsed >= 1 && parsed <= 30) {
setState(() {
_autoSkipDelay = parsed;
});
await _settingsService.setAutoSkipDelay(parsed);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
}
},
child: Text(t.common.save),
),
],
);
},
);
_showNumericInputDialog(
title: t.settings.autoSkipDelay,
labelText: t.settings.secondsLabel,
suffixText: t.settings.secondsShort,
min: 1,
max: 30,
currentValue: _autoSkipDelay,
onSave: (value) async {
setState(() => _autoSkipDelay = value);
await _settingsService.setAutoSkipDelay(value);
},
);
}
@@ -1309,68 +1190,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showLibraryDensityDialog() {
/// Generic option selection dialog for settings with SettingsProvider
void _showOptionSelectionDialog<T>({
required String title,
required List<_DialogOption<T>> options,
required T Function(SettingsProvider) getCurrentValue,
required Future<void> Function(T value, SettingsProvider provider) onSelect,
}) {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
context: context,
builder: (BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, provider, child) {
final currentValue = getCurrentValue(provider);
return AlertDialog(
title: Text(t.settings.libraryDensity),
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
children: options.map((option) {
return ListTile(
leading: AppIcon(
provider.libraryDensity == settings.LibraryDensity.compact
currentValue == option.value
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.compact),
subtitle: Text(t.settings.compactDescription),
title: Text(option.title),
subtitle:
option.subtitle != null ? Text(option.subtitle!) : null,
onTap: () async {
await settingsProvider.setLibraryDensity(
settings.LibraryDensity.compact,
);
await onSelect(option.value, settingsProvider);
if (context.mounted) Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
provider.libraryDensity == settings.LibraryDensity.normal
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.normal),
subtitle: Text(t.settings.normalDescription),
onTap: () async {
await settingsProvider.setLibraryDensity(
settings.LibraryDensity.normal,
);
if (context.mounted) Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
provider.libraryDensity ==
settings.LibraryDensity.comfortable
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.comfortable),
subtitle: Text(t.settings.comfortableDescription),
onTap: () async {
await settingsProvider.setLibraryDensity(
settings.LibraryDensity.comfortable,
);
if (context.mounted) Navigator.pop(context);
},
),
],
);
}).toList(),
),
actions: [
TextButton(
@@ -1385,62 +1239,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showLibraryDensityDialog() {
_showOptionSelectionDialog<settings.LibraryDensity>(
title: t.settings.libraryDensity,
options: [
_DialogOption(
value: settings.LibraryDensity.compact,
title: t.settings.compact,
subtitle: t.settings.compactDescription,
),
_DialogOption(
value: settings.LibraryDensity.normal,
title: t.settings.normal,
subtitle: t.settings.normalDescription,
),
_DialogOption(
value: settings.LibraryDensity.comfortable,
title: t.settings.comfortable,
subtitle: t.settings.comfortableDescription,
),
],
getCurrentValue: (p) => p.libraryDensity,
onSelect: (value, provider) => provider.setLibraryDensity(value),
);
}
void _showViewModeDialog() {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
context: context,
builder: (BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, provider, child) {
return AlertDialog(
title: Text(t.settings.viewMode),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: AppIcon(
provider.viewMode == settings.ViewMode.grid
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.gridView),
subtitle: Text(t.settings.gridViewDescription),
onTap: () async {
await settingsProvider.setViewMode(
settings.ViewMode.grid,
);
if (context.mounted) Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
provider.viewMode == settings.ViewMode.list
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.listView),
subtitle: Text(t.settings.listViewDescription),
onTap: () async {
await settingsProvider.setViewMode(
settings.ViewMode.list,
);
if (context.mounted) Navigator.pop(context);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
],
);
},
);
},
_showOptionSelectionDialog<settings.ViewMode>(
title: t.settings.viewMode,
options: [
_DialogOption(
value: settings.ViewMode.grid,
title: t.settings.gridView,
subtitle: t.settings.gridViewDescription,
),
_DialogOption(
value: settings.ViewMode.list,
title: t.settings.listView,
subtitle: t.settings.listViewDescription,
),
],
getCurrentValue: (p) => p.viewMode,
onSelect: (value, provider) => provider.setViewMode(value),
);
}
}
+151 -215
View File
@@ -4,6 +4,7 @@ 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/models/plex_metadata_extensions.dart';
import '../database/app_database.dart';
import 'settings_service.dart';
import '../models/download_status.dart';
@@ -15,6 +16,8 @@ import '../services/plex_client.dart';
import '../services/download_storage_service.dart';
import '../services/plex_api_cache.dart';
import '../utils/app_logger.dart';
import '../utils/codec_utils.dart';
import '../utils/plex_cache_parser.dart';
/// Extension methods on AppDatabase for download operations
extension DownloadDatabaseOperations on AppDatabase {
@@ -186,6 +189,20 @@ extension DownloadDatabaseOperations on AppDatabase {
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();
}
}
class DownloadManagerService {
@@ -242,6 +259,17 @@ class DownloadManagerService {
_storageService = storageService,
_dio = dio ?? Dio();
/// Delete a file if it exists and log the deletion
/// Returns true if file was deleted, false otherwise
Future<bool> _deleteFileIfExists(File file, String description) async {
if (await file.exists()) {
await file.delete();
appLogger.i('Deleted $description: ${file.path}');
return true;
}
return false;
}
/// Queue a download for a media item
Future<void> queueDownload({
required PlexMetadata metadata,
@@ -355,12 +383,8 @@ class DownloadManagerService {
appLogger.i('Starting download for $globalKey');
// Update status to downloading
await _database.updateDownloadStatus(
globalKey,
DownloadStatus.downloading.index,
);
appLogger.d('Status updated to downloading, emitting initial progress');
_emitProgress(globalKey, DownloadStatus.downloading, 0);
await _transitionStatus(globalKey, DownloadStatus.downloading);
appLogger.d('Status updated to downloading');
// Parse globalKey to get serverId and ratingKey
final parts = globalKey.split(':');
@@ -377,13 +401,12 @@ class DownloadManagerService {
}
// Parse metadata from cached response
final metadataList =
cachedResponse['MediaContainer']?['Metadata'] as List?;
if (metadataList == null || metadataList.isEmpty) {
final firstMetadata = PlexCacheParser.extractFirstMetadata(cachedResponse);
if (firstMetadata == null) {
throw Exception('Invalid cached metadata for $globalKey');
}
final metadata = PlexMetadata.fromJson(
metadataList[0],
firstMetadata,
).copyWith(serverId: serverId);
// Get video playback data (includes URL, streams, etc.)
@@ -411,12 +434,10 @@ class DownloadManagerService {
serverId,
'/library/metadata/${metadataWithServer.grandparentRatingKey}',
);
if (showCached != null) {
final showList = showCached['MediaContainer']?['Metadata'] as List?;
if (showList != null && showList.isNotEmpty) {
final showMetadata = PlexMetadata.fromJson(showList[0]);
showYear = showMetadata.year;
}
final showJson = PlexCacheParser.extractFirstMetadata(showCached);
if (showJson != null) {
final showMetadata = PlexMetadata.fromJson(showJson);
showYear = showMetadata.year;
}
}
@@ -552,12 +573,8 @@ class DownloadManagerService {
}
// Mark as completed
await _database.updateDownloadStatus(
globalKey,
DownloadStatus.completed.index,
);
await _transitionStatus(globalKey, DownloadStatus.completed);
await _database.removeFromQueue(globalKey);
_emitProgress(globalKey, DownloadStatus.completed, 100);
_activeDownloads.remove(globalKey);
@@ -573,19 +590,14 @@ class DownloadManagerService {
}
appLogger.e('Download failed for $globalKey', error: e);
await _database.updateDownloadStatus(
await _transitionStatus(
globalKey,
DownloadStatus.failed.index,
DownloadStatus.failed,
errorMessage: e.toString(),
);
await _database.updateDownloadError(globalKey, e.toString());
// Remove from queue to prevent endless retry loop
await _database.removeFromQueue(globalKey);
_emitProgress(
globalKey,
DownloadStatus.failed,
0,
errorMessage: e.toString(),
);
_activeDownloads.remove(globalKey);
}
}
@@ -818,18 +830,18 @@ class DownloadManagerService {
if (subtitleUrl == null) continue;
// Determine file extension
final extension = _getExtensionFromCodec(subtitle.codec);
final extension = CodecUtils.getSubtitleExtension(subtitle.codec);
// Get user-friendly subtitle path based on media type
final String subtitlePath;
if (metadata.type == 'episode') {
if (metadata.isEpisode) {
subtitlePath = await _storageService.getEpisodeSubtitlePath(
metadata,
subtitle.id,
extension,
showYear: showYear,
);
} else if (metadata.type == 'movie') {
} else if (metadata.isMovie) {
subtitlePath = await _storageService.getMovieSubtitlePath(
metadata,
subtitle.id,
@@ -867,33 +879,6 @@ class DownloadManagerService {
return path.substring(lastDot + 1).split('?').first;
}
String _getExtensionFromCodec(String? codec) {
if (codec == null) return 'srt';
switch (codec.toLowerCase()) {
case 'subrip':
case 'srt':
return 'srt';
case 'ass':
return 'ass';
case 'ssa':
return 'ssa';
case 'webvtt':
case 'vtt':
return 'vtt';
case 'mov_text':
return 'srt';
case 'pgs':
case 'hdmv_pgs_subtitle':
return 'sup';
case 'dvd_subtitle':
case 'dvdsub':
return 'sub';
default:
return 'srt';
}
}
void _emitProgress(
String globalKey,
DownloadStatus status,
@@ -912,6 +897,28 @@ class DownloadManagerService {
);
}
/// Update download status in database and emit progress notification.
///
/// This helper combines two common operations:
/// 1. Update status in the database
/// 2. Emit progress to listeners
///
/// Default progress is 0 for most statuses, 100 for completed.
Future<void> _transitionStatus(
String globalKey,
DownloadStatus status, {
int? progress,
String? errorMessage,
}) async {
await _database.updateDownloadStatus(globalKey, status.index);
_emitProgress(
globalKey,
status,
progress ?? (status == DownloadStatus.completed ? 100 : 0),
errorMessage: errorMessage,
);
}
/// Emit progress update with artwork paths so DownloadProvider can sync
void _emitProgressWithArtwork(String globalKey, {String? thumbPath}) {
// Emit a progress update containing artwork path
@@ -935,25 +942,16 @@ class DownloadManagerService {
cancelToken.cancel('Paused by user');
_activeDownloads.remove(globalKey);
}
// Update status to paused
await _database.updateDownloadStatus(
globalKey,
DownloadStatus.paused.index,
);
// Remove from queue so it doesn't restart
// Update status to paused and remove from queue so it doesn't restart
await _transitionStatus(globalKey, DownloadStatus.paused);
await _database.removeFromQueue(globalKey);
_emitProgress(globalKey, DownloadStatus.paused, 0);
}
/// Resume a paused download
Future<void> resumeDownload(String globalKey, PlexClient client) async {
await _database.updateDownloadStatus(
globalKey,
DownloadStatus.queued.index,
);
await _transitionStatus(globalKey, DownloadStatus.queued);
// Re-add to queue (pauseDownload removes from queue)
await _database.addToQueue(mediaGlobalKey: globalKey);
_emitProgress(globalKey, DownloadStatus.queued, 0);
_processQueue(client);
}
@@ -962,13 +960,9 @@ class DownloadManagerService {
// Clear error and reset retry count
await _database.clearDownloadError(globalKey);
// Reset status to queued
await _database.updateDownloadStatus(
globalKey,
DownloadStatus.queued.index,
);
await _transitionStatus(globalKey, DownloadStatus.queued);
// Re-add to queue
await _database.addToQueue(mediaGlobalKey: globalKey);
_emitProgress(globalKey, DownloadStatus.queued, 0);
_processQueue(client);
}
@@ -979,12 +973,8 @@ class DownloadManagerService {
cancelToken.cancel('Cancelled by user');
_activeDownloads.remove(globalKey);
}
await _database.updateDownloadStatus(
globalKey,
DownloadStatus.cancelled.index,
);
await _transitionStatus(globalKey, DownloadStatus.cancelled);
await _database.removeFromQueue(globalKey);
_emitProgress(globalKey, DownloadStatus.cancelled, 0);
}
/// Delete a downloaded item and its files
@@ -1065,17 +1055,11 @@ class DownloadManagerService {
return 1; // Single movie
case 'season':
// Count episodes in season
final seasonKey = metadata.ratingKey;
final episodes = await (_database.select(
_database.downloadedMedia,
)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
final episodes = await _database.getEpisodesBySeason(metadata.ratingKey);
return episodes.length;
case 'show':
// Count all episodes in show
final showKey = metadata.ratingKey;
final episodes = await (_database.select(
_database.downloadedMedia,
)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
final episodes = await _database.getEpisodesByShow(metadata.ratingKey);
return episodes.length;
default:
return 1;
@@ -1135,13 +1119,9 @@ class DownloadManagerService {
serverId,
'/library/metadata/$ratingKey',
);
if (cachedData != null && cachedData['MediaContainer'] != null) {
final container = cachedData['MediaContainer'] as Map<String, dynamic>;
if (container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
final metadataJson = container['Metadata'][0] as Map<String, dynamic>;
return PlexMetadata.fromJson(metadataJson).copyWith(serverId: serverId);
}
final metadataJson = PlexCacheParser.extractFirstMetadata(cachedData);
if (metadataJson != null) {
return PlexMetadata.fromJson(metadataJson).copyWith(serverId: serverId);
}
return null;
}
@@ -1156,34 +1136,14 @@ class DownloadManagerService {
serverId,
'/library/metadata/$ratingKey',
);
final chapters = PlexCacheParser.extractChapters(cachedData);
if (chapters == null) return [];
if (cachedData == null || cachedData['MediaContainer'] == null) {
return [];
}
final container = cachedData['MediaContainer'] as Map<String, dynamic>;
final metadataList = container['Metadata'] as List?;
if (metadataList == null || metadataList.isEmpty) {
return [];
}
final metadata = metadataList[0] as Map<String, dynamic>;
final chapters = metadata['Chapter'] as List?;
if (chapters == null) {
return [];
}
final thumbPaths = <String>[];
for (final chapter in chapters) {
final thumbPath = chapter['thumb'] as String?;
if (thumbPath != null && thumbPath.isNotEmpty) {
thumbPaths.add(thumbPath);
}
}
return thumbPaths;
return chapters
.map((ch) => ch['thumb'] as String?)
.where((thumb) => thumb != null && thumb.isNotEmpty)
.cast<String>()
.toList();
} catch (e) {
appLogger.w('Error getting chapter thumb paths for $ratingKey', error: e);
return [];
@@ -1266,12 +1226,11 @@ class DownloadManagerService {
serverId,
thumbPath,
);
final file = File(artworkPath);
if (await file.exists()) {
await file.delete();
if (await _deleteFileIfExists(
File(artworkPath),
'chapter thumbnail',
)) {
deletedCount++;
appLogger.d('Deleted chapter thumbnail: $thumbPath');
}
} catch (e) {
appLogger.w(
@@ -1321,9 +1280,8 @@ class DownloadManagerService {
final actualVideoFile = await _findFileWithAnyExtension(
videoPathWithoutExt,
);
if (actualVideoFile != null && await actualVideoFile.exists()) {
await actualVideoFile.delete();
appLogger.i('Deleted episode video: ${actualVideoFile.path}');
if (actualVideoFile != null) {
await _deleteFileIfExists(actualVideoFile, 'episode video');
}
// Delete thumbnail
@@ -1331,11 +1289,7 @@ class DownloadManagerService {
episode,
showYear: showYear,
);
final thumbFile = File(thumbPath);
if (await thumbFile.exists()) {
await thumbFile.delete();
appLogger.i('Deleted episode thumbnail: $thumbPath');
}
await _deleteFileIfExists(File(thumbPath), 'episode thumbnail');
// Delete subtitles directory
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(
@@ -1366,42 +1320,18 @@ class DownloadManagerService {
final showYear = parentMetadata?.year;
// Get all episodes in this season
final seasonKey = season.ratingKey;
final episodesInSeason = await (_database.select(
_database.downloadedMedia,
)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
final episodesInSeason =
await _database.getEpisodesBySeason(season.ratingKey);
appLogger.d(
'Deleting ${episodesInSeason.length} episodes in season $seasonKey',
'Deleting ${episodesInSeason.length} episodes in season ${season.ratingKey}',
);
await _deleteEpisodesInCollection(
episodes: episodesInSeason,
serverId: serverId,
parentKey: season.ratingKey,
parentTitle: season.title,
);
for (int i = 0; i < episodesInSeason.length; i++) {
final episode = episodesInSeason[i];
final episodeGlobalKey = '$serverId:${episode.ratingKey}';
// Emit progress update
_emitDeletionProgress(
DeletionProgress(
globalKey: '$serverId:$seasonKey',
itemTitle: season.title,
currentItem: i + 1,
totalItems: episodesInSeason.length,
currentOperation:
'Deleting episode ${i + 1} of ${episodesInSeason.length}',
),
);
// Delete chapter thumbnails
await _deleteChapterThumbnails(serverId, episode.ratingKey);
// Delete episode files (video, subtitles) if stored outside season directory
await _deleteByFilePath(episode);
// Delete episode from API cache
await _apiCache.deleteForItem(serverId, episode.ratingKey);
// Delete episode DB entry
await _database.deleteDownload(episodeGlobalKey);
}
final seasonDir = await _storageService.getSeasonDirectory(
season,
@@ -1418,46 +1348,59 @@ class DownloadManagerService {
}
}
/// Delete episodes in a collection (season or show)
/// Returns the number of episodes deleted
Future<void> _deleteEpisodesInCollection({
required List<DownloadedMediaItem> episodes,
required String serverId,
required String parentKey,
required String parentTitle,
}) async {
for (int i = 0; i < episodes.length; i++) {
final episode = episodes[i];
final episodeGlobalKey = '$serverId:${episode.ratingKey}';
// Emit progress update
_emitDeletionProgress(
DeletionProgress(
globalKey: '$serverId:$parentKey',
itemTitle: parentTitle,
currentItem: i + 1,
totalItems: episodes.length,
currentOperation:
'Deleting episode ${i + 1} of ${episodes.length}',
),
);
// Delete chapter thumbnails
await _deleteChapterThumbnails(serverId, episode.ratingKey);
// Delete episode files (video, subtitles)
await _deleteByFilePath(episode);
// Delete episode from API cache
await _apiCache.deleteForItem(serverId, episode.ratingKey);
// Delete episode DB entry
await _database.deleteDownload(episodeGlobalKey);
}
}
/// Delete show files
Future<void> _deleteShowFiles(PlexMetadata show, String serverId) async {
try {
// Get all episodes in this show
final showKey = show.ratingKey;
final episodesInShow = await (_database.select(
_database.downloadedMedia,
)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
final episodesInShow = await _database.getEpisodesByShow(show.ratingKey);
appLogger.d(
'Deleting ${episodesInShow.length} episodes in show $showKey',
'Deleting ${episodesInShow.length} episodes in show ${show.ratingKey}',
);
await _deleteEpisodesInCollection(
episodes: episodesInShow,
serverId: serverId,
parentKey: show.ratingKey,
parentTitle: show.title,
);
for (int i = 0; i < episodesInShow.length; i++) {
final episode = episodesInShow[i];
final episodeGlobalKey = '$serverId:${episode.ratingKey}';
// Emit progress update
_emitDeletionProgress(
DeletionProgress(
globalKey: '$serverId:$showKey',
itemTitle: show.title,
currentItem: i + 1,
totalItems: episodesInShow.length,
currentOperation:
'Deleting episode ${i + 1} of ${episodesInShow.length}',
),
);
// Delete chapter thumbnails
await _deleteChapterThumbnails(serverId, episode.ratingKey);
// Delete episode files (video, subtitles) if stored outside show directory
await _deleteByFilePath(episode);
// Delete episode from API cache
await _apiCache.deleteForItem(serverId, episode.ratingKey);
// Delete episode DB entry
await _database.deleteDownload(episodeGlobalKey);
}
final showDir = await _storageService.getShowDirectory(show);
if (await showDir.exists()) {
@@ -1550,9 +1493,7 @@ class DownloadManagerService {
final seasonKey = episode.parentRatingKey;
if (seasonKey == null) return false;
final otherEpisodes = await (_database.select(
_database.downloadedMedia,
)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
final otherEpisodes = await _database.getEpisodesBySeason(seasonKey);
// Check if any episodes besides this one
return otherEpisodes.any(
@@ -1608,12 +1549,11 @@ class DownloadManagerService {
final videoPath = await _storageService.toAbsolutePath(
record.videoFilePath!,
);
final videoFile = File(videoPath);
if (await videoFile.exists()) {
await videoFile.delete();
appLogger.i('Deleted video file: $videoPath');
final videoDeleted =
await _deleteFileIfExists(File(videoPath), 'video file');
// Delete subtitle directory
// Delete subtitle directory if video was deleted
if (videoDeleted) {
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
final subsDir = Directory(subsPath);
if (await subsDir.exists()) {
@@ -1627,11 +1567,7 @@ class DownloadManagerService {
final thumbPath = await _storageService.toAbsolutePath(
record.thumbPath!,
);
final thumbFile = File(thumbPath);
if (await thumbFile.exists()) {
await thumbFile.delete();
appLogger.i('Deleted thumbnail: $thumbPath');
}
await _deleteFileIfExists(File(thumbPath), 'thumbnail');
}
} catch (e, stack) {
appLogger.e('Error in fallback deletion', error: e, stackTrace: stack);
+10 -13
View File
@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../models/plex_metadata.dart';
import '../utils/byte_formatter.dart';
import '../utils/number_formatter.dart';
import 'settings_service.dart';
import 'saf_storage_service.dart';
@@ -65,10 +67,12 @@ class DownloadStorageService {
/// Format episode filename base: S{XX}E{XX} - {Title}
String _formatEpisodeFileName(PlexMetadata episode) {
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final seCode = NumberFormatter.formatSeasonEpisode(
episode.parentIndex,
episode.index,
);
final episodeName = _sanitizeFileName(episode.title);
return 'S${seasonNum}E$episodeNum - $episodeName';
return '$seCode - $episodeName';
}
/// Check if using custom download path
@@ -346,7 +350,7 @@ class DownloadStorageService {
int? showYear,
}) async {
final showDir = await getShowDirectory(metadata, showYear: showYear);
final seasonNum = (metadata.parentIndex ?? 0).toString().padLeft(2, '0');
final seasonNum = NumberFormatter.formatSeason(metadata.parentIndex);
return _ensureDirectoryExists(
Directory(path.join(showDir.path, 'Season $seasonNum')),
);
@@ -526,14 +530,7 @@ class DownloadStorageService {
}
/// Format bytes to human readable string
static String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
static String formatBytes(int bytes) => ByteFormatter.formatBytes(bytes);
// ============================================================
// SAF (Storage Access Framework) SUPPORT FOR ANDROID
@@ -644,7 +641,7 @@ class DownloadStorageService {
int? showYear,
}) {
final showFolder = _getShowFolderName(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final seasonNum = NumberFormatter.formatSeason(episode.parentIndex);
return ['TV Shows', showFolder, 'Season $seasonNum'];
}
+41 -85
View File
@@ -18,6 +18,8 @@ import '../models/plex_video_playback_data.dart';
import '../utils/endpoint_failover_interceptor.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/plex_cache_parser.dart';
import '../utils/plex_url_helper.dart';
import 'plex_api_cache.dart';
/// Constants for Plex stream types
@@ -245,17 +247,21 @@ class PlexClient {
return null;
}
/// Tag a PlexMetadata with this client's serverId and serverName
PlexMetadata _tagMetadata(PlexMetadata metadata) =>
metadata.copyWith(serverId: serverId, serverName: serverName);
/// Create and tag a PlexMetadata from JSON
PlexMetadata _createTaggedMetadata(Map<String, dynamic> json) =>
_tagMetadata(PlexMetadata.fromJson(json));
/// Extract list of PlexMetadata from response
/// Automatically tags all items with this client's serverId and serverName
List<PlexMetadata> _extractMetadataList(Response response) {
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List)
.map(
(json) => PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
)
.map((json) => _createTaggedMetadata(json))
.toList();
}
return [];
@@ -363,15 +369,9 @@ class PlexClient {
List<PlexMetadata> _parseMetadataListFromCachedResponse(
Map<String, dynamic> cached,
) {
if (cached['MediaContainer'] != null &&
cached['MediaContainer']['Metadata'] != null) {
return (cached['MediaContainer']['Metadata'] as List)
.map(
(json) => PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
)
.toList();
final metadataList = PlexCacheParser.extractMetadataList(cached);
if (metadataList != null) {
return metadataList.map((json) => _createTaggedMetadata(json)).toList();
}
return [];
}
@@ -433,9 +433,7 @@ class PlexClient {
final metadataJson = _getFirstMetadataJson(response);
if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName);
metadata = _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson));
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
@@ -446,9 +444,7 @@ class PlexClient {
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
final onDeckMetadata = onDeckData['Metadata'];
if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(
onDeckMetadata,
).copyWith(serverId: serverId, serverName: serverName);
onDeckEpisode = _createTaggedMetadata(onDeckMetadata);
}
}
}
@@ -477,9 +473,7 @@ class PlexClient {
parseResponse: (response) {
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName)
? _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson))
: null;
},
);
@@ -489,12 +483,9 @@ class PlexClient {
PlexMetadata? _parseMetadataWithImagesFromCachedResponse(
Map<String, dynamic> cached,
) {
if (cached['MediaContainer'] != null &&
cached['MediaContainer']['Metadata'] != null &&
(cached['MediaContainer']['Metadata'] as List).isNotEmpty) {
return PlexMetadata.fromJsonWithImages(
cached['MediaContainer']['Metadata'][0],
).copyWith(serverId: serverId, serverName: serverName);
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
if (firstMetadata != null) {
return _tagMetadata(PlexMetadata.fromJsonWithImages(firstMetadata));
}
return null;
}
@@ -569,16 +560,10 @@ class PlexClient {
}
/// Get first metadata JSON from response data
Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) {
if (data == null) return null;
final container = data['MediaContainer'];
if (container != null &&
container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
return container['Metadata'][0];
}
return null;
}
Map<String, dynamic>? _getFirstMetadataJsonFromData(
Map<String, dynamic>? data,
) =>
PlexCacheParser.extractFirstMetadata(data);
/// Wraps an API call that returns a boolean success status
Future<bool> _wrapBoolApiCall(
@@ -757,8 +742,8 @@ class PlexClient {
final results = <PlexMetadata>[];
if (response.data is Map && response.data.containsKey('MediaContainer')) {
final container = response.data['MediaContainer'];
final container = _getMediaContainer(response);
if (container != null) {
if (container['Hub'] != null) {
// Each hub contains results of a specific type (movies, shows, etc.)
for (final hub in container['Hub'] as List) {
@@ -773,11 +758,7 @@ class PlexClient {
if (hub['Metadata'] != null) {
for (final json in hub['Metadata'] as List) {
try {
results.add(
PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
);
results.add(_createTaggedMetadata(json));
} catch (e) {
// Skip items that fail to parse
appLogger.w('Failed to parse search result', error: e);
@@ -788,11 +769,7 @@ class PlexClient {
if (hub['Directory'] != null) {
for (final json in hub['Directory'] as List) {
try {
results.add(
PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
);
results.add(_createTaggedMetadata(json));
} catch (e) {
// Skip items that fail to parse
appLogger.w('Failed to parse search result', error: e);
@@ -825,11 +802,7 @@ class PlexClient {
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
final allItems = (container['Metadata'] as List)
.map(
(json) => PlexMetadata.fromJsonWithImages(
json,
).copyWith(serverId: serverId, serverName: serverName),
)
.map((json) => _tagMetadata(PlexMetadata.fromJsonWithImages(json)))
.toList();
// Filter out music content (artists, albums, tracks)
@@ -906,10 +879,7 @@ class PlexClient {
// Remove leading slash if present
final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath;
// Check if path already has query parameters
final separator = path.contains('?') ? '&' : '?';
return '${config.baseUrl}/$path${separator}X-Plex-Token=${config.token}';
return '${config.baseUrl}/$path'.withPlexToken(config.token);
}
/// Get video URL for direct playback
@@ -936,7 +906,7 @@ class PlexClient {
if (partKey != null) {
// Return direct play URL
return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
return '${config.baseUrl}$partKey'.withPlexToken(config.token);
}
}
}
@@ -1011,8 +981,7 @@ class PlexClient {
appLogger.d('getPlaybackExtras: serverId=$serverId, cacheKey=$cacheKey');
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
final chapters =
cached['MediaContainer']?['Metadata']?[0]?['Chapter'] as List?;
final chapters = PlexCacheParser.extractChapters(cached);
appLogger.d(
'getPlaybackExtras: cache hit, ${chapters?.length ?? 0} chapters',
);
@@ -1050,13 +1019,7 @@ class PlexClient {
PlaybackExtras _parsePlaybackExtrasFromCachedResponse(
Map<String, dynamic> cached,
) {
Map<String, dynamic>? metadataJson;
if (cached['MediaContainer'] != null &&
cached['MediaContainer']['Metadata'] != null &&
(cached['MediaContainer']['Metadata'] as List).isNotEmpty) {
metadataJson =
cached['MediaContainer']['Metadata'][0] as Map<String, dynamic>;
}
final metadataJson = PlexCacheParser.extractFirstMetadata(cached);
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
}
@@ -1136,7 +1099,7 @@ class PlexClient {
final chapters = _parseChapters(metadataJson);
return PlexMediaInfo(
videoUrl: '${config.baseUrl}$partKey?X-Plex-Token=${config.token}',
videoUrl: '${config.baseUrl}$partKey'.withPlexToken(config.token),
audioTracks: streams.audio,
subtitleTracks: streams.subtitles,
chapters: chapters,
@@ -1208,7 +1171,7 @@ class PlexClient {
if (partKey != null) {
// Get video URL
videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token);
// Parse streams using helper
final streams = _parseStreams(part['Stream'] as List<dynamic>?);
@@ -1813,9 +1776,10 @@ class PlexClient {
// Extract the collection ID from the response
// The response should contain the created collection metadata
if (response.data != null && response.data['MediaContainer'] != null) {
final metadata = response.data['MediaContainer']['Metadata'];
if (metadata != null && metadata.isNotEmpty) {
final container = _getMediaContainer(response);
if (container != null) {
final metadata = container['Metadata'];
if (metadata != null && (metadata as List).isNotEmpty) {
final collectionId = metadata[0]['ratingKey']?.toString();
appLogger.d('Created collection with ID: $collectionId');
return collectionId;
@@ -2031,11 +1995,7 @@ class PlexClient {
for (final json in container['Metadata'] as List) {
try {
// Try to parse with full PlexMetadata.fromJson first
items.add(
PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
);
items.add(_createTaggedMetadata(json));
} catch (e) {
// If full parsing fails, use minimal safe parsing
appLogger.d('Using minimal parsing for metadata item: $e');
@@ -2065,11 +2025,7 @@ class PlexClient {
for (final json in container['Directory'] as List) {
try {
// Try to parse as PlexMetadata first
items.add(
PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
);
items.add(_createTaggedMetadata(json));
} catch (e) {
// If that fails, use minimal folder representation
try {
+1
View File
@@ -145,6 +145,7 @@ ThemeData monoTheme({required bool dark}) {
space: 12,
fast: const Duration(milliseconds: 120),
normal: const Duration(milliseconds: 200),
slow: const Duration(milliseconds: 300),
bg: c.bg,
surface: c.surface,
outline: c.outline,
+11
View File
@@ -8,6 +8,7 @@ class MonoTokens extends ThemeExtension<MonoTokens> {
final double space;
final Duration fast;
final Duration normal;
final Duration slow;
final Color bg;
final Color surface;
final Color outline;
@@ -21,6 +22,7 @@ class MonoTokens extends ThemeExtension<MonoTokens> {
required this.space,
required this.fast,
required this.normal,
required this.slow,
required this.bg,
required this.surface,
required this.outline,
@@ -36,6 +38,7 @@ class MonoTokens extends ThemeExtension<MonoTokens> {
double? space,
Duration? fast,
Duration? normal,
Duration? slow,
Color? bg,
Color? surface,
Color? outline,
@@ -48,6 +51,7 @@ class MonoTokens extends ThemeExtension<MonoTokens> {
space: space ?? this.space,
fast: fast ?? this.fast,
normal: normal ?? this.normal,
slow: slow ?? this.slow,
bg: bg ?? this.bg,
surface: surface ?? this.surface,
outline: outline ?? this.outline,
@@ -78,6 +82,13 @@ class MonoTokens extends ThemeExtension<MonoTokens> {
t,
)!.round(),
),
slow: Duration(
milliseconds: lerpDouble(
slow.inMilliseconds.toDouble(),
other.slow.inMilliseconds.toDouble(),
t,
)!.round(),
),
bg: lerpC(bg, other.bg),
surface: lerpC(surface, other.surface),
outline: lerpC(outline, other.outline),
+61
View File
@@ -0,0 +1,61 @@
/// Utility class for formatting byte sizes and speeds
class ByteFormatter {
ByteFormatter._();
static const int _kb = 1024;
static const int _mb = _kb * 1024;
static const int _gb = _mb * 1024;
/// Format bytes to human-readable string (e.g., "1.5 GB", "256.3 MB")
///
/// [bytes] The number of bytes to format
/// [decimals] Number of decimal places (default: 1 for KB/MB, 2 for GB)
static String formatBytes(int bytes, {int? decimals}) {
if (bytes < _kb) return '$bytes B';
if (bytes < _mb) {
return '${(bytes / _kb).toStringAsFixed(decimals ?? 1)} KB';
}
if (bytes < _gb) {
return '${(bytes / _mb).toStringAsFixed(decimals ?? 1)} MB';
}
return '${(bytes / _gb).toStringAsFixed(decimals ?? 2)} GB';
}
/// Format speed in bytes per second to human-readable string
///
/// [bytesPerSecond] The speed in bytes per second
static String formatSpeed(double bytesPerSecond) {
if (bytesPerSecond < _kb) {
return '${bytesPerSecond.toStringAsFixed(0)} B/s';
}
if (bytesPerSecond < _mb) {
return '${(bytesPerSecond / _kb).toStringAsFixed(1)} KB/s';
}
return '${(bytesPerSecond / _mb).toStringAsFixed(1)} MB/s';
}
/// Format bitrate in kbps to human-readable string
///
/// [kbps] The bitrate in kilobits per second
static String formatBitrate(int kbps) {
if (kbps < 1000) return '$kbps kbps';
return '${(kbps / 1000).toStringAsFixed(1)} Mbps';
}
/// Format bitrate in bps to human-readable string
///
/// [bps] The bitrate in bits per second
/// Returns formatted string like "8.5 Mbps", "256 Kbps", or "128 bps"
static String formatBitrateBps(int bps) {
const kbps = 1000;
const mbps = kbps * 1000;
if (bps >= mbps) {
return '${(bps / mbps).toStringAsFixed(2)} Mbps';
} else if (bps >= kbps) {
return '${(bps / kbps).toStringAsFixed(2)} Kbps';
} else {
return '$bps bps';
}
}
}
+91
View File
@@ -0,0 +1,91 @@
/// Utility class for codec-related operations.
///
/// Provides centralized codec name mappings, file extension lookups,
/// and display name formatting.
class CodecUtils {
CodecUtils._();
/// Maps Plex subtitle codec names to file extensions.
///
/// Returns the appropriate file extension for a given subtitle codec.
/// Defaults to 'srt' for unknown or null codecs.
static String getSubtitleExtension(String? codec) {
if (codec == null) return 'srt';
switch (codec.toLowerCase()) {
case 'subrip':
case 'srt':
return 'srt';
case 'ass':
return 'ass';
case 'ssa':
return 'ssa';
case 'webvtt':
case 'vtt':
return 'vtt';
case 'mov_text':
return 'srt';
case 'pgs':
case 'hdmv_pgs_subtitle':
return 'sup';
case 'dvd_subtitle':
case 'dvdsub':
return 'sub';
default:
return 'srt';
}
}
/// Formats a subtitle codec name to a user-friendly display format.
///
/// Converts internal codec names like 'SUBRIP' to friendly names like 'SRT'.
static String formatSubtitleCodec(String codec) {
final upper = codec.toUpperCase();
return switch (upper) {
'SUBRIP' => 'SRT',
'DVD_SUBTITLE' => 'DVD',
'WEBVTT' => 'VTT',
'HDMV_PGS_SUBTITLE' => 'PGS',
'MOV_TEXT' => 'MOV',
_ => upper,
};
}
/// Formats a video codec name to a user-friendly display format.
///
/// Converts internal codec names like 'hevc' to friendly names like 'HEVC'.
static String formatVideoCodec(String codec) {
final lower = codec.toLowerCase();
return switch (lower) {
'h264' || 'avc1' || 'avc' => 'H.264',
'hevc' || 'h265' || 'hev1' => 'HEVC',
'av1' => 'AV1',
'vp8' => 'VP8',
'vp9' => 'VP9',
'mpeg2video' || 'mpeg2' => 'MPEG-2',
'mpeg4' => 'MPEG-4',
'vc1' => 'VC-1',
_ => codec.toUpperCase(),
};
}
/// Formats an audio codec name to a user-friendly display format.
static String formatAudioCodec(String codec) {
final lower = codec.toLowerCase();
return switch (lower) {
'aac' => 'AAC',
'ac3' => 'AC3',
'eac3' || 'ec3' => 'E-AC3',
'truehd' => 'TrueHD',
'dts' => 'DTS',
'dca' => 'DTS',
'dtshd' || 'dts-hd' => 'DTS-HD',
'flac' => 'FLAC',
'mp3' || 'mp3float' => 'MP3',
'opus' => 'Opus',
'vorbis' => 'Vorbis',
'pcm_s16le' || 'pcm_s24le' || 'pcm' => 'PCM',
_ => codec.toUpperCase(),
};
}
}
+48 -37
View File
@@ -1,56 +1,67 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
/// Content type constants used throughout the app
class ContentTypes {
ContentTypes._();
static const String movie = 'movie';
static const String show = 'show';
static const String season = 'season';
static const String episode = 'episode';
static const String artist = 'artist';
static const String album = 'album';
static const String track = 'track';
static const String collection = 'collection';
static const String playlist = 'playlist';
static const String clip = 'clip';
static const Set<String> musicTypes = {artist, album, track};
static const Set<String> videoTypes = {movie, show, season, episode};
static const Set<String> playableTypes = {movie, episode, clip, track};
}
/// Utility class for content type checking and filtering
class ContentTypeHelper {
ContentTypeHelper._();
/// Checks if the given type is music content (artist, album, or track)
///
/// [type] The content type string to check
/// Returns true if the type is artist, album, or track (case-insensitive)
static bool isMusicContent(String type) {
final lowerType = type.toLowerCase();
return lowerType == 'artist' ||
lowerType == 'album' ||
lowerType == 'track';
}
static bool isMusicContent(String type) =>
ContentTypes.musicTypes.contains(type.toLowerCase());
/// Checks if the given type is video content (movie, show, episode, or season)
static bool isVideoContent(String type) =>
ContentTypes.videoTypes.contains(type.toLowerCase());
/// Checks if the given library is a music library
///
/// [lib] The library object to check (must have a 'type' property)
/// Returns true if the library type is 'artist' (case-insensitive)
static bool isMusicLibrary(dynamic lib) {
if (lib == null) return false;
try {
final type = (lib as dynamic).type as String?;
return type?.toLowerCase() == 'artist';
return type?.toLowerCase() == ContentTypes.artist;
} catch (e) {
return false;
}
}
/// Checks if the given type is video content (movie, show, episode, or season)
///
/// [type] The content type string to check
/// Returns true if the type is movie, show, episode, or season (case-insensitive)
static bool isVideoContent(String type) {
final lowerType = type.toLowerCase();
return lowerType == 'movie' ||
lowerType == 'show' ||
lowerType == 'episode' ||
lowerType == 'season';
}
/// Filters out music content from a list of items
///
/// [items] The list of items to filter
/// [getType] A function that extracts the type string from each item
/// Returns a new list with music content removed
///
/// Example:
/// ```dart
/// final filtered = ContentTypeHelper.filterOutMusic(
/// items,
/// (item) => item.type,
/// );
/// ```
static List<T> filterOutMusic<T>(List<T> items, String Function(T) getType) {
return items.where((item) => !isMusicContent(getType(item))).toList();
}
/// Returns the appropriate icon for a given library type
static IconData getLibraryIcon(String type) {
switch (type.toLowerCase()) {
case ContentTypes.movie:
return Symbols.movie_rounded;
case ContentTypes.show:
return Symbols.tv_rounded;
case ContentTypes.artist:
return Symbols.music_note_rounded;
case 'photo':
return Symbols.photo_rounded;
default:
return Symbols.folder_rounded;
}
}
}
+165
View File
@@ -3,6 +3,86 @@ import '../i18n/strings.g.dart';
/// Utility functions for showing common dialogs
/// Shows a loading dialog that cannot be dismissed by tapping outside.
///
/// Returns a function to close the dialog when the operation completes.
/// Usage:
/// ```dart
/// final close = showLoadingDialog(context);
/// try {
/// await performOperation();
/// } finally {
/// close();
/// }
/// ```
VoidCallback showLoadingDialog(BuildContext context, {String? message}) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => PopScope(
canPop: false,
child: Center(
child: message != null
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Material(
color: Colors.transparent,
child: Text(
message,
style: const TextStyle(color: Colors.white),
),
),
],
)
: const CircularProgressIndicator(),
),
),
);
return () {
if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
};
}
/// Shows a generic confirmation dialog with customizable button text.
/// Returns true if confirmed, false if cancelled.
Future<bool> showConfirmation(
BuildContext context, {
required String title,
required String message,
String? confirmText,
String? cancelText,
Color? confirmColor,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(cancelText ?? t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: confirmColor != null
? TextButton.styleFrom(foregroundColor: confirmColor)
: null,
child: Text(confirmText ?? t.common.confirm),
),
],
),
);
return confirmed ?? false;
}
/// Shows a delete confirmation dialog
/// Returns true if user confirmed, false if cancelled
Future<bool> showDeleteConfirmation(
@@ -31,3 +111,88 @@ Future<bool> showDeleteConfirmation(
return confirmed ?? false;
}
/// Shows a text input dialog for creating/naming items
/// Returns the entered text, or null if cancelled
Future<String?> showTextInputDialog(
BuildContext context, {
required String title,
required String labelText,
required String hintText,
String? initialValue,
}) async {
return showDialog<String>(
context: context,
builder: (context) => _TextInputDialog(
title: title,
labelText: labelText,
hintText: hintText,
initialValue: initialValue,
),
);
}
class _TextInputDialog extends StatefulWidget {
final String title;
final String labelText;
final String hintText;
final String? initialValue;
const _TextInputDialog({
required this.title,
required this.labelText,
required this.hintText,
this.initialValue,
});
@override
State<_TextInputDialog> createState() => _TextInputDialogState();
}
class _TextInputDialogState extends State<_TextInputDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
if (_controller.text.isNotEmpty) {
Navigator.pop(context, _controller.text);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
labelText: widget.labelText,
hintText: widget.hintText,
),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
TextButton(
onPressed: _submit,
child: Text(t.common.save),
),
],
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/widgets.dart';
/// Utility class for common focus operations
class FocusUtils {
FocusUtils._();
/// Request focus on a FocusNode after the current frame completes.
/// Safely checks if the State is still mounted before requesting focus.
///
/// Usage:
/// ```dart
/// FocusUtils.requestFocusAfterBuild(this, _focusNode);
/// ```
static void requestFocusAfterBuild(State state, FocusNode focusNode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (state.mounted) {
focusNode.requestFocus();
}
});
}
/// Execute a callback after the current frame completes, with mounted check.
/// The callback will only execute if the State is still mounted.
///
/// Usage:
/// ```dart
/// FocusUtils.afterBuildIfMounted(this, () {
/// // do something
/// });
/// ```
static void afterBuildIfMounted(State state, VoidCallback callback) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (state.mounted) {
callback();
}
});
}
/// Execute a callback after the current frame completes, without mounted check.
/// Use this when you don't need the mounted check or are managing it yourself.
///
/// Usage:
/// ```dart
/// FocusUtils.afterBuild(() {
/// // do something
/// });
/// ```
static void afterBuild(VoidCallback callback) {
WidgetsBinding.instance.addPostFrameCallback((_) {
callback();
});
}
}
+32
View File
@@ -0,0 +1,32 @@
/// Utility class for formatting numbers consistently across the app.
class NumberFormatter {
NumberFormatter._();
/// Formats a season number with leading zeros (e.g., "01", "02", "10").
///
/// Used for consistent season display in file names and UI.
static String formatSeason(int? seasonNumber) {
return (seasonNumber ?? 0).toString().padLeft(2, '0');
}
/// Formats an episode number with leading zeros (e.g., "01", "02", "10").
///
/// Used for consistent episode display in file names and UI.
static String formatEpisode(int? episodeNumber) {
return (episodeNumber ?? 0).toString().padLeft(2, '0');
}
/// Formats a season and episode as "SXXEXX" (e.g., "S01E05", "S12E23").
///
/// Commonly used for episode identifiers in file names.
static String formatSeasonEpisode(int? season, int? episode) {
return 'S${formatSeason(season)}E${formatEpisode(episode)}';
}
/// Formats a number with a minimum number of digits using leading zeros.
///
/// Example: `padNumber(5, 3)` returns "005"
static String padNumber(int number, int width) {
return number.toString().padLeft(width, '0');
}
}
+57
View File
@@ -0,0 +1,57 @@
/// Utility class for parsing Plex API cache responses
///
/// Provides consistent extraction of MediaContainer data across the codebase.
class PlexCacheParser {
PlexCacheParser._();
/// Extract the Metadata list from a cached response
///
/// Returns null if MediaContainer or Metadata is not present
static List<dynamic>? extractMetadataList(Map<String, dynamic>? cached) {
if (cached == null) return null;
return cached['MediaContainer']?['Metadata'] as List?;
}
/// Extract the first metadata item from a cached response
///
/// Returns null if no metadata exists
static Map<String, dynamic>? extractFirstMetadata(
Map<String, dynamic>? cached,
) {
final list = extractMetadataList(cached);
if (list == null || list.isEmpty) return null;
return list[0] as Map<String, dynamic>;
}
/// Check if a cached response has valid metadata
static bool hasMetadata(Map<String, dynamic>? cached) {
final list = extractMetadataList(cached);
return list != null && list.isNotEmpty;
}
/// Extract Directory list from a cached response (for libraries, playlists)
static List<dynamic>? extractDirectoryList(Map<String, dynamic>? cached) {
if (cached == null) return null;
return cached['MediaContainer']?['Directory'] as List?;
}
/// Extract Hub list from a cached response
static List<dynamic>? extractHubList(Map<String, dynamic>? cached) {
if (cached == null) return null;
return cached['MediaContainer']?['Hub'] as List?;
}
/// Extract Chapter list from the first metadata item
static List<dynamic>? extractChapters(Map<String, dynamic>? cached) {
final metadata = extractFirstMetadata(cached);
if (metadata == null) return null;
return metadata['Chapter'] as List?;
}
/// Extract Marker list from the first metadata item
static List<dynamic>? extractMarkers(Map<String, dynamic>? cached) {
final metadata = extractFirstMetadata(cached);
if (metadata == null) return null;
return metadata['Marker'] as List?;
}
}
+36
View File
@@ -0,0 +1,36 @@
/// Extension methods for appending Plex authentication tokens to URLs.
extension PlexUrlExtension on String {
/// Appends a Plex authentication token to this URL string.
///
/// Automatically determines whether to use '?' or '&' as the separator
/// based on whether the URL already contains query parameters.
///
/// If [token] is null or empty, returns the URL unchanged.
///
/// Example:
/// ```dart
/// final url = '/library/metadata/123'.withPlexToken('abc123');
/// // Result: '/library/metadata/123?X-Plex-Token=abc123'
///
/// final urlWithParams = '/library/metadata/123?type=1'.withPlexToken('abc123');
/// // Result: '/library/metadata/123?type=1&X-Plex-Token=abc123'
/// ```
String withPlexToken(String? token) {
if (token == null || token.isEmpty) return this;
final separator = contains('?') ? '&' : '?';
return '$this${separator}X-Plex-Token=$token';
}
/// Appends a base URL and Plex authentication token to this path string.
///
/// If [token] is null or empty, returns the URL without a token parameter.
///
/// Example:
/// ```dart
/// final fullUrl = '/library/metadata/123'.toPlexUrl('http://server:32400', 'abc123');
/// // Result: 'http://server:32400/library/metadata/123?X-Plex-Token=abc123'
/// ```
String toPlexUrl(String baseUrl, String? token) {
return '$baseUrl$this'.withPlexToken(token);
}
}
+44
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../services/plex_client.dart';
import '../i18n/strings.g.dart';
import '../models/plex_library.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/multi_server_provider.dart';
@@ -66,4 +67,47 @@ extension ProviderExtensions on BuildContext {
}
return getClientForServer(library.serverId!);
}
/// Get PlexClient for metadata, with fallback to first available server
/// Throws an exception if no servers are available
PlexClient getClientForMetadata(PlexMetadata metadata) {
if (metadata.serverId != null) {
return getClientForServer(metadata.serverId!);
}
return getFirstAvailableClient();
}
/// Get PlexClient for metadata, or null if offline mode or no serverId
/// Use this for screens that support offline mode
PlexClient? getClientForMetadataOrNull(
PlexMetadata metadata, {
bool isOffline = false,
}) {
if (isOffline || metadata.serverId == null) {
return null;
}
return getClientForServer(metadata.serverId!);
}
/// Get the first available client from connected servers
/// Throws an exception if no servers are available
PlexClient getFirstAvailableClient() {
final multiServerProvider = Provider.of<MultiServerProvider>(
this,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception(t.errors.noClientAvailable);
}
return getClientForServer(multiServerProvider.onlineServerIds.first);
}
/// Get client for a serverId with fallback to first available server
/// Useful for items that might not have a serverId
PlexClient getClientWithFallback(String? serverId) {
if (serverId != null) {
return getClientForServer(serverId);
}
return getFirstAvailableClient();
}
}
+44 -26
View File
@@ -1,7 +1,48 @@
import 'package:flutter/material.dart';
/// Types of snackbars available in the app
enum SnackBarType {
/// Standard informational snackbar
info,
/// Success snackbar (green background)
success,
/// Error snackbar (red background)
error,
}
/// Utility functions for showing snackbars throughout the application
/// Shows a snackbar with the specified type
///
/// [context] The build context
/// [message] The message to display
/// [type] The type of snackbar (info, success, error)
/// [duration] Optional duration override
void showSnackBar(
BuildContext context,
String message, {
SnackBarType type = SnackBarType.info,
Duration? duration,
}) {
if (!context.mounted) return;
final (backgroundColor, defaultDuration) = switch (type) {
SnackBarType.info => (null, const Duration(seconds: 3)),
SnackBarType.success => (Colors.green, const Duration(seconds: 3)),
SnackBarType.error => (Colors.red, const Duration(seconds: 4)),
};
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: duration ?? defaultDuration,
),
);
}
/// Shows a standard snackbar with a message
///
/// [context] The build context
@@ -12,14 +53,7 @@ void showAppSnackBar(
String message, {
Duration? duration,
}) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: duration ?? const Duration(seconds: 3),
),
);
showSnackBar(context, message, type: SnackBarType.info, duration: duration);
}
/// Shows an error snackbar with a message
@@ -27,15 +61,7 @@ void showAppSnackBar(
/// [context] The build context
/// [message] The error message to display
void showErrorSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
showSnackBar(context, message, type: SnackBarType.error);
}
/// Shows a success snackbar with a message
@@ -43,13 +69,5 @@ void showErrorSnackBar(BuildContext context, String message) {
/// [context] The build context
/// [message] The success message to display
void showSuccessSnackBar(BuildContext context, String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
duration: const Duration(seconds: 3),
),
);
showSnackBar(context, message, type: SnackBarType.success);
}
+54
View File
@@ -0,0 +1,54 @@
import 'codec_utils.dart';
/// Utility for building track labels for audio and subtitle tracks.
class TrackLabelBuilder {
TrackLabelBuilder._();
/// Build a label for an audio track.
///
/// Combines title, language, codec, and channel count.
static String buildAudioLabel({
String? title,
String? language,
String? codec,
int? channelsCount,
required int index,
}) {
final parts = <String>[];
if (title != null && title.isNotEmpty) {
parts.add(title);
}
if (language != null && language.isNotEmpty) {
parts.add(language.toUpperCase());
}
if (codec != null && codec.isNotEmpty) {
parts.add(CodecUtils.formatAudioCodec(codec));
}
if (channelsCount != null) {
parts.add('${channelsCount}ch');
}
return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · ');
}
/// Build a label for a subtitle track.
///
/// Combines title, language, and codec (with friendly codec names).
static String buildSubtitleLabel({
String? title,
String? language,
String? codec,
required int index,
}) {
final parts = <String>[];
if (title != null && title.isNotEmpty) {
parts.add(title);
}
if (language != null && language.isNotEmpty) {
parts.add(language.toUpperCase());
}
if (codec != null && codec.isNotEmpty) {
parts.add(CodecUtils.formatSubtitleCodec(codec));
}
return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' · ');
}
}
+47
View File
@@ -4,6 +4,7 @@ import '../mpv/mpv.dart';
import '../models/plex_metadata.dart';
import '../screens/video_player_screen.dart';
import '../services/settings_service.dart';
import 'app_logger.dart';
/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash.
///
@@ -74,3 +75,49 @@ Future<bool?> navigateToVideoPlayer(
return navigator.push<bool>(route);
}
}
/// Navigates to the video player and optionally refreshes content when returning.
///
/// This helper consolidates the common pattern of:
/// 1. Navigating to the video player
/// 2. Logging the return
/// 3. Calling a refresh callback if not offline
///
/// Parameters:
/// - [context]: The build context for navigation
/// - [metadata]: The Plex metadata for the content to play
/// - [isOffline]: If true, plays from downloaded content
/// - [onRefresh]: Optional callback to refresh data when returning from playback
/// (only called when not offline)
/// - All other parameters are passed through to [navigateToVideoPlayer]
Future<bool?> navigateToVideoPlayerWithRefresh(
BuildContext context, {
required PlexMetadata metadata,
bool isOffline = false,
VoidCallback? onRefresh,
AudioTrack? preferredAudioTrack,
SubtitleTrack? preferredSubtitleTrack,
double? preferredPlaybackRate,
int? selectedMediaIndex,
bool usePushReplacement = false,
}) async {
final result = await navigateToVideoPlayer(
context,
metadata: metadata,
isOffline: isOffline,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredPlaybackRate: preferredPlaybackRate,
selectedMediaIndex: selectedMediaIndex,
usePushReplacement: usePushReplacement,
);
appLogger.d('Returned from playback, refreshing metadata');
// Refresh data when returning from video player (skip if offline)
if (!isOffline && onRefresh != null) {
onRefresh();
}
return result;
}
+5
View File
@@ -43,6 +43,9 @@ class BottomSheetHeader extends StatelessWidget {
/// Defaults to true
final bool showBorder;
/// Optional focus node for the close button
final FocusNode? closeFocusNode;
const BottomSheetHeader({
super.key,
required this.title,
@@ -55,6 +58,7 @@ class BottomSheetHeader extends StatelessWidget {
this.titleStyle,
this.titleColor,
this.showBorder = true,
this.closeFocusNode,
});
@override
@@ -107,6 +111,7 @@ class BottomSheetHeader extends StatelessWidget {
),
if (action != null) action!,
IconButton(
focusNode: closeFocusNode,
icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor),
onPressed: onClose ?? () => Navigator.pop(context),
),
+7 -34
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/plex_file_info.dart';
import '../i18n/strings.g.dart';
import 'bottom_sheet_header.dart';
import 'focusable_bottom_sheet.dart';
class FileInfoBottomSheet extends StatefulWidget {
@@ -54,40 +54,13 @@ class _FileInfoBottomSheetState extends State<FileInfoBottomSheet> {
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const AppIcon(
Symbols.info_rounded,
fill: 1,
color: Colors.white,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
t.fileInfo.title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
focusNode: _initialFocusNode,
icon: const AppIcon(
Symbols.close_rounded,
fill: 1,
color: Colors.white,
),
onPressed: () => Navigator.pop(context),
),
],
),
BottomSheetHeader(
title: t.fileInfo.title,
icon: Symbols.info_rounded,
iconColor: Colors.white,
titleColor: Colors.white,
closeFocusNode: _initialFocusNode,
),
const Divider(color: Colors.grey, height: 1),
// Content
Expanded(
child: ListView(
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../theme/theme_helper.dart';
import '../utils/platform_detector.dart';
/// A wrapper widget that adds hover-activated navigation arrows to horizontal scrolling content.
@@ -70,7 +71,7 @@ class _HorizontalScrollWithArrowsState
_scrollController.animateTo(
targetScroll,
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
curve: Curves.easeInOut,
);
}
@@ -83,7 +84,7 @@ class _HorizontalScrollWithArrowsState
_scrollController.animateTo(
targetScroll,
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
curve: Curves.easeInOut,
);
}
+3 -2
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../theme/theme_helper.dart';
import '../utils/layout_constants.dart';
import '../focus/locked_hub_controller.dart';
import '../models/plex_hub.dart';
@@ -165,7 +166,7 @@ class HubSectionState extends State<HubSection> {
/// Handle ALL key events at the hub level
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
// Handle key down and repeat events
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -270,7 +271,7 @@ class HubSectionState extends State<HubSection> {
onTap: widget.hub.more
? () => _navigateToHubDetail(context)
: null,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
+10 -34
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:plezy/models/plex_metadata_extensions.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
@@ -6,7 +7,6 @@ import '../../services/plex_client.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/download_storage_service.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
@@ -236,7 +236,7 @@ class _MediaCardGrid extends StatelessWidget {
child: InkWell(
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
onTap: onTap,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
@@ -293,7 +293,7 @@ class _MediaCardGrid extends StatelessWidget {
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
@@ -490,7 +490,7 @@ class _MediaCardList extends StatelessWidget {
child: InkWell(
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
onTap: onTap,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
@@ -503,7 +503,7 @@ class _MediaCardList extends StatelessWidget {
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: _buildPosterImage(
context,
item,
@@ -591,31 +591,6 @@ class _MediaCardList extends StatelessWidget {
}
}
/// Helper to get the correct PlexClient for an item's server
PlexClient _getClientForItem(BuildContext context, dynamic item) {
String? serverId;
if (item is PlexMetadata) {
serverId = item.serverId;
} else if (item is PlexPlaylist) {
serverId = item.serverId;
}
// If serverId is null, fall back to first available server
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
serverId = multiServerProvider.onlineServerIds.first;
}
return context.getClientForServer(serverId);
}
Widget _buildPosterImage(
BuildContext context,
dynamic item, {
@@ -630,7 +605,7 @@ Widget _buildPosterImage(
fallbackIcon = Symbols.playlist_play_rounded;
return PlexOptimizedImage.playlist(
client: isOffline ? null : _getClientForItem(context, item),
client: isOffline ? null : context.getClientWithFallback(item.serverId),
imagePath: posterUrl,
width: double.infinity,
height: double.infinity,
@@ -642,7 +617,7 @@ Widget _buildPosterImage(
posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
return PlexOptimizedImage.poster(
client: isOffline ? null : _getClientForItem(context, item),
client: isOffline ? null : context.getClientWithFallback(item.serverId),
imagePath: posterUrl,
width: double.infinity,
height: double.infinity,
@@ -806,7 +781,7 @@ class _MediaCardHelpers {
),
),
// Progress bar for seasons (viewedLeafCount / leafCount)
if (metadata.type == 'season' &&
if (metadata.isSeason &&
metadata.viewedLeafCount != null &&
metadata.leafCount != null &&
metadata.leafCount! > 0 &&
@@ -883,7 +858,8 @@ class _SkeletonLoaderState extends State<SkeletonLoader>
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: _animation.value),
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
borderRadius: widget.borderRadius ??
BorderRadius.circular(tokens(context).radiusSm),
),
child: widget.child,
),
+40 -200
View File
@@ -7,7 +7,6 @@ import '../services/plex_client.dart';
import '../services/play_queue_launcher.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/multi_server_provider.dart';
import '../providers/download_provider.dart';
import '../providers/offline_mode_provider.dart';
import '../providers/offline_watch_provider.dart';
@@ -15,9 +14,12 @@ import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/library_refresh_notifier.dart';
import '../utils/snackbar_helper.dart';
import '../utils/dialogs.dart';
import '../utils/focus_utils.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../utils/smart_deletion_handler.dart';
import '../theme/theme_helper.dart';
import '../widgets/file_info_bottom_sheet.dart';
import '../widgets/focusable_bottom_sheet.dart';
import '../widgets/focusable_list_tile.dart';
@@ -95,32 +97,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
_showContextMenu(menuContext);
}
/// Get the correct PlexClient for this item's server
PlexClient _getClientForItem() {
String? serverId;
// Get serverId from the item (could be PlexMetadata or PlexPlaylist)
if (widget.item is PlexMetadata) {
serverId = (widget.item as PlexMetadata).serverId;
} else if (widget.item is PlexPlaylist) {
serverId = (widget.item as PlexPlaylist).serverId;
}
// If serverId is null, fall back to first available server
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
if (!multiServerProvider.hasConnectedServers) {
throw Exception('No servers available');
}
serverId = multiServerProvider.onlineServerIds.first;
}
return context.getClientForServer(serverId);
/// Get the serverId from the item (PlexMetadata or PlexPlaylist)
String? get _itemServerId {
if (widget.item is PlexMetadata) return (widget.item as PlexMetadata).serverId;
if (widget.item is PlexPlaylist) return (widget.item as PlexPlaylist).serverId;
return null;
}
/// Get the correct PlexClient for this item's server
PlexClient _getClientForItem() => context.getClientWithFallback(_itemServerId);
void _handleTap() {
if (_isContextMenuOpen) return;
widget.onTap?.call();
@@ -729,9 +715,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (result == '_create_new') {
// Create new playlist flow
final playlistName = await showDialog<String>(
context: context,
builder: (context) => _CreatePlaylistDialog(),
final playlistName = await showTextInputDialog(
context,
title: t.playlists.create,
labelText: t.playlists.playlistName,
hintText: t.playlists.enterPlaylistName,
);
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
@@ -893,9 +881,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (result == '_create_new') {
// Create new collection flow
final collectionName = await showDialog<String>(
context: context,
builder: (context) => _CreateCollectionDialog(),
final collectionName = await showTextInputDialog(
context,
title: t.collections.createNewCollection,
labelText: t.collections.collectionName,
hintText: t.collections.enterCollectionName,
);
if (collectionName == null ||
@@ -1020,28 +1010,13 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.collections.removeFromCollection),
content: Text(
t.collections.removeFromCollectionConfirm(title: metadata.title),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.common.delete),
),
],
),
final confirmed = await showDeleteConfirmation(
context,
title: t.collections.removeFromCollection,
message: t.collections.removeFromCollectionConfirm(title: metadata.title),
);
if (confirmed != true || !context.mounted) return;
if (!confirmed || !context.mounted) return;
try {
appLogger.d(
@@ -1135,32 +1110,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
: t.playlists.playlist;
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(
isCollection ? t.collections.deleteCollection : t.playlists.delete,
),
content: Text(
isCollection
? t.collections.deleteConfirm(title: itemTitle)
: t.playlists.deleteMessage(name: itemTitle),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.common.delete),
),
],
),
final confirmed = await showDeleteConfirmation(
context,
title: isCollection ? t.collections.deleteCollection : t.playlists.delete,
message: isCollection
? t.collections.deleteConfirm(title: itemTitle)
: t.playlists.deleteMessage(name: itemTitle),
);
if (confirmed != true || !context.mounted) return;
if (!confirmed || !context.mounted) return;
try {
bool success = false;
@@ -1247,26 +1205,13 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final globalKey = '${metadata.serverId}:${metadata.ratingKey}';
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.downloads.deleteDownload),
content: Text(t.downloads.deleteConfirm(title: metadata.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.common.delete),
),
],
),
final confirmed = await showDeleteConfirmation(
context,
title: t.downloads.deleteDownload,
message: t.downloads.deleteConfirm(title: metadata.title),
);
if (confirmed != true || !context.mounted) return;
if (!confirmed || !context.mounted) return;
try {
// Use smart deletion handler (shows progress only if >500ms)
@@ -1361,56 +1306,6 @@ class _PlaylistSelectionDialog extends StatelessWidget {
}
}
/// Dialog to create a new playlist
class _CreatePlaylistDialog extends StatefulWidget {
@override
State<_CreatePlaylistDialog> createState() => _CreatePlaylistDialogState();
}
class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(t.playlists.create),
content: TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
labelText: t.playlists.playlistName,
hintText: t.playlists.enterPlaylistName,
),
onSubmitted: (value) {
if (value.isNotEmpty) {
Navigator.pop(context, value);
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () {
if (_controller.text.isNotEmpty) {
Navigator.pop(context, _controller.text);
}
},
child: Text(t.common.save),
),
],
);
}
}
/// Dialog to select a collection or create a new one
class _CollectionSelectionDialog extends StatelessWidget {
final List<PlexMetadata> collections;
@@ -1458,57 +1353,6 @@ class _CollectionSelectionDialog extends StatelessWidget {
}
}
/// Dialog to create a new collection
class _CreateCollectionDialog extends StatefulWidget {
@override
State<_CreateCollectionDialog> createState() =>
_CreateCollectionDialogState();
}
class _CreateCollectionDialogState extends State<_CreateCollectionDialog> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(t.collections.createNewCollection),
content: TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
labelText: t.collections.collectionName,
hintText: t.collections.enterCollectionName,
),
onSubmitted: (value) {
if (value.isNotEmpty) {
Navigator.pop(context, value);
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () {
if (_controller.text.isNotEmpty) {
Navigator.pop(context, _controller.text);
}
},
child: Text(t.common.save),
),
],
);
}
}
/// Focusable context menu sheet for keyboard/gamepad navigation (mobile)
class _FocusableContextMenuSheet extends StatefulWidget {
final String title;
@@ -1609,11 +1453,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'PopupMenuInitialFocus');
if (widget.focusFirstItem) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_initialFocusNode.requestFocus();
}
});
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
}
}
@@ -1659,7 +1499,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
top: top,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
/// A standardized placeholder container used for loading states,
/// error states, and missing images throughout the app.
///
/// Uses the theme's surfaceContainerHighest color by default.
class PlaceholderContainer extends StatelessWidget {
/// Optional child widget to display inside the placeholder
final Widget? child;
/// Optional custom color (defaults to theme's surfaceContainerHighest)
final Color? color;
/// Optional border radius
final BorderRadius? borderRadius;
const PlaceholderContainer({
super.key,
this.child,
this.color,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
final container = Container(
color: borderRadius != null
? null
: (color ?? Theme.of(context).colorScheme.surfaceContainerHighest),
decoration: borderRadius != null
? BoxDecoration(
color:
color ??
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: borderRadius,
)
: null,
child: child != null ? Center(child: child) : null,
);
return container;
}
}
+3 -3
View File
@@ -496,7 +496,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_librariesExpanded = !_librariesExpanded;
});
},
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -508,7 +508,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
: isLibrariesFocused
? t.text.withValues(alpha: 0.08)
: null,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
child: Row(
children: [
@@ -652,7 +652,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
onTap: () => widget.onLibrarySelected(library.globalKey),
focusNode: focusNode,
padding: const EdgeInsets.only(left: 28, right: 12, top: 10, bottom: 10),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
iconSize: 18,
);
}
@@ -5,6 +5,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../../focus/dpad_navigator.dart';
import '../../mpv/mpv.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_media_version.dart';
@@ -191,7 +192,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
FocusNode? leftTarget,
FocusNode? rightTarget,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -249,7 +250,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
/// Handle key events for timeline navigation
KeyEventResult _handleTimelineKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/track_label_builder.dart';
import 'track_selection_sheet.dart';
/// Bottom sheet for selecting audio tracks
@@ -21,22 +22,13 @@ class AudioTrackSheet {
icon: Symbols.audiotrack_rounded,
extractTracks: (tracks) => tracks?.audio ?? [],
getCurrentTrack: (track) => track.audio,
buildLabel: (audioTrack, index) {
final parts = <String>[];
if (audioTrack.title != null && audioTrack.title!.isNotEmpty) {
parts.add(audioTrack.title!);
}
if (audioTrack.language != null && audioTrack.language!.isNotEmpty) {
parts.add(audioTrack.language!.toUpperCase());
}
if (audioTrack.codec != null && audioTrack.codec!.isNotEmpty) {
parts.add(audioTrack.codec!.toUpperCase());
}
if (audioTrack.channelsCount != null) {
parts.add('${audioTrack.channelsCount}ch');
}
return parts.isEmpty ? 'Audio Track ${index + 1}' : parts.join(' · ');
},
buildLabel: (audioTrack, index) => TrackLabelBuilder.buildAudioLabel(
title: audioTrack.title,
language: audioTrack.language,
codec: audioTrack.codec,
channelsCount: audioTrack.channelsCount,
index: index,
),
setTrack: (track) => player.selectAudioTrack(track),
onTrackChanged: onTrackChanged,
onOpen: onOpen,
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/track_label_builder.dart';
import 'track_selection_sheet.dart';
/// Bottom sheet for selecting subtitle tracks
@@ -21,30 +22,12 @@ class SubtitleTrackSheet {
icon: Symbols.subtitles_rounded,
extractTracks: (tracks) => tracks?.subtitle ?? [],
getCurrentTrack: (track) => track.subtitle,
buildLabel: (subtitle, index) {
final parts = <String>[];
if (subtitle.title != null && subtitle.title!.isNotEmpty) {
parts.add(subtitle.title!);
}
if (subtitle.language != null && subtitle.language!.isNotEmpty) {
parts.add(subtitle.language!.toUpperCase());
}
if (subtitle.codec != null && subtitle.codec!.isNotEmpty) {
// Format codec names nicely
String codecName = subtitle.codec!.toUpperCase();
if (codecName == 'SUBRIP') {
codecName = 'SRT';
} else if (codecName == 'DVD_SUBTITLE') {
codecName = 'DVD';
} else if (codecName == 'ASS' || codecName == 'SSA') {
codecName = codecName; // Keep as-is
} else if (codecName == 'WEBVTT') {
codecName = 'VTT';
}
parts.add(codecName);
}
return parts.isEmpty ? 'Track ${index + 1}' : parts.join(' · ');
},
buildLabel: (subtitle, index) => TrackLabelBuilder.buildSubtitleLabel(
title: subtitle.title,
language: subtitle.language,
codec: subtitle.codec,
index: index,
),
setTrack: (track) => player.selectSubtitleTrack(track),
onTrackChanged: onTrackChanged,
showOffOption: true,
+48 -64
View File
@@ -10,12 +10,14 @@ import 'package:flutter/services.dart'
SystemChrome,
DeviceOrientation,
LogicalKeyboardKey,
KeyEvent,
KeyDownEvent,
KeyRepeatEvent;
import 'package:macos_window_utils/macos_window_utils.dart';
import 'package:window_manager/window_manager.dart';
import '../../mpv/mpv.dart';
import '../../focus/dpad_navigator.dart';
import '../../services/plex_client.dart';
import '../../services/plex_api_cache.dart';
@@ -26,7 +28,9 @@ import '../../screens/video_player_screen.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../services/settings_service.dart';
import '../../utils/platform_detector.dart';
import '../../utils/plex_cache_parser.dart';
import '../../utils/player_utils.dart';
import '../../theme/theme_helper.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/video_control_icons.dart';
@@ -241,10 +245,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_controlsFullyHidden = false;
});
_hideTimer?.cancel();
// On Linux, ensure Flutter view is visible
if (Platform.isLinux) {
widget.player.setControlsVisible(true);
}
_showLinuxControls();
}
});
}
@@ -455,7 +456,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// Reset debounce timer - resume when resizing stops
_resizeDebounceTimer?.cancel();
_resizeDebounceTimer = Timer(const Duration(milliseconds: 300), () {
final slowDuration = tokens(context).slow;
_resizeDebounceTimer = Timer(slowDuration, () {
if (_wasPlayingBeforeResize && mounted) {
widget.player.play();
}
@@ -477,18 +479,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
// On Linux, fully hide after animation completes (200ms)
if (Platform.isLinux) {
Future.delayed(const Duration(milliseconds: 250), () {
if (mounted && !_showControls) {
setState(() {
_controlsFullyHidden = true;
});
// Hide Flutter view to show only video
widget.player.setControlsVisible(false);
}
});
}
_hideLinuxControlsAfterAnimation();
}
});
}
@@ -501,32 +492,41 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
/// Show controls immediately on Linux
void _showLinuxControls() {
if (Platform.isLinux) {
widget.player.setControlsVisible(true);
}
}
/// Hide controls on Linux after animation completes (250ms delay)
void _hideLinuxControlsAfterAnimation() {
if (Platform.isLinux) {
Future.delayed(const Duration(milliseconds: 250), () {
if (mounted && !_showControls) {
setState(() {
_controlsFullyHidden = true;
});
widget.player.setControlsVisible(false);
}
});
}
}
void _toggleControls() {
setState(() {
_showControls = !_showControls;
if (_showControls) {
_controlsFullyHidden = false;
// On Linux, show Flutter view when controls are shown
if (Platform.isLinux) {
widget.player.setControlsVisible(true);
}
_showLinuxControls();
}
});
if (_showControls) {
_startHideTimer();
// Cancel auto-skip when user manually shows controls
_cancelAutoSkipTimer();
} else if (Platform.isLinux) {
// On Linux, fully hide after animation completes (200ms)
Future.delayed(const Duration(milliseconds: 250), () {
if (mounted && !_showControls) {
setState(() {
_controlsFullyHidden = true;
});
// Hide Flutter view to show only video
widget.player.setControlsVisible(false);
}
});
} else {
_hideLinuxControlsAfterAnimation();
}
// On macOS, hide/show traffic lights with controls
@@ -627,13 +627,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
final chapters = <PlexChapter>[];
final markers = <PlexMarker>[];
Map<String, dynamic>? metadataJson;
if (cached['MediaContainer']?['Metadata'] != null &&
(cached['MediaContainer']['Metadata'] as List).isNotEmpty) {
metadataJson =
cached['MediaContainer']['Metadata'][0] as Map<String, dynamic>;
}
final metadataJson = PlexCacheParser.extractFirstMetadata(cached);
if (metadataJson != null) {
// Parse chapters
if (metadataJson['Chapter'] != null) {
@@ -771,6 +765,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_doubleTapFeedbackOpacity = 1.0;
});
// Capture duration before timer to avoid context access in callback
final slowDuration = tokens(context).slow;
// Fade out after delay
_feedbackTimer = Timer(const Duration(milliseconds: 500), () {
if (mounted) {
@@ -778,7 +775,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_doubleTapFeedbackOpacity = 0.0;
});
Timer(const Duration(milliseconds: 300), () {
Timer(slowDuration, () {
if (mounted) {
setState(() {
_showDoubleTapFeedback = false;
@@ -871,9 +868,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_showControls = true;
_controlsFullyHidden = false;
});
if (Platform.isLinux) {
widget.player.setControlsVisible(true);
}
_showLinuxControls();
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
@@ -897,16 +892,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
if (Platform.isLinux) {
Future.delayed(const Duration(milliseconds: 250), () {
if (mounted && !_showControls) {
setState(() {
_controlsFullyHidden = true;
});
widget.player.setControlsVisible(false);
}
});
}
_hideLinuxControlsAfterAnimation();
}
}
@@ -921,7 +907,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
autofocus: true,
onKeyEvent: (node, event) {
// Only handle KeyDown and KeyRepeat events
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -992,10 +978,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
_showControls = true;
_controlsFullyHidden = false;
});
// On Linux, show Flutter view when controls are shown
if (Platform.isLinux) {
widget.player.setControlsVisible(true);
}
_showLinuxControls();
_startHideTimer();
// On macOS, show traffic lights when controls appear
if (Platform.isMacOS) {
@@ -1207,7 +1190,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _doubleTapFeedbackOpacity,
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
child: _buildDoubleTapFeedback(),
),
),
@@ -1219,7 +1202,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
bottom: isMobile ? 80 : 115,
child: AnimatedOpacity(
opacity: 1.0,
duration: const Duration(milliseconds: 300),
duration: tokens(context).slow,
child: _buildSkipMarkerButton(),
),
),
@@ -1267,14 +1250,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// Always perform the skip action when tapped
_performAutoSkip();
},
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Stack(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
@@ -1303,7 +1286,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (isAutoSkipActive && shouldShowAutoSkip)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
child: Row(
children: [
Expanded(
@@ -1311,7 +1294,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
child: Container(
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
borderRadius:
BorderRadius.circular(tokens(context).radiusSm),
),
),
),
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../mpv/mpv.dart';
import '../../../models/plex_media_info.dart';
import '../../../models/plex_media_version.dart';
@@ -84,7 +85,7 @@ class TrackChapterControls extends StatelessWidget {
int index,
int totalButtons,
) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
@@ -117,6 +118,39 @@ class TrackChapterControls extends StatelessWidget {
return KeyEventResult.ignored;
}
/// Build a track control button with consistent focus handling
Widget _buildTrackButton({
required int buttonIndex,
required IconData icon,
required String semanticLabel,
required VoidCallback? onPressed,
required Tracks? tracks,
required bool isMobile,
required bool isDesktop,
String? tooltip,
bool isActive = false,
}) {
return VideoControlButton(
icon: icon,
tooltip: tooltip,
semanticLabel: semanticLabel,
isActive: isActive,
focusNode: focusNodes != null && focusNodes!.length > buttonIndex
? focusNodes![buttonIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
buttonIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
onPressed: onPressed,
);
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
@@ -142,23 +176,14 @@ class TrackChapterControls extends StatelessWidget {
sleepTimer.isActive ||
audioSyncOffset != 0 ||
subtitleSyncOffset != 0;
final currentIndex = 0;
return VideoControlButton(
return _buildTrackButton(
buttonIndex: 0,
icon: Symbols.tune_rounded,
isActive: isActive,
semanticLabel: t.videoControls.settingsButton,
focusNode: focusNodes != null && focusNodes!.isNotEmpty
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () async {
await VideoSettingsSheet.show(
context,
@@ -180,21 +205,13 @@ class TrackChapterControls extends StatelessWidget {
if (_hasMultipleAudioTracks(tracks)) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.audiotrack_rounded,
semanticLabel: t.videoControls.audioTrackButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () => AudioTrackSheet.show(
context,
player,
@@ -211,21 +228,13 @@ class TrackChapterControls extends StatelessWidget {
if (_hasSubtitles(tracks)) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.subtitles_rounded,
semanticLabel: t.videoControls.subtitlesButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () => SubtitleTrackSheet.show(
context,
player,
@@ -242,21 +251,13 @@ class TrackChapterControls extends StatelessWidget {
if (chapters.isNotEmpty) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.video_library_rounded,
semanticLabel: t.videoControls.chaptersButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () => ChapterSheet.show(
context,
player,
@@ -275,21 +276,13 @@ class TrackChapterControls extends StatelessWidget {
if (availableVersions.length > 1 && onSwitchVersion != null) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.video_file_rounded,
semanticLabel: t.videoControls.versionsButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () => VersionSheet.show(
context,
availableVersions,
@@ -307,22 +300,14 @@ class TrackChapterControls extends StatelessWidget {
if (onCycleBoxFitMode != null) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: _getBoxFitIcon(boxFitMode),
tooltip: _getBoxFitTooltip(boxFitMode),
semanticLabel: t.videoControls.aspectRatioButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onCycleBoxFitMode,
),
);
@@ -333,7 +318,8 @@ class TrackChapterControls extends StatelessWidget {
if (isMobile) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: isRotationLocked
? Symbols.screen_lock_rotation_rounded
: Symbols.screen_rotation_rounded,
@@ -341,18 +327,9 @@ class TrackChapterControls extends StatelessWidget {
? t.videoControls.unlockRotation
: t.videoControls.lockRotation,
semanticLabel: t.videoControls.rotationLockButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onToggleRotationLock,
),
);
@@ -363,25 +340,17 @@ class TrackChapterControls extends StatelessWidget {
if (isDesktop) {
final currentIndex = buttonIndex;
buttons.add(
VideoControlButton(
_buildTrackButton(
buttonIndex: currentIndex,
icon: isFullscreen
? Symbols.fullscreen_exit_rounded
: Symbols.fullscreen_rounded,
semanticLabel: isFullscreen
? t.videoControls.exitFullscreenButton
: t.videoControls.fullscreenButton,
focusNode: focusNodes != null && focusNodes!.length > currentIndex
? focusNodes![currentIndex]
: null,
onKeyEvent: focusNodes != null
? (node, event) => _handleButtonKeyEvent(
node,
event,
currentIndex,
_getButtonCount(tracks, isMobile, isDesktop),
)
: null,
onFocusChange: onFocusChange,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onToggleFullscreen,
),
);
@@ -73,7 +73,7 @@ class _VolumeControlState extends State<VolumeControl> {
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}