refactor: simplify & deduplicate
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user