Merge branch 'main' into copilot/add-sanity-checks-ci

This commit is contained in:
Doezer
2025-11-16 09:22:36 +01:00
committed by GitHub
70 changed files with 7541 additions and 708 deletions
+8 -1
View File
@@ -1,9 +1,12 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- media_kit_libs_ios_video
- os_media_controls (0.0.1):
- Flutter
- package_info_plus (0.4.5):
@@ -25,6 +28,7 @@ PODS:
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
@@ -38,6 +42,8 @@ DEPENDENCIES:
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
media_kit_libs_ios_video:
@@ -62,9 +68,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
-4
View File
@@ -39,10 +39,6 @@
<string>This app needs to connect to your Plex Media Server on your local network.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+348 -1
View File
@@ -10,9 +10,12 @@ import '../models/plex_library.dart';
import '../models/plex_media_info.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../models/plex_sort.dart';
import '../models/plex_video_playback_data.dart';
import '../network/endpoint_failover_interceptor.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
/// Result of testing a connection, including success status and latency
class ConnectionTestResult {
@@ -25,6 +28,8 @@ class ConnectionTestResult {
class PlexClient {
PlexConfig config;
late final Dio _dio;
final EndpointFailoverManager? _endpointManager;
final Future<void> Function(String newBaseUrl)? _onEndpointChanged;
/// Custom response decoder that handles malformed UTF-8 gracefully
static String _lenientUtf8Decoder(
@@ -35,7 +40,18 @@ class PlexClient {
return utf8.decode(responseBytes, allowMalformed: true);
}
PlexClient(this.config) {
PlexClient(
this.config, {
List<String>? prioritizedEndpoints,
Future<void> Function(String newBaseUrl)? onEndpointChanged,
}) : _endpointManager =
(prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty)
? EndpointFailoverManager(prioritizedEndpoints)
: null,
_onEndpointChanged = onEndpointChanged {
LogRedactionManager.registerServerUrl(config.baseUrl);
LogRedactionManager.registerToken(config.token);
_dio = Dio(
BaseOptions(
baseUrl: config.baseUrl,
@@ -59,6 +75,16 @@ class PlexClient {
responseHeader: false,
),
);
if (_endpointManager != null) {
_dio.interceptors.add(
EndpointFailoverInterceptor(
dio: _dio,
endpointManager: _endpointManager,
onEndpointSwitch: _handleEndpointSwitch,
),
);
}
}
/// Update the token used by this client
@@ -66,9 +92,29 @@ class PlexClient {
// Update both the Dio headers and the config to ensure consistency
_dio.options.headers['X-Plex-Token'] = newToken;
config = config.copyWith(token: newToken);
LogRedactionManager.registerToken(newToken);
appLogger.d('PlexClient token updated (headers and config)');
}
/// Update endpoint priority list and optionally hop to the new best endpoint.
Future<void> updateEndpointPreferences(
List<String> prioritizedEndpoints, {
bool switchToFirst = false,
}) async {
if (_endpointManager == null || prioritizedEndpoints.isEmpty) {
return;
}
final targetBaseUrl = switchToFirst
? prioritizedEndpoints.first
: config.baseUrl;
_endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl);
if (switchToFirst && targetBaseUrl != config.baseUrl) {
await _handleEndpointSwitch(targetBaseUrl);
}
}
/// Test connection to server
Future<bool> testConnection() async {
try {
@@ -257,6 +303,30 @@ class PlexClient {
return _extractSingleMetadata(response);
}
/// Get the server's machine identifier
Future<String?> getMachineIdentifier() async {
try {
final response = await _dio.get('/');
final container = _getMediaContainer(response);
if (container == null) return null;
return container['machineIdentifier'] as String?;
} catch (e) {
appLogger.e('Failed to get machine identifier', error: e);
return null;
}
}
/// Build a proper metadata URI for adding to playlists
/// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}
Future<String> buildMetadataUri(String ratingKey) async {
// Use cached machine identifier from config if available
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
if (machineId == null) {
throw Exception('Could not get server machine identifier');
}
return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey';
}
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(
String ratingKey,
@@ -302,6 +372,40 @@ class PlexClient {
: null;
}
/// Set per-media language preferences (audio and subtitle)
/// For TV shows, use grandparentRatingKey to set preference for the entire series
/// For movies, use the movie's ratingKey
Future<bool> setMetadataPreferences(
String ratingKey, {
String? audioLanguage,
String? subtitleLanguage,
}) async {
try {
final queryParams = <String, dynamic>{};
if (audioLanguage != null) {
queryParams['audioLanguage'] = audioLanguage;
}
if (subtitleLanguage != null) {
queryParams['subtitleLanguage'] = subtitleLanguage;
}
// If no preferences to set, return early
if (queryParams.isEmpty) {
return true;
}
final response = await _dio.put(
'/library/metadata/$ratingKey/prefs',
queryParameters: queryParams,
);
return response.statusCode == 200;
} catch (e) {
appLogger.e('Failed to set metadata preferences', error: e);
return false;
}
}
/// Search across all libraries using the hub search endpoint
/// Only returns movies and shows, filtering out seasons and episodes
Future<List<PlexMetadata>> search(String query, {int limit = 10}) async {
@@ -1152,6 +1256,234 @@ class PlexClient {
}
}
/// Get all playlists
/// Filters by playlistType=video by default
/// Set smart to true/false to filter smart playlists, or null for all
Future<List<PlexPlaylist>> getPlaylists({
String playlistType = 'video',
bool? smart,
}) async {
try {
final queryParams = <String, dynamic>{'playlistType': playlistType};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
final response = await _dio.get(
'/playlists',
queryParameters: queryParams,
);
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return [];
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return [];
}
return metadata
.map((item) => PlexPlaylist.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
appLogger.e('Failed to get playlists: $e');
return [];
}
}
/// Get playlist metadata by playlist ID
/// Returns the playlist details (not the items)
Future<PlexPlaylist?> getPlaylistMetadata(String playlistId) async {
try {
final response = await _dio.get('/playlists/$playlistId');
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to get playlist metadata: $e');
return null;
}
}
/// Create a new playlist
/// [title] - Name of the playlist
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
/// [playQueueId] - Optional play queue ID to create playlist from
Future<PlexPlaylist?> createPlaylist({
required String title,
String? uri,
int? playQueueId,
}) async {
try {
final queryParams = <String, dynamic>{
'type': 'video',
'title': title,
'smart': '0',
};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playQueueId != null) {
queryParams['playQueueID'] = playQueueId.toString();
}
final response = await _dio.post(
'/playlists',
queryParameters: queryParams,
);
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to create playlist: $e');
return null;
}
}
/// Delete a playlist
Future<bool> deletePlaylist(String playlistId) async {
try {
await _dio.delete('/playlists/$playlistId');
return true;
} catch (e) {
appLogger.e('Failed to delete playlist: $e');
return false;
}
}
/// Add items to a playlist
/// [playlistId] - The playlist to add items to
/// [uri] - Comma-separated list of item URIs to add
Future<bool> addToPlaylist({
required String playlistId,
required String uri,
}) async {
try {
appLogger.d(
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
);
final response = await _dio.put(
'/playlists/$playlistId/items',
queryParameters: {'uri': uri},
);
appLogger.d('Add to playlist response status: ${response.statusCode}');
return response.statusCode == 200;
} catch (e) {
appLogger.e('Failed to add to playlist', error: e);
return false;
}
}
/// Remove an item from a playlist
/// [playlistId] - The playlist to remove from
/// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field)
Future<bool> removeFromPlaylist({
required String playlistId,
required String playlistItemId,
}) async {
try {
await _dio.delete('/playlists/$playlistId/items/$playlistItemId');
return true;
} catch (e) {
appLogger.e('Failed to remove from playlist: $e');
return false;
}
}
/// Move a playlist item to a new position
/// Only works with non-smart playlists
/// [playlistId] - The playlist rating key
/// [playlistItemId] - The playlist item ID to move
/// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top)
Future<bool> movePlaylistItem({
required String playlistId,
required int playlistItemId,
required int afterPlaylistItemId,
}) async {
try {
appLogger.d(
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
);
await _dio.put(
'/playlists/$playlistId/items/$playlistItemId/move',
queryParameters: {'after': afterPlaylistItemId},
);
appLogger.d('Successfully moved playlist item');
return true;
} catch (e) {
appLogger.e('Failed to move playlist item', error: e);
return false;
}
}
/// Clear all items from a playlist
Future<bool> clearPlaylist(String playlistId) async {
try {
await _dio.delete('/playlists/$playlistId/items');
return true;
} catch (e) {
appLogger.e('Failed to clear playlist: $e');
return false;
}
}
/// Update playlist metadata (e.g., title, summary)
/// Uses the same metadata editing mechanism as other items
Future<bool> updatePlaylist({
required String playlistId,
String? title,
String? summary,
}) async {
try {
final queryParams = <String, dynamic>{
'type': 'playlist',
'id': playlistId,
};
if (title != null) {
queryParams['title.value'] = title;
queryParams['title.locked'] = '1';
}
if (summary != null) {
queryParams['summary.value'] = summary;
queryParams['summary.locked'] = '1';
}
await _dio.put(
'/library/metadata/$playlistId',
queryParameters: queryParams,
);
return true;
} catch (e) {
appLogger.e('Failed to update playlist: $e');
return false;
}
}
// ============================================================================
// Library Management Methods
// ============================================================================
@@ -1175,4 +1507,19 @@ class PlexClient {
Future<void> analyzeLibrary(String sectionId) async {
await _dio.get('/library/sections/$sectionId/analyze');
}
Future<void> _handleEndpointSwitch(String newBaseUrl) async {
if (config.baseUrl == newBaseUrl) {
return;
}
appLogger.i('Applying Plex endpoint switch', error: newBaseUrl);
_dio.options.baseUrl = newBaseUrl;
config = config.copyWith(baseUrl: newBaseUrl);
LogRedactionManager.registerServerUrl(newBaseUrl);
if (_onEndpointChanged != null) {
await _onEndpointChanged(newBaseUrl);
}
}
}
+6
View File
@@ -9,6 +9,7 @@ class PlexConfig {
final String platform;
final String? device;
final bool acceptJson;
final String? machineIdentifier;
PlexConfig({
required this.baseUrl,
@@ -19,6 +20,7 @@ class PlexConfig {
this.platform = 'Flutter',
this.device,
this.acceptJson = true,
this.machineIdentifier,
});
static Future<PlexConfig> create({
@@ -29,6 +31,7 @@ class PlexConfig {
String? platform,
String? device,
bool acceptJson = true,
String? machineIdentifier,
}) async {
final packageInfo = await PackageInfo.fromPlatform();
return PlexConfig(
@@ -40,6 +43,7 @@ class PlexConfig {
platform: platform ?? 'Flutter',
device: device,
acceptJson: acceptJson,
machineIdentifier: machineIdentifier,
);
}
@@ -70,6 +74,7 @@ class PlexConfig {
String? platform,
String? device,
bool? acceptJson,
String? machineIdentifier,
}) {
return PlexConfig(
baseUrl: baseUrl ?? this.baseUrl,
@@ -80,6 +85,7 @@ class PlexConfig {
platform: platform ?? this.platform,
device: device ?? this.device,
acceptJson: acceptJson ?? this.acceptJson,
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
);
}
}
+2422 -4
View File
File diff suppressed because it is too large Load Diff
+44 -1
View File
@@ -100,6 +100,8 @@
"secondsUnit": "${seconds} seconds",
"defaultSleepTimer": "Default Sleep Timer",
"minutesUnit": "${minutes} minutes",
"rememberTrackSelections": "Remember track selections per show/movie",
"rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback",
"unwatchedOnly": "Unwatched Only",
"unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue",
"shuffleOrderNavigation": "Shuffle Order Navigation",
@@ -202,7 +204,8 @@
"userStatus": {
"admin": "Admin",
"restricted": "Restricted",
"protected": "Protected"
"protected": "Protected",
"current": "CURRENT"
},
"messages": {
"markedAsWatched": "Marked as watched",
@@ -266,6 +269,10 @@
"pause": "Pause",
"overview": "Overview",
"cast": "Cast",
"seasons": "Seasons",
"studio": "Studio",
"rating": "Rating",
"watched": "Watched",
"episodeCount": "${count} episodes",
"watchedProgress": "${watched}/${total} watched",
"movie": "Movie",
@@ -366,5 +373,41 @@
"search": "Search",
"libraries": "Libraries",
"settings": "Settings"
},
"playlists": {
"title": "Playlists",
"noPlaylists": "No playlists found",
"create": "Create Playlist",
"newPlaylist": "New Playlist",
"playlistName": "Playlist Name",
"enterPlaylistName": "Enter playlist name",
"edit": "Edit Playlist",
"delete": "Delete Playlist",
"addTo": "Add to Playlist",
"addItems": "Add Items",
"removeItem": "Remove from Playlist",
"clearPlaylist": "Clear Playlist",
"playAll": "Play All",
"shuffle": "Shuffle",
"smartPlaylist": "Smart Playlist",
"regularPlaylist": "Regular Playlist",
"itemCount": "${count} items",
"oneItem": "1 item",
"emptyPlaylist": "This playlist is empty",
"deleteConfirm": "Delete Playlist?",
"deleteMessage": "Are you sure you want to delete \"${name}\"?",
"created": "Playlist created",
"updated": "Playlist updated",
"deleted": "Playlist deleted",
"itemAdded": "Added to playlist",
"itemRemoved": "Removed from playlist",
"selectPlaylist": "Select Playlist",
"createNewPlaylist": "Create New Playlist",
"errorCreating": "Failed to create playlist",
"errorDeleting": "Failed to delete playlist",
"errorLoading": "Failed to load playlists",
"errorAdding": "Failed to add to playlist",
"errorReordering": "Failed to reorder playlist item",
"errorRemoving": "Failed to remove from playlist"
}
}
+413
View File
@@ -0,0 +1,413 @@
{
"app": {
"title": "Plezy",
"loading": "Lädt..."
},
"auth": {
"signInWithPlex": "Mit Plex anmelden",
"showQRCode": "QR-Code anzeigen",
"cancel": "Abbrechen",
"authenticate": "Authentifizieren",
"retry": "Erneut versuchen",
"debugEnterToken": "Debug: Plex-Token eingeben",
"plexTokenLabel": "Plex-Auth-Token",
"plexTokenHint": "Plex.tv-Token eingeben",
"authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.",
"scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.",
"waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen."
},
"common": {
"cancel": "Abbrechen",
"save": "Speichern",
"close": "Schließen",
"clear": "Leeren",
"reset": "Zurücksetzen",
"later": "Später",
"submit": "Senden",
"confirm": "Bestätigen",
"retry": "Erneut versuchen",
"playNow": "Jetzt abspielen",
"logout": "Abmelden",
"online": "Online",
"offline": "Offline",
"owned": "Besitzer",
"shared": "Geteilt",
"current": "AKTUELL",
"unknown": "Unbekannt",
"refresh": "Aktualisieren",
"yes": "Ja",
"no": "Nein",
"server": "Server"
},
"screens": {
"licenses": "Lizenzen",
"selectServer": "Server auswählen",
"switchProfile": "Profil wechseln",
"subtitleStyling": "Untertitel-Stil",
"search": "Suche",
"logs": "Protokolle"
},
"update": {
"available": "Update verfügbar",
"versionAvailable": "Version ${version} ist verfügbar",
"currentVersion": "Aktuell: ${version}",
"skipVersion": "Diese Version überspringen",
"viewRelease": "Release anzeigen",
"latestVersion": "Aktuellste Version installiert",
"checkFailed": "Fehler bei der Updateprüfung"
},
"settings": {
"title": "Einstellungen",
"language": "Sprache",
"theme": "Design",
"appearance": "Darstellung",
"videoPlayback": "Videowiedergabe",
"shufflePlay": "Zufallswiedergabe",
"advanced": "Erweitert",
"useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen",
"showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen",
"secondsLabel": "Sekunden",
"minutesLabel": "Minuten",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Dauer eingeben (${min}-${max})",
"systemTheme": "System",
"systemThemeDescription": "Systemeinstellungen folgen",
"lightTheme": "Hell",
"darkTheme": "Dunkel",
"libraryDensity": "Mediathekdichte",
"compact": "Kompakt",
"compactDescription": "Kleinere Karten, mehr Elemente sichtbar",
"normal": "Normal",
"normalDescription": "Standardgröße",
"comfortable": "Großzügig",
"comfortableDescription": "Größere Karten, weniger Elemente sichtbar",
"viewMode": "Ansichtsmodus",
"gridView": "Raster",
"gridViewDescription": "Elemente im Raster anzeigen",
"listView": "Liste",
"listViewDescription": "Elemente in Listenansicht anzeigen",
"useSeasonPosters": "Staffelposter verwenden",
"showHeroSection": "Hero-Bereich anzeigen",
"hardwareDecoding": "Hardware-Decodierung",
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
"bufferSize": "Puffergröße",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Untertitel-Stil",
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
"smallSkipDuration": "Kleine Sprungdauer",
"largeSkipDuration": "Große Sprungdauer",
"secondsUnit": "${seconds} Sekunden",
"defaultSleepTimer": "Standard-Sleep-Timer",
"minutesUnit": "${minutes} Minuten",
"rememberTrackSelections": "Spurauswahl pro Serie/Film merken",
"rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert",
"unwatchedOnly": "Nur ungesehene",
"unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen",
"shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge",
"shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge",
"loopShuffleQueue": "Shuffle-Warteschlange wiederholen",
"loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist",
"videoPlayerControls": "Videoplayer-Steuerung",
"keyboardShortcuts": "Tastenkürzel",
"keyboardShortcutsDescription": "Tastenkürzel anpassen",
"debugLogging": "Debug-Protokollierung",
"debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren",
"viewLogs": "Protokolle anzeigen",
"viewLogsDescription": "App-Protokolle anzeigen",
"clearCache": "Cache löschen",
"clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.",
"clearCacheSuccess": "Cache erfolgreich gelöscht",
"resetSettings": "Einstellungen zurücksetzen",
"resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.",
"resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt",
"shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt",
"about": "Über",
"aboutDescription": "App-Informationen und Lizenzen",
"updates": "Updates",
"updateAvailable": "Update verfügbar",
"checkForUpdates": "Nach Updates suchen",
"validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben",
"validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen",
"shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}",
"shortcutUpdated": "Tastenkürzel aktualisiert für ${action}"
},
"search": {
"hint": "Filme, Serien, Musik suchen...",
"tryDifferentTerm": "Anderen Suchbegriff versuchen",
"searchYourMedia": "In den eigenen Medien suchen",
"enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben"
},
"hotkeys": {
"setShortcutFor": "Tastenkürzel festlegen für ${actionName}",
"clearShortcut": "Kürzel löschen"
},
"pinEntry": {
"enterPin": "PIN eingeben",
"showPin": "PIN anzeigen",
"hidePin": "PIN verbergen"
},
"fileInfo": {
"title": "Dateiinfo",
"video": "Video",
"audio": "Audio",
"file": "Datei",
"advanced": "Erweitert",
"codec": "Codec",
"resolution": "Auflösung",
"bitrate": "Bitrate",
"frameRate": "Bildrate",
"aspectRatio": "Seitenverhältnis",
"profile": "Profil",
"bitDepth": "Farbtiefe",
"colorSpace": "Farbraum",
"colorRange": "Farbbereich",
"colorPrimaries": "Primärfarben",
"chromaSubsampling": "Chroma-Subsampling",
"channels": "Kanäle",
"path": "Pfad",
"size": "Größe",
"container": "Container",
"duration": "Dauer",
"optimizedForStreaming": "Für Streaming optimiert",
"has64bitOffsets": "64-Bit-Offsets"
},
"mediaMenu": {
"markAsWatched": "Als gesehen markieren",
"markAsUnwatched": "Als ungesehen markieren",
"removeFromContinueWatching": "Aus Weiterschauen entfernen",
"goToSeries": "Zur Serie",
"goToSeason": "Zur Staffel",
"shufflePlay": "Zufallswiedergabe",
"fileInfo": "Dateiinfo"
},
"tooltips": {
"shufflePlay": "Zufallswiedergabe",
"markAsWatched": "Als gesehen markieren",
"markAsUnwatched": "Als ungesehen markieren"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Untertitel",
"resetToZero": "Auf 0 ms zurücksetzen",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} spielt später",
"playsEarlier": "${label} spielt früher",
"noOffset": "Kein Offset",
"letterbox": "Letterbox",
"fillScreen": "Bild füllen",
"stretch": "Strecken",
"lockRotation": "Rotation sperren",
"unlockRotation": "Rotation entsperren"
},
"userStatus": {
"admin": "Eigentümer",
"restricted": "Eingeschränkt",
"protected": "Geschützt",
"current": "AKTUELL"
},
"messages": {
"markedAsWatched": "Als gesehen markiert",
"markedAsUnwatched": "Als ungesehen markiert",
"removedFromContinueWatching": "Aus Weiterschauen entfernt",
"errorLoading": "Fehler: ${error}",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
"errorLoadingSeason": "Fehler beim Laden der Staffel",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
"logsCleared": "Protokolle gelöscht",
"logsCopied": "Protokolle in Zwischenablage kopiert",
"noLogsAvailable": "Keine Protokolle verfügbar",
"libraryScanning": "Scanne „${title}“...",
"libraryScanStarted": "Mediathekscan gestartet für „${title}“",
"libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}",
"metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...",
"metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“",
"metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}",
"noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.",
"logoutConfirm": "Abmeldung wirklich durchführen?",
"noSeasonsFound": "Keine Staffeln gefunden",
"noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden",
"noEpisodesFoundGeneral": "Keine Episoden gefunden",
"noResultsFound": "Keine Ergebnisse gefunden",
"sleepTimerSet": "Sleep-Timer gesetzt auf ${label}",
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen"
},
"profile": {
"noUsersAvailable": "Keine Benutzer verfügbar"
},
"subtitlingStyling": {
"stylingOptions": "Stiloptionen",
"fontSize": "Schriftgröße",
"textColor": "Textfarbe",
"borderSize": "Rahmengröße",
"borderColor": "Rahmenfarbe",
"backgroundOpacity": "Hintergrunddeckkraft",
"backgroundColor": "Hintergrundfarbe"
},
"dialog": {
"confirmAction": "Aktion bestätigen",
"areYouSure": "Aktion wirklich ausführen?",
"cancel": "Abbrechen",
"playNow": "Jetzt abspielen"
},
"discover": {
"title": "Entdecken",
"switchProfile": "Profil wechseln",
"switchServer": "Server wechseln",
"logout": "Abmelden",
"noContentAvailable": "Kein Inhalt verfügbar",
"addMediaToLibraries": "Medien zur Mediathek hinzufügen",
"continueWatching": "Weiterschauen",
"recentlyAdded": "Kürzlich hinzugefügt",
"play": "Abspielen",
"resume": "Fortsetzen",
"playEpisode": "S${season}, E${episode} - Abspielen",
"resumeEpisode": "S${season}, E${episode} - Fortsetzen",
"pause": "Pause",
"overview": "Übersicht",
"cast": "Besetzung",
"seasons": "Staffeln",
"studio": "Studio",
"rating": "Altersfreigabe",
"watched": "Gesehen",
"episodeCount": "${count} Episoden",
"watchedProgress": "${watched} von ${total} gesehen",
"movie": "Film",
"tvShow": "Serie",
"minutesLeft": "${minutes} Min übrig"
},
"errors": {
"searchFailed": "Suche fehlgeschlagen: ${error}",
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
"connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen",
"failedToLoad": "Fehler beim Laden von ${context}: ${error}",
"noClientAvailable": "Kein Client verfügbar",
"authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}",
"couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden",
"pleaseEnterToken": "Bitte Token eingeben",
"invalidToken": "Ungültiges Token",
"failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}",
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
"connectionFailedGeneric": "Verbindung fehlgeschlagen"
},
"libraries": {
"title": "Mediatheken",
"scanLibraryFiles": "Mediatheksdateien scannen",
"scanLibrary": "Mediathek scannen",
"analyze": "Analysieren",
"analyzeLibrary": "Mediathek analysieren",
"refreshMetadata": "Metadaten aktualisieren",
"emptyTrash": "Papierkorb leeren",
"emptyingTrash": "Papierkorb für „${title}“ wird geleert...",
"trashEmptied": "Papierkorb für „${title}“ geleert",
"failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}",
"analyzing": "Analysiere „${title}“...",
"analysisStarted": "Analyse gestartet für „${title}“",
"failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}",
"noLibrariesFound": "Keine Mediatheken gefunden",
"thisLibraryIsEmpty": "Diese Mediathek ist leer",
"all": "Alle",
"clearAll": "Alle löschen",
"scanLibraryConfirm": "„${title}“ wirklich scannen?",
"analyzeLibraryConfirm": "„${title}“ wirklich analysieren?",
"refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?",
"emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?",
"manageLibraries": "Mediatheken verwalten",
"sort": "Sortieren",
"sortBy": "Sortieren nach",
"filters": "Filter",
"loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)",
"confirmActionMessage": "Aktion wirklich durchführen?",
"showLibrary": "Mediathek anzeigen",
"hideLibrary": "Mediathek ausblenden",
"libraryOptions": "Mediatheksoptionen"
},
"about": {
"title": "Über",
"openSourceLicenses": "Open-Source-Lizenzen",
"versionLabel": "Version ${version}",
"appDescription": "Ein schöner Plex-Client für Flutter",
"viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen"
},
"serverSelection": {
"connectingToServer": "Verbinde mit Server...",
"serverDebugCopied": "Server-Debugdaten kopiert",
"copyDebugData": "Debugdaten kopieren",
"noServersFound": "Keine Server gefunden",
"malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.",
"incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.",
"incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.",
"malformedServerInfo": "Serverinformationen fehlerhaft: ${message}",
"networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.",
"authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.",
"plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.",
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}"
},
"hubDetail": {
"title": "Titel",
"releaseYear": "Erscheinungsjahr",
"dateAdded": "Hinzugefügt am",
"rating": "Bewertung",
"noItemsFound": "Keine Elemente gefunden"
},
"logs": {
"title": "Protokolle",
"clearLogs": "Protokolle löschen",
"copyLogs": "Protokolle kopieren",
"exportLogs": "Protokolle exportieren",
"noLogsToShow": "Keine Protokolle zum Anzeigen",
"error": "Fehler:",
"stackTrace": "Stacktrace:"
},
"licenses": {
"relatedPackages": "Verwandte Pakete",
"license": "Lizenz",
"licenseNumber": "Lizenz ${number}",
"licensesCount": "${count} Lizenzen"
},
"navigation": {
"home": "Start",
"search": "Suche",
"libraries": "Mediatheken",
"settings": "Einstellungen"
},
"playlists": {
"title": "Wiedergabelisten",
"noPlaylists": "Keine Wiedergabelisten gefunden",
"create": "Wiedergabeliste erstellen",
"newPlaylist": "Neue Wiedergabeliste",
"playlistName": "Name der Wiedergabeliste",
"enterPlaylistName": "Name der Wiedergabeliste eingeben",
"edit": "Wiedergabeliste bearbeiten",
"delete": "Wiedergabeliste löschen",
"addTo": "Zur Wiedergabeliste hinzufügen",
"addItems": "Elemente hinzufügen",
"removeItem": "Aus Wiedergabeliste entfernen",
"clearPlaylist": "Wiedergabeliste leeren",
"playAll": "Alle abspielen",
"shuffle": "Zufällig",
"smartPlaylist": "Intelligente Wiedergabeliste",
"regularPlaylist": "Normale Wiedergabeliste",
"itemCount": "${count} Elemente",
"oneItem": "1 Element",
"emptyPlaylist": "Diese Wiedergabeliste ist leer",
"deleteConfirm": "Wiedergabeliste löschen?",
"deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?",
"created": "Wiedergabeliste erstellt",
"updated": "Wiedergabeliste aktualisiert",
"deleted": "Wiedergabeliste gelöscht",
"itemAdded": "Zur Wiedergabeliste hinzugefügt",
"itemRemoved": "Aus Wiedergabeliste entfernt",
"selectPlaylist": "Wiedergabeliste auswählen",
"createNewPlaylist": "Neue Wiedergabeliste erstellen",
"errorCreating": "Wiedergabeliste konnte nicht erstellt werden",
"errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden",
"errorLoading": "Wiedergabelisten konnten nicht geladen werden",
"errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden",
"errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden",
"errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden"
}
}
+44 -1
View File
@@ -100,6 +100,8 @@
"secondsUnit": "${seconds} secondi",
"defaultSleepTimer": "Timer spegnimento predefinito",
"minutesUnit": "${minutes} minuti",
"rememberTrackSelections": "Ricorda selezioni tracce per serie/film",
"rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione",
"unwatchedOnly": "Solo non guardati",
"unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale",
"shuffleOrderNavigation": "Navigazione in ordine casuale",
@@ -202,7 +204,8 @@
"userStatus": {
"admin": "Admin",
"restricted": "Limitato",
"protected": "Protetto"
"protected": "Protetto",
"current": "ATTUALE"
},
"messages": {
"markedAsWatched": "Segna come visto",
@@ -266,6 +269,10 @@
"pause": "Pausa",
"overview": "Panoramica",
"cast": "Cast",
"seasons": "Stagioni",
"studio": "Studio",
"rating": "Classificazione",
"watched": "Guardato",
"episodeCount": "${count} episodi",
"watchedProgress": "${watched}/${total} guardati",
"movie": "Film",
@@ -366,5 +373,41 @@
"search": "Cerca",
"libraries": "Librerie",
"settings": "Impostazioni"
},
"playlists": {
"title": "Playlist",
"noPlaylists": "Nessuna playlist trovata",
"create": "Crea playlist",
"newPlaylist": "Nuova playlist",
"playlistName": "Nome playlist",
"enterPlaylistName": "Inserisci nome playlist",
"edit": "Modifica playlist",
"delete": "Elimina playlist",
"addTo": "Aggiungi a playlist",
"addItems": "Aggiungi elementi",
"removeItem": "Rimuovi da playlist",
"clearPlaylist": "Svuota playlist",
"playAll": "Riproduci tutto",
"shuffle": "Casuale",
"smartPlaylist": "Playlist intelligente",
"regularPlaylist": "Playlist normale",
"itemCount": "${count} elementi",
"oneItem": "1 elemento",
"emptyPlaylist": "Questa playlist è vuota",
"deleteConfirm": "Eliminare playlist?",
"deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?",
"created": "Playlist creata",
"updated": "Playlist aggiornata",
"deleted": "Playlist eliminata",
"itemAdded": "Aggiunto alla playlist",
"itemRemoved": "Rimosso dalla playlist",
"selectPlaylist": "Seleziona playlist",
"createNewPlaylist": "Crea nuova playlist",
"errorCreating": "Errore durante la creazione della playlist",
"errorDeleting": "Errore durante l'eliminazione della playlist",
"errorLoading": "Errore durante il caricamento delle playlist",
"errorAdding": "Errore durante l'aggiunta alla playlist",
"errorReordering": "Errore durante il riordino dell'elemento della playlist",
"errorRemoving": "Errore durante la rimozione dalla playlist"
}
}
+44 -1
View File
@@ -100,6 +100,8 @@
"secondsUnit": "${seconds} seconden",
"defaultSleepTimer": "Standaard slaap timer",
"minutesUnit": "bij ${minutes} minuten",
"rememberTrackSelections": "Onthoud track selecties per serie/film",
"rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen",
"unwatchedOnly": "Alleen ongekeken",
"unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij",
"shuffleOrderNavigation": "Willekeurige volgorde navigatie",
@@ -202,7 +204,8 @@
"userStatus": {
"admin": "Beheerder",
"restricted": "Beperkt",
"protected": "Beschermd"
"protected": "Beschermd",
"current": "HUIDIG"
},
"messages": {
"markedAsWatched": "Gemarkeerd als gekeken",
@@ -266,6 +269,10 @@
"pause": "Pauzeren",
"overview": "Overzicht",
"cast": "Cast",
"seasons": "Seizoenen",
"studio": "Studio",
"rating": "Leeftijd",
"watched": "Bekeken",
"episodeCount": "${count} afleveringen",
"watchedProgress": "${watched}/${total} gekeken",
"movie": "Film",
@@ -366,5 +373,41 @@
"search": "Zoeken",
"libraries": "Bibliotheken",
"settings": "Instellingen"
},
"playlists": {
"title": "Afspeellijsten",
"noPlaylists": "Geen afspeellijsten gevonden",
"create": "Afspeellijst maken",
"newPlaylist": "Nieuwe afspeellijst",
"playlistName": "Naam afspeellijst",
"enterPlaylistName": "Voer naam afspeellijst in",
"edit": "Afspeellijst bewerken",
"delete": "Afspeellijst verwijderen",
"addTo": "Toevoegen aan afspeellijst",
"addItems": "Items toevoegen",
"removeItem": "Verwijderen uit afspeellijst",
"clearPlaylist": "Afspeellijst wissen",
"playAll": "Alles afspelen",
"shuffle": "Shuffle",
"smartPlaylist": "Slimme afspeellijst",
"regularPlaylist": "Normale afspeellijst",
"itemCount": "${count} items",
"oneItem": "1 item",
"emptyPlaylist": "Deze afspeellijst is leeg",
"deleteConfirm": "Afspeellijst verwijderen?",
"deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?",
"created": "Afspeellijst gemaakt",
"updated": "Afspeellijst bijgewerkt",
"deleted": "Afspeellijst verwijderd",
"itemAdded": "Toegevoegd aan afspeellijst",
"itemRemoved": "Verwijderd uit afspeellijst",
"selectPlaylist": "Selecteer afspeellijst",
"createNewPlaylist": "Nieuwe afspeellijst maken",
"errorCreating": "Fout bij maken afspeellijst",
"errorDeleting": "Fout bij verwijderen afspeellijst",
"errorLoading": "Fout bij laden afspeellijsten",
"errorAdding": "Fout bij toevoegen aan afspeellijst",
"errorReordering": "Fout bij herschikken van afspeellijstitem",
"errorRemoving": "Fout bij verwijderen uit afspeellijst"
}
}
+44 -1
View File
@@ -100,6 +100,8 @@
"secondsUnit": "${seconds} sekunder",
"defaultSleepTimer": "Standard sovtimer",
"minutesUnit": "${minutes} minuter",
"rememberTrackSelections": "Kom ihåg spårval per serie/film",
"rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning",
"unwatchedOnly": "Endast osedda",
"unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön",
"shuffleOrderNavigation": "Blandningsordning-navigation",
@@ -202,7 +204,8 @@
"userStatus": {
"admin": "Admin",
"restricted": "Begränsad",
"protected": "Skyddad"
"protected": "Skyddad",
"current": "NUVARANDE"
},
"messages": {
"markedAsWatched": "Markerad som sedd",
@@ -266,6 +269,10 @@
"pause": "Pausa",
"overview": "Översikt",
"cast": "Rollbesättning",
"seasons": "Säsonger",
"studio": "Studio",
"rating": "Åldersgräns",
"watched": "Tittad",
"episodeCount": "${count} avsnitt",
"watchedProgress": "${watched}/${total} sedda",
"movie": "Film",
@@ -366,5 +373,41 @@
"search": "Sök",
"libraries": "Bibliotek",
"settings": "Inställningar"
},
"playlists": {
"title": "Spellistor",
"noPlaylists": "Inga spellistor hittades",
"create": "Skapa spellista",
"newPlaylist": "Ny spellista",
"playlistName": "Spellistans namn",
"enterPlaylistName": "Ange spellistans namn",
"edit": "Redigera spellista",
"delete": "Ta bort spellista",
"addTo": "Lägg till i spellista",
"addItems": "Lägg till objekt",
"removeItem": "Ta bort från spellista",
"clearPlaylist": "Rensa spellista",
"playAll": "Spela alla",
"shuffle": "Blanda",
"smartPlaylist": "Smart spellista",
"regularPlaylist": "Vanlig spellista",
"itemCount": "${count} objekt",
"oneItem": "1 objekt",
"emptyPlaylist": "Denna spellista är tom",
"deleteConfirm": "Ta bort spellista?",
"deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?",
"created": "Spellista skapad",
"updated": "Spellista uppdaterad",
"deleted": "Spellista borttagen",
"itemAdded": "Tillagd i spellista",
"itemRemoved": "Borttagen från spellista",
"selectPlaylist": "Välj spellista",
"createNewPlaylist": "Skapa ny spellista",
"errorCreating": "Det gick inte att skapa spellista",
"errorDeleting": "Det gick inte att ta bort spellista",
"errorLoading": "Det gick inte att ladda spellistor",
"errorAdding": "Det gick inte att lägga till i spellista",
"errorReordering": "Det gick inte att omordna spellisteobjekt",
"errorRemoving": "Det gick inte att ta bort från spellista"
}
}
+413
View File
@@ -0,0 +1,413 @@
{
"app": {
"title": "Plezy",
"loading": "加载中..."
},
"auth": {
"signInWithPlex": "使用 Plex 登录",
"showQRCode": "显示二维码",
"cancel": "取消",
"authenticate": "验证",
"retry": "重试",
"debugEnterToken": "调试:输入 Plex Token",
"plexTokenLabel": "Plex 授权令牌 (Auth Token)",
"plexTokenHint": "输入你的 Plex.tv 令牌",
"authenticationTimeout": "验证超时。请重试。",
"scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。",
"waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。"
},
"common": {
"cancel": "取消",
"save": "保存",
"close": "关闭",
"clear": "清除",
"reset": "重置",
"later": "稍后",
"submit": "提交",
"confirm": "确认",
"retry": "重试",
"playNow": "立即播放",
"logout": "登出",
"online": "在线",
"offline": "离线",
"owned": "拥有",
"shared": "共享",
"current": "当前",
"unknown": "未知",
"refresh": "刷新",
"yes": "是",
"no": "否",
"server": "服务器"
},
"screens": {
"licenses": "许可证",
"selectServer": "选择服务器",
"switchProfile": "切换配置文件",
"subtitleStyling": "字幕样式",
"search": "搜索",
"logs": "日志"
},
"update": {
"available": "有可用更新",
"versionAvailable": "版本 ${version} 已发布",
"currentVersion": "当前版本: ${version}",
"skipVersion": "跳过此版本",
"viewRelease": "查看发布详情",
"latestVersion": "已安装的版本是可用的最新版本",
"checkFailed": "无法检查更新"
},
"settings": {
"title": "设置",
"language": "语言",
"theme": "主题",
"appearance": "外观",
"videoPlayback": "视频播放",
"shufflePlay": "随机播放",
"advanced": "高级",
"useSeasonPostersDescription": "为剧集显示季海报而非剧集海报",
"showHeroSectionDescription": "在主屏幕上显示精选内容轮播区",
"secondsLabel": "秒",
"minutesLabel": "分钟",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "输入时长 (${min}-${max})",
"systemTheme": "系统",
"systemThemeDescription": "跟随系统设置",
"lightTheme": "浅色",
"darkTheme": "深色",
"libraryDensity": "媒体库密度",
"compact": "紧凑",
"compactDescription": "卡片更小,显示更多项目",
"normal": "标准",
"normalDescription": "默认尺寸",
"comfortable": "舒适",
"comfortableDescription": "卡片更大,显示更少项目",
"viewMode": "视图模式",
"gridView": "网格视图",
"gridViewDescription": "以网格布局显示项目",
"listView": "列表视图",
"listViewDescription": "以列表布局显示项目",
"useSeasonPosters": "使用季海报",
"showHeroSection": "显示主要精选区",
"hardwareDecoding": "硬件解码",
"hardwareDecodingDescription": "如果可用,使用硬件加速",
"bufferSize": "缓冲区大小",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "字幕样式",
"subtitleStylingDescription": "调整字幕外观",
"smallSkipDuration": "短跳过时长",
"largeSkipDuration": "长跳过时长",
"secondsUnit": "${seconds} 秒",
"defaultSleepTimer": "默认睡眠定时器",
"minutesUnit": "${minutes} 分钟",
"rememberTrackSelections": "记住每个剧集/电影的音轨选择",
"rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好",
"unwatchedOnly": "仅未观看",
"unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集",
"shuffleOrderNavigation": "随机顺序导航",
"shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序",
"loopShuffleQueue": "循环随机队列",
"loopShuffleQueueDescription": "在队列结束时重新开始",
"videoPlayerControls": "视频播放器控制",
"keyboardShortcuts": "键盘快捷键",
"keyboardShortcutsDescription": "自定义键盘快捷键",
"debugLogging": "调试日志",
"debugLoggingDescription": "启用详细日志记录以便故障排除",
"viewLogs": "查看日志",
"viewLogsDescription": "查看应用程序日志",
"clearCache": "清除缓存",
"clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。",
"clearCacheSuccess": "缓存清除成功",
"resetSettings": "重置设置",
"resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。",
"resetSettingsSuccess": "设置重置成功",
"shortcutsReset": "快捷键已重置为默认值",
"about": "关于",
"aboutDescription": "应用程序信息和许可证",
"updates": "更新",
"updateAvailable": "有可用更新",
"checkForUpdates": "检查更新",
"validationErrorEnterNumber": "请输入一个有效的数字",
"validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间",
"shortcutAlreadyAssigned": "快捷键已被分配给 ${action}",
"shortcutUpdated": "快捷键已为 ${action} 更新"
},
"search": {
"hint": "搜索电影、系列、音乐...",
"tryDifferentTerm": "尝试不同的搜索词",
"searchYourMedia": "搜索媒体",
"enterTitleActorOrKeyword": "输入标题、演员或关键词"
},
"hotkeys": {
"setShortcutFor": "为 ${actionName} 设置快捷键",
"clearShortcut": "清除快捷键"
},
"pinEntry": {
"enterPin": "输入 PIN",
"showPin": "显示 PIN",
"hidePin": "隐藏 PIN"
},
"fileInfo": {
"title": "文件信息",
"video": "视频",
"audio": "音频",
"file": "文件",
"advanced": "高级",
"codec": "编解码器",
"resolution": "分辨率",
"bitrate": "比特率",
"frameRate": "帧率",
"aspectRatio": "宽高比",
"profile": "配置文件",
"bitDepth": "位深度",
"colorSpace": "色彩空间",
"colorRange": "色彩范围",
"colorPrimaries": "颜色原色",
"chromaSubsampling": "色度子采样",
"channels": "声道",
"path": "路径",
"size": "大小",
"container": "容器",
"duration": "时长",
"optimizedForStreaming": "已优化用于流媒体",
"has64bitOffsets": "64位偏移量"
},
"mediaMenu": {
"markAsWatched": "标记为已观看",
"markAsUnwatched": "标记为未观看",
"removeFromContinueWatching": "从继续观看中移除",
"goToSeries": "转到系列",
"goToSeason": "转到季",
"shufflePlay": "随机播放",
"fileInfo": "文件信息"
},
"tooltips": {
"shufflePlay": "随机播放",
"markAsWatched": "标记为已观看",
"markAsUnwatched": "标记为未观看"
},
"videoControls": {
"audioLabel": "音频",
"subtitlesLabel": "字幕",
"resetToZero": "重置为 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} 播放较晚",
"playsEarlier": "${label} 播放较早",
"noOffset": "无偏移",
"letterbox": "信箱模式(Letterbox",
"fillScreen": "填充屏幕",
"stretch": "拉伸",
"lockRotation": "锁定旋转",
"unlockRotation": "解锁旋转"
},
"userStatus": {
"admin": "管理员",
"restricted": "受限",
"protected": "受保护",
"current": "当前"
},
"messages": {
"markedAsWatched": "已标记为已观看",
"markedAsUnwatched": "已标记为未观看",
"removedFromContinueWatching": "已从继续观看中移除",
"errorLoading": "错误: ${error}",
"fileInfoNotAvailable": "文件信息不可用",
"errorLoadingFileInfo": "加载文件信息时出错: ${error}",
"errorLoadingSeries": "加载系列时出错",
"errorLoadingSeason": "加载季时出错",
"musicNotSupported": "尚不支持播放音乐",
"logsCleared": "日志已清除",
"logsCopied": "日志已复制到剪贴板",
"noLogsAvailable": "没有可用日志",
"libraryScanning": "正在扫描 “${title}”...",
"libraryScanStarted": "已开始扫描 “${title}” 媒体库",
"libraryScanFailed": "无法扫描媒体库: ${error}",
"metadataRefreshing": "正在刷新 “${title}” 的元数据...",
"metadataRefreshStarted": "已开始刷新 “${title}” 的元数据",
"metadataRefreshFailed": "无法刷新元数据: ${error}",
"noPlexToken": "未找到 Plex 令牌。请重新登录。",
"logoutConfirm": "你确定要登出吗?",
"noSeasonsFound": "未找到季",
"noEpisodesFound": "在第一季中未找到剧集",
"noEpisodesFoundGeneral": "未找到剧集",
"noResultsFound": "未找到结果",
"sleepTimerSet": "睡眠定时器已设置为 ${label}",
"failedToSwitchProfile": "无法切换到 ${displayName}"
},
"profile": {
"noUsersAvailable": "没有可用用户"
},
"subtitlingStyling": {
"stylingOptions": "样式选项",
"fontSize": "字号",
"textColor": "文本颜色",
"borderSize": "边框大小",
"borderColor": "边框颜色",
"backgroundOpacity": "背景不透明度",
"backgroundColor": "背景颜色"
},
"dialog": {
"confirmAction": "确认操作",
"areYouSure": "确定要执行此操作吗?",
"cancel": "取消",
"playNow": "立即播放"
},
"discover": {
"title": "发现",
"switchProfile": "切换配置文件",
"switchServer": "切换服务器",
"logout": "登出",
"noContentAvailable": "没有可用内容",
"addMediaToLibraries": "请向你的媒体库添加一些媒体",
"continueWatching": "继续观看",
"recentlyAdded": "最近添加",
"play": "播放",
"resume": "继续",
"playEpisode": "播放 S${season}, E${episode}",
"resumeEpisode": "继续 S${season}, E${episode}",
"pause": "暂停",
"overview": "概述",
"cast": "演员表",
"seasons": "季数",
"studio": "制作公司",
"rating": "年龄分级",
"watched": "已观看",
"episodeCount": "${count} 集",
"watchedProgress": "已观看 ${watched}/${total} 集",
"movie": "电影",
"tvShow": "电视剧",
"minutesLeft": "剩余 ${minutes} 分钟"
},
"errors": {
"searchFailed": "搜索失败: ${error}",
"connectionTimeout": "加载 ${context} 时连接超时",
"connectionFailed": "无法连接到 Plex 服务器",
"failedToLoad": "无法加载 ${context}: ${error}",
"noClientAvailable": "没有可用客户端",
"authenticationFailed": "验证失败: ${error}",
"couldNotLaunchUrl": "无法打开授权 URL",
"pleaseEnterToken": "请输入一个令牌",
"invalidToken": "令牌无效",
"failedToVerifyToken": "无法验证令牌: ${error}",
"failedToSwitchProfile": "无法切换到 ${displayName}",
"connectionFailedGeneric": "连接失败"
},
"libraries": {
"title": "媒体库",
"scanLibraryFiles": "扫描媒体库文件",
"scanLibrary": "扫描媒体库",
"analyze": "分析",
"analyzeLibrary": "分析媒体库",
"refreshMetadata": "刷新元数据",
"emptyTrash": "清空回收站",
"emptyingTrash": "正在清空 “${title}” 的回收站...",
"trashEmptied": "已清空 “${title}” 的回收站",
"failedToEmptyTrash": "无法清空回收站: ${error}",
"analyzing": "正在分析 “${title}”...",
"analysisStarted": "已开始分析 “${title}”",
"failedToAnalyze": "无法分析媒体库: ${error}",
"noLibrariesFound": "未找到媒体库",
"thisLibraryIsEmpty": "此媒体库为空",
"all": "全部",
"clearAll": "全部清除",
"scanLibraryConfirm": "确定要扫描 “${title}” 吗?",
"analyzeLibraryConfirm": "确定要分析 “${title}” 吗?",
"refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?",
"emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?",
"manageLibraries": "管理媒体库",
"sort": "排序",
"sortBy": "排序依据",
"filters": "筛选器",
"loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)",
"confirmActionMessage": "确定要执行此操作吗?",
"showLibrary": "显示媒体库",
"hideLibrary": "隐藏媒体库",
"libraryOptions": "媒体库选项"
},
"about": {
"title": "关于",
"openSourceLicenses": "开源许可证",
"versionLabel": "版本 ${version}",
"appDescription": "一款精美的 Flutter Plex 客户端",
"viewLicensesDescription": "查看第三方库的许可证"
},
"serverSelection": {
"connectingToServer": "正在连接服务器...",
"serverDebugCopied": "服务器调试数据已复制到剪贴板",
"copyDebugData": "复制调试数据",
"noServersFound": "未找到服务器",
"malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。",
"incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。",
"incompleteConnectionInfo": "服务器连接信息不完整。请重试。",
"malformedServerInfo": "服务器信息格式错误: ${message}",
"networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。",
"authenticationFailed": "验证失败。请重新登录。",
"plexServiceUnavailable": "Plex 服务不可用。请稍后重试。",
"failedToLoadServers": "无法加载服务器: ${error}"
},
"hubDetail": {
"title": "标题",
"releaseYear": "发行年份",
"dateAdded": "添加日期",
"rating": "评分",
"noItemsFound": "未找到项目"
},
"logs": {
"title": "日志",
"clearLogs": "清除日志",
"copyLogs": "复制日志",
"exportLogs": "导出日志",
"noLogsToShow": "没有可显示的日志",
"error": "错误:",
"stackTrace": "堆栈跟踪 (Stack Trace):"
},
"licenses": {
"relatedPackages": "相关软件包",
"license": "许可证",
"licenseNumber": "许可证 ${number}",
"licensesCount": "${count} 个许可证"
},
"navigation": {
"home": "主页",
"search": "搜索",
"libraries": "媒体库",
"settings": "设置"
},
"playlists": {
"title": "播放列表",
"noPlaylists": "未找到播放列表",
"create": "创建播放列表",
"newPlaylist": "新播放列表",
"playlistName": "播放列表名称",
"enterPlaylistName": "输入播放列表名称",
"edit": "编辑播放列表",
"delete": "删除播放列表",
"addTo": "添加到播放列表",
"addItems": "添加项目",
"removeItem": "从播放列表中移除",
"clearPlaylist": "清空播放列表",
"playAll": "全部播放",
"shuffle": "随机播放",
"smartPlaylist": "智能播放列表",
"regularPlaylist": "普通播放列表",
"itemCount": "${count} 个项目",
"oneItem": "1 个项目",
"emptyPlaylist": "此播放列表为空",
"deleteConfirm": "删除播放列表?",
"deleteMessage": "确定要删除 \"${name}\" 吗?",
"created": "播放列表已创建",
"updated": "播放列表已更新",
"deleted": "播放列表已删除",
"itemAdded": "已添加到播放列表",
"itemRemoved": "已从播放列表中移除",
"selectPlaylist": "选择播放列表",
"createNewPlaylist": "创建新播放列表",
"errorCreating": "创建播放列表失败",
"errorDeleting": "删除播放列表失败",
"errorLoading": "加载播放列表失败",
"errorAdding": "添加到播放列表失败",
"errorReordering": "重新排序播放列表项目失败",
"errorRemoving": "从播放列表中移除失败"
}
}
+2 -1
View File
@@ -140,7 +140,8 @@ class PlexFileInfo {
/// Format audio channels (e.g., "2 channels (stereo)")
String get audioChannelsFormatted {
if (audioChannels != null) {
String channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
String channelText =
'$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
if (audioChannelLayout != null) {
channelText += ' ($audioChannelLayout)';
}
+12
View File
@@ -38,6 +38,9 @@ class PlexMetadata {
final int? viewedLeafCount; // Number of watched episodes in a series/season
@JsonKey(name: 'Role')
final List<PlexRole>? role; // Cast members
final String? audioLanguage; // Per-media preferred audio language
final String? subtitleLanguage; // Per-media preferred subtitle language
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
// Transient field for clear logo (extracted from Image array)
String? _clearLogo;
@@ -75,6 +78,9 @@ class PlexMetadata {
this.leafCount,
this.viewedLeafCount,
this.role,
this.audioLanguage,
this.subtitleLanguage,
this.playlistItemID,
});
/// Create a copy of this metadata with optional field overrides
@@ -110,6 +116,9 @@ class PlexMetadata {
int? leafCount,
int? viewedLeafCount,
List<PlexRole>? role,
String? audioLanguage,
String? subtitleLanguage,
int? playlistItemID,
}) {
final copy = PlexMetadata(
ratingKey: ratingKey ?? this.ratingKey,
@@ -143,6 +152,9 @@ class PlexMetadata {
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
role: role ?? this.role,
audioLanguage: audioLanguage ?? this.audioLanguage,
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
playlistItemID: playlistItemID ?? this.playlistItemID,
);
// Preserve clearLogo
copy._clearLogo = _clearLogo;
+6
View File
@@ -40,6 +40,9 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
@@ -75,4 +78,7 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
'playlistItemID': instance.playlistItemID,
};
+66
View File
@@ -0,0 +1,66 @@
import 'package:json_annotation/json_annotation.dart';
part 'plex_playlist.g.dart';
@JsonSerializable()
class PlexPlaylist {
final String ratingKey;
final String key;
final String type; // "playlist"
final String title;
final String? summary;
final bool smart;
final String playlistType; // video, audio, photo
final int? duration;
final int? leafCount; // Number of items in playlist
final String? composite; // Composite thumbnail image
final int? addedAt;
final int? updatedAt;
final int? lastViewedAt;
final int? viewCount;
final String? content; // For smart playlists - generator URI
final String? guid;
final String? thumb;
PlexPlaylist({
required this.ratingKey,
required this.key,
required this.type,
required this.title,
this.summary,
required this.smart,
required this.playlistType,
this.duration,
this.leafCount,
this.composite,
this.addedAt,
this.updatedAt,
this.lastViewedAt,
this.viewCount,
this.content,
this.guid,
this.thumb,
});
/// Helper to get display image (composite or thumb)
String? get displayImage => composite ?? thumb;
/// Helper to get formatted duration
String? get formattedDuration {
if (duration == null) return null;
final hours = duration! ~/ 3600000;
final minutes = (duration! % 3600000) ~/ 60000;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
/// Helper to determine if playlist is editable
bool get isEditable => !smart;
factory PlexPlaylist.fromJson(Map<String, dynamic> json) =>
_$PlexPlaylistFromJson(json);
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
}
+48
View File
@@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'plex_playlist.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
ratingKey: json['ratingKey'] as String,
key: json['key'] as String,
type: json['type'] as String,
title: json['title'] as String,
summary: json['summary'] as String?,
smart: json['smart'] as bool,
playlistType: json['playlistType'] as String,
duration: (json['duration'] as num?)?.toInt(),
leafCount: (json['leafCount'] as num?)?.toInt(),
composite: json['composite'] as String?,
addedAt: (json['addedAt'] as num?)?.toInt(),
updatedAt: (json['updatedAt'] as num?)?.toInt(),
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
viewCount: (json['viewCount'] as num?)?.toInt(),
content: json['content'] as String?,
guid: json['guid'] as String?,
thumb: json['thumb'] as String?,
);
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'type': instance.type,
'title': instance.title,
'summary': instance.summary,
'smart': instance.smart,
'playlistType': instance.playlistType,
'duration': instance.duration,
'leafCount': instance.leafCount,
'composite': instance.composite,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'viewCount': instance.viewCount,
'content': instance.content,
'guid': instance.guid,
'thumb': instance.thumb,
};
+1 -1
View File
@@ -27,4 +27,4 @@ class PlexVideoPlaybackData {
/// Returns true if there are multiple media versions available
bool get hasMultipleVersions => availableVersions.length > 1;
}
}
@@ -0,0 +1,174 @@
import 'package:dio/dio.dart';
import '../utils/app_logger.dart';
/// Maintains the list of endpoints we can cycle through when one fails.
class EndpointFailoverManager {
EndpointFailoverManager(List<String> urls) {
_setEndpoints(urls);
}
late List<String> _endpoints;
int _currentIndex = 0;
List<String> get endpoints => List.unmodifiable(_endpoints);
String get current => _endpoints[_currentIndex];
bool get hasFallback => _currentIndex < _endpoints.length - 1;
/// Move to the next endpoint, returning its URL or null if exhausted.
String? moveToNext() {
if (!hasFallback) return null;
_currentIndex++;
return _endpoints[_currentIndex];
}
/// Replace the endpoint list and optionally set the active endpoint.
void reset(List<String> urls, {String? currentBaseUrl}) {
_setEndpoints(urls);
if (currentBaseUrl != null) {
final index = _endpoints.indexOf(currentBaseUrl);
_currentIndex = index >= 0 ? index : 0;
} else {
_currentIndex = 0;
}
}
void _setEndpoints(List<String> urls) {
final sanitized = <String>[];
final seen = <String>{};
for (final url in urls) {
if (url.isEmpty || seen.contains(url)) continue;
seen.add(url);
sanitized.add(url);
}
if (sanitized.isEmpty) {
throw ArgumentError('At least one endpoint is required');
}
_endpoints = sanitized;
_currentIndex = _currentIndex.clamp(0, _endpoints.length - 1);
}
}
/// Dio interceptor that retries failed requests on the next available endpoint.
class EndpointFailoverInterceptor extends Interceptor {
EndpointFailoverInterceptor({
required Dio dio,
required this.endpointManager,
required Future<void> Function(String newBaseUrl) onEndpointSwitch,
}) : _dio = dio,
_onEndpointSwitch = onEndpointSwitch;
final Dio _dio;
final EndpointFailoverManager endpointManager;
final Future<void> Function(String newBaseUrl) _onEndpointSwitch;
bool _isSwitching = false;
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isSwitching ||
!_shouldAttemptFailover(err) ||
!endpointManager.hasFallback) {
handler.next(err);
return;
}
final failedEndpoint = endpointManager.current;
appLogger.w(
'Endpoint request failed, evaluating failover',
error: {
'endpoint': failedEndpoint,
'type': err.type.name,
'statusCode': err.response?.statusCode,
},
stackTrace: err.stackTrace,
);
final nextBaseUrl = endpointManager.moveToNext();
if (nextBaseUrl == null) {
appLogger.w(
'Endpoint failure but no fallback endpoints remain',
error: {'failedEndpoint': failedEndpoint},
);
handler.next(err);
return;
}
_isSwitching = true;
try {
appLogger.i(
'Switching Plex endpoint after request failure',
error: {
'from': failedEndpoint,
'to': nextBaseUrl,
'path': err.requestOptions.path,
},
);
await _onEndpointSwitch(nextBaseUrl);
final response = await _retryRequest(err.requestOptions);
appLogger.i(
'Endpoint failover retry succeeded',
error: {'newEndpoint': nextBaseUrl},
);
handler.resolve(response);
} on DioException catch (dioError) {
appLogger.w(
'Endpoint failover retry failed',
error: {
'newEndpoint': nextBaseUrl,
'type': dioError.type.name,
'statusCode': dioError.response?.statusCode,
},
stackTrace: dioError.stackTrace,
);
handler.next(dioError);
} catch (_) {
handler.next(err);
} finally {
_isSwitching = false;
}
}
bool _shouldAttemptFailover(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.connectionError) {
return true;
}
if (error.type == DioExceptionType.badResponse) {
final statusCode = error.response?.statusCode ?? 0;
return statusCode >= 500;
}
return false;
}
Future<Response<dynamic>> _retryRequest(RequestOptions requestOptions) {
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
responseType: requestOptions.responseType,
contentType: requestOptions.contentType,
followRedirects: requestOptions.followRedirects,
receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError,
validateStatus: requestOptions.validateStatus,
sendTimeout: requestOptions.sendTimeout,
receiveTimeout: requestOptions.receiveTimeout,
extra: requestOptions.extra,
listFormat: requestOptions.listFormat,
);
return _dio.request<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
cancelToken: requestOptions.cancelToken,
onSendProgress: requestOptions.onSendProgress,
onReceiveProgress: requestOptions.onReceiveProgress,
);
}
}
+70 -38
View File
@@ -1,95 +1,127 @@
import 'package:flutter/foundation.dart';
import '../models/plex_metadata.dart';
/// Manages shuffle playback state for TV shows and seasons.
/// Playback mode types
enum PlaybackMode {
none, // No active playback queue
sequential, // Normal episode-to-episode playback (uses Plex API)
shufflePlay, // Shuffle play for shows/seasons
playlist, // Playlist playback (ordered or shuffled)
}
/// Manages playback state for TV shows, seasons, and playlists.
/// This provider is session-only and does not persist across app restarts.
class PlaybackStateProvider with ChangeNotifier {
List<PlexMetadata> _shuffleQueue = [];
String?
_shuffleContextKey; // The show/season ratingKey for this shuffle session
List<PlexMetadata> _queue = [];
String? _contextKey; // The show/season/playlist ratingKey for this session
int _currentIndex = 0;
PlaybackMode _playbackMode = PlaybackMode.none;
/// Current playback mode
PlaybackMode get playbackMode => _playbackMode;
/// Whether shuffle mode is currently active
bool get isShuffleActive => _shuffleQueue.isNotEmpty;
bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay;
/// The context key (show or season ratingKey) for the current shuffle session
String? get shuffleContextKey => _shuffleContextKey;
/// Whether playlist mode is currently active
bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist;
/// Whether any queue-based playback is active
bool get isQueueActive =>
_queue.isNotEmpty && _playbackMode != PlaybackMode.none;
/// The context key (show/season/playlist ratingKey) for the current session
String? get shuffleContextKey => _contextKey;
/// Sets a new shuffle queue and starts shuffle mode
void setShuffleQueue(List<PlexMetadata> episodes, String contextKey) {
_shuffleQueue = List.from(episodes);
_shuffleContextKey = contextKey;
_queue = List.from(episodes);
_contextKey = contextKey;
_currentIndex = 0;
_playbackMode = PlaybackMode.shufflePlay;
notifyListeners();
}
/// Gets the next episode in the shuffle queue.
/// Returns null if queue is exhausted or current episode is not in queue.
/// [loopQueue] - If true, restart from beginning when queue is exhausted
PlexMetadata? getNextEpisode(String currentEpisodeKey, {bool loopQueue = false}) {
if (_shuffleQueue.isEmpty) return null;
/// Sets a playback queue for playlist playback (ordered, not shuffled)
void setPlaybackQueue(List<PlexMetadata> items, String contextKey) {
_queue = List.from(items);
_contextKey = contextKey;
_currentIndex = 0;
_playbackMode = PlaybackMode.playlist;
notifyListeners();
}
// Find current episode in queue
final currentIndex = _shuffleQueue.indexWhere(
(ep) => ep.ratingKey == currentEpisodeKey,
/// Gets the next item in the playback queue.
/// Returns null if queue is exhausted or current item is not in queue.
/// [loopQueue] - If true, restart from beginning when queue is exhausted
PlexMetadata? getNextEpisode(
String currentItemKey, {
bool loopQueue = false,
}) {
if (_queue.isEmpty) return null;
// Find current item in queue
final currentIndex = _queue.indexWhere(
(item) => item.ratingKey == currentItemKey,
);
if (currentIndex == -1) {
// Current episode not in queue, clear shuffle
// Current item not in queue, clear queue
clearShuffle();
return null;
}
// Check if there's a next episode
if (currentIndex + 1 >= _shuffleQueue.length) {
// Check if there's a next item
if (currentIndex + 1 >= _queue.length) {
// Queue exhausted
if (loopQueue && _shuffleQueue.isNotEmpty) {
if (loopQueue && _queue.isNotEmpty) {
// Loop back to beginning
_currentIndex = 0;
return _shuffleQueue[_currentIndex];
return _queue[_currentIndex];
}
return null;
}
_currentIndex = currentIndex + 1;
return _shuffleQueue[_currentIndex];
return _queue[_currentIndex];
}
/// Gets the previous episode in the shuffle queue.
/// Returns null if at the beginning of the queue or current episode is not in queue.
PlexMetadata? getPreviousEpisode(String currentEpisodeKey) {
if (_shuffleQueue.isEmpty) return null;
/// Gets the previous item in the playback queue.
/// Returns null if at the beginning of the queue or current item is not in queue.
PlexMetadata? getPreviousEpisode(String currentItemKey) {
if (_queue.isEmpty) return null;
// Find current episode in queue
final currentIndex = _shuffleQueue.indexWhere(
(ep) => ep.ratingKey == currentEpisodeKey,
// Find current item in queue
final currentIndex = _queue.indexWhere(
(item) => item.ratingKey == currentItemKey,
);
if (currentIndex == -1) {
// Current episode not in queue
// Current item not in queue
return null;
}
// Check if there's a previous episode
// Check if there's a previous item
if (currentIndex <= 0) {
// At the beginning of queue
return null;
}
_currentIndex = currentIndex - 1;
return _shuffleQueue[_currentIndex];
return _queue[_currentIndex];
}
/// Clears the shuffle queue and exits shuffle mode
/// Clears the playback queue and exits queue mode
void clearShuffle() {
_shuffleQueue = [];
_shuffleContextKey = null;
_queue = [];
_contextKey = null;
_currentIndex = 0;
_playbackMode = PlaybackMode.none;
notifyListeners();
}
/// Gets the total number of episodes in the current shuffle queue
int get queueLength => _shuffleQueue.length;
/// Gets the total number of items in the current playback queue
int get queueLength => _queue.length;
/// Gets the current position in the queue (1-indexed)
int get currentPosition => _currentIndex + 1;
+1 -3
View File
@@ -78,9 +78,7 @@ class _AboutScreenState extends State<AboutScreen> {
child: ListTile(
leading: const Icon(Icons.description),
title: Text(t.about.openSourceLicenses),
subtitle: Text(
t.about.viewLicensesDescription,
),
subtitle: Text(t.about.viewLicensesDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
+22 -14
View File
@@ -245,13 +245,16 @@ class _AuthScreenState extends State<AuthScreen> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
Image.asset(
'assets/plezy.png',
width: 120,
height: 120,
),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
@@ -321,7 +324,9 @@ class _AuthScreenState extends State<AuthScreen> {
ElevatedButton(
onPressed: _startAuthentication,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: Text(t.auth.signInWithPlex),
),
@@ -334,7 +339,9 @@ class _AuthScreenState extends State<AuthScreen> {
_startAuthentication();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: Text(t.auth.showQRCode),
),
@@ -343,11 +350,12 @@ class _AuthScreenState extends State<AuthScreen> {
OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(
vertical: 12,
),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.5),
color: Theme.of(context).colorScheme.outline
.withValues(alpha: 0.5),
),
),
child: Text(
@@ -380,9 +388,8 @@ class _AuthScreenState extends State<AuthScreen> {
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
@@ -405,7 +412,8 @@ class _AuthScreenState extends State<AuthScreen> {
),
child: Text(t.auth.retry),
),
] else ...[ // add QR button here
] else ...[
// add QR button here
ElevatedButton(
onPressed: _startAuthentication,
style: ElevatedButton.styleFrom(
+12 -11
View File
@@ -240,7 +240,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// This is called when returning to the home screen to avoid blocking UI
Future<void> _refreshContinueWatching() async {
appLogger.d('Refreshing Continue Watching in background');
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
@@ -250,7 +250,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
final onDeck = await client.getOnDeck();
if (mounted) {
setState(() {
_onDeck = onDeck;
@@ -412,11 +412,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (plexToken == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.noPlexToken),
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noPlexToken)));
}
return;
}
@@ -627,10 +625,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
),
),
_buildHorizontalList(_onDeck, isLarge: false, isInContinueWatching: true),
_buildHorizontalList(
_onDeck,
isLarge: false,
isInContinueWatching: true,
),
],
// Recommendation Hubs (Trending, Top in Genre, etc.)
for (final hub in _hubs) ...[
SliverToBoxAdapter(
@@ -1279,8 +1280,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
width: cardWidth,
height: posterHeight,
onRefresh: updateItem,
onRemoveFromContinueWatching: isInContinueWatching
? _refreshContinueWatching
onRemoveFromContinueWatching: isInContinueWatching
? _refreshContinueWatching
: null,
forceGridMode: true,
isInContinueWatching: isInContinueWatching,
+5 -1
View File
@@ -100,7 +100,11 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
List<PlexSort> _getDefaultSortOptions() {
return [
PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'),
PlexSort(
key: 'titleSort',
title: t.hubDetail.title,
defaultDirection: 'asc',
),
PlexSort(
key: 'year',
descKey: 'year:desc',
+51 -16
View File
@@ -21,6 +21,7 @@ import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
import 'playlists_screen.dart';
class LibrariesScreen extends StatefulWidget {
const LibrariesScreen({super.key});
@@ -72,7 +73,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return t.errors.connectionFailed;
default:
appLogger.e('Error loading $context', error: error);
return t.errors.failedToLoad(context: context, error: error.message ?? 'Unknown error');
return t.errors.failedToLoad(
context: context,
error: error.message ?? 'Unknown error',
);
}
}
@@ -601,8 +605,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
label: t.libraries.scanLibraryFiles,
requiresConfirmation: true,
confirmationTitle: t.libraries.scanLibrary,
confirmationMessage:
t.libraries.scanLibraryConfirm(title: library.title),
confirmationMessage: t.libraries.scanLibraryConfirm(
title: library.title,
),
),
ContextMenuItem(
value: 'analyze',
@@ -610,8 +615,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
label: t.libraries.analyze,
requiresConfirmation: true,
confirmationTitle: t.libraries.analyzeLibrary,
confirmationMessage:
t.libraries.analyzeLibraryConfirm(title: library.title),
confirmationMessage: t.libraries.analyzeLibraryConfirm(
title: library.title,
),
),
ContextMenuItem(
value: 'refresh',
@@ -619,8 +625,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
label: t.libraries.refreshMetadata,
requiresConfirmation: true,
confirmationTitle: t.libraries.refreshMetadata,
confirmationMessage:
t.libraries.refreshMetadataConfirm(title: library.title),
confirmationMessage: t.libraries.refreshMetadataConfirm(
title: library.title,
),
isDestructive: true,
),
ContextMenuItem(
@@ -629,8 +636,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
label: t.libraries.emptyTrash,
requiresConfirmation: true,
confirmationTitle: t.libraries.emptyTrash,
confirmationMessage:
t.libraries.emptyTrashConfirm(title: library.title),
confirmationMessage: t.libraries.emptyTrashConfirm(
title: library.title,
),
isDestructive: true,
),
];
@@ -743,7 +751,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.metadataRefreshStarted(title: library.title)),
content: Text(
t.messages.metadataRefreshStarted(title: library.title),
),
duration: const Duration(seconds: 3),
),
);
@@ -753,7 +763,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.metadataRefreshFailed(error: e.toString())),
content: Text(
t.messages.metadataRefreshFailed(error: e.toString()),
),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
@@ -1037,7 +1049,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const Icon(
Icons.folder_open,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(t.libraries.thisLibraryIsEmpty),
],
@@ -1097,7 +1113,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
const CircularProgressIndicator(),
const SizedBox(height: 8),
Text(
t.libraries.loadingLibraryWithCount(count: _items.length),
t.libraries.loadingLibraryWithCount(
count: _items.length,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -1108,6 +1126,16 @@ class _LibrariesScreenState extends State<LibrariesScreen>
],
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const PlaylistsScreen()),
);
},
tooltip: t.playlists.title,
child: const Icon(Icons.playlist_play),
),
);
}
@@ -1393,7 +1421,10 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
const SizedBox(width: 12),
Text(
t.libraries.filters,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_tempSelectedFilters.isNotEmpty)
@@ -1718,7 +1749,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction),
title: Text(
selectedItem.confirmationTitle ?? t.dialog.confirmAction,
),
content: Text(
selectedItem.confirmationMessage ??
t.libraries.confirmActionMessage,
@@ -1852,7 +1885,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
: Icons.visibility,
),
onPressed: () => widget.onToggleVisibility(library),
tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary,
tooltip: isHidden
? t.libraries.showLibrary
: t.libraries.hideLibrary,
),
IconButton(
icon: const Icon(Icons.more_vert),
+6 -2
View File
@@ -93,7 +93,9 @@ class _LicensesScreenState extends State<LicensesScreen> {
),
subtitle: mergedLicense.licenseEntries.length > 1
? Text(
t.licenses.licensesCount(count: mergedLicense.licenseEntries.length),
t.licenses.licensesCount(
count: mergedLicense.licenseEntries.length,
),
)
: null,
trailing: const Icon(Icons.chevron_right),
@@ -178,7 +180,9 @@ class _LicenseDetailScreen extends StatelessWidget {
children: [
Text(
isMultipleLicenses
? t.licenses.licenseNumber(number: index + 1)
? t.licenses.licenseNumber(
number: index + 1,
)
: t.licenses.license,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
+36 -49
View File
@@ -40,9 +40,9 @@ class _LogsScreenState extends State<LogsScreen> {
MemoryLogOutput.clearLogs();
_logs = [];
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.logsCleared)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.logsCleared)));
}
void _copyAllLogs() {
@@ -55,7 +55,8 @@ class _LogsScreenState extends State<LogsScreen> {
isFirst = false;
buffer.write(
'[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}');
'[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}',
);
if (log.error != null) {
buffer.write('\nError: ${log.error}');
}
@@ -64,9 +65,9 @@ class _LogsScreenState extends State<LogsScreen> {
}
}
Clipboard.setData(ClipboardData(text: buffer.toString()));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.logsCopied)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.logsCopied)));
}
Color _getLevelColor(Level level) {
@@ -131,26 +132,21 @@ class _LogsScreenState extends State<LogsScreen> {
),
if (_logs.isEmpty)
SliverFillRemaining(
child: Center(
child: Text(t.messages.noLogsAvailable),
),
child: Center(child: Text(t.messages.noLogsAvailable)),
)
else
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final log = _logs[index];
return _LogEntryCard(
log: log,
formatTime: _formatTime,
levelColor: _getLevelColor(log.level),
levelIcon: _getLevelIcon(log.level),
);
},
childCount: _logs.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final log = _logs[index];
return _LogEntryCard(
log: log,
formatTime: _formatTime,
levelColor: _getLevelColor(log.level),
levelIcon: _getLevelIcon(log.level),
);
}, childCount: _logs.length),
),
),
],
@@ -198,11 +194,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
widget.levelIcon,
color: widget.levelColor,
size: 20,
),
Icon(widget.levelIcon, color: widget.levelColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -221,9 +213,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
const SizedBox(width: 8),
Text(
widget.formatTime(widget.log.timestamp),
style: Theme.of(context)
.textTheme
.bodySmall
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context)
.textTheme
@@ -244,13 +234,10 @@ class _LogEntryCardState extends State<_LogEntryCard> {
),
if (hasErrorOrStackTrace)
Icon(
_isExpanded
? Icons.expand_less
: Icons.expand_more,
color: Theme.of(context)
.iconTheme
.color
?.withValues(alpha: 0.6),
_isExpanded ? Icons.expand_less : Icons.expand_more,
color: Theme.of(
context,
).iconTheme.color?.withValues(alpha: 0.6),
),
],
),
@@ -262,9 +249,9 @@ class _LogEntryCardState extends State<_LogEntryCard> {
Text(
t.logs.error,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
@@ -277,9 +264,9 @@ class _LogEntryCardState extends State<_LogEntryCard> {
),
child: SelectableText(
widget.log.error.toString(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
@@ -288,9 +275,9 @@ class _LogEntryCardState extends State<_LogEntryCard> {
Text(
t.logs.stackTrace,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
color: widget.levelColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
@@ -303,9 +290,9 @@ class _LogEntryCardState extends State<_LogEntryCard> {
),
child: SelectableText(
widget.log.stackTrace.toString(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
+3 -3
View File
@@ -768,7 +768,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Seasons (for TV shows)
if (isShow) ...[
Text(
'Seasons',
t.discover.seasons,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -930,12 +930,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Additional info
if (metadata.studio != null) ...[
_buildInfoRow('Studio', metadata.studio!),
_buildInfoRow(t.discover.studio, metadata.studio!),
const SizedBox(height: 12),
],
if (metadata.contentRating != null) ...[
_buildInfoRow(
'Rating',
t.discover.rating,
formatContentRating(metadata.contentRating!),
),
const SizedBox(height: 12),
+520
View File
@@ -0,0 +1,520 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_playlist.dart';
import '../models/plex_metadata.dart';
import '../providers/settings_provider.dart';
import '../providers/playback_state_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/media_card.dart';
import '../widgets/playlist_item_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../i18n/strings.g.dart';
/// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget {
final PlexPlaylist playlist;
const PlaylistDetailScreen({super.key, required this.playlist});
@override
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
}
class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => context.clientSafe;
List<PlexMetadata> _items = [];
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadPlaylistItems();
}
Future<void> _loadPlaylistItems() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
final items = await client.getPlaylist(widget.playlist.ratingKey);
setState(() {
_items = items;
_isLoading = false;
});
appLogger.d(
'Loaded ${items.length} items for playlist: ${widget.playlist.title}',
);
} catch (e) {
appLogger.e('Failed to load playlist items', error: e);
setState(() {
_errorMessage = 'Failed to load playlist items: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _deletePlaylist() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.playlists.deleteConfirm),
content: Text(t.playlists.deleteMessage(name: widget.playlist.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(t.playlists.delete),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
if (confirmed == true && mounted) {
final success = await client.deletePlaylist(widget.playlist.ratingKey);
if (mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
Navigator.pop(context); // Return to playlists screen
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
}
}
}
}
Future<void> _onReorder(int oldIndex, int newIndex) async {
// Adjust newIndex if moving down in the list
if (newIndex > oldIndex) {
newIndex--;
}
// Can't reorder if indices are the same
if (oldIndex == newIndex) return;
final movedItem = _items[oldIndex];
// Check if item has playlistItemID (required for reordering)
if (movedItem.playlistItemID == null) {
appLogger.e('Cannot reorder: item missing playlistItemID');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
}
return;
}
// Determine the "after" item ID
// If moving to position 0, afterPlaylistItemId should be 0 (move to top)
// Otherwise, use the playlistItemID of the item before the new position
final int afterPlaylistItemId;
if (newIndex == 0) {
afterPlaylistItemId = 0; // Move to top
} else {
final afterItem = _items[newIndex - 1];
if (afterItem.playlistItemID == null) {
appLogger.e('Cannot reorder: after item missing playlistItemID');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
}
return;
}
afterPlaylistItemId = afterItem.playlistItemID!;
}
appLogger.d(
'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)',
);
// Optimistically update UI
setState(() {
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
// Call API to persist the change
final success = await client.movePlaylistItem(
playlistId: widget.playlist.ratingKey,
playlistItemId: movedItem.playlistItemID!,
afterPlaylistItemId: afterPlaylistItemId,
);
if (!success) {
// Revert on failure
appLogger.e('Failed to reorder playlist item, reverting UI');
if (mounted) {
setState(() {
final item = _items.removeAt(newIndex);
_items.insert(oldIndex, item);
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
}
}
}
Future<void> _removeItem(int index) async {
final item = _items[index];
// Check if item has playlistItemID (required for removal)
if (item.playlistItemID == null) {
appLogger.e('Cannot remove: item missing playlistItemID');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
}
return;
}
appLogger.d(
'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist',
);
// Optimistically update UI
setState(() {
_items.removeAt(index);
});
// Call API to persist the change
final success = await client.removeFromPlaylist(
playlistId: widget.playlist.ratingKey,
playlistItemId: item.playlistItemID.toString(),
);
if (mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved)));
} else {
// Revert on failure
appLogger.e('Failed to remove playlist item, reverting UI');
setState(() {
_items.insert(index, item);
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
}
}
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
_items[index] = updatedMetadata;
}
}
@override
void refresh() {
_loadPlaylistItems();
}
Future<void> _playPlaylist() async {
if (_items.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist)));
}
return;
}
final playbackState = context.read<PlaybackStateProvider>();
// Set the playlist items as the playback queue (in order, not shuffled)
playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey);
// Navigate to the first item
if (mounted) {
await navigateToVideoPlayer(context, metadata: _items.first);
}
}
Future<void> _shufflePlayPlaylist() async {
if (_items.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist)));
}
return;
}
final playbackState = context.read<PlaybackStateProvider>();
// Shuffle the items
final shuffledItems = List<PlexMetadata>.from(_items)..shuffle();
// Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode)
playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey);
// Navigate to the first shuffled item
if (mounted) {
await navigateToVideoPlayer(context, metadata: shuffledItems.first);
}
}
Future<void> _playFromItem(int index) async {
if (_items.isEmpty || index < 0 || index >= _items.length) return;
final playbackState = context.read<PlaybackStateProvider>();
// Set the full playlist as playback queue (in order)
playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey);
// Start playing from the clicked item
if (mounted) {
await navigateToVideoPlayer(context, metadata: _items[index]);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.playlist.title,
style: const TextStyle(fontSize: 16),
),
if (widget.playlist.smart)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.auto_awesome,
size: 12,
color: Colors.blue[300],
),
const SizedBox(width: 4),
Text(
t.playlists.smartPlaylist,
style: TextStyle(
fontSize: 11,
color: Colors.blue[300],
fontWeight: FontWeight.normal,
),
),
],
),
],
),
pinned: true,
actions: [
// Play button
if (_items.isNotEmpty)
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: t.discover.play,
onPressed: _playPlaylist,
),
// Shuffle button
if (_items.isNotEmpty)
IconButton(
icon: const Icon(Icons.shuffle),
tooltip: t.playlists.shuffle,
onPressed: _shufflePlayPlaylist,
),
// Delete button for non-smart playlists
if (!widget.playlist.smart)
IconButton(
icon: const Icon(Icons.delete),
tooltip: t.playlists.delete,
onPressed: _deletePlaylist,
color: Colors.red,
),
],
),
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPlaylistItems,
child: Text(t.common.retry),
),
],
),
),
)
else if (_items.isEmpty && _isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_items.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.playlist_play,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
t.playlists.emptyPlaylist,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
)
else if (widget.playlist.smart)
// Smart playlists: Use grid view (cannot be reordered)
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
return MediaCard(item: _items[index], onRefresh: updateItem);
}, childCount: _items.length),
),
)
else
// Regular playlists: Use reorderable list view
SliverReorderableList(
itemBuilder: (context, index) {
final item = _items[index];
return PlaylistItemCard(
key: ValueKey(item.playlistItemID ?? item.ratingKey),
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
canReorder: !widget.playlist.smart,
);
},
itemCount: _items.length,
onReorder: _onReorder,
),
],
),
);
}
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
final screenWidth = MediaQuery.of(context).size.width;
final padding = 16.0;
final availableWidth = screenWidth - padding;
if (screenWidth >= 900) {
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 6.5;
maxItemWidth = 280;
break;
case LibraryDensity.normal:
divisor = 8.0;
maxItemWidth = 200;
break;
case LibraryDensity.compact:
divisor = 10.0;
maxItemWidth = 160;
break;
}
return (availableWidth / divisor).clamp(120, maxItemWidth);
} else if (screenWidth >= 600) {
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 4.5;
maxItemWidth = 220;
break;
case LibraryDensity.normal:
divisor = 5.5;
maxItemWidth = 180;
break;
case LibraryDensity.compact:
divisor = 7.0;
maxItemWidth = 140;
break;
}
return (availableWidth / divisor).clamp(100, maxItemWidth);
} else {
double divisor;
switch (density) {
case LibraryDensity.comfortable:
divisor = 2.2;
break;
case LibraryDensity.normal:
divisor = 2.8;
break;
case LibraryDensity.compact:
divisor = 3.5;
break;
}
return availableWidth / divisor;
}
}
}
+398
View File
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_playlist.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../widgets/desktop_app_bar.dart';
import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
import 'playlist_detail_screen.dart';
/// Screen to display all video playlists
class PlaylistsScreen extends StatefulWidget {
const PlaylistsScreen({super.key});
@override
State<PlaylistsScreen> createState() => _PlaylistsScreenState();
}
class _PlaylistsScreenState extends State<PlaylistsScreen> with Refreshable {
PlexClient get client => context.clientSafe;
List<PlexPlaylist> _playlists = [];
bool _isLoading = false;
String? _errorMessage;
bool? _filterSmart;
@override
void initState() {
super.initState();
_loadPlaylists();
}
Future<void> _loadPlaylists() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
}
final playlists = await client.getPlaylists(
playlistType: 'video',
smart: _filterSmart,
);
setState(() {
_playlists = playlists;
_isLoading = false;
});
appLogger.d('Loaded ${playlists.length} playlists');
} catch (e) {
appLogger.e('Failed to load playlists', error: e);
setState(() {
_errorMessage = 'Failed to load playlists: ${e.toString()}';
_isLoading = false;
});
}
}
void _toggleSmartFilter() {
setState(() {
if (_filterSmart == null) {
_filterSmart = true; // Show only smart
} else if (_filterSmart == true) {
_filterSmart = false; // Show only regular
} else {
_filterSmart = null; // Show all
}
});
_loadPlaylists();
}
String _getFilterLabel() {
if (_filterSmart == null) return 'All';
if (_filterSmart == true) return 'Smart';
return 'Regular';
}
@override
void refresh() {
_loadPlaylists();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(
title: Text(t.playlists.title),
pinned: true,
actions: [
TextButton.icon(
icon: const Icon(Icons.filter_list),
label: Text(_getFilterLabel()),
onPressed: _toggleSmartFilter,
),
],
),
if (_errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPlaylists,
child: Text(t.common.retry),
),
],
),
),
)
else if (_playlists.isEmpty && _isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_playlists.isEmpty)
SliverFillRemaining(
child: Center(child: Text(t.playlists.noPlaylists)),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _PlaylistCard(
playlist: _playlists[index],
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PlaylistDetailScreen(playlist: _playlists[index]),
),
).then((_) => _loadPlaylists()); // Refresh on return
},
onDeleted: _loadPlaylists,
);
}, childCount: _playlists.length),
),
),
],
),
);
}
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
final screenWidth = MediaQuery.of(context).size.width;
final padding = 16.0;
final availableWidth = screenWidth - padding;
if (screenWidth >= 900) {
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 6.5;
maxItemWidth = 280;
break;
case LibraryDensity.normal:
divisor = 8.0;
maxItemWidth = 200;
break;
case LibraryDensity.compact:
divisor = 10.0;
maxItemWidth = 160;
break;
}
return (availableWidth / divisor).clamp(120, maxItemWidth);
} else if (screenWidth >= 600) {
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 4.5;
maxItemWidth = 220;
break;
case LibraryDensity.normal:
divisor = 5.5;
maxItemWidth = 180;
break;
case LibraryDensity.compact:
divisor = 7.0;
maxItemWidth = 140;
break;
}
return (availableWidth / divisor).clamp(100, maxItemWidth);
} else {
double divisor;
switch (density) {
case LibraryDensity.comfortable:
divisor = 2.2;
break;
case LibraryDensity.normal:
divisor = 2.8;
break;
case LibraryDensity.compact:
divisor = 3.5;
break;
}
return availableWidth / divisor;
}
}
}
/// Widget to display a single playlist card
class _PlaylistCard extends StatelessWidget {
final PlexPlaylist playlist;
final VoidCallback onTap;
final VoidCallback onDeleted;
const _PlaylistCard({
required this.playlist,
required this.onTap,
required this.onDeleted,
});
Future<void> _showDeleteDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.playlists.deleteConfirm),
content: Text(t.playlists.deleteMessage(name: playlist.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(t.playlists.delete),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
if (confirmed == true && context.mounted) {
final client = context.clientSafe;
final success = await client.deletePlaylist(playlist.ratingKey);
if (context.mounted) {
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
onDeleted();
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
}
}
}
}
@override
Widget build(BuildContext context) {
final client = context.clientSafe;
final imageUrl = playlist.displayImage != null
? client.getThumbnailUrl(playlist.displayImage!)
: null;
return Card(
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.all(4),
child: InkWell(
onTap: onTap,
onLongPress: () => _showDeleteDialog(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Playlist image
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
if (imageUrl != null)
Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholder();
},
)
else
_buildPlaceholder(),
// Smart playlist indicator
if (playlist.smart)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.9),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.auto_awesome,
size: 12,
color: Colors.white,
),
),
),
],
),
),
// Playlist info
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.playlist_play,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
playlist.leafCount != null && playlist.leafCount! > 0
? (playlist.leafCount == 1
? t.playlists.oneItem
: t.playlists.itemCount(
count: playlist.leafCount!,
))
: t.playlists.emptyPlaylist,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildPlaceholder() {
return Container(
color: Colors.grey[850],
child: const Center(
child: Icon(Icons.playlist_play, size: 48, color: Colors.grey),
),
);
}
}
+3 -1
View File
@@ -116,7 +116,9 @@ class ProfileSwitchScreen extends StatelessWidget {
} else if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.errors.failedToSwitchProfile(displayName: user.displayName)),
content: Text(
t.errors.failedToSwitchProfile(displayName: user.displayName),
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
+1 -1
View File
@@ -350,7 +350,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
),
),
Text(
'Watched',
'${t.discover.watched}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
+12 -6
View File
@@ -75,7 +75,9 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
String _getErrorMessage(dynamic error) {
if (error is ServerParsingException) {
return t.serverSelection.malformedServerData(count: error.invalidServerData.length);
return t.serverSelection.malformedServerData(
count: error.invalidServerData.length,
);
} else if (error is FormatException) {
// Handle JSON parsing errors with more user-friendly messages
if (error.message.contains('Invalid server data')) {
@@ -85,13 +87,13 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
}
return t.serverSelection.malformedServerInfo(message: error.message);
} else if (error.toString().contains('SocketException') ||
error.toString().contains('TimeoutException')) {
error.toString().contains('TimeoutException')) {
return t.serverSelection.networkConnectionFailed;
} else if (error.toString().contains('401') ||
error.toString().contains('Unauthorized')) {
error.toString().contains('Unauthorized')) {
return t.serverSelection.authenticationFailed;
} else if (error.toString().contains('404') ||
error.toString().contains('Not Found')) {
error.toString().contains('Not Found')) {
return t.serverSelection.plexServiceUnavailable;
}
@@ -101,7 +103,9 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
Future<void> _copyDebugDataToClipboard() async {
if (_debugServerData == null) return;
final jsonString = const JsonEncoder.withIndent(' ').convert(_debugServerData);
final jsonString = const JsonEncoder.withIndent(
' ',
).convert(_debugServerData);
await Clipboard.setData(ClipboardData(text: jsonString));
if (mounted) {
@@ -207,7 +211,9 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
// Show error
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.error ?? t.errors.connectionFailedGeneric)),
SnackBar(
content: Text(result.error ?? t.errors.connectionFailedGeneric),
),
);
}
}
+18
View File
@@ -33,6 +33,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
int _seekTimeSmall = 10;
int _seekTimeLarge = 30;
int _sleepTimerDuration = 30;
bool _rememberTrackSelections = true;
// Update checking state
bool _isCheckingForUpdate = false;
@@ -55,6 +56,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_seekTimeSmall = _settingsService.getSeekTimeSmall();
_seekTimeLarge = _settingsService.getSeekTimeLarge();
_sleepTimerDuration = _settingsService.getSleepTimerDuration();
_rememberTrackSelections = _settingsService.getRememberTrackSelections();
_isLoading = false;
});
}
@@ -264,6 +266,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showSleepTimerDurationDialog(),
),
SwitchListTile(
secondary: const Icon(Icons.bookmark),
title: Text(t.settings.rememberTrackSelections),
subtitle: Text(t.settings.rememberTrackSelectionsDescription),
value: _rememberTrackSelections,
onChanged: (value) async {
setState(() {
_rememberTrackSelections = value;
});
await _settingsService.setRememberTrackSelections(value);
},
),
],
),
);
@@ -871,6 +885,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return 'Italiano';
case AppLocale.nl:
return 'Nederlands';
case AppLocale.de:
return 'Deutsch';
case AppLocale.zh:
return '中文';
}
}
+53 -18
View File
@@ -52,10 +52,15 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
}
String _colorToHex(Color color) {
return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}'.toUpperCase();
return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}'
.toUpperCase();
}
Future<void> _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async {
Future<void> _showColorPicker(
String title,
String currentColor,
Function(String) onColorSelected,
) async {
Color initialColor = _hexToColor(currentColor);
final Color selectedColor = await showColorPickerDialog(
@@ -123,7 +128,9 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
padding: const EdgeInsets.all(16),
child: Text(
t.subtitlingStyling.stylingOptions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
// Font Size Slider
@@ -142,7 +149,10 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
const SizedBox(height: 8),
Row(
children: [
const Text('30', style: TextStyle(fontSize: 12, color: Colors.grey)),
const Text(
'30',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: _fontSize.toDouble(),
@@ -160,7 +170,10 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
},
),
),
const Text('80', style: TextStyle(fontSize: 12, color: Colors.grey)),
const Text(
'80',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
@@ -182,7 +195,9 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
subtitle: Text(_textColor),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (color) {
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (
color,
) {
setState(() {
_textColor = color;
});
@@ -207,7 +222,10 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
const SizedBox(height: 8),
Row(
children: [
const Text('0', style: TextStyle(fontSize: 12, color: Colors.grey)),
const Text(
'0',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: _borderSize.toDouble(),
@@ -225,7 +243,10 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
},
),
),
const Text('5', style: TextStyle(fontSize: 12, color: Colors.grey)),
const Text(
'5',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
@@ -247,7 +268,9 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
subtitle: Text(_borderColor),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) {
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (
color,
) {
setState(() {
_borderColor = color;
});
@@ -272,7 +295,10 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
const SizedBox(height: 8),
Row(
children: [
const Text('0%', style: TextStyle(fontSize: 12, color: Colors.grey)),
const Text(
'0%',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
Expanded(
child: Slider(
value: _backgroundOpacity.toDouble(),
@@ -286,11 +312,16 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
});
},
onChangeEnd: (value) {
_settingsService.setSubtitleBackgroundOpacity(_backgroundOpacity);
_settingsService.setSubtitleBackgroundOpacity(
_backgroundOpacity,
);
},
),
),
const Text('100%', style: TextStyle(fontSize: 12, color: Colors.grey)),
const Text(
'100%',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
],
@@ -312,12 +343,16 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
subtitle: Text(_backgroundColor),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) {
setState(() {
_backgroundColor = color;
});
_settingsService.setSubtitleBackgroundColor(color);
});
_showColorPicker(
t.subtitlingStyling.backgroundColor,
_backgroundColor,
(color) {
setState(() {
_backgroundColor = color;
});
_settingsService.setSubtitleBackgroundColor(color);
},
);
},
),
],
+506 -40
View File
@@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:provider/provider.dart';
import '../client/plex_client.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
@@ -43,7 +44,8 @@ class VideoPlayerScreen extends StatefulWidget {
State<VideoPlayerScreen> createState() => VideoPlayerScreenState();
}
class VideoPlayerScreenState extends State<VideoPlayerScreen> {
class VideoPlayerScreenState extends State<VideoPlayerScreen>
with WidgetsBindingObserver {
Player? player;
VideoController? controller;
bool _isPlayerInitialized = false;
@@ -68,6 +70,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
int _boxFitMode = 0;
bool _isPinching = false; // Track if a pinch gesture is occurring
// Video cropping state for fill screen mode
Size? _playerSize;
Size? _videoSize;
Timer? _resizeDebounceTimer;
@override
void initState() {
super.initState();
@@ -85,6 +92,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
appLogger.d('Preferred subtitle track: $subtitleDesc');
}
// Register app lifecycle observer
WidgetsBinding.instance.addObserver(this);
// Initialize player asynchronously with buffer size from settings
_initializePlayer();
}
@@ -108,6 +118,40 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
appLogger.w('Failed to determine device type', error: e);
_isPhone = false; // Default to tablet/desktop (all orientations)
}
// Update video filter when dependencies change (orientation, screen size, etc.)
WidgetsBinding.instance.addPostFrameCallback((_) {
_debouncedUpdateVideoFilter();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
// Clear media controls when app goes to background or screen locks
// (we don't support background playback)
OsMediaControls.clear();
appLogger.d(
'Media controls cleared due to app lifecycle state: $state',
);
break;
case AppLifecycleState.resumed:
// Restore media controls when app is resumed
if (_isPlayerInitialized && mounted) {
_updateMediaMetadata();
_updateMediaControlsPlaybackState();
appLogger.d('Media controls restored on app resume');
}
break;
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
// No action needed for these states
break;
}
}
Future<void> _initializePlayer() async {
@@ -137,6 +181,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
'sub-border-color': settingsService.getSubtitleBorderColor(),
'sub-back-color':
'#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}',
'sub-ass-override': 'no',
},
),
);
@@ -241,10 +286,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
}
Future<void> _loadAdjacentEpisodes() async {
if (widget.metadata.type.toLowerCase() != 'episode') {
return;
}
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
@@ -256,8 +297,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
PlexMetadata? next;
PlexMetadata? previous;
// Check if playlist mode is active (takes priority)
if (playbackState.isPlaylistActive) {
// For playlists, always use the queue regardless of item type
// Playlists can contain both movies and episodes
next = playbackState.getNextEpisode(
widget.metadata.ratingKey,
loopQueue: false, // Don't loop playlists by default
);
previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey);
}
// Check if shuffle mode is active
if (playbackState.isShuffleActive) {
else if (playbackState.isShuffleActive) {
// Only works for episodes in shuffle mode
if (widget.metadata.type.toLowerCase() != 'episode') {
return;
}
// Get settings
final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation;
final loopQueue = settingsProvider.shuffleLoopQueue;
@@ -276,7 +332,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
next = await client.findAdjacentEpisode(widget.metadata, 1);
previous = await client.findAdjacentEpisode(widget.metadata, -1);
}
} else {
}
// Normal sequential playback
else {
// Only works for episodes in sequential mode
if (widget.metadata.type.toLowerCase() != 'episode') {
return;
}
// Use normal sequential episode loading
next = await client.findAdjacentEpisode(widget.metadata, 1);
previous = await client.findAdjacentEpisode(widget.metadata, -1);
@@ -316,6 +379,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
setState(() {
_availableVersions = playbackData.availableVersions;
});
// Update video filter once dimensions are available
_updateVideoFilter();
}
// Build list of external subtitle tracks for media_kit
@@ -428,9 +493,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
}
@@ -440,6 +505,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
setState(() {
_boxFitMode = (_boxFitMode + 1) % 3;
});
_updateVideoFilter();
}
/// Toggle between contain and cover modes only (for pinch gesture)
@@ -447,6 +513,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
setState(() {
_boxFitMode = _boxFitMode == 0 ? 1 : 0;
});
_updateVideoFilter();
}
/// Get current BoxFit based on mode
@@ -463,11 +530,186 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
}
}
/// Calculate crop parameters to match what BoxFit.cover will show
Map<String, dynamic>? _calculateCropParameters() {
if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) {
return null;
}
final playerAspectRatio = _playerSize!.width / _playerSize!.height;
final videoAspectRatio = _videoSize!.width / _videoSize!.height;
// No cropping needed if aspect ratios are very similar
if ((playerAspectRatio - videoAspectRatio).abs() < 0.01) {
return null;
}
int cropWidth, cropHeight, cropX, cropY;
// BoxFit.cover scales the video to fill the container, cropping the excess
// We need to crop the video to match what will actually be visible
if (videoAspectRatio > playerAspectRatio) {
// Video is wider than player - BoxFit.cover will crop horizontally
// Scale video height to match player height, then crop the width
final scale = _playerSize!.height / _videoSize!.height;
cropHeight = _videoSize!.height.toInt();
cropWidth = (_playerSize!.width / scale).toInt();
cropX = ((_videoSize!.width - cropWidth) / 2).toInt();
cropY = 0;
} else {
// Video is taller than player - BoxFit.cover will crop vertically
// Scale video width to match player width, then crop the height
final scale = _playerSize!.width / _videoSize!.width;
cropWidth = _videoSize!.width.toInt();
cropHeight = (_playerSize!.height / scale).toInt();
cropX = 0;
cropY = ((_videoSize!.height - cropHeight) / 2).toInt();
}
// Calculate subtitle margins to prevent text subtitles from appearing in cropped areas
// MPV subtitle coordinates use a normalized system where video height = 720
const double subCoordinateHeight = 720.0;
const double baseMarginY =
40.0; // Base margin from bottom edge (subtitle coordinates)
const double baseMarginX =
20.0; // Base margin from sides (subtitle coordinates)
double subMarginX = baseMarginX;
double subMarginY = baseMarginY;
double subScale = 1.0; // Default scale
if (videoAspectRatio > playerAspectRatio) {
// Horizontal crop - need additional horizontal margins and scaling
// Convert pixel margin to subtitle coordinate system
final subCoordinateWidth = subCoordinateHeight * videoAspectRatio;
final cropMarginX = (cropX / _videoSize!.width) * subCoordinateWidth;
// Calculate scale factor first
subScale = cropWidth / _videoSize!.width;
// Apply margin accounting for scaling (scaled margins are effectively larger)
subMarginX = (baseMarginX + cropMarginX) / subScale;
} else {
// Vertical crop - need additional vertical margins and scaling
// Convert pixel margin to subtitle coordinate system
final cropMarginY = (cropY / _videoSize!.height) * subCoordinateHeight;
// Calculate scale factor first
subScale = cropHeight / _videoSize!.height;
// Apply margin accounting for scaling (scaled margins are effectively larger)
subMarginY = (baseMarginY + cropMarginY) / subScale;
}
return {
'width': cropWidth,
'height': cropHeight,
'x': cropX,
'y': cropY,
'subMarginX': subMarginX.round(),
'subMarginY': subMarginY.round(),
'subScale': subScale,
};
}
/// Get video dimensions from the currently selected media version
Size? _getCurrentVideoSize() {
if (_availableVersions.isEmpty ||
widget.selectedMediaIndex >= _availableVersions.length) {
return null;
}
final currentVersion = _availableVersions[widget.selectedMediaIndex];
if (currentVersion.width != null && currentVersion.height != null) {
return Size(
currentVersion.width!.toDouble(),
currentVersion.height!.toDouble(),
);
}
return null;
}
/// Update the video filter based on current crop mode
void _updateVideoFilter() async {
if (player == null) return;
try {
final nativePlayer = player!.platform as dynamic;
if (_boxFitMode == 1) {
// Fill screen mode - apply crop filter
_videoSize = _getCurrentVideoSize();
final cropParams = _calculateCropParameters();
if (cropParams != null) {
final cropFilter =
'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}';
appLogger.d(
'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)',
);
// Apply crop filter
await nativePlayer.setProperty('vf', cropFilter);
// Apply subtitle margins and scaling to compensate for crop zoom
final subMarginX = cropParams['subMarginX']!;
final subMarginY = cropParams['subMarginY']!;
final subScale = cropParams['subScale']!;
appLogger.d(
'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale',
);
await nativePlayer.setProperty('sub-margin-x', subMarginX.toString());
await nativePlayer.setProperty('sub-margin-y', subMarginY.toString());
await nativePlayer.setProperty('sub-scale', subScale.toString());
} else {
// Clear filter but apply base margins if no cropping needed
appLogger.d(
'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)',
);
await nativePlayer.setProperty('vf', '');
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
}
} else {
// Other modes - clear video filter but apply base margins
appLogger.d(
'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode',
);
await nativePlayer.setProperty('vf', '');
await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin
await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin
await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale
}
} catch (e) {
appLogger.w('Failed to update video filter', error: e);
}
}
/// Debounced version of _updateVideoFilter for resize events
void _debouncedUpdateVideoFilter() {
_resizeDebounceTimer?.cancel();
_resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () {
_updateVideoFilter();
});
}
@override
void dispose() {
// Unregister app lifecycle observer
WidgetsBinding.instance.removeObserver(this);
// Stop progress tracking
_progressTimer?.cancel();
// Cancel debounce timer
_resizeDebounceTimer?.cancel();
// Cancel stream subscriptions
_playingSubscription?.cancel();
_completedSubscription?.cancel();
@@ -482,6 +724,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
// Send final stopped state
_sendProgress('stopped');
// Clear video filter and reset subtitle margins before disposing player
try {
if (player != null) {
final nativePlayer = player!.platform as dynamic;
nativePlayer.setProperty('vf', '');
nativePlayer.setProperty('sub-margin-x', '0');
nativePlayer.setProperty('sub-margin-y', '0');
nativePlayer.setProperty('sub-scale', '1.0');
}
} catch (e) {
// Ignore errors during cleanup
}
// Restore system UI and orientation preferences (skip if navigating to another video)
if (!_isReplacingWithVideo) {
OrientationHelper.restoreSystemUI();
@@ -873,6 +1128,34 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
return title.contains('forced');
}
/// Checks if a track language matches a preferred language
///
/// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes
/// Also handles bibliographic variants and region codes (e.g., "en-US")
bool _languageMatches(String? trackLanguage, String? preferredLanguage) {
if (trackLanguage == null || preferredLanguage == null) {
return false;
}
final track = trackLanguage.toLowerCase();
final preferred = preferredLanguage.toLowerCase();
// Direct match
if (track == preferred) return true;
// Extract base language codes (handle region codes like "en-US")
final trackBase = track.split('-').first;
final preferredBase = preferred.split('-').first;
if (trackBase == preferredBase) return true;
// Get all variations of the preferred language (e.g., "en" → ["en", "eng"])
final variations = LanguageCodes.getVariations(preferredBase);
// Check if track's base code matches any variation
return variations.contains(trackBase);
}
void _waitForTracksAndApply() async {
// Helper function to process tracks
Future<void> processTracks(Tracks tracks) async {
@@ -902,7 +1185,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
);
}
// Select audio track with priority: preferred > user profile > default > first
// Select audio track with priority: preferred > per-media > user profile > default > first
appLogger.d('Audio track selection');
if (realAudioTracks.isNotEmpty) {
AudioTrack? trackToSelect;
@@ -926,20 +1209,46 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
appLogger.d('Priority 1: No preferred track from navigation');
}
// Priority 2: If no preferred track matched, try user profile preferences
// Priority 2: If no preferred track matched, try per-media language preference
if (trackToSelect == null && widget.metadata.audioLanguage != null) {
appLogger.d(
'Priority 2: Checking per-media audio language preference',
);
appLogger.d(
' Per-media audio language: ${widget.metadata.audioLanguage}',
);
trackToSelect = realAudioTracks.firstWhere(
(track) =>
_languageMatches(track.language, widget.metadata.audioLanguage),
orElse: () => realAudioTracks.first,
);
if (_languageMatches(
trackToSelect.language,
widget.metadata.audioLanguage,
)) {
appLogger.d(' Matched per-media audio language preference');
} else {
appLogger.d(' No match found for per-media audio language');
trackToSelect = null;
}
} else if (trackToSelect == null) {
appLogger.d('Priority 2: No per-media audio language preference');
}
// Priority 3: If no preferred track matched, try user profile preferences
if (trackToSelect == null && profileSettings != null) {
appLogger.d('Priority 2: Checking user profile preferences');
appLogger.d('Priority 3: Checking user profile preferences');
trackToSelect = _findAudioTrackByProfile(
realAudioTracks,
profileSettings,
);
} else if (trackToSelect == null) {
appLogger.d('Priority 2: No user profile available');
appLogger.d('Priority 3: No user profile available');
}
// Priority 3: If no match, use default or first track
// Priority 4: If no match, use default or first track
if (trackToSelect == null) {
appLogger.d('Priority 3: Using default or first available track');
appLogger.d('Priority 4: Using default or first available track');
trackToSelect = realAudioTracks.firstWhere(
(t) => t.isDefault == true,
orElse: () => realAudioTracks.first,
@@ -958,7 +1267,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
appLogger.d('No audio tracks available');
}
// Select subtitle track with priority: preferred > user profile > default > off
// Select subtitle track with priority: preferred > per-media > user profile > default > off
appLogger.d('Subtitle track selection');
SubtitleTrack? subtitleToSelect;
@@ -987,11 +1296,47 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
appLogger.d('Priority 1: No preferred track from navigation');
}
// Priority 2: If no preferred match, apply user profile preferences
// Priority 2: If no preferred match, try per-media language preference
if (subtitleToSelect == null &&
widget.metadata.subtitleLanguage != null) {
appLogger.d(
'Priority 2: Checking per-media subtitle language preference',
);
appLogger.d(
' Per-media subtitle language: ${widget.metadata.subtitleLanguage}',
);
// Check if subtitle should be disabled
if (widget.metadata.subtitleLanguage == 'none' ||
widget.metadata.subtitleLanguage!.isEmpty) {
appLogger.d(' Per-media preference: Subtitles OFF');
subtitleToSelect = SubtitleTrack.no();
} else if (realSubtitleTracks.isNotEmpty) {
final matchedTrack = realSubtitleTracks.firstWhere(
(track) => _languageMatches(
track.language,
widget.metadata.subtitleLanguage,
),
orElse: () => realSubtitleTracks.first,
);
if (_languageMatches(
matchedTrack.language,
widget.metadata.subtitleLanguage,
)) {
subtitleToSelect = matchedTrack;
appLogger.d(' Matched per-media subtitle language preference');
} else {
appLogger.d(' No match found for per-media subtitle language');
}
}
} else if (subtitleToSelect == null) {
appLogger.d('Priority 2: No per-media subtitle language preference');
}
// Priority 3: If no preferred match, apply user profile preferences
if (subtitleToSelect == null &&
profileSettings != null &&
realSubtitleTracks.isNotEmpty) {
appLogger.d('Priority 2: Checking user profile preferences');
appLogger.d('Priority 3: Checking user profile preferences');
// Get the currently selected audio track
final currentAudioTrack = realAudioTracks.firstWhere(
(t) => t.id == player!.state.track.audio.id,
@@ -1003,12 +1348,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
selectedAudioTrack: currentAudioTrack,
);
} else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
appLogger.d('Priority 2: No user profile available');
appLogger.d('Priority 3: No user profile available');
}
// Priority 3: If no profile match, check for default subtitle
// Priority 4: If no profile match, check for default subtitle
if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) {
appLogger.d('Priority 3: Checking for default subtitle track');
appLogger.d('Priority 4: Checking for default subtitle track');
final defaultTrackIndex = realSubtitleTracks.indexWhere(
(t) => t.isDefault == true,
);
@@ -1024,7 +1369,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
// If still no subtitle selected, turn off
if (subtitleToSelect == null) {
appLogger.d('Priority 4: No subtitle selected - Subtitles OFF');
appLogger.d('Priority 5: No subtitle selected - Subtitles OFF');
subtitleToSelect = SubtitleTrack.no();
}
@@ -1158,16 +1503,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
}
});
// Enable/disable next/previous track controls based on content type
// Enable/disable next/previous track controls based on content type and playback mode
final playbackState = context.read<PlaybackStateProvider>();
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
if (isEpisode) {
// Enable next/previous track controls for episodes
final isInPlaylist = playbackState.isPlaylistActive;
// Enable controls for episodes OR playlist items
if (isEpisode || isInPlaylist) {
// Enable next/previous track controls for episodes and playlist items
await OsMediaControls.enableControls([
MediaControl.next,
MediaControl.previous,
]);
} else {
// Disable next/previous track controls for movies
// Disable next/previous track controls for standalone movies
await OsMediaControls.disableControls([
MediaControl.next,
MediaControl.previous,
@@ -1285,6 +1634,94 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
await _navigateToEpisode(_previousEpisode!);
}
/// Handle audio track changes from the user - save as per-media preference if enabled
Future<void> _onAudioTrackChanged(AudioTrack track) async {
final settings = await SettingsService.getInstance();
// Only save if remember track selections is enabled
if (!settings.getRememberTrackSelections()) {
return;
}
// Extract language code from the track
final languageCode = track.language;
if (languageCode == null || languageCode.isEmpty) {
appLogger.d('Audio track has no language code, not saving preference');
return;
}
// Determine which ratingKey to use
// For TV shows: use grandparentRatingKey (series level)
// For movies: use ratingKey (movie level)
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
final targetRatingKey = isEpisode
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
: widget.metadata.ratingKey;
appLogger.i(
'Saving audio language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)',
);
try {
if (!mounted) return;
final client = context.read<PlexClient>();
await client.setMetadataPreferences(
targetRatingKey,
audioLanguage: languageCode,
);
appLogger.d('Successfully saved audio language preference');
} catch (e) {
appLogger.e('Failed to save audio language preference', error: e);
}
}
/// Handle subtitle track changes from the user - save as per-media preference if enabled
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async {
final settings = await SettingsService.getInstance();
// Only save if remember track selections is enabled
if (!settings.getRememberTrackSelections()) {
return;
}
// Handle "Off" selection
String? languageCode;
if (track.id == 'no') {
languageCode = 'none';
appLogger.i('User turned subtitles off, saving preference');
} else {
languageCode = track.language;
if (languageCode == null || languageCode.isEmpty) {
appLogger.d(
'Subtitle track has no language code, not saving preference',
);
return;
}
}
// Determine which ratingKey to use
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
final targetRatingKey = isEpisode
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
: widget.metadata.ratingKey;
appLogger.i(
'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)',
);
try {
if (!mounted) return;
final client = context.read<PlexClient>();
await client.setMetadataPreferences(
targetRatingKey,
subtitleLanguage: languageCode,
);
appLogger.d('Successfully saved subtitle language preference');
} catch (e) {
appLogger.e('Failed to save subtitle language preference', error: e);
}
}
/// Set flag to skip orientation restoration when replacing with another video
void setReplacingWithVideo() {
_isReplacingWithVideo = true;
@@ -1378,19 +1815,48 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
children: [
// Video player
Center(
child: Video(
controller: controller!,
fit: _getCurrentBoxFit,
controls: (state) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null ? _playPrevious : null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _boxFitMode,
onCycleBoxFitMode: _cycleBoxFitMode,
),
child: LayoutBuilder(
builder: (context, constraints) {
// Update player size when layout changes
final newSize = Size(
constraints.maxWidth,
constraints.maxHeight,
);
// Check if size actually changed to avoid unnecessary updates
if (_playerSize == null ||
(_playerSize!.width - newSize.width).abs() > 0.1 ||
(_playerSize!.height - newSize.height).abs() > 0.1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_playerSize = newSize;
});
// Use debounced update for resize events
_debouncedUpdateVideoFilter();
}
});
}
return Video(
controller: controller!,
fit: _getCurrentBoxFit,
controls: (state) => plexVideoControlsBuilder(
player!,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null
? _playPrevious
: null,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: _boxFitMode,
onCycleBoxFitMode: _cycleBoxFitMode,
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
),
);
},
),
),
// Play Next Dialog
+2 -2
View File
@@ -239,8 +239,8 @@ class KeyboardShortcutsService {
// Clamp between 0 and video duration
final clampedPosition = newPosition.isNegative
? Duration.zero
: (newPosition > duration ? duration : newPosition);
? Duration.zero
: (newPosition > duration ? duration : newPosition);
player.seek(clampedPosition);
}
+371 -58
View File
@@ -6,6 +6,7 @@ import '../client/plex_client.dart';
import '../models/plex_user_profile.dart';
import '../models/plex_home.dart';
import '../models/user_switch_response.dart';
import '../utils/app_logger.dart';
class PlexAuthService {
static const String _appName = 'Plezy';
@@ -240,8 +241,14 @@ class _ConnectionCandidate {
final PlexConnection connection;
final String url;
final bool isPlexDirectUri;
final bool isHttps;
_ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri);
_ConnectionCandidate(
this.connection,
this.url,
this.isPlexDirectUri,
this.isHttps,
);
}
/// Represents a Plex Media Server
@@ -271,7 +278,9 @@ class PlexServer {
factory PlexServer.fromJson(Map<String, dynamic> json) {
// Validate required fields first
if (!_isValidServerJson(json)) {
throw FormatException('Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)');
throw FormatException(
'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)',
);
}
final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
@@ -309,8 +318,10 @@ class PlexServer {
return PlexServer(
name: json['name'] as String, // Safe because validated above
clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
accessToken: json['accessToken'] as String, // Safe because validated above
clientIdentifier:
json['clientIdentifier'] as String, // Safe because validated above
accessToken:
json['accessToken'] as String, // Safe because validated above
connections: connections,
owned: json['owned'] as bool? ?? false,
product: json['product'] as String?,
@@ -326,10 +337,12 @@ class PlexServer {
if (json['name'] is! String || (json['name'] as String).isEmpty) {
return false;
}
if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) {
if (json['clientIdentifier'] is! String ||
(json['clientIdentifier'] as String).isEmpty) {
return false;
}
if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) {
if (json['accessToken'] is! String ||
(json['accessToken'] as String).isEmpty) {
return false;
}
@@ -385,66 +398,111 @@ class PlexServer {
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
/// Tests both plex.direct URI and direct IP for each connection
/// HTTPS connections are tested first, with HTTP as fallback
Stream<PlexConnection> findBestWorkingConnection() async* {
if (connections.isEmpty) return;
Stream<PlexConnection> findBestWorkingConnection({
String? preferredUri,
}) async* {
if (connections.isEmpty) {
appLogger.w('No connections available for server discovery');
return;
}
// Create candidates: test both uri and directUrl for each connection
// Separate HTTPS and HTTP candidates to prioritize HTTPS first
final httpsCandidates = <_ConnectionCandidate>[];
final httpCandidates = <_ConnectionCandidate>[];
const preferredTimeout = Duration(seconds: 2);
const raceTimeout = Duration(seconds: 4);
for (final connection in connections) {
final uriCandidate = _ConnectionCandidate(connection, connection.uri, true);
final directCandidate = _ConnectionCandidate(connection, connection.directUrl, false);
final candidates = _buildPrioritizedCandidates();
if (candidates.isEmpty) {
appLogger.w('No connection candidates generated for server discovery');
return;
}
if (connection.protocol == 'https') {
httpsCandidates.add(uriCandidate);
httpsCandidates.add(directCandidate);
} else {
httpCandidates.add(uriCandidate);
httpCandidates.add(directCandidate);
final totalCandidates = candidates.length;
appLogger.d(
'Starting server connection discovery',
error: {'preferred': preferredUri, 'candidateCount': totalCandidates},
);
_ConnectionCandidate? firstCandidate;
// Fast-path: if we have a cached working URI, probe it with a short timeout
if (preferredUri != null) {
final cachedCandidate = _candidateForUrl(preferredUri);
if (cachedCandidate != null) {
appLogger.d(
'Testing cached endpoint before running full race',
error: {'uri': preferredUri},
);
final result = await PlexClient.testConnectionWithLatency(
cachedCandidate.url,
accessToken,
timeout: preferredTimeout,
);
if (result.success) {
appLogger.i(
'Cached endpoint succeeded, using immediately',
error: {'uri': preferredUri},
);
firstCandidate = cachedCandidate;
} else {
appLogger.w(
'Cached endpoint failed, falling back to candidate race',
error: {'uri': preferredUri},
);
}
}
}
// Combine candidates with HTTPS first, then HTTP
final candidates = [...httpsCandidates, ...httpCandidates];
// Phase 1: Race to find first working connection
final completer = Completer<_ConnectionCandidate?>();
_ConnectionCandidate? firstCandidate;
int completedTests = 0;
// Start testing all candidates simultaneously
for (final candidate in candidates) {
PlexClient.testConnectionWithLatency(candidate.url, accessToken).then((
result,
) {
completedTests++;
// If this is the first successful connection, emit it immediately
if (result.success && !completer.isCompleted) {
completer.complete(candidate);
}
// If all tests complete without success, complete with null
if (completedTests == candidates.length && !completer.isCompleted) {
completer.complete(null);
}
});
}
// Wait for and emit the first successful connection
firstCandidate = await completer.future;
// If no cached candidate or it failed, race candidates to find first success
if (firstCandidate == null) {
return; // No working connections found
final completer = Completer<_ConnectionCandidate?>();
int completedTests = 0;
appLogger.d(
'Running connection race to find first working endpoint',
error: {'candidateCount': totalCandidates},
);
for (final candidate in candidates) {
PlexClient.testConnectionWithLatency(
candidate.url,
accessToken,
timeout: raceTimeout,
).then((result) {
completedTests++;
if (result.success && !completer.isCompleted) {
completer.complete(candidate);
}
if (completedTests == candidates.length && !completer.isCompleted) {
completer.complete(null);
}
});
}
firstCandidate = await completer.future;
if (firstCandidate == null) {
appLogger.e('No working server connections after race');
return; // No working connections found
}
appLogger.i(
'Connection race found first working endpoint',
error: {
'uri': firstCandidate.url,
'type': firstCandidate.connection.displayType,
},
);
}
// Update the connection object to use the working URL
final firstConnection = _updateConnectionUrl(
firstCandidate.connection,
firstCandidate.url,
);
yield firstConnection;
appLogger.d(
'Emitted first working connection, continuing latency tests in background',
error: {'uri': firstConnection.uri},
);
// Phase 2: Continue testing in background to find best connection
// Test each candidate 2-3 times and average the latency
@@ -466,20 +524,39 @@ class PlexServer {
// If no connections succeeded, we're done
if (candidateResults.isEmpty) {
appLogger.w('Latency sweep found no additional working endpoints');
return;
}
appLogger.d(
'Completed latency sweep for server connections',
error: {'successfulCandidates': candidateResults.length},
);
// Find the best connection considering priority, latency, and URL type
final bestCandidate = _selectBestCandidateWithLatency(candidateResults);
// Emit the best connection if it's different from the first one
if (bestCandidate != null) {
final upgradedCandidate =
await _upgradeCandidateToHttpsIfPossible(bestCandidate) ??
bestCandidate;
final bestConnection = _updateConnectionUrl(
bestCandidate.connection,
bestCandidate.url,
upgradedCandidate.connection,
upgradedCandidate.url,
);
if (bestConnection.uri != firstConnection.uri) {
appLogger.i(
'Latency sweep selected better endpoint',
error: {'uri': bestConnection.uri},
);
yield bestConnection;
} else {
appLogger.d(
'Latency sweep confirmed initial endpoint is optimal',
error: {'uri': bestConnection.uri},
);
}
}
}
@@ -503,6 +580,230 @@ class PlexServer {
);
}
_ConnectionCandidate? _candidateForUrl(String url) {
for (final connection in connections) {
final httpUrl = connection.httpDirectUrl;
if (httpUrl == url) {
return _ConnectionCandidate(connection, httpUrl, false, false);
}
final uri = connection.uri;
if (uri == url) {
final isHttps = uri.startsWith('https://');
final parsedHost = Uri.tryParse(uri)?.host ?? '';
final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct');
return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps);
}
}
return null;
}
List<_ConnectionCandidate> _buildPrioritizedCandidates({
Set<String>? excludeUrls,
}) {
final seen = <String>{};
if (excludeUrls != null) {
seen.addAll(excludeUrls);
}
final httpsLocal = <_ConnectionCandidate>[];
final httpsRemote = <_ConnectionCandidate>[];
final httpsRelay = <_ConnectionCandidate>[];
final httpLocal = <_ConnectionCandidate>[];
final httpRemote = <_ConnectionCandidate>[];
final httpRelay = <_ConnectionCandidate>[];
List<_ConnectionCandidate> bucketFor(
PlexConnection connection,
bool isHttps,
) {
if (isHttps) {
if (connection.relay) return httpsRelay;
if (connection.local) return httpsLocal;
return httpsRemote;
} else {
if (connection.relay) return httpRelay;
if (connection.local) return httpLocal;
return httpRemote;
}
}
void addCandidate(
PlexConnection connection,
String url,
bool isPlexDirectUri,
bool isHttps,
) {
if (url.isEmpty || seen.contains(url)) {
return;
}
seen.add(url);
bucketFor(
connection,
isHttps,
).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps));
}
for (final connection in connections) {
addCandidate(connection, connection.httpDirectUrl, false, false);
}
return [
...httpsLocal,
...httpsRemote,
...httpsRelay,
...httpLocal,
...httpRemote,
...httpRelay,
];
}
List<String> prioritizedEndpointUrls({String? preferredFirst}) {
final urls = <String>[];
final exclude = <String>{};
if (preferredFirst != null && preferredFirst.isNotEmpty) {
urls.add(preferredFirst);
exclude.add(preferredFirst);
}
final candidates = _buildPrioritizedCandidates(excludeUrls: exclude);
urls.addAll(candidates.map((candidate) => candidate.url));
return urls;
}
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(
_ConnectionCandidate candidate,
) async {
final currentUrl = candidate.url;
if (currentUrl.startsWith('https://')) {
return null;
}
late final String httpsUrl;
bool resultingIsPlexDirect = candidate.isPlexDirectUri;
if (candidate.isPlexDirectUri) {
if (!currentUrl.startsWith('http://')) {
return null;
}
httpsUrl = currentUrl.replaceFirst('http://', 'https://');
} else {
// Raw IP endpoints can't present HTTPS certificates—prefer their plex.direct alias.
final plexDirectUri = candidate.connection.uri;
if (plexDirectUri.isEmpty) {
return null;
}
if (plexDirectUri.startsWith('https://')) {
httpsUrl = plexDirectUri;
} else if (plexDirectUri.startsWith('http://')) {
httpsUrl = plexDirectUri.replaceFirst('http://', 'https://');
} else {
return null;
}
final upgradedHost = Uri.tryParse(httpsUrl)?.host;
if (upgradedHost == null ||
!upgradedHost.toLowerCase().endsWith('.plex.direct')) {
appLogger.d(
'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available',
error: {'candidate': currentUrl, 'target': httpsUrl},
);
return null;
}
resultingIsPlexDirect = true;
}
if (httpsUrl == currentUrl) {
return null;
}
appLogger.d(
'Attempting HTTPS upgrade for candidate endpoint',
error: {'from': currentUrl, 'to': httpsUrl},
);
final result = await PlexClient.testConnectionWithLatency(
httpsUrl,
accessToken,
timeout: const Duration(seconds: 4),
);
if (!result.success) {
appLogger.w(
'HTTPS upgrade failed, staying on HTTP candidate',
error: {'url': currentUrl},
);
return null;
}
appLogger.i(
'HTTPS upgrade succeeded for candidate endpoint',
error: {'httpsUrl': httpsUrl},
);
final httpsConnection = PlexConnection(
protocol: 'https',
address: candidate.connection.address,
port: candidate.connection.port,
uri: httpsUrl,
local: candidate.connection.local,
relay: candidate.connection.relay,
ipv6: candidate.connection.ipv6,
);
return _ConnectionCandidate(
httpsConnection,
httpsUrl,
resultingIsPlexDirect,
true,
);
}
Future<PlexConnection?> upgradeConnectionToHttps(
PlexConnection current,
) async {
if (current.uri.startsWith('https://')) {
return current;
}
final baseConnection = _findMatchingBaseConnection(current);
if (baseConnection == null) {
return null;
}
final candidate = _ConnectionCandidate(
baseConnection,
current.uri,
current.uri.contains('.plex.direct'),
current.uri.startsWith('https://'),
);
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(
candidate,
);
if (upgradedCandidate == null) {
return null;
}
return _updateConnectionUrl(
upgradedCandidate.connection,
upgradedCandidate.url,
);
}
PlexConnection? _findMatchingBaseConnection(PlexConnection connection) {
for (final base in connections) {
final sameAddress = base.address == connection.address;
final samePort = base.port == connection.port;
final sameLocal = base.local == connection.local;
final sameRelay = base.relay == connection.relay;
if (sameAddress && samePort && sameLocal && sameRelay) {
return base;
}
}
return null;
}
/// Select the best candidate considering priority, latency, and URL type preference
_ConnectionCandidate? _selectBestCandidateWithLatency(
Map<_ConnectionCandidate, ConnectionTestResult> results,
@@ -536,8 +837,8 @@ class PlexServer {
if (latencyCompare != 0) return latencyCompare;
// If latencies are equal, prefer HTTPS over HTTP
final aIsHttps = a.key.connection.protocol == 'https';
final bIsHttps = b.key.connection.protocol == 'https';
final aIsHttps = a.key.isHttps;
final bIsHttps = b.key.isHttps;
if (aIsHttps && !bIsHttps) return -1;
if (!aIsHttps && bIsHttps) return 1;
@@ -574,7 +875,9 @@ class PlexConnection {
factory PlexConnection.fromJson(Map<String, dynamic> json) {
// Validate required fields
if (!_isValidConnectionJson(json)) {
throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
throw FormatException(
'Invalid connection data: missing required fields (protocol, address, port, or uri)',
);
}
return PlexConnection(
@@ -625,6 +928,13 @@ class PlexConnection {
/// This bypasses plex.direct DNS and connects directly to the IP
String get directUrl => '$protocol://$address:$port';
/// Always return an HTTP URL that points directly at the IP/port combo.
String get httpDirectUrl {
final needsBrackets = address.contains(':') && !address.startsWith('[');
final safeAddress = needsBrackets ? '[$address]' : address;
return 'http://$safeAddress:$port';
}
String get displayType {
if (relay) return 'Relay';
if (local) return 'Local';
@@ -634,7 +944,10 @@ class PlexConnection {
/// Create an HTTP fallback version of this HTTPS connection
/// This allows testing HTTP when HTTPS is unavailable (e.g., certificate issues)
PlexConnection toHttpFallback() {
assert(protocol == 'https', 'Can only create HTTP fallback for HTTPS connections');
assert(
protocol == 'https',
'Can only create HTTP fallback for HTTPS connections',
);
return PlexConnection(
protocol: 'http',
+258 -63
View File
@@ -1,3 +1,7 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'plex_auth_service.dart';
import 'storage_service.dart';
import '../client/plex_client.dart';
@@ -19,6 +23,12 @@ class ServerConnectionResult {
/// Service for handling optimized server connections
/// Implements fast-first connection with background optimization
class ServerConnectionService {
static StreamSubscription<List<ConnectivityResult>>?
_connectivitySubscription;
static Future<void>? _activeOptimization;
static PlexServer? _activeServer;
static PlexClient? _activeClient;
/// Connect to a Plex server with optimized connection testing
///
/// Returns immediately with first working connection, then continues
@@ -39,86 +49,125 @@ class ServerConnectionService {
bool fetchUserProfile = false,
void Function(String message)? onProgress,
}) async {
PlexConnection? firstConnection;
final storage = await StorageService.getInstance();
final connectionStream = server
.findBestWorkingConnection()
.asBroadcastStream();
PlexClient? client;
final optimizationSubscription = connectionStream
.skip(1)
.listen(
(connection) async {
await _handleOptimizedConnection(
connection: connection,
storage: storage,
server: server,
client: client,
reason: 'initial_latency_sweep',
);
},
onError: (error, stackTrace) {
appLogger.w(
'Background connection optimization error',
error: error,
stackTrace: stackTrace,
);
},
);
try {
// Listen to the connection stream for progressive connection testing
await for (final connection in server.findBestWorkingConnection()) {
if (firstConnection == null) {
// First emission - use this connection immediately
firstConnection = connection;
final connection = await connectionStream.first;
if (onProgress != null) {
onProgress('Connected to ${connection.displayType} endpoint');
}
if (onProgress != null) {
onProgress('Connected to ${connection.displayType} endpoint');
}
// Save server information to storage
await storage.saveServerData(server.toJson());
await storage.saveServerUrl(connection.uri);
await storage.saveServerAccessToken(server.accessToken);
// Save server information to storage
await storage.saveServerData(server.toJson());
await storage.saveServerUrl(connection.uri);
await storage.saveServerAccessToken(server.accessToken);
// Save plex token if provided
if (plexToken != null) {
await storage.savePlexToken(plexToken);
}
// Save plex token if provided
if (plexToken != null) {
await storage.savePlexToken(plexToken);
}
// Create client with working connection
final config = await PlexConfig.create(
baseUrl: connection.uri,
token: server.accessToken,
clientIdentifier: clientIdentifier,
// Create client with working connection
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: connection.uri,
);
final config = await PlexConfig.create(
baseUrl: connection.uri,
token: server.accessToken,
clientIdentifier: clientIdentifier,
);
client = PlexClient(
config,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
await storage.saveServerUrl(newUrl);
appLogger.i(
'Updated stored server URL after failover',
error: newUrl,
);
final client = PlexClient(config);
},
);
// Verify server is accessible if requested
if (verifyServer) {
try {
await client.getServerIdentity();
} catch (e) {
appLogger.w('Server identity verification failed', error: e);
await storage.clearCredentials();
return ServerConnectionResult(
error: 'Server is not accessible: $e',
);
}
}
// Fetch machine identifier and cache it in config
try {
final machineId = await client.getMachineIdentifier();
if (machineId != null) {
client.config = config.copyWith(machineIdentifier: machineId);
appLogger.d('Cached machine identifier: $machineId');
}
} catch (e) {
appLogger.w('Failed to fetch machine identifier', error: e);
// Continue without it - buildMetadataUri will fallback to fetching it
}
// Fetch user profile if requested
PlexUserProfile? userProfile;
if (fetchUserProfile && plexToken != null) {
userProfile = await _fetchUserProfile(plexToken);
}
// Return success result
// Note: Stream continues in background to find better connection
return ServerConnectionResult(
client: client,
userProfile: userProfile,
);
} else {
// Second emission - better connection found
// Update stored connection seamlessly for future app launches
await storage.saveServerUrl(connection.uri);
appLogger.d(
'Switched to better connection: ${connection.displayType} (${connection.uri})',
);
// Verify server is accessible if requested
if (verifyServer) {
try {
await client.getServerIdentity();
} catch (e) {
await optimizationSubscription.cancel();
appLogger.w('Server identity verification failed', error: e);
await storage.clearCredentials();
return ServerConnectionResult(error: 'Server is not accessible: $e');
}
}
// Handle case where no connections were found
if (firstConnection == null) {
return ServerConnectionResult(
error: 'No working connections found for this server',
);
// Fetch user profile if requested
PlexUserProfile? userProfile;
if (fetchUserProfile && plexToken != null) {
userProfile = await _fetchUserProfile(plexToken);
}
// Should never reach here due to return in the stream loop
// Return success result while optimization continues in background
_activeServer = server;
_activeClient = client;
_startConnectivityMonitoring(server);
return ServerConnectionResult(client: client, userProfile: userProfile);
} on StateError catch (e, stackTrace) {
await optimizationSubscription.cancel();
appLogger.e(
'No working connections found for this server',
error: e,
stackTrace: stackTrace,
);
return ServerConnectionResult(
error: 'Unexpected error in connection flow',
error: 'No working connections found for this server',
);
} catch (e, stackTrace) {
await optimizationSubscription.cancel();
appLogger.e(
'Error connecting to server',
error: e,
stackTrace: stackTrace,
);
} catch (e) {
appLogger.e('Error connecting to server', error: e);
return ServerConnectionResult(error: 'Connection failed: $e');
}
}
@@ -148,4 +197,150 @@ class ServerConnectionService {
return null;
}
}
static void _startConnectivityMonitoring(PlexServer server) {
_connectivitySubscription?.cancel();
final connectivity = Connectivity();
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
(results) {
final status = results.isNotEmpty
? results.first
: ConnectivityResult.none;
if (status == ConnectivityResult.none) {
appLogger.w(
'Connectivity lost, pausing optimization until network returns',
);
return;
}
appLogger.d(
'Connectivity change detected, triggering endpoint optimization',
error: {
'status': status.name,
'interfaces': results.map((r) => r.name).toList(),
},
);
_activeServer = server;
_triggerReoptimization(reason: 'connectivity:${status.name}');
},
onError: (error, stackTrace) {
appLogger.w(
'Connectivity listener error',
error: error,
stackTrace: stackTrace,
);
},
);
}
static void _triggerReoptimization({required String reason}) {
if (_activeServer == null) {
appLogger.d(
'Optimization trigger ignored because there is no active server',
error: {'reason': reason},
);
return;
}
if (_activeOptimization != null) {
appLogger.d(
'Optimization already running, skipping new trigger',
error: {'reason': reason},
);
return;
}
_activeOptimization =
_runOptimization(
server: _activeServer!,
client: _activeClient,
reason: reason,
).whenComplete(() {
_activeOptimization = null;
});
}
static Future<void> _runOptimization({
required PlexServer server,
required PlexClient? client,
required String reason,
}) async {
final storage = await StorageService.getInstance();
try {
appLogger.d(
'Starting background connection optimization run',
error: {'reason': reason},
);
await for (final connection in server.findBestWorkingConnection()) {
await _handleOptimizedConnection(
connection: connection,
storage: storage,
server: server,
client: client,
reason: reason,
);
}
} catch (e, stackTrace) {
appLogger.w(
'Background connection optimization failed',
error: e,
stackTrace: stackTrace,
);
}
}
static Future<void> _handleOptimizedConnection({
required PlexConnection connection,
required StorageService storage,
required PlexServer server,
required PlexClient? client,
required String reason,
}) async {
final previousUrl = storage.getServerUrl();
final isNewEndpoint = previousUrl != connection.uri;
await storage.saveServerUrl(connection.uri);
appLogger.d(
'Evaluated optimized endpoint candidate',
error: {
'uri': connection.uri,
'displayType': connection.displayType,
'reason': reason,
'isNewEndpoint': isNewEndpoint,
},
);
if (client != null) {
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: connection.uri,
);
await client.updateEndpointPreferences(
prioritizedEndpoints,
switchToFirst: isNewEndpoint,
);
if (isNewEndpoint) {
appLogger.i(
'Active client switched to optimized endpoint',
error: {'uri': connection.uri, 'reason': reason},
);
}
} else if (isNewEndpoint) {
appLogger.i(
'Stored optimized endpoint for future sessions',
error: {'uri': connection.uri, 'reason': reason},
);
}
if (isNewEndpoint && !connection.uri.startsWith('https://')) {
final upgraded = await server.upgradeConnectionToHttps(connection);
if (upgraded != null && upgraded.uri != connection.uri) {
await _handleOptimizedConnection(
connection: upgraded,
storage: storage,
server: server,
client: client,
reason: '$reason:https-upgrade',
);
}
}
}
}
+18 -2
View File
@@ -37,11 +37,13 @@ class SettingsService {
static const String _keySubtitleBorderSize = 'subtitle_border_size';
static const String _keySubtitleBorderColor = 'subtitle_border_color';
static const String _keySubtitleBackgroundColor = 'subtitle_background_color';
static const String _keySubtitleBackgroundOpacity = 'subtitle_background_opacity';
static const String _keySubtitleBackgroundOpacity =
'subtitle_background_opacity';
static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only';
static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation';
static const String _keyShuffleLoopQueue = 'shuffle_loop_queue';
static const String _keyAppLocale = 'app_locale';
static const String _keyRememberTrackSelections = 'remember_track_selections';
static SettingsService? _instance;
late SharedPreferences _prefs;
@@ -228,7 +230,8 @@ class SettingsService {
}
bool getRotationLocked() {
return _prefs.getBool(_keyRotationLocked) ?? true; // Default: locked (landscape only)
return _prefs.getBool(_keyRotationLocked) ??
true; // Default: locked (landscape only)
}
// Subtitle Styling Settings
@@ -826,6 +829,17 @@ class SettingsService {
return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false
}
// Track Selection Settings
/// Remember Track Selections - Save per-media audio/subtitle language preferences
Future<void> setRememberTrackSelections(bool enabled) async {
await _prefs.setBool(_keyRememberTrackSelections, enabled);
}
bool getRememberTrackSelections() {
return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true
}
// Reset all settings to defaults
Future<void> resetAllSettings() async {
await Future.wait([
@@ -858,6 +872,7 @@ class SettingsService {
_prefs.remove(_keyShuffleOrderNavigation),
_prefs.remove(_keyShuffleLoopQueue),
_prefs.remove(_keyAppLocale),
_prefs.remove(_keyRememberTrackSelections),
]);
}
@@ -889,6 +904,7 @@ class SettingsService {
'keyboardHotkeys': hotkeys.map(
(key, value) => MapEntry(key, _serializeHotKey(value)),
),
'rememberTrackSelections': getRememberTrackSelections(),
};
}
}
+11
View File
@@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/log_redaction_manager.dart';
class StorageService {
static const String _keyServerUrl = 'server_url';
static const String _keyToken = 'token';
@@ -32,11 +35,16 @@ class StorageService {
Future<void> _init() async {
_prefs = await SharedPreferences.getInstance();
// Seed known values so logs can redact immediately on startup.
LogRedactionManager.registerServerUrl(getServerUrl());
LogRedactionManager.registerToken(getToken());
LogRedactionManager.registerToken(getPlexToken());
}
// Server URL
Future<void> saveServerUrl(String url) async {
await _prefs.setString(_keyServerUrl, url);
LogRedactionManager.registerServerUrl(url);
}
String? getServerUrl() {
@@ -46,6 +54,7 @@ class StorageService {
// Server Access Token
Future<void> saveToken(String token) async {
await _prefs.setString(_keyToken, token);
LogRedactionManager.registerToken(token);
}
String? getToken() {
@@ -64,6 +73,7 @@ class StorageService {
// Plex.tv Token (for API access)
Future<void> savePlexToken(String token) async {
await _prefs.setString(_keyPlexToken, token);
LogRedactionManager.registerToken(token);
}
String? getPlexToken() {
@@ -127,6 +137,7 @@ class StorageService {
_prefs.remove(_keyHomeUsersCache),
_prefs.remove(_keyHomeUsersCacheExpiry),
]);
LogRedactionManager.clearTrackedValues();
}
// Get all credentials as a map
+89 -56
View File
@@ -1,18 +1,43 @@
import '../models/plex_media_info.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
import '../utils/language_codes.dart';
/// Service for selecting audio and subtitle tracks based on user preferences
class TrackSelectionService {
/// Selects the best audio track based on user preferences
///
/// Priority order:
/// 1. Per-media preferred audio language (from metadata.audioLanguage)
/// 2. Profile-wide language preferences (if auto-select is enabled)
/// 3. Plex's selected track (if auto-select is disabled)
/// 4. First track
///
/// Returns the selected audio track, or null if no suitable track is found
static PlexAudioTrack? selectAudioTrack(
List<PlexAudioTrack> tracks,
PlexUserProfile profile,
) {
PlexUserProfile profile, {
PlexMetadata? metadata,
}) {
if (tracks.isEmpty) return null;
// If auto-select is disabled, use Plex's selected track
// Priority 1: Check for per-media audio language preference
if (metadata?.audioLanguage != null) {
final perMediaTrack = tracks.firstWhere(
(track) =>
_matchesLanguage(track.languageCode, metadata!.audioLanguage),
orElse: () => tracks.first,
);
// Only use it if we actually found a matching track
if (_matchesLanguage(
perMediaTrack.languageCode,
metadata!.audioLanguage,
)) {
return perMediaTrack;
}
}
// Priority 2: If auto-select is disabled, use Plex's selected track
if (!profile.autoSelectAudio) {
return tracks.firstWhere(
(track) => track.selected,
@@ -20,7 +45,7 @@ class TrackSelectionService {
);
}
// Build list of preferred language codes
// Priority 3: Use profile-wide language preferences
final preferredLanguages = <String>[];
if (profile.defaultAudioLanguage != null) {
preferredLanguages.add(profile.defaultAudioLanguage!);
@@ -52,14 +77,44 @@ class TrackSelectionService {
/// Selects the best subtitle track based on user preferences
///
/// Priority order:
/// 1. Per-media preferred subtitle language (from metadata.subtitleLanguage)
/// 2. Profile-wide subtitle preferences (based on auto-select mode)
/// 3. Disabled (null)
///
/// Returns the selected subtitle track, or null if subtitles should be disabled
static PlexSubtitleTrack? selectSubtitleTrack(
List<PlexSubtitleTrack> tracks,
PlexUserProfile profile,
PlexAudioTrack? selectedAudioTrack,
) {
PlexAudioTrack? selectedAudioTrack, {
PlexMetadata? metadata,
}) {
if (tracks.isEmpty) return null;
// Priority 1: Check for per-media subtitle language preference
if (metadata?.subtitleLanguage != null &&
metadata!.subtitleLanguage!.isNotEmpty) {
// Check if subtitle should be disabled (empty string or "none")
if (metadata.subtitleLanguage == 'none' ||
metadata.subtitleLanguage == '') {
return null;
}
final perMediaTrack = tracks.firstWhere(
(track) =>
_matchesLanguage(track.languageCode, metadata.subtitleLanguage),
orElse: () => tracks.first,
);
// Only use it if we actually found a matching track
if (_matchesLanguage(
perMediaTrack.languageCode,
metadata.subtitleLanguage,
)) {
return perMediaTrack;
}
}
// Priority 2: Use profile-wide subtitle preferences
// Mode 0: Manually selected - return null to disable subtitles
if (profile.autoSelectSubtitle == 0) {
return null;
@@ -68,7 +123,8 @@ class TrackSelectionService {
// Mode 1: Shown with foreign audio
if (profile.autoSelectSubtitle == 1) {
// Check if audio language matches user's preferred subtitle language
if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) {
if (selectedAudioTrack != null &&
profile.defaultSubtitleLanguage != null) {
final audioLang = selectedAudioTrack.languageCode;
final prefLang = profile.defaultSubtitleLanguage;
@@ -108,10 +164,16 @@ class TrackSelectionService {
var candidateTracks = tracks;
// Apply SDH (hearing impaired) filtering
candidateTracks = _filterBySDH(candidateTracks, profile.defaultSubtitleAccessibility);
candidateTracks = _filterBySDH(
candidateTracks,
profile.defaultSubtitleAccessibility,
);
// Apply forced subtitle filtering
candidateTracks = _filterByForced(candidateTracks, profile.defaultSubtitleForced);
candidateTracks = _filterByForced(
candidateTracks,
profile.defaultSubtitleForced,
);
// If no candidates after filtering, relax filters
if (candidateTracks.isEmpty) {
@@ -200,17 +262,21 @@ class TrackSelectionService {
// Look for common SDH indicators
return title.contains('sdh') ||
displayTitle.contains('sdh') ||
title.contains('cc') ||
displayTitle.contains('cc') ||
title.contains('hearing impaired') ||
displayTitle.contains('hearing impaired');
displayTitle.contains('sdh') ||
title.contains('cc') ||
displayTitle.contains('cc') ||
title.contains('hearing impaired') ||
displayTitle.contains('hearing impaired');
}
/// Checks if a language code matches a preferred language
///
/// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes
static bool _matchesLanguage(String? trackLanguage, String? preferredLanguage) {
/// Also handles bibliographic variants and region codes (e.g., "en-US")
static bool _matchesLanguage(
String? trackLanguage,
String? preferredLanguage,
) {
if (trackLanguage == null || preferredLanguage == null) {
return false;
}
@@ -221,49 +287,16 @@ class TrackSelectionService {
// Direct match
if (track == preferred) return true;
// Handle common 2-letter to 3-letter mappings
final languageMap = {
'en': 'eng',
'es': 'spa',
'fr': 'fra',
'de': 'deu',
'it': 'ita',
'pt': 'por',
'ja': 'jpn',
'ko': 'kor',
'zh': 'zho',
'ru': 'rus',
'ar': 'ara',
'hi': 'hin',
'nl': 'nld',
'pl': 'pol',
'tr': 'tur',
'sv': 'swe',
'no': 'nor',
'da': 'dan',
'fi': 'fin',
'cs': 'ces',
'hu': 'hun',
'ro': 'ron',
'th': 'tha',
'vi': 'vie',
'id': 'ind',
'uk': 'ukr',
'el': 'ell',
'he': 'heb',
};
// Extract base language codes (handle region codes like "en-US")
final trackBase = track.split('-').first;
final preferredBase = preferred.split('-').first;
// Try mapping preferred to 3-letter and compare
if (languageMap[preferred] == track) return true;
if (trackBase == preferredBase) return true;
// Try mapping track to 3-letter and compare with preferred 3-letter
if (languageMap[track] == preferred) return true;
// Get all variations of the preferred language (e.g., "en" → ["en", "eng"])
final variations = LanguageCodes.getVariations(preferredBase);
// Try reverse mapping (3-letter to 2-letter)
final reverseMap = languageMap.map((k, v) => MapEntry(v, k));
if (reverseMap[preferred] == track) return true;
if (reverseMap[track] == preferred) return true;
return false;
// Check if track's base code matches any variation
return variations.contains(trackBase);
}
}
+8 -8
View File
@@ -18,7 +18,10 @@ class UpdateService {
/// Check if update checking is enabled via build flag
static bool get isUpdateCheckEnabled {
const enabled = bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false);
const enabled = bool.fromEnvironment(
'ENABLE_UPDATE_CHECK',
defaultValue: false,
);
return enabled;
}
@@ -62,24 +65,21 @@ class UpdateService {
/// Check for updates on GitHub (manual check, ignores cooldown)
/// Returns a map with update info, or null if no update or error
static Future<Map<String, dynamic>?> checkForUpdates({bool silent = false}) async {
static Future<Map<String, dynamic>?> checkForUpdates({
bool silent = false,
}) async {
if (!isUpdateCheckEnabled) {
return null;
}
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
final dio = Dio();
final response = await dio.get(
'https://api.github.com/repos/$_githubRepo/releases/latest',
options: Options(
headers: {
'Accept': 'application/vnd.github+json',
},
),
options: Options(headers: {'Accept': 'application/vnd.github+json'}),
);
if (response.statusCode == 200) {
+7 -52
View File
@@ -1,67 +1,22 @@
import 'package:logger/logger.dart';
/// Redacts sensitive information from log messages
import 'log_redaction_manager.dart';
/// Redacts sensitive information from log messages based on known values.
String _redactSensitiveData(String message) {
String redacted = message;
var redacted = LogRedactionManager.redact(message);
// Redact Plex tokens (alphanumeric strings typically 20+ characters)
// Pattern: X-Plex-Token=... or token=... or accessToken=... or similar
// Fallbacks for sensitive fields we cannot track ahead of time.
redacted = redacted.replaceAllMapped(
RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false),
RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'),
(match) => '${match.group(1)}[REDACTED]',
);
// Redact authorization headers
redacted = redacted.replaceAllMapped(
RegExp(r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', caseSensitive: false),
RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'),
(match) => '${match.group(1)}[REDACTED]',
);
// Redact API keys
redacted = redacted.replaceAllMapped(
RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false),
(match) => '${match.group(1)}[REDACTED]',
);
// Redact passwords
redacted = redacted.replaceAllMapped(
RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false),
(match) => '${match.group(1)}[REDACTED]',
);
// Redact full URLs with tokens in query parameters
redacted = redacted.replaceAllMapped(
RegExp(r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)'),
(match) => '${match.group(1)}${match.group(2)}=[REDACTED]',
);
// Redact IP addresses in dot notation (e.g., 192.168.1.100)
redacted = redacted.replaceAllMapped(
RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'),
(match) => '${match.group(1)}***.***.${match.group(4)}',
);
// Redact IP addresses in dash notation (e.g., 192-168-1-11)
redacted = redacted.replaceAllMapped(
RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'),
(match) => '${match.group(1)}***-***-${match.group(4)}',
);
// Redact standalone token-like strings (20+ alphanumeric characters)
// Only if they appear in common token contexts
redacted = redacted.replaceAllMapped(
RegExp(r'\b([A-Za-z0-9_-]{20,})\b'),
(match) {
final token = match.group(1)!;
// Only redact if it looks like a token (mixed case or contains hyphens/underscores)
if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) ||
token.contains('_') || token.contains('-')) {
return '[REDACTED_TOKEN]';
}
return token;
},
);
return redacted;
}
+144
View File
@@ -0,0 +1,144 @@
class LogRedactionManager {
static final Set<String> _tokens = <String>{};
static final Set<String> _urls = <String>{};
static final Set<String> _customValues = <String>{};
static final RegExp _ipv4Pattern = RegExp(
r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b',
);
static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$');
/// Register a server access token or Plex.tv token for redaction.
static void registerToken(String? token) {
final normalized = _normalize(token);
if (normalized == null) return;
_tokens.add(normalized);
// Tokens often appear URL encoded in query params.
final encoded = Uri.encodeQueryComponent(normalized);
if (encoded != normalized) {
_tokens.add(encoded);
}
}
/// Register the server/base URL currently in use.
static void registerServerUrl(String? url) {
final normalized = _normalize(url);
if (normalized == null) return;
final uri = Uri.tryParse(normalized);
final host = uri?.host;
if (host != null && host.isNotEmpty && _isIpv4Like(host)) {
// Do not register full IP-based URLs; regex redaction handles them.
return;
}
if (host == null && _isIpv4Like(normalized)) {
return;
}
final strippedSlash = normalized.endsWith('/')
? normalized.substring(0, normalized.length - 1)
: normalized;
if (strippedSlash.isNotEmpty) {
_urls.add(strippedSlash);
_urls.add('$strippedSlash/'); // Include trailing slash variant.
}
// Capture origin and host-level strings as well to cover most cases.
if (uri != null && uri.host.isNotEmpty) {
final origin =
'${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
_urls.add(origin);
if (origin.endsWith('/')) {
_urls.add(origin.substring(0, origin.length - 1));
}
}
}
/// Register other sensitive values that need redaction.
static void registerCustomValue(String? value) {
final normalized = _normalize(value);
if (normalized == null) return;
_customValues.add(normalized);
}
/// Reset any tracked sensitive values (e.g., on logout).
static void clearTrackedValues() {
_tokens.clear();
_urls.clear();
_customValues.clear();
}
/// Redact known sensitive values from the provided message.
static String redact(String message) {
var redacted = message;
redacted = redacted.replaceAllMapped(
_ipv4Pattern,
(match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!),
);
for (final url in _urls) {
redacted = redacted.replaceAll(url, _maskUrlPreview(url));
}
for (final token in _tokens) {
redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]');
}
for (final custom in _customValues) {
redacted = redacted.replaceAll(custom, '[REDACTED]');
}
return redacted;
}
static String? _normalize(String? value) {
if (value == null) return null;
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
return trimmed;
}
static bool _isIpv4Like(String value) {
return _ipv4HostPattern.hasMatch(value);
}
static String _maskIpv4(String first, String separator, String last) {
return '$first$separator'
'x$separator'
'x$separator'
'$last';
}
static String _maskUrlPreview(String url) {
const startPreviewLength = 12;
const endPreviewLength = 8;
if (url.isEmpty) {
return '[REDACTED_URL]';
}
if (url.length <= 4) {
return '[REDACTED_URL]';
}
final startLength = url.length <= startPreviewLength
? (url.length / 2).ceil()
: startPreviewLength;
final remainingForEnd = url.length - startLength;
final endLength = remainingForEnd <= endPreviewLength
? remainingForEnd
: endPreviewLength;
final start = url.substring(0, startLength);
if (endLength <= 0) {
return '$start...[REDACTED_URL]';
}
final end = url.substring(url.length - endLength);
return '$start...[REDACTED_URL]...$end';
}
}
+6 -11
View File
@@ -32,8 +32,7 @@ Future<void> handleShufflePlay(
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
builder: (context) => const Center(child: CircularProgressIndicator()),
);
}
@@ -42,9 +41,7 @@ Future<void> handleShufflePlay(
if (itemType == 'show') {
if (unwatchedOnly) {
// Get only unwatched episodes
episodes = await client.getAllUnwatchedEpisodes(
metadata.ratingKey,
);
episodes = await client.getAllUnwatchedEpisodes(metadata.ratingKey);
} else {
// Get all episodes from all seasons
final allEpisodes = <PlexMetadata>[];
@@ -71,9 +68,7 @@ Future<void> handleShufflePlay(
} else {
// Get all episodes in season
final seasonEpisodes = await client.getChildren(metadata.ratingKey);
episodes = seasonEpisodes
.where((ep) => ep.type == 'episode')
.toList();
episodes = seasonEpisodes.where((ep) => ep.type == 'episode').toList();
}
}
@@ -84,9 +79,9 @@ Future<void> handleShufflePlay(
if (episodes.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.noEpisodesFound)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
}
return;
}
+3 -1
View File
@@ -18,7 +18,9 @@ class UserSwitchingUtils {
} else if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.failedToSwitchProfile(displayName: user.displayName)),
content: Text(
t.messages.failedToSwitchProfile(displayName: user.displayName),
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
+3 -1
View File
@@ -41,7 +41,9 @@ Future<bool?> navigateToVideoPlayer(
try {
final settingsService = await SettingsService.getInstance();
final seriesKey = metadata.grandparentRatingKey ?? metadata.ratingKey;
final savedPreference = settingsService.getMediaVersionPreference(seriesKey);
final savedPreference = settingsService.getMediaVersionPreference(
seriesKey,
);
if (savedPreference != null) {
mediaIndex = savedPreference;
}
+1 -3
View File
@@ -167,9 +167,7 @@ class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
if (selectedItem.requiresConfirmation) {
final confirmed = await _showConfirmationDialog(
title: selectedItem.confirmationTitle ?? t.dialog.confirmAction,
message:
selectedItem.confirmationMessage ??
t.dialog.areYouSure,
message: selectedItem.confirmationMessage ?? t.dialog.areYouSure,
isDestructive: selectedItem.isDestructive,
);
+68 -21
View File
@@ -77,30 +77,66 @@ class FileInfoBottomSheet extends StatelessWidget {
// Video Section
_buildSectionHeader(t.fileInfo.video),
const SizedBox(height: 8),
_buildInfoRow(t.fileInfo.codec, fileInfo.videoCodec ?? t.common.unknown),
_buildInfoRow(t.fileInfo.resolution, fileInfo.resolutionFormatted),
_buildInfoRow(t.fileInfo.bitrate, fileInfo.bitrateFormatted),
_buildInfoRow(t.fileInfo.frameRate, fileInfo.frameRateFormatted),
_buildInfoRow(t.fileInfo.aspectRatio, fileInfo.aspectRatioFormatted),
_buildInfoRow(
t.fileInfo.codec,
fileInfo.videoCodec ?? t.common.unknown,
),
_buildInfoRow(
t.fileInfo.resolution,
fileInfo.resolutionFormatted,
),
_buildInfoRow(
t.fileInfo.bitrate,
fileInfo.bitrateFormatted,
),
_buildInfoRow(
t.fileInfo.frameRate,
fileInfo.frameRateFormatted,
),
_buildInfoRow(
t.fileInfo.aspectRatio,
fileInfo.aspectRatioFormatted,
),
if (fileInfo.videoProfile != null)
_buildInfoRow(t.fileInfo.profile, fileInfo.videoProfile!),
if (fileInfo.bitDepth != null)
_buildInfoRow(t.fileInfo.bitDepth, '${fileInfo.bitDepth} bit'),
_buildInfoRow(
t.fileInfo.bitDepth,
'${fileInfo.bitDepth} bit',
),
if (fileInfo.colorSpace != null)
_buildInfoRow(t.fileInfo.colorSpace, fileInfo.colorSpace!),
_buildInfoRow(
t.fileInfo.colorSpace,
fileInfo.colorSpace!,
),
if (fileInfo.colorRange != null)
_buildInfoRow(t.fileInfo.colorRange, fileInfo.colorRange!),
_buildInfoRow(
t.fileInfo.colorRange,
fileInfo.colorRange!,
),
if (fileInfo.colorPrimaries != null)
_buildInfoRow(t.fileInfo.colorPrimaries, fileInfo.colorPrimaries!),
_buildInfoRow(
t.fileInfo.colorPrimaries,
fileInfo.colorPrimaries!,
),
if (fileInfo.chromaSubsampling != null)
_buildInfoRow(t.fileInfo.chromaSubsampling, fileInfo.chromaSubsampling!),
_buildInfoRow(
t.fileInfo.chromaSubsampling,
fileInfo.chromaSubsampling!,
),
const SizedBox(height: 20),
// Audio Section
_buildSectionHeader(t.fileInfo.audio),
const SizedBox(height: 8),
_buildInfoRow(t.fileInfo.codec, fileInfo.audioCodec ?? t.common.unknown),
_buildInfoRow(t.fileInfo.channels, fileInfo.audioChannelsFormatted),
_buildInfoRow(
t.fileInfo.codec,
fileInfo.audioCodec ?? t.common.unknown,
),
_buildInfoRow(
t.fileInfo.channels,
fileInfo.audioChannelsFormatted,
),
if (fileInfo.audioProfile != null)
_buildInfoRow(t.fileInfo.profile, fileInfo.audioProfile!),
const SizedBox(height: 20),
@@ -109,10 +145,20 @@ class FileInfoBottomSheet extends StatelessWidget {
_buildSectionHeader(t.fileInfo.file),
const SizedBox(height: 8),
if (fileInfo.filePath != null)
_buildInfoRow(t.fileInfo.path, fileInfo.filePath!, isMonospace: true),
_buildInfoRow(
t.fileInfo.path,
fileInfo.filePath!,
isMonospace: true,
),
_buildInfoRow(t.fileInfo.size, fileInfo.fileSizeFormatted),
_buildInfoRow(t.fileInfo.container, fileInfo.container ?? t.common.unknown),
_buildInfoRow(t.fileInfo.duration, fileInfo.durationFormatted),
_buildInfoRow(
t.fileInfo.container,
fileInfo.container ?? t.common.unknown,
),
_buildInfoRow(
t.fileInfo.duration,
fileInfo.durationFormatted,
),
const SizedBox(height: 20),
// Advanced Section
@@ -120,11 +166,15 @@ class FileInfoBottomSheet extends StatelessWidget {
const SizedBox(height: 8),
_buildInfoRow(
t.fileInfo.optimizedForStreaming,
fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no,
fileInfo.optimizedForStreaming == true
? t.common.yes
: t.common.no,
),
_buildInfoRow(
t.fileInfo.has64bitOffsets,
fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no,
fileInfo.has64bitOffsets == true
? t.common.yes
: t.common.no,
),
],
),
@@ -157,10 +207,7 @@ class FileInfoBottomSheet extends StatelessWidget {
width: 140,
child: Text(
label,
style: TextStyle(
color: Colors.grey[400],
fontSize: 14,
),
style: TextStyle(color: Colors.grey[400], fontSize: 14),
),
),
Expanded(
+8 -15
View File
@@ -56,9 +56,9 @@ class _HorizontalScrollWithArrowsState
void _scrollLeft() {
final position = _scrollController.position;
final targetScroll = (position.pixels -
(position.viewportDimension * widget.scrollAmount))
.clamp(0.0, position.maxScrollExtent);
final targetScroll =
(position.pixels - (position.viewportDimension * widget.scrollAmount))
.clamp(0.0, position.maxScrollExtent);
_scrollController.animateTo(
targetScroll,
@@ -69,9 +69,9 @@ class _HorizontalScrollWithArrowsState
void _scrollRight() {
final position = _scrollController.position;
final targetScroll = (position.pixels +
(position.viewportDimension * widget.scrollAmount))
.clamp(0.0, position.maxScrollExtent);
final targetScroll =
(position.pixels + (position.viewportDimension * widget.scrollAmount))
.clamp(0.0, position.maxScrollExtent);
_scrollController.animateTo(
targetScroll,
@@ -145,10 +145,7 @@ class _NavigationArrow extends StatefulWidget {
final IconData icon;
final VoidCallback onPressed;
const _NavigationArrow({
required this.icon,
required this.onPressed,
});
const _NavigationArrow({required this.icon, required this.onPressed});
@override
State<_NavigationArrow> createState() => _NavigationArrowState();
@@ -182,11 +179,7 @@ class _NavigationArrowState extends State<_NavigationArrow> {
),
],
),
child: Icon(
widget.icon,
color: Colors.white,
size: 32,
),
child: Icon(widget.icon, color: Colors.white, size: 32),
),
),
);
+32 -29
View File
@@ -181,34 +181,31 @@ class _MediaCardGrid extends StatelessWidget {
item.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (item.parentTitle != null)
Text(
item.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (item.year != null)
Text(
'${item.year}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
),
],
),
@@ -473,10 +470,12 @@ class _MediaCardList extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: FontWeight.w500,
),
color: tokens(
context,
).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
],
@@ -487,9 +486,11 @@ class _MediaCardList extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
color: tokens(
context,
).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
const SizedBox(height: 4),
],
@@ -500,10 +501,12 @@ class _MediaCardList extends StatelessWidget {
maxLines: _summaryMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
color: tokens(
context,
).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
),
],
],
+241 -8
View File
@@ -1,7 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../widgets/file_info_bottom_sheet.dart';
@@ -102,7 +104,11 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
if ((itemType == 'episode' || itemType == 'season') &&
widget.metadata.grandparentTitle != null) {
menuActions.add(
_MenuAction(value: 'series', icon: Icons.tv, label: t.mediaMenu.goToSeries),
_MenuAction(
value: 'series',
icon: Icons.tv,
label: t.mediaMenu.goToSeries,
),
);
}
@@ -139,6 +145,20 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
);
}
// Add to Playlist (for episodes, movies, shows, and seasons)
if (itemType == 'episode' ||
itemType == 'movie' ||
itemType == 'show' ||
itemType == 'season') {
menuActions.add(
_MenuAction(
value: 'add_to_playlist',
icon: Icons.playlist_add,
label: t.playlists.addTo,
),
);
}
String? selected;
if (useBottomSheet) {
@@ -262,7 +282,9 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
SnackBar(
content: Text(t.messages.errorLoading(error: e.toString())),
),
);
}
}
@@ -290,6 +312,10 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
await _showFileInfo(context);
break;
case 'add_to_playlist':
await _showAddToPlaylistDialog(context);
break;
case 'shuffle_play':
await handleShufflePlay(context, widget.metadata);
break;
@@ -312,9 +338,9 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
}
@@ -397,13 +423,114 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
}
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.errorLoadingFileInfo(error: e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.errorLoadingFileInfo(error: e.toString())),
),
);
}
}
}
/// Show dialog to select playlist and add item
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
final client = context.client;
if (client == null) return;
try {
final itemType = widget.metadata.type.toLowerCase();
// Load playlists
final playlists = await client.getPlaylists(playlistType: 'video');
if (!context.mounted) return;
// Show dialog to select playlist or create new
final result = await showDialog<String>(
context: context,
builder: (context) => _PlaylistSelectionDialog(playlists: playlists),
);
if (result == null || !context.mounted) return;
// Build URI for the item (works for all types: movies, episodes, seasons, shows)
// For seasons/shows, the Plex API should automatically expand to include all episodes
final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey);
appLogger.d('Built URI for $itemType: $itemUri');
if (result == '_create_new') {
// Create new playlist flow
final playlistName = await showDialog<String>(
context: context,
builder: (context) => _CreatePlaylistDialog(),
);
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
return;
}
// Create playlist with the item(s)
appLogger.d(
'Creating playlist "$playlistName" with URI length: ${itemUri.length}',
);
final newPlaylist = await client.createPlaylist(
title: playlistName,
uri: itemUri,
);
if (context.mounted) {
if (newPlaylist != null) {
appLogger.d('Successfully created playlist: ${newPlaylist.title}');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.created)));
} else {
appLogger.e('Failed to create playlist - API returned null');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorCreating)));
}
}
} else {
// Add to existing playlist
appLogger.d('Adding to playlist $result with URI: $itemUri');
final success = await client.addToPlaylist(
playlistId: result,
uri: itemUri,
);
if (context.mounted) {
if (success) {
appLogger.d('Successfully added item(s) to playlist $result');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded)));
} else {
appLogger.e(
'Failed to add item(s) to playlist $result - API returned false',
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.errorAdding)));
}
}
}
} catch (e, stackTrace) {
appLogger.e(
'Error in add to playlist flow',
error: e,
stackTrace: stackTrace,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${t.playlists.errorLoading}: ${e.toString()}'),
duration: const Duration(seconds: 5),
),
);
}
}
}
@override
Widget build(BuildContext context) {
@@ -417,3 +544,109 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
);
}
}
/// Dialog to select a playlist or create a new one
class _PlaylistSelectionDialog extends StatelessWidget {
final List<PlexPlaylist> playlists;
const _PlaylistSelectionDialog({required this.playlists});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(t.playlists.selectPlaylist),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: playlists.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
// Create new playlist option (always shown first)
return ListTile(
leading: const Icon(Icons.add),
title: Text(t.playlists.createNewPlaylist),
onTap: () => Navigator.pop(context, '_create_new'),
);
}
final playlist = playlists[index - 1];
return ListTile(
leading: playlist.smart
? const Icon(Icons.auto_awesome)
: const Icon(Icons.playlist_play),
title: Text(playlist.title),
subtitle: playlist.leafCount != null
? Text(
playlist.leafCount == 1
? t.playlists.oneItem
: t.playlists.itemCount(count: playlist.leafCount!),
)
: null,
onTap: playlist.smart
? null // Disable smart playlists
: () => Navigator.pop(context, playlist.ratingKey),
enabled: !playlist.smart,
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
],
);
}
}
/// 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),
),
],
);
}
}
+3 -1
View File
@@ -125,7 +125,9 @@ class _PinEntryDialogState extends State<PinEntryDialog>
_obscureText = !_obscureText;
});
},
tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin,
tooltip: _obscureText
? t.pinEntry.showPin
: t.pinEntry.hidePin,
),
),
onSubmitted: (_) => _submit(),
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../i18n/strings.g.dart';
/// Custom list item widget for playlist items
/// Shows drag handle, poster, title/metadata, duration, and remove button
class PlaylistItemCard extends StatelessWidget {
final PlexMetadata item;
final int index;
final VoidCallback onRemove;
final VoidCallback? onTap;
final bool canReorder; // Whether drag handle should be shown
const PlaylistItemCard({
super.key,
required this.item,
required this.index,
required this.onRemove,
this.onTap,
this.canReorder = true,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// Drag handle (if reorderable)
if (canReorder)
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(Icons.drag_indicator, color: Colors.grey),
),
),
// Poster thumbnail
_buildPosterImage(context),
const SizedBox(width: 12),
// Title and metadata
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Title
Text(
item.displayTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Subtitle (episode info or type)
Text(
_buildSubtitle(),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Progress indicator if partially watched
if (item.viewOffset != null && item.duration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(
value: item.viewOffset! / item.duration!,
backgroundColor: Colors.grey[800],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 3,
),
),
],
),
),
const SizedBox(width: 12),
// Duration
if (item.duration != null)
Text(
_formatDuration(item.duration!),
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
const SizedBox(width: 8),
// Remove button
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: onRemove,
tooltip: t.playlists.removeItem,
color: Colors.grey[400],
),
],
),
),
),
);
}
Widget _buildPosterImage(BuildContext context) {
final posterUrl = item.posterThumb();
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return _buildPlaceholder();
}
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: client.getThumbnailUrl(posterUrl),
width: 60,
height: 90,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(),
errorWidget: (context, url, error) => _buildPlaceholder(),
),
);
},
);
}
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
width: 60,
height: 90,
decoration: BoxDecoration(
color: Colors.grey[850],
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.movie, color: Colors.grey, size: 24),
);
}
String _buildSubtitle() {
final itemType = item.type.toLowerCase();
if (itemType == 'episode') {
// For episodes, show "S#E# - Episode Title"
final season = item.parentIndex;
final episode = item.index;
if (season != null && episode != null) {
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
}
return item.displaySubtitle ?? t.discover.tvShow;
} else if (itemType == 'movie') {
// For movies, show year
return item.year?.toString() ?? t.discover.movie;
}
// Default to type
return item.type;
}
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
}
+28 -14
View File
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import '../models/plex_home_user.dart';
import 'user_avatar_widget.dart';
enum UserAttribute { admin, restricted, protected }
class ProfileListTile extends StatelessWidget {
final PlexHomeUser user;
final VoidCallback onTap;
@@ -34,7 +37,7 @@ class ProfileListTile extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
),
child: Text(
'CURRENT',
t.userStatus.current,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onPrimary,
@@ -55,21 +58,21 @@ class ProfileListTile extends StatelessWidget {
List<Widget> _buildUserAttributes(ThemeData theme) {
final attributes = <Widget>[];
final labels = <String>[];
final List<UserAttribute> userAttributes = [];
if (user.isAdminUser) {
labels.add('Admin');
userAttributes.add(UserAttribute.admin);
}
if (user.isRestrictedUser && !user.isAdminUser) {
labels.add('Restricted');
userAttributes.add(UserAttribute.restricted);
}
if (user.requiresPassword) {
labels.add('Protected');
userAttributes.add(UserAttribute.protected);
}
for (int i = 0; i < labels.length; i++) {
for (int i = 0; i < userAttributes.length; i++) {
if (i > 0) {
attributes.addAll([
const SizedBox(width: 8),
@@ -84,12 +87,14 @@ class ProfileListTile extends StatelessWidget {
]);
}
final attribute = userAttributes[i];
attributes.add(
Text(
labels[i],
_getAttributeLabel(attribute),
style: TextStyle(
fontSize: 12,
color: _getAttributeColor(labels[i], theme),
color: _getAttributeColor(attribute, theme),
fontWeight: FontWeight.w500,
),
),
@@ -99,16 +104,25 @@ class ProfileListTile extends StatelessWidget {
return attributes;
}
Color _getAttributeColor(String attribute, ThemeData theme) {
String _getAttributeLabel(UserAttribute attribute) {
switch (attribute) {
case 'Admin':
case UserAttribute.admin:
return t.userStatus.admin;
case UserAttribute.restricted:
return t.userStatus.restricted;
case UserAttribute.protected:
return t.userStatus.protected;
}
}
Color _getAttributeColor(UserAttribute attribute, ThemeData theme) {
switch (attribute) {
case UserAttribute.admin:
return theme.colorScheme.primary;
case 'Restricted':
case UserAttribute.restricted:
return theme.colorScheme.warning ?? Colors.orange;
case 'Protected':
case UserAttribute.protected:
return theme.colorScheme.secondary;
default:
return theme.colorScheme.onSurface;
}
}
}
+10 -10
View File
@@ -95,10 +95,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
groupValue: _currentSort,
onChanged: (PlexSort? value) {
if (value != null) {
_handleSortChange(
value,
value.defaultDirection == 'desc',
);
_handleSortChange(value, value.defaultDirection == 'desc');
}
},
child: ListView.builder(
@@ -124,7 +121,10 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
),
ButtonSegment(
value: true,
icon: Icon(Icons.arrow_downward, size: 16),
icon: Icon(
Icons.arrow_downward,
size: 16,
),
),
],
selected: {_currentDescending},
@@ -135,12 +135,12 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
],
)
: null,
leading: Radio<PlexSort>(
value: sort,
toggleable: false,
),
leading: Radio<PlexSort>(value: sort, toggleable: false),
onTap: () {
_handleSortChange(sort, sort.defaultDirection == 'desc');
_handleSortChange(
sort,
sort.defaultDirection == 'desc',
);
},
);
},
@@ -5,11 +5,9 @@ import '../../../i18n/strings.g.dart';
/// Bottom sheet for selecting audio tracks
class AudioTrackSheet extends StatelessWidget {
final Player player;
final Function(AudioTrack)? onTrackChanged;
const AudioTrackSheet({
super.key,
required this.player,
});
const AudioTrackSheet({super.key, required this.player, this.onTrackChanged});
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
final size = MediaQuery.of(context).size;
@@ -22,13 +20,18 @@ class AudioTrackSheet extends StatelessWidget {
);
}
static void show(BuildContext context, Player player) {
static void show(
BuildContext context,
Player player, {
Function(AudioTrack)? onTrackChanged,
}) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: getBottomSheetConstraints(context),
builder: (context) => AudioTrackSheet(player: player),
builder: (context) =>
AudioTrackSheet(player: player, onTrackChanged: onTrackChanged),
);
}
@@ -123,18 +126,17 @@ class AudioTrackSheet extends StatelessWidget {
title: Text(
label,
style: TextStyle(
color:
isSelected ? Colors.blue : Colors.white,
color: isSelected
? Colors.blue
: Colors.white,
),
),
trailing: isSelected
? const Icon(
Icons.check,
color: Colors.blue,
)
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
player.setAudioTrack(audioTrack);
onTrackChanged?.call(audioTrack);
Navigator.pop(context);
},
);
@@ -73,7 +73,8 @@ class ChapterSheet extends StatelessWidget {
for (int i = 0; i < chapters.length; i++) {
final chapter = chapters[i];
final startMs = chapter.startTimeOffset ?? 0;
final endMs = chapter.endTimeOffset ??
final endMs =
chapter.endTimeOffset ??
(i < chapters.length - 1
? chapters[i + 1].startTimeOffset ?? 0
: double.maxFinite.toInt());
@@ -142,41 +143,43 @@ class ChapterSheet extends StatelessWidget {
child: Consumer<PlexClientProvider>(
builder:
(context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return const Icon(
Icons.image,
color: Colors.white54,
size: 34,
);
}
return Image.network(
client.getThumbnailUrl(
chapter.thumb,
),
width: 60,
height: 34,
fit: BoxFit.cover,
errorBuilder: (
context,
error,
stackTrace,
) =>
const Icon(
Icons.image,
color: Colors.white54,
size: 34,
),
);
},
final client =
clientProvider.client;
if (client == null) {
return const Icon(
Icons.image,
color: Colors.white54,
size: 34,
);
}
return Image.network(
client.getThumbnailUrl(
chapter.thumb,
),
width: 60,
height: 34,
fit: BoxFit.cover,
errorBuilder:
(
context,
error,
stackTrace,
) => const Icon(
Icons.image,
color: Colors.white54,
size: 34,
),
);
},
),
),
if (isCurrentChapter)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(4),
borderRadius: BorderRadius.circular(
4,
),
border: Border.all(
color: Colors.blue,
width: 2,
@@ -190,8 +193,9 @@ class ChapterSheet extends StatelessWidget {
title: Text(
chapter.label,
style: TextStyle(
color:
isCurrentChapter ? Colors.blue : Colors.white,
color: isCurrentChapter
? Colors.blue
: Colors.white,
fontWeight: isCurrentChapter
? FontWeight.bold
: FontWeight.normal,
@@ -5,10 +5,7 @@ import 'package:media_kit/media_kit.dart';
class PlaybackSpeedSheet extends StatelessWidget {
final Player player;
const PlaybackSpeedSheet({
super.key,
required this.player,
});
const PlaybackSpeedSheet({super.key, required this.player});
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
final size = MediaQuery.of(context).size;
@@ -37,10 +37,8 @@ class SleepTimerSheet extends StatelessWidget {
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: getBottomSheetConstraints(context),
builder: (context) => SleepTimerSheet(
player: player,
defaultDuration: defaultDuration,
),
builder: (context) =>
SleepTimerSheet(player: player, defaultDuration: defaultDuration),
);
}
@@ -86,7 +84,9 @@ class SleepTimerSheet extends StatelessWidget {
sleepTimer.isActive
? Icons.bedtime
: Icons.bedtime_outlined,
color: sleepTimer.isActive ? Colors.amber : Colors.white,
color: sleepTimer.isActive
? Colors.amber
: Colors.white,
),
const SizedBox(width: 12),
const Text(
@@ -136,12 +136,15 @@ class SleepTimerSheet extends StatelessWidget {
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add),
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
label: Text(
t.videoControls.addTime(
amount: "15",
unit: " min",
),
),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(
color: Colors.white54,
),
side: const BorderSide(color: Colors.white54),
),
onPressed: () {
sleepTimer.extendTimer(
@@ -180,10 +183,7 @@ class SleepTimerSheet extends StatelessWidget {
: '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}';
return ListTile(
leading: const Icon(
Icons.timer,
color: Colors.white70,
),
leading: const Icon(Icons.timer, color: Colors.white70),
title: Text(
label,
style: const TextStyle(
@@ -192,31 +192,30 @@ class SleepTimerSheet extends StatelessWidget {
),
),
onTap: () {
sleepTimer.startTimer(
Duration(minutes: minutes),
() {
// Pause playback when timer completes
player.pause();
sleepTimer.startTimer(Duration(minutes: minutes), () {
// Pause playback when timer completes
player.pause();
// Show a snackbar notification
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sleep timer completed - playback paused',
),
duration: Duration(seconds: 3),
// Show a snackbar notification
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sleep timer completed - playback paused',
),
);
}
},
);
duration: Duration(seconds: 3),
),
);
}
});
Navigator.pop(context);
// Show confirmation snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.sleepTimerSet(label: label)),
content: Text(
t.messages.sleepTimerSet(label: label),
),
duration: const Duration(seconds: 2),
),
);
@@ -5,10 +5,12 @@ import '../../../i18n/strings.g.dart';
/// Bottom sheet for selecting subtitle tracks
class SubtitleTrackSheet extends StatelessWidget {
final Player player;
final Function(SubtitleTrack)? onTrackChanged;
const SubtitleTrackSheet({
super.key,
required this.player,
this.onTrackChanged,
});
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
@@ -22,13 +24,18 @@ class SubtitleTrackSheet extends StatelessWidget {
);
}
static void show(BuildContext context, Player player) {
static void show(
BuildContext context,
Player player, {
Function(SubtitleTrack)? onTrackChanged,
}) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.grey[900],
isScrollControlled: true,
constraints: getBottomSheetConstraints(context),
builder: (context) => SubtitleTrackSheet(player: player),
builder: (context) =>
SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged),
);
}
@@ -115,9 +122,8 @@ class SubtitleTrackSheet extends StatelessWidget {
)
: null,
onTap: () {
player.setSubtitleTrack(
SubtitleTrack.no(),
);
player.setSubtitleTrack(SubtitleTrack.no());
onTrackChanged?.call(SubtitleTrack.no());
Navigator.pop(context);
},
);
@@ -162,18 +168,17 @@ class SubtitleTrackSheet extends StatelessWidget {
title: Text(
label,
style: TextStyle(
color:
isSelected ? Colors.blue : Colors.white,
color: isSelected
? Colors.blue
: Colors.white,
),
),
trailing: isSelected
? const Icon(
Icons.check,
color: Colors.blue,
)
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () {
player.setSubtitleTrack(subtitle);
onTrackChanged?.call(subtitle);
Navigator.pop(context);
},
);
@@ -321,7 +321,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
stream: widget.player.stream.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, snapshot) {
final currentDevice = snapshot.data ?? widget.player.state.audioDevice;
final currentDevice =
snapshot.data ?? widget.player.state.audioDevice;
final deviceLabel = currentDevice.description.isEmpty
? currentDevice.name
: currentDevice.description;
@@ -338,7 +339,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
Flexible(
child: Text(
deviceLabel,
style: const TextStyle(color: Colors.white70, fontSize: 14),
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
@@ -432,7 +436,9 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add),
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
label: Text(
t.videoControls.addTime(amount: "15", unit: " min"),
),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
@@ -552,7 +558,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
stream: widget.player.stream.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, selectedSnapshot) {
final currentDevice = selectedSnapshot.data ?? widget.player.state.audioDevice;
final currentDevice =
selectedSnapshot.data ?? widget.player.state.audioDevice;
return ListView.builder(
itemCount: devices.length,
+21 -6
View File
@@ -38,6 +38,8 @@ Widget plexVideoControlsBuilder(
int? selectedMediaIndex,
int boxFitMode = 0,
VoidCallback? onCycleBoxFitMode,
Function(AudioTrack)? onAudioTrackChanged,
Function(SubtitleTrack)? onSubtitleTrackChanged,
}) {
return PlexVideoControls(
player: player,
@@ -48,6 +50,8 @@ Widget plexVideoControlsBuilder(
selectedMediaIndex: selectedMediaIndex ?? 0,
boxFitMode: boxFitMode,
onCycleBoxFitMode: onCycleBoxFitMode,
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
);
}
@@ -60,6 +64,8 @@ class PlexVideoControls extends StatefulWidget {
final int selectedMediaIndex;
final int boxFitMode;
final VoidCallback? onCycleBoxFitMode;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
const PlexVideoControls({
super.key,
@@ -71,6 +77,8 @@ class PlexVideoControls extends StatefulWidget {
this.selectedMediaIndex = 0,
this.boxFitMode = 0,
this.onCycleBoxFitMode,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
});
@override
@@ -464,13 +472,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (_hasMultipleAudioTracks(tracks))
VideoControlButton(
icon: Icons.audiotrack,
onPressed: () => AudioTrackSheet.show(context, widget.player),
onPressed: () => AudioTrackSheet.show(
context,
widget.player,
onTrackChanged: widget.onAudioTrackChanged,
),
),
if (_hasSubtitles(tracks))
VideoControlButton(
icon: Icons.subtitles,
onPressed: () =>
SubtitleTrackSheet.show(context, widget.player),
onPressed: () => SubtitleTrackSheet.show(
context,
widget.player,
onTrackChanged: widget.onSubtitleTrackChanged,
),
),
if (_chapters.isNotEmpty)
VideoControlButton(
@@ -1602,9 +1617,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
}
@@ -103,7 +103,10 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
// Slider
Row(
children: [
Text(t.videoControls.minusTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)),
Text(
t.videoControls.minusTime(amount: "2", unit: "s"),
style: const TextStyle(color: Colors.white70),
),
Expanded(
child: Slider(
value: _currentOffset,
@@ -122,7 +125,10 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
},
),
),
Text(t.videoControls.addTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)),
Text(
t.videoControls.addTime(amount: "2", unit: "s"),
style: const TextStyle(color: Colors.white70),
),
],
),
const SizedBox(height: 24),
@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import connectivity_plus
import hotkey_manager_macos
import macos_window_utils
import media_kit_libs_macos_video
@@ -21,6 +22,7 @@ import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
+9 -2
View File
@@ -1,4 +1,6 @@
PODS:
- connectivity_plus (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- HotKey (0.2.1)
- hotkey_manager_macos (0.0.1):
@@ -10,6 +12,7 @@ PODS:
- FlutterMacOS
- media_kit_video (0.0.1):
- FlutterMacOS
- media_kit_libs_macos_video
- os_media_controls (0.0.1):
- FlutterMacOS
- package_info_plus (0.0.1):
@@ -35,6 +38,7 @@ PODS:
- FlutterMacOS
DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
@@ -56,6 +60,8 @@ SPEC REPOS:
- HotKey
EXTERNAL SOURCES:
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
FlutterMacOS:
:path: Flutter/ephemeral
hotkey_manager_macos:
@@ -88,12 +94,13 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
media_kit_libs_macos_video: 69caff44badac986515a602bae1eb7f988a29f93
media_kit_video: 71dcfc00fc7d2e7dbe43e60b5eb505f15ab6a88e
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+52 -27
View File
@@ -185,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
@@ -540,26 +556,26 @@ packages:
dependency: "direct main"
description:
path: media_kit
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.2.1"
media_kit_libs_android_video:
dependency: transitive
dependency: "direct overridden"
description:
path: "libs/android/media_kit_libs_android_video"
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.3.9"
media_kit_libs_ios_video:
dependency: transitive
dependency: "direct overridden"
description:
path: "libs/ios/media_kit_libs_ios_video"
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.1.4"
@@ -567,17 +583,17 @@ packages:
dependency: transitive
description:
path: "libs/linux/media_kit_libs_linux"
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.2.1"
media_kit_libs_macos_video:
dependency: transitive
dependency: "direct overridden"
description:
path: "libs/macos/media_kit_libs_macos_video"
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.1.5"
@@ -585,8 +601,8 @@ packages:
dependency: "direct main"
description:
path: "libs/universal/media_kit_libs_video"
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.0.7"
@@ -594,27 +610,28 @@ packages:
dependency: transitive
description:
path: "libs/windows/media_kit_libs_windows_video"
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.0.12"
media_kit_video:
dependency: "direct main"
description:
name: media_kit_video
sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a"
url: "https://pub.dev"
source: hosted
path: media_kit_video
ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4"
url: "https://github.com/edde746/media-kit"
source: git
version: "1.3.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -631,6 +648,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
octo_image:
dependency: transitive
description:
@@ -643,8 +668,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f"
resolved-ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f"
ref: a196b022dcbcc3433bae9233be179f02062327e7
resolved-ref: a196b022dcbcc3433bae9233be179f02062327e7
url: "https://github.com/edde746/os-media-controls"
source: git
version: "0.0.2"
@@ -1097,10 +1122,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
timing:
dependency: transitive
description:
+24 -3
View File
@@ -28,22 +28,43 @@ dependencies:
qr_flutter: ^4.1.0
slang: ^3.31.2
slang_flutter: ^3.31.0
connectivity_plus: ^6.0.5
os_media_controls:
git:
url: https://github.com/edde746/os-media-controls
ref: 53d803e1c228eb7eab9ca642119d404c21f4a52f
ref: a196b022dcbcc3433bae9233be179f02062327e7
dependency_overrides:
media_kit:
git:
url: https://github.com/edde746/media-kit
ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
path: media_kit
media_kit_video:
git:
url: https://github.com/edde746/media-kit
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
path: media_kit_video
media_kit_libs_video:
git:
url: https://github.com/edde746/media-kit
ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
path: libs/universal/media_kit_libs_video
media_kit_libs_macos_video:
git:
url: https://github.com/edde746/media-kit
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
path: libs/macos/media_kit_libs_macos_video
media_kit_libs_ios_video:
git:
url: https://github.com/edde746/media-kit
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
path: libs/ios/media_kit_libs_ios_video
media_kit_libs_android_video:
git:
url: https://github.com/edde746/media-kit
ref: 6077829fa6705f30f85c1393a36008d5f66c06f4
path: libs/android/media_kit_libs_android_video
dev_dependencies:
flutter_test:
@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
@@ -16,6 +17,8 @@
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
hotkey_manager_windows
media_kit_libs_windows_video
media_kit_video