feat: downloads

This commit is contained in:
edde746
2025-12-12 02:59:27 +01:00
parent b7c630f10e
commit dc44354036
65 changed files with 28245 additions and 6883 deletions
+102
View File
@@ -3,7 +3,43 @@ PODS:
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- gamepads_ios (0.1.1):
- Flutter
- os_media_controls (0.0.1):
- Flutter
- package_info_plus (0.4.5):
@@ -11,33 +47,83 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.51.1):
- sqlite3/common (= 3.51.1)
- sqlite3/common (3.51.1)
- sqlite3/dbstatvtab (3.51.1):
- sqlite3/common
- sqlite3/fts5 (3.51.1):
- sqlite3/common
- sqlite3/math (3.51.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.51.1):
- sqlite3/common
- sqlite3/rtree (3.51.1):
- sqlite3/common
- sqlite3/session (3.51.1):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.51.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- SDWebImage
- sqlite3
- SwiftyGif
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
gamepads_ios:
:path: ".symlinks/plugins/gamepads_ios/ios"
os_media_controls:
:path: ".symlinks/plugins/os_media_controls/ios"
package_info_plus:
@@ -48,19 +134,35 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+3
View File
@@ -496,6 +496,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Plezy;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -682,6 +683,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Plezy;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -708,6 +710,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Plezy;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
+4
View File
@@ -26,6 +26,8 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@@ -39,6 +41,8 @@
<string>This app needs to connect to your Plex Media Server on your local network.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+208
View File
@@ -0,0 +1,208 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables/downloaded_media.dart';
import 'tables/download_queue.dart';
import 'tables/api_cache.dart';
import 'tables/offline_watch_progress.dart';
import '../models/download_status.dart';
import '../utils/app_logger.dart';
part 'app_database.g.dart';
// Simplified database with API cache for offline support
@DriftDatabase(
tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress],
)
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 7; // Added OfflineWatchProgress table
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Additive migration for schema version 7
if (from < 7) {
appLogger.i('Adding OfflineWatchProgress table (v7 migration)');
await m.createTable(offlineWatchProgress);
}
},
);
}
// ============================================================
// Offline Watch Progress Operations
// ============================================================
/// Get all pending offline watch actions for sync
Future<List<OfflineWatchProgressItem>> getPendingWatchActions() {
return (select(
offlineWatchProgress,
)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
}
/// Get pending watch actions for a specific server
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(
String serverId,
) {
return (select(offlineWatchProgress)
..where((t) => t.serverId.equals(serverId))
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.get();
}
/// Get the latest action for a specific item
Future<OfflineWatchProgressItem?> getLatestWatchAction(String globalKey) {
return (select(offlineWatchProgress)
..where((t) => t.globalKey.equals(globalKey))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
..limit(1))
.getSingleOrNull();
}
/// Insert or update a progress action (merges with existing)
Future<void> upsertProgressAction({
required String serverId,
required String ratingKey,
required int viewOffset,
required int duration,
required bool shouldMarkWatched,
}) async {
final globalKey = '$serverId:$ratingKey';
final now = DateTime.now().millisecondsSinceEpoch;
// Check for existing progress entry
final existing =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
t.actionType.equals('progress'),
)
..limit(1))
.getSingleOrNull();
if (existing != null) {
// Update existing progress entry
await (update(
offlineWatchProgress,
)..where((t) => t.id.equals(existing.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
updatedAt: Value(now),
),
);
} else {
// Insert new progress entry
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
ratingKey: ratingKey,
globalKey: globalKey,
actionType: 'progress',
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
}
}
/// Insert a manual watch action (watched or unwatched)
/// Removes conflicting actions for the same item
Future<void> insertWatchAction({
required String serverId,
required String ratingKey,
required String actionType, // 'watched' or 'unwatched'
}) async {
final globalKey = '$serverId:$ratingKey';
final now = DateTime.now().millisecondsSinceEpoch;
// Remove conflicting actions (opposite action type and progress)
await (delete(
offlineWatchProgress,
)..where((t) => t.globalKey.equals(globalKey))).go();
// Insert the new action
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
ratingKey: ratingKey,
globalKey: globalKey,
actionType: actionType,
createdAt: now,
updatedAt: now,
),
);
}
/// Delete a specific watch action after successful sync
Future<void> deleteWatchAction(int id) {
return (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go();
}
/// Update sync attempt count and error message
Future<void> updateSyncAttempt(int id, String? errorMessage) async {
final existing = await (select(
offlineWatchProgress,
)..where((t) => t.id.equals(id))).getSingleOrNull();
if (existing != null) {
await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write(
OfflineWatchProgressCompanion(
syncAttempts: Value(existing.syncAttempts + 1),
lastError: Value(errorMessage),
),
);
}
}
/// Get count of pending sync items
Future<int> getPendingSyncCount() async {
final count =
await (selectOnly(offlineWatchProgress)
..addColumns([offlineWatchProgress.id.count()]))
.map((row) => row.read(offlineWatchProgress.id.count()))
.getSingle();
return count ?? 0;
}
/// Clear all pending watch actions (e.g., after logout)
Future<void> clearAllWatchActions() {
return delete(offlineWatchProgress).go();
}
// ============================================================
// Downloaded Media Queries for Watch State Sync
// ============================================================
/// Get all downloaded media items (for syncing watch states)
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
return (select(downloadedMedia)
..where(
(t) => t.status.equals(DownloadStatus.completed.index),
))
.get();
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
return NativeDatabase(file);
});
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
/// Key-value cache table for Plex API responses.
/// Used for offline support - stores raw JSON responses.
class ApiCache extends Table {
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345")
TextColumn get cacheKey => text()();
/// JSON response data
TextColumn get data => text()();
/// Whether this item is pinned for offline access
BoolColumn get pinned => boolean().withDefault(const Constant(false))();
/// Timestamp for cache invalidation (optional future use)
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {cacheKey};
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:drift/drift.dart';
@DataClassName('DownloadQueueItem')
class DownloadQueue extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get mediaGlobalKey => text().unique()();
IntColumn get priority => integer().withDefault(const Constant(0))();
IntColumn get addedAt => integer()();
BoolColumn get downloadSubtitles =>
boolean().withDefault(const Constant(true))();
BoolColumn get downloadArtwork =>
boolean().withDefault(const Constant(true))();
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:drift/drift.dart';
@DataClassName('DownloadedMediaItem')
class DownloadedMedia extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text()();
TextColumn get ratingKey => text()();
TextColumn get globalKey => text().unique()();
TextColumn get type => text()();
TextColumn get parentRatingKey => text().nullable()();
TextColumn get grandparentRatingKey => text().nullable()();
IntColumn get status => integer()();
IntColumn get progress => integer().withDefault(const Constant(0))();
IntColumn get totalBytes => integer().nullable()();
IntColumn get downloadedBytes => integer().withDefault(const Constant(0))();
TextColumn get videoFilePath => text().nullable()();
TextColumn get thumbPath => text().nullable()();
IntColumn get downloadedAt => integer().nullable()();
TextColumn get errorMessage => text().nullable()();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
}
@@ -0,0 +1,46 @@
import 'package:drift/drift.dart';
/// Queue for offline watch progress and manual watch actions.
///
/// Stores watch progress updates and manual watch/unwatch actions
/// that need to be synced to the Plex server when back online.
@DataClassName('OfflineWatchProgressItem')
class OfflineWatchProgress extends Table {
/// Auto-incrementing primary key
IntColumn get id => integer().autoIncrement()();
/// Server ID this media belongs to
TextColumn get serverId => text()();
/// Rating key of the media item
TextColumn get ratingKey => text()();
/// Global key (serverId:ratingKey) for easy lookup
TextColumn get globalKey => text()();
/// Type of action: 'progress', 'watched', 'unwatched'
TextColumn get actionType => text()();
/// Current playback position in milliseconds (for 'progress' actions)
IntColumn get viewOffset => integer().nullable()();
/// Duration of the media in milliseconds (for calculating percentage)
IntColumn get duration => integer().nullable()();
/// Whether this item should be marked as watched (for progress sync)
/// Auto-set to true when viewOffset >= 90% of duration
BoolColumn get shouldMarkWatched =>
boolean().withDefault(const Constant(false))();
/// Timestamp when this action was recorded (milliseconds since epoch)
IntColumn get createdAt => integer()();
/// Timestamp when this action was last updated (for merging progress updates)
IntColumn get updatedAt => integer()();
/// Number of sync attempts (for retry logic)
IntColumn get syncAttempts => integer().withDefault(const Constant(0))();
/// Last sync error message
TextColumn get lastError => text().nullable()();
}
+14218 -6375
View File
File diff suppressed because it is too large Load Diff
+70 -5
View File
@@ -139,7 +139,22 @@
"autoSkipCredits": "Auto Skip Credits",
"autoSkipCreditsDescription": "Automatically skip credits and play next episode",
"autoSkipDelay": "Auto Skip Delay",
"autoSkipDelayDescription": "Wait ${seconds} seconds before auto-skipping"
"autoSkipDelayDescription": "Wait ${seconds} seconds before auto-skipping",
"downloads": "Downloads",
"downloadLocationDescription": "Choose where to store downloaded content",
"downloadLocationDefault": "Default (App Storage)",
"downloadLocationCustom": "Custom Location",
"selectFolder": "Select Folder",
"resetToDefault": "Reset to Default",
"currentPath": "Current: ${path}",
"downloadLocationNotSupported": "Custom download location is not supported on iOS",
"downloadLocationChanged": "Download location changed",
"downloadLocationReset": "Download location reset to default",
"downloadLocationInvalid": "Selected folder is not writable",
"downloadLocationSelectError": "Failed to select folder",
"downloadOnWifiOnly": "Download on WiFi only",
"downloadOnWifiOnlyDescription": "Prevent downloads when on cellular data",
"cellularDownloadBlocked": "Downloads are disabled on cellular data. Connect to WiFi or change the setting."
},
"search": {
"hint": "Search movies, shows, music...",
@@ -256,6 +271,8 @@
"messages": {
"markedAsWatched": "Marked as watched",
"markedAsUnwatched": "Marked as unwatched",
"markedAsWatchedOffline": "Marked as watched (will sync when online)",
"markedAsUnwatchedOffline": "Marked as unwatched (will sync when online)",
"removedFromContinueWatching": "Removed from Continue Watching",
"errorLoading": "Error: ${error}",
"fileInfoNotAvailable": "File information not available",
@@ -283,7 +300,9 @@
"noItemsAvailable": "No items available",
"failedToCreatePlayQueue": "Failed to create play queue",
"failedToCreatePlayQueueNoItems": "Failed to create play queue - no items",
"failedPlayback": "Failed to ${action}: ${error}"
"failedPlayback": "Failed to ${action}: ${error}",
"youAreOffline": "You're Offline",
"offlineFeatureUnavailable": "This feature requires an internet connection. Your downloads and settings are still available."
},
"profile": {
"noUsersAvailable": "No users available"
@@ -313,8 +332,8 @@
"recentlyAdded": "Recently Added",
"play": "Play",
"resume": "Resume",
"playEpisode": "Play S${season}, E${episode}",
"resumeEpisode": "Resume S${season}, E${episode}",
"playEpisode": "S${season}E${episode}",
"resumeEpisode": "S${season}E${episode}",
"pause": "Pause",
"overview": "Overview",
"cast": "Cast",
@@ -448,7 +467,8 @@
"home": "Home",
"search": "Search",
"libraries": "Libraries",
"settings": "Settings"
"settings": "Settings",
"downloads": "Downloads"
},
"collections": {
"title": "Collections",
@@ -512,5 +532,50 @@
"errorAdding": "Failed to add to playlist",
"errorReordering": "Failed to reorder playlist item",
"errorRemoving": "Failed to remove from playlist"
},
"downloads": {
"title": "Downloads",
"manage": "Manage",
"tvShows": "TV Shows",
"movies": "Movies",
"queue": "Queue",
"noDownloads": "No downloads yet",
"noDownloadsDescription": "Downloaded content will appear here for offline viewing",
"browseContent": "Browse content",
"downloading": "Downloading",
"downloaded": "Downloaded",
"paused": "Paused",
"failed": "Failed",
"queued": "Queued",
"downloadNow": "Download",
"deleteDownload": "Delete download",
"cancelDownload": "Cancel download",
"pauseDownload": "Pause download",
"resumeDownload": "Resume download",
"retryDownload": "Retry download",
"downloadComplete": "Download complete",
"downloadFailed": "Download failed",
"downloadCancelled": "Download cancelled",
"downloadQueued": "Download queued",
"episodesQueued": "${count} episodes queued for download",
"downloadDeleted": "Download deleted",
"deleteConfirmTitle": "Delete download?",
"deleteConfirm": "Are you sure you want to delete \"${title}\"? This will remove the downloaded file from your device.",
"deleteConfirmMessage": "This will remove the downloaded file from your device.",
"deletingWithProgress": "Deleting ${title}... (${current} of ${total})",
"deletingSimple": "Deleting ${title}...",
"storageUsed": "Storage used",
"availableOffline": "Available offline",
"downloadingSeason": "Downloading season",
"downloadingEpisode": "Downloading episode"
},
"offline": {
"youAreOffline": "You're offline",
"offlineMode": "Offline mode",
"backOnline": "Back online",
"showingDownloadedContent": "Showing downloaded content only",
"noInternetConnection": "No internet connection",
"serversUnreachable": "Plex servers unreachable",
"connectToDownload": "Connect to the internet to download content"
}
}
+71 -6
View File
@@ -140,7 +140,22 @@
"autoSkipCredits": "Abspann automatisch überspringen",
"autoSkipCreditsDescription": "Abspann automatisch überspringen und nächste Episode abspielen",
"autoSkipDelay": "Verzögerung für automatisches Überspringen",
"autoSkipDelayDescription": "${seconds} Sekunden vor dem automatischen Überspringen warten"
"autoSkipDelayDescription": "${seconds} Sekunden vor dem automatischen Überspringen warten",
"downloads": "Downloads",
"downloadLocationDescription": "Speicherort für heruntergeladene Inhalte wählen",
"downloadLocationDefault": "Standard (App-Speicher)",
"downloadLocationCustom": "Benutzerdefinierter Speicherort",
"selectFolder": "Ordner auswählen",
"resetToDefault": "Auf Standard zurücksetzen",
"currentPath": "Aktuell: ${path}",
"downloadLocationNotSupported": "Benutzerdefinierter Download-Speicherort wird auf iOS nicht unterstützt",
"downloadLocationChanged": "Download-Speicherort geändert",
"downloadLocationReset": "Download-Speicherort auf Standard zurückgesetzt",
"downloadLocationInvalid": "Ausgewählter Ordner ist nicht beschreibbar",
"downloadLocationSelectError": "Ordnerauswahl fehlgeschlagen",
"downloadOnWifiOnly": "Nur über WLAN herunterladen",
"downloadOnWifiOnlyDescription": "Downloads über mobile Daten verhindern",
"cellularDownloadBlocked": "Downloads sind über mobile Daten deaktiviert. Verbinde dich mit einem WLAN oder ändere die Einstellung."
},
"search": {
"hint": "Filme, Serien, Musik suchen...",
@@ -257,7 +272,9 @@
"messages": {
"markedAsWatched": "Als gesehen markiert",
"markedAsUnwatched": "Als ungesehen markiert",
"removedFromContinueWatching": "Aus Weiterschauen entfernt",
"markedAsWatchedOffline": "Als gesehen markiert (wird synchronisiert, wenn online)",
"markedAsUnwatchedOffline": "Als ungesehen markiert (wird synchronisiert, wenn online)",
"removedFromContinueWatching": "Aus Weiterschauen' entfernt",
"errorLoading": "Fehler: ${error}",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
@@ -284,7 +301,9 @@
"noItemsAvailable": "Keine Elemente verfügbar",
"failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden",
"failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden keine Elemente",
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}"
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}",
"youAreOffline": "Du bist offline",
"offlineFeatureUnavailable": "Diese Funktion erfordert eine Internetverbindung. Deine Downloads und Einstellungen sind weiterhin verfügbar."
},
"profile": {
"noUsersAvailable": "Keine Benutzer verfügbar"
@@ -315,8 +334,8 @@
"recentlyAdded": "Kürzlich hinzugefügt",
"play": "Abspielen",
"resume": "Fortsetzen",
"playEpisode": "S${season}, E${episode} - Abspielen",
"resumeEpisode": "S${season}, E${episode} - Fortsetzen",
"playEpisode": "S${season}E${episode}",
"resumeEpisode": "S${season}E${episode}",
"pause": "Pause",
"overview": "Übersicht",
"cast": "Besetzung",
@@ -450,7 +469,53 @@
"home": "Start",
"search": "Suche",
"libraries": "Mediatheken",
"settings": "Einstellungen"
"settings": "Einstellungen",
"downloads": "Downloads"
},
"downloads": {
"title": "Downloads",
"manage": "Verwalten",
"tvShows": "Serien",
"movies": "Filme",
"queue": "Warteschlange",
"noDownloads": "Noch keine Downloads",
"noDownloadsDescription": "Heruntergeladene Inhalte werden hier für die Offline-Wiedergabe angezeigt",
"browseContent": "Inhalte durchsuchen",
"downloading": "Wird heruntergeladen",
"downloaded": "Heruntergeladen",
"paused": "Pausiert",
"failed": "Fehlgeschlagen",
"queued": "In Warteschlange",
"downloadNow": "Herunterladen",
"deleteDownload": "Download löschen",
"cancelDownload": "Download abbrechen",
"pauseDownload": "Download pausieren",
"resumeDownload": "Download fortsetzen",
"retryDownload": "Download wiederholen",
"downloadComplete": "Download abgeschlossen",
"downloadFailed": "Download fehlgeschlagen",
"downloadCancelled": "Download abgebrochen",
"downloadQueued": "Download in Warteschlange",
"episodesQueued": "${count} Episoden zum Download hinzugefügt",
"downloadDeleted": "Download gelöscht",
"deleteConfirmTitle": "Download löschen?",
"deleteConfirm": "Möchtest du \"${title}\" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.",
"deleteConfirmMessage": "Die heruntergeladene Datei wird von deinem Gerät entfernt.",
"deletingWithProgress": "Lösche ${title}... (${current} von ${total})",
"deletingSimple": "Lösche ${title}...",
"storageUsed": "Speicherplatz belegt",
"availableOffline": "Offline verfügbar",
"downloadingSeason": "Staffel wird heruntergeladen",
"downloadingEpisode": "Episode wird heruntergeladen"
},
"offline": {
"youAreOffline": "Du bist offline",
"offlineMode": "Offline-Modus",
"backOnline": "Wieder online",
"showingDownloadedContent": "Nur heruntergeladene Inhalte werden angezeigt",
"noInternetConnection": "Keine Internetverbindung",
"serversUnreachable": "Plex-Server nicht erreichbar",
"connectToDownload": "Verbinde dich mit dem Internet, um Inhalte herunterzuladen"
},
"playlists": {
"title": "Wiedergabelisten",
+70 -5
View File
@@ -140,7 +140,22 @@
"autoSkipCredits": "Salta Crediti Automaticamente",
"autoSkipCreditsDescription": "Salta automaticamente i crediti e riproduci l'episodio successivo",
"autoSkipDelay": "Ritardo Salto Automatico",
"autoSkipDelayDescription": "Aspetta ${seconds} secondi prima del salto automatico"
"autoSkipDelayDescription": "Aspetta ${seconds} secondi prima del salto automatico",
"downloads": "Download",
"downloadLocationDescription": "Scegli dove salvare i contenuti scaricati",
"downloadLocationDefault": "Predefinita (Archiviazione App)",
"downloadLocationCustom": "Posizione Personalizzata",
"selectFolder": "Seleziona Cartella",
"resetToDefault": "Ripristina Predefinita",
"currentPath": "Corrente: ${path}",
"downloadLocationNotSupported": "La posizione di download personalizzata non è supportata su iOS",
"downloadLocationChanged": "Posizione di download modificata",
"downloadLocationReset": "Posizione di download ripristinata a predefinita",
"downloadLocationInvalid": "La cartella selezionata non è scrivibile",
"downloadLocationSelectError": "Impossibile selezionare la cartella",
"downloadOnWifiOnly": "Scarica solo con WiFi",
"downloadOnWifiOnlyDescription": "Impedisci i download quando si utilizza la rete dati cellulare",
"cellularDownloadBlocked": "I download sono disabilitati sulla rete dati cellulare. Connettiti al WiFi o modifica l'impostazione."
},
"search": {
"hint": "Cerca film. spettacoli, musica...",
@@ -257,6 +272,8 @@
"messages": {
"markedAsWatched": "Segna come visto",
"markedAsUnwatched": "Segna come non visto",
"markedAsWatchedOffline": "Segnato come visto (sincronizzato online)",
"markedAsUnwatchedOffline": "Segnato come non visto (sincronizzato online)",
"removedFromContinueWatching": "Rimosso da Continua a guardare",
"errorLoading": "Errore: ${error}",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
@@ -284,7 +301,9 @@
"noItemsAvailable": "Nessun elemento disponibile",
"failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione",
"failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento",
"failedPlayback": "Impossibile ${action}: ${error}"
"failedPlayback": "Impossibile ${action}: ${error}",
"youAreOffline": "Sei offline",
"offlineFeatureUnavailable": "Questa funzione richiede una connessione internet. I tuoi download e le impostazioni sono ancora disponibili."
},
"profile": {
"noUsersAvailable": "Nessun utente disponibile"
@@ -315,8 +334,8 @@
"recentlyAdded": "Aggiunti di recente",
"play": "Riproduci",
"resume": "Riprendi",
"playEpisode": "Riproduci S${season}, E${episode}",
"resumeEpisode": "Riprendi S${season}, E${episode}",
"playEpisode": "S${season}E${episode}",
"resumeEpisode": "S${season}E${episode}",
"pause": "Pausa",
"overview": "Panoramica",
"cast": "Attori",
@@ -450,7 +469,53 @@
"home": "Home",
"search": "Cerca",
"libraries": "Librerie",
"settings": "Impostazioni"
"settings": "Impostazioni",
"downloads": "Download"
},
"downloads": {
"title": "Download",
"manage": "Gestisci",
"tvShows": "Serie TV",
"movies": "Film",
"queue": "Coda",
"noDownloads": "Nessun download",
"noDownloadsDescription": "I contenuti scaricati appariranno qui per la visualizzazione offline",
"browseContent": "Sfoglia contenuti",
"downloading": "Download in corso",
"downloaded": "Scaricato",
"paused": "In pausa",
"failed": "Fallito",
"queued": "In coda",
"downloadNow": "Scarica",
"deleteDownload": "Elimina download",
"cancelDownload": "Annulla download",
"pauseDownload": "Metti in pausa download",
"resumeDownload": "Riprendi download",
"retryDownload": "Riprova download",
"downloadComplete": "Download completato",
"downloadFailed": "Download fallito",
"downloadCancelled": "Download annullato",
"downloadQueued": "Download in coda",
"episodesQueued": "${count} episodi in coda per il download",
"downloadDeleted": "Download eliminato",
"deleteConfirmTitle": "Eliminare il download?",
"deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Il file scaricato verrà rimosso dal tuo dispositivo.",
"deleteConfirmMessage": "Il file scaricato verrà rimosso dal tuo dispositivo.",
"deletingWithProgress": "Eliminazione di ${title}... (${current} di ${total})",
"deletingSimple": "Eliminazione di ${title}...",
"storageUsed": "Spazio utilizzato",
"availableOffline": "Disponibile offline",
"downloadingSeason": "Download stagione in corso",
"downloadingEpisode": "Download episodio in corso"
},
"offline": {
"youAreOffline": "Sei offline",
"offlineMode": "Modalità offline",
"backOnline": "Di nuovo online",
"showingDownloadedContent": "Visualizzazione solo contenuti scaricati",
"noInternetConnection": "Nessuna connessione internet",
"serversUnreachable": "Server Plex non raggiungibili",
"connectToDownload": "Connettiti a internet per scaricare contenuti"
},
"playlists": {
"title": "Playlist",
+70 -5
View File
@@ -140,7 +140,22 @@
"autoSkipCredits": "Credits Automatisch Overslaan",
"autoSkipCreditsDescription": "Credits automatisch overslaan en volgende aflevering afspelen",
"autoSkipDelay": "Vertraging Automatisch Overslaan",
"autoSkipDelayDescription": "${seconds} seconden wachten voor automatisch overslaan"
"autoSkipDelayDescription": "${seconds} seconden wachten voor automatisch overslaan",
"downloads": "Downloads",
"downloadLocationDescription": "Kies waar gedownloade content wordt opgeslagen",
"downloadLocationDefault": "Standaard (App-opslag)",
"downloadLocationCustom": "Aangepaste Locatie",
"selectFolder": "Selecteer Map",
"resetToDefault": "Herstel naar Standaard",
"currentPath": "Huidig: ${path}",
"downloadLocationNotSupported": "Aangepaste downloadlocatie wordt niet ondersteund op iOS",
"downloadLocationChanged": "Downloadlocatie gewijzigd",
"downloadLocationReset": "Downloadlocatie hersteld naar standaard",
"downloadLocationInvalid": "Geselecteerde map is niet beschrijfbaar",
"downloadLocationSelectError": "Kan map niet selecteren",
"downloadOnWifiOnly": "Alleen via WiFi downloaden",
"downloadOnWifiOnlyDescription": "Voorkom downloads bij gebruik van mobiele data",
"cellularDownloadBlocked": "Downloads zijn uitgeschakeld bij mobiele data. Maak verbinding met WiFi of wijzig de instelling."
},
"search": {
"hint": "Zoek films, series, muziek...",
@@ -257,6 +272,8 @@
"messages": {
"markedAsWatched": "Gemarkeerd als gekeken",
"markedAsUnwatched": "Gemarkeerd als ongekeken",
"markedAsWatchedOffline": "Gemarkeerd als gekeken (sync wanneer online)",
"markedAsUnwatchedOffline": "Gemarkeerd als ongekeken (sync wanneer online)",
"removedFromContinueWatching": "Verwijderd uit Doorgaan met kijken",
"errorLoading": "Fout: ${error}",
"fileInfoNotAvailable": "Bestand informatie niet beschikbaar",
@@ -284,7 +301,9 @@
"noItemsAvailable": "Geen items beschikbaar",
"failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken",
"failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items",
"failedPlayback": "Afspelen van ${action} mislukt: ${error}"
"failedPlayback": "Afspelen van ${action} mislukt: ${error}",
"youAreOffline": "Je bent offline",
"offlineFeatureUnavailable": "Deze functie vereist een internetverbinding. Je downloads en instellingen zijn nog steeds beschikbaar."
},
"profile": {
"noUsersAvailable": "Geen gebruikers beschikbaar"
@@ -315,8 +334,8 @@
"recentlyAdded": "Recent toegevoegd",
"play": "Afspelen",
"resume": "Hervatten",
"playEpisode": "Speel S${season}, E${episode}",
"resumeEpisode": "Hervat S${season}, E${episode}",
"playEpisode": "S${season}E${episode}",
"resumeEpisode": "S${season}E${episode}",
"pause": "Pauzeren",
"overview": "Overzicht",
"cast": "Acteurs",
@@ -450,7 +469,53 @@
"home": "Thuis",
"search": "Zoeken",
"libraries": "Bibliotheken",
"settings": "Instellingen"
"settings": "Instellingen",
"downloads": "Downloads"
},
"downloads": {
"title": "Downloads",
"manage": "Beheren",
"tvShows": "Series",
"movies": "Films",
"queue": "Wachtrij",
"noDownloads": "Nog geen downloads",
"noDownloadsDescription": "Gedownloade content verschijnt hier voor offline weergave",
"browseContent": "Blader door content",
"downloading": "Downloaden",
"downloaded": "Gedownload",
"paused": "Gepauzeerd",
"failed": "Mislukt",
"queued": "In wachtrij",
"downloadNow": "Download",
"deleteDownload": "Download verwijderen",
"cancelDownload": "Download annuleren",
"pauseDownload": "Download pauzeren",
"resumeDownload": "Download hervatten",
"retryDownload": "Download opnieuw proberen",
"downloadComplete": "Download voltooid",
"downloadFailed": "Download mislukt",
"downloadCancelled": "Download geannuleerd",
"downloadQueued": "Download in wachtrij",
"episodesQueued": "${count} afleveringen in wachtrij voor download",
"downloadDeleted": "Download verwijderd",
"deleteConfirmTitle": "Download verwijderen?",
"deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.",
"deleteConfirmMessage": "Het gedownloade bestand wordt van je apparaat verwijderd.",
"deletingWithProgress": "Verwijderen van ${title}... (${current} van ${total})",
"deletingSimple": "Verwijderen van ${title}...",
"storageUsed": "Opslag gebruikt",
"availableOffline": "Offline beschikbaar",
"downloadingSeason": "Seizoen downloaden",
"downloadingEpisode": "Aflevering downloaden"
},
"offline": {
"youAreOffline": "Je bent offline",
"offlineMode": "Offline modus",
"backOnline": "Weer online",
"showingDownloadedContent": "Alleen gedownloade content wordt getoond",
"noInternetConnection": "Geen internetverbinding",
"serversUnreachable": "Plex servers onbereikbaar",
"connectToDownload": "Maak verbinding met internet om content te downloaden"
},
"playlists": {
"title": "Afspeellijsten",
+70 -5
View File
@@ -140,7 +140,22 @@
"autoSkipCredits": "Hoppa Över Credits Automatiskt",
"autoSkipCreditsDescription": "Hoppa automatiskt över credits och spela nästa avsnitt",
"autoSkipDelay": "Fördröjning Auto Hoppa Över",
"autoSkipDelayDescription": "Vänta ${seconds} sekunder innan automatisk överhoppning"
"autoSkipDelayDescription": "Vänta ${seconds} sekunder innan automatisk överhoppning",
"downloads": "Nedladdningar",
"downloadLocationDescription": "Välj var nedladdat innehåll ska lagras",
"downloadLocationDefault": "Standard (App-lagring)",
"downloadLocationCustom": "Anpassad Plats",
"selectFolder": "Välj Mapp",
"resetToDefault": "Återställ till Standard",
"currentPath": "Nuvarande: ${path}",
"downloadLocationNotSupported": "Anpassad nedladdningsplats stöds inte på iOS",
"downloadLocationChanged": "Nedladdningsplats ändrad",
"downloadLocationReset": "Nedladdningsplats återställd till standard",
"downloadLocationInvalid": "Vald mapp är inte skrivbar",
"downloadLocationSelectError": "Kunde inte välja mapp",
"downloadOnWifiOnly": "Ladda ner endast på WiFi",
"downloadOnWifiOnlyDescription": "Förhindra nedladdningar vid användning av mobildata",
"cellularDownloadBlocked": "Nedladdningar är inaktiverade på mobildata. Anslut till WiFi eller ändra inställningen."
},
"search": {
"hint": "Sök filmer, serier, musik...",
@@ -257,6 +272,8 @@
"messages": {
"markedAsWatched": "Markerad som sedd",
"markedAsUnwatched": "Markerad som osedd",
"markedAsWatchedOffline": "Markerad som sedd (synkroniseras när online)",
"markedAsUnwatchedOffline": "Markerad som osedd (synkroniseras när online)",
"removedFromContinueWatching": "Borttagen från Fortsätt titta",
"errorLoading": "Fel: ${error}",
"fileInfoNotAvailable": "Filinformation inte tillgänglig",
@@ -284,7 +301,9 @@
"noItemsAvailable": "Inga objekt tillgängliga",
"failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö",
"failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö inga objekt",
"failedPlayback": "Kunde inte ${action}: ${error}"
"failedPlayback": "Kunde inte ${action}: ${error}",
"youAreOffline": "Du är offline",
"offlineFeatureUnavailable": "Denna funktion kräver en internetanslutning. Dina nedladdningar och inställningar är fortfarande tillgängliga."
},
"profile": {
"noUsersAvailable": "Inga användare tillgängliga"
@@ -315,8 +334,8 @@
"recentlyAdded": "Nyligen tillagda",
"play": "Spela",
"resume": "Återuppta",
"playEpisode": "Spela S${season}, E${episode}",
"resumeEpisode": "Återuppta S${season}, E${episode}",
"playEpisode": "S${season}E${episode}",
"resumeEpisode": "S${season}E${episode}",
"pause": "Pausa",
"overview": "Översikt",
"cast": "Rollbesättning",
@@ -450,7 +469,53 @@
"home": "Hem",
"search": "Sök",
"libraries": "Bibliotek",
"settings": "Inställningar"
"settings": "Inställningar",
"downloads": "Nedladdningar"
},
"downloads": {
"title": "Nedladdningar",
"manage": "Hantera",
"tvShows": "TV-serier",
"movies": "Filmer",
"queue": "Kö",
"noDownloads": "Inga nedladdningar ännu",
"noDownloadsDescription": "Nedladdat innehåll visas här för offline-visning",
"browseContent": "Bläddra i innehåll",
"downloading": "Laddar ner",
"downloaded": "Nedladdad",
"paused": "Pausad",
"failed": "Misslyckades",
"queued": "I kö",
"downloadNow": "Ladda ner",
"deleteDownload": "Ta bort nedladdning",
"cancelDownload": "Avbryt nedladdning",
"pauseDownload": "Pausa nedladdning",
"resumeDownload": "Återuppta nedladdning",
"retryDownload": "Försök igen",
"downloadComplete": "Nedladdning klar",
"downloadFailed": "Nedladdning misslyckades",
"downloadCancelled": "Nedladdning avbruten",
"downloadQueued": "Nedladdning köad",
"episodesQueued": "${count} avsnitt köade för nedladdning",
"downloadDeleted": "Nedladdning borttagen",
"deleteConfirmTitle": "Ta bort nedladdning?",
"deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Den nedladdade filen kommer att tas bort från din enhet.",
"deleteConfirmMessage": "Den nedladdade filen kommer att tas bort från din enhet.",
"deletingWithProgress": "Tar bort ${title}... (${current} av ${total})",
"deletingSimple": "Tar bort ${title}...",
"storageUsed": "Lagring använd",
"availableOffline": "Tillgänglig offline",
"downloadingSeason": "Laddar ner säsong",
"downloadingEpisode": "Laddar ner avsnitt"
},
"offline": {
"youAreOffline": "Du är offline",
"offlineMode": "Offline-läge",
"backOnline": "Tillbaka online",
"showingDownloadedContent": "Visar endast nedladdat innehåll",
"noInternetConnection": "Ingen internetanslutning",
"serversUnreachable": "Plex-servrar kan inte nås",
"connectToDownload": "Anslut till internet för att ladda ner innehåll"
},
"playlists": {
"title": "Spellistor",
+70 -5
View File
@@ -140,7 +140,22 @@
"autoSkipCredits": "自动跳过片尾",
"autoSkipCreditsDescription": "自动跳过片尾并播放下一集",
"autoSkipDelay": "自动跳过延迟",
"autoSkipDelayDescription": "自动跳过前等待 ${seconds} 秒"
"autoSkipDelayDescription": "自动跳过前等待 ${seconds} 秒",
"downloads": "下载",
"downloadLocationDescription": "选择下载内容的存储位置",
"downloadLocationDefault": "默认(应用存储)",
"downloadLocationCustom": "自定义位置",
"selectFolder": "选择文件夹",
"resetToDefault": "重置为默认",
"currentPath": "当前: ${path}",
"downloadLocationNotSupported": "iOS 不支持自定义下载位置",
"downloadLocationChanged": "下载位置已更改",
"downloadLocationReset": "下载位置已重置为默认",
"downloadLocationInvalid": "所选文件夹不可写入",
"downloadLocationSelectError": "选择文件夹失败",
"downloadOnWifiOnly": "仅在 WiFi 时下载",
"downloadOnWifiOnlyDescription": "使用蜂窝数据时禁止下载",
"cellularDownloadBlocked": "蜂窝数据下已禁用下载。请连接 WiFi 或更改设置。"
},
"search": {
"hint": "搜索电影、系列、音乐...",
@@ -257,6 +272,8 @@
"messages": {
"markedAsWatched": "已标记为已观看",
"markedAsUnwatched": "已标记为未观看",
"markedAsWatchedOffline": "已标记为已观看 (将在联网时同步)",
"markedAsUnwatchedOffline": "已标记为未观看 (将在联网时同步)",
"removedFromContinueWatching": "已从继续观看中移除",
"errorLoading": "错误: ${error}",
"fileInfoNotAvailable": "文件信息不可用",
@@ -284,7 +301,9 @@
"noItemsAvailable": "没有可用的项目",
"failedToCreatePlayQueue": "创建播放队列失败",
"failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目",
"failedPlayback": "无法${action}: ${error}"
"failedPlayback": "无法${action}: ${error}",
"youAreOffline": "您已离线",
"offlineFeatureUnavailable": "此功能需要网络连接。您的下载内容和设置仍然可用。"
},
"profile": {
"noUsersAvailable": "没有可用用户"
@@ -315,8 +334,8 @@
"recentlyAdded": "最近添加",
"play": "播放",
"resume": "继续",
"playEpisode": "播放 S${season}, E${episode}",
"resumeEpisode": "继续 S${season}, E${episode}",
"playEpisode": "S${season}E${episode}",
"resumeEpisode": "S${season}E${episode}",
"pause": "暂停",
"overview": "概述",
"cast": "演员表",
@@ -450,7 +469,53 @@
"home": "主页",
"search": "搜索",
"libraries": "媒体库",
"settings": "设置"
"settings": "设置",
"downloads": "下载"
},
"downloads": {
"title": "下载",
"manage": "管理",
"tvShows": "电视剧",
"movies": "电影",
"queue": "队列",
"noDownloads": "暂无下载",
"noDownloadsDescription": "下载的内容将在此处显示以供离线观看",
"browseContent": "浏览内容",
"downloading": "下载中",
"downloaded": "已下载",
"paused": "已暂停",
"failed": "失败",
"queued": "排队中",
"downloadNow": "下载",
"deleteDownload": "删除下载",
"cancelDownload": "取消下载",
"pauseDownload": "暂停下载",
"resumeDownload": "恢复下载",
"retryDownload": "重试下载",
"downloadComplete": "下载完成",
"downloadFailed": "下载失败",
"downloadCancelled": "下载已取消",
"downloadQueued": "下载已排队",
"episodesQueued": "${count} 集已加入下载队列",
"downloadDeleted": "下载已删除",
"deleteConfirmTitle": "删除下载?",
"deleteConfirm": "确定要删除 \"${title}\" 吗?下载的文件将从您的设备中删除。",
"deleteConfirmMessage": "下载的文件将从您的设备中删除。",
"deletingWithProgress": "正在删除 ${title}... (${current}/${total})",
"deletingSimple": "正在删除 ${title}...",
"storageUsed": "已使用存储空间",
"availableOffline": "可离线使用",
"downloadingSeason": "正在下载季",
"downloadingEpisode": "正在下载剧集"
},
"offline": {
"youAreOffline": "您已离线",
"offlineMode": "离线模式",
"backOnline": "已恢复在线",
"showingDownloadedContent": "仅显示已下载的内容",
"noInternetConnection": "无网络连接",
"serversUnreachable": "无法连接到 Plex 服务器",
"connectToDownload": "连接网络以下载内容"
},
"playlists": {
"title": "播放列表",
+127 -11
View File
@@ -20,9 +20,17 @@ import 'providers/theme_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/hidden_libraries_provider.dart';
import 'providers/playback_state_provider.dart';
import 'providers/download_provider.dart';
import 'providers/offline_mode_provider.dart';
import 'providers/offline_watch_provider.dart';
import 'services/multi_server_manager.dart';
import 'services/offline_watch_sync_service.dart';
import 'services/data_aggregation_service.dart';
import 'services/server_registry.dart';
import 'services/download_manager_service.dart';
import 'services/download_storage_service.dart';
import 'services/plex_api_cache.dart';
import 'database/app_database.dart';
import 'utils/app_logger.dart';
import 'utils/orientation_helper.dart';
import 'utils/language_codes.dart';
@@ -71,6 +79,9 @@ void main() async {
final debugEnabled = settings.getEnableDebugLogging();
setLoggerLevel(debugEnabled);
// Initialize download storage service with settings
await DownloadStorageService.instance.initialize(settings);
// Start global fullscreen state monitoring
FullscreenStateManager().startMonitoring();
@@ -87,15 +98,60 @@ void main() async {
// Global RouteObserver for tracking navigation
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
class MainApp extends StatelessWidget {
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
// Initialize multi-server infrastructure
final serverManager = MultiServerManager();
final aggregationService = DataAggregationService(serverManager);
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Initialize multi-server infrastructure
late final MultiServerManager _serverManager;
late final DataAggregationService _aggregationService;
late final AppDatabase _appDatabase;
late final DownloadManagerService _downloadManager;
late final OfflineWatchSyncService _offlineWatchSyncService;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_serverManager = MultiServerManager();
_aggregationService = DataAggregationService(_serverManager);
_appDatabase = AppDatabase();
// Initialize API cache with database
PlexApiCache.initialize(_appDatabase);
_downloadManager = DownloadManagerService(
database: _appDatabase,
storageService: DownloadStorageService.instance,
);
_offlineWatchSyncService = OfflineWatchSyncService(
database: _appDatabase,
serverManager: _serverManager,
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// App came back to foreground - trigger sync check
_offlineWatchSyncService.onAppResumed();
}
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Legacy provider for backward compatibility
@@ -103,9 +159,60 @@ class MainApp extends StatelessWidget {
// New multi-server providers
ChangeNotifierProvider(
create: (context) =>
MultiServerProvider(serverManager, aggregationService),
MultiServerProvider(_serverManager, _aggregationService),
),
ChangeNotifierProvider(create: (context) => ServerStateProvider()),
// Offline mode provider - depends on MultiServerProvider
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
create: (_) => OfflineModeProvider(_serverManager),
update: (_, multiServerProvider, previous) {
final provider = previous ?? OfflineModeProvider(_serverManager);
provider.initialize();
return provider;
},
),
// Download provider
ChangeNotifierProvider(
create: (context) =>
DownloadProvider(downloadManager: _downloadManager),
),
// Offline watch sync service
ChangeNotifierProvider<OfflineWatchSyncService>(
create: (context) {
final offlineModeProvider = context.read<OfflineModeProvider>();
final downloadProvider = context.read<DownloadProvider>();
// Wire up callback to refresh download provider after watch state sync
_offlineWatchSyncService.onWatchStatesRefreshed = () {
downloadProvider.refreshMetadataFromCache();
};
_offlineWatchSyncService.startConnectivityMonitoring(
offlineModeProvider,
);
return _offlineWatchSyncService;
},
),
// Offline watch provider - depends on sync service and download provider
ChangeNotifierProxyProvider2<
OfflineWatchSyncService,
DownloadProvider,
OfflineWatchProvider
>(
create: (context) => OfflineWatchProvider(
syncService: _offlineWatchSyncService,
downloadProvider: context.read<DownloadProvider>(),
apiCache: PlexApiCache.instance,
),
update: (_, syncService, downloadProvider, previous) {
return previous ??
OfflineWatchProvider(
syncService: syncService,
downloadProvider: downloadProvider,
apiCache: PlexApiCache.instance,
);
},
),
// Existing providers
ChangeNotifierProvider(create: (context) => UserProfileProvider()),
ChangeNotifierProvider(create: (context) => ThemeProvider()),
@@ -307,6 +414,9 @@ class _SetupScreenState extends State<SetupScreen> {
appLogger.i('Successfully connected to $connectedCount servers');
if (mounted) {
// Now that Plex clients are available, trigger initial watch sync
context.read<OfflineWatchSyncService>().onServersConnected();
// Navigate to main screen immediately
// Get first connected client for backward compatibility
final firstClient =
@@ -323,14 +433,17 @@ class _SetupScreenState extends State<SetupScreen> {
_checkForUpdatesOnStartup();
}
} else {
// All connections failed
appLogger.w('Failed to connect to any servers');
// All connections failed - navigate to offline mode
appLogger.w('Failed to connect to any servers, entering offline mode');
if (mounted) {
// Show auth screen to re-authenticate
// Navigate to MainScreen in offline mode
// User can still access Downloads and Settings
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
MaterialPageRoute(
builder: (context) => const MainScreen(isOfflineMode: true),
),
);
}
}
@@ -342,9 +455,12 @@ class _SetupScreenState extends State<SetupScreen> {
);
if (mounted) {
// Navigate to MainScreen in offline mode
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const AuthScreen()),
MaterialPageRoute(
builder: (context) => const MainScreen(isOfflineMode: true),
),
);
}
}
+45
View File
@@ -0,0 +1,45 @@
class DeletionProgress {
final String globalKey;
final String itemTitle;
final int currentItem;
final int totalItems;
final String? currentOperation;
const DeletionProgress({
required this.globalKey,
required this.itemTitle,
required this.currentItem,
required this.totalItems,
this.currentOperation,
});
double get progressPercent =>
totalItems > 0 ? (currentItem / totalItems) : 0.0;
int get progressPercentInt => (progressPercent * 100).round();
bool get isComplete => currentItem >= totalItems;
DeletionProgress copyWith({
String? globalKey,
String? itemTitle,
int? currentItem,
int? totalItems,
String? currentOperation,
}) {
return DeletionProgress(
globalKey: globalKey ?? this.globalKey,
itemTitle: itemTitle ?? this.itemTitle,
currentItem: currentItem ?? this.currentItem,
totalItems: totalItems ?? this.totalItems,
currentOperation: currentOperation ?? this.currentOperation,
);
}
@override
String toString() {
return 'DeletionProgress(globalKey: $globalKey, itemTitle: $itemTitle, '
'currentItem: $currentItem, totalItems: $totalItems, '
'progressPercent: $progressPercentInt%)';
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'download_status.dart';
class DownloadProgress {
final String globalKey;
final DownloadStatus status;
final int progress; // 0-100
final int downloadedBytes;
final int totalBytes;
final double speed; // bytes per second
final String? errorMessage;
final String?
currentFile; // What's being downloaded (video, subtitles, artwork)
// Thumbnail path (populated after artwork download completes)
final String? thumbPath;
const DownloadProgress({
required this.globalKey,
required this.status,
this.progress = 0,
this.downloadedBytes = 0,
this.totalBytes = 0,
this.speed = 0,
this.errorMessage,
this.currentFile,
this.thumbPath,
});
double get progressPercent => progress / 100.0;
String get speedFormatted {
if (speed < 1024) return '${speed.toStringAsFixed(0)} B/s';
if (speed < 1024 * 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s';
return '${(speed / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
String get downloadedFormatted => _formatBytes(downloadedBytes);
String get totalFormatted => _formatBytes(totalBytes);
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024)
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
Duration? get estimatedTimeRemaining {
if (speed <= 0 || totalBytes <= 0) return null;
final remainingBytes = totalBytes - downloadedBytes;
if (remainingBytes <= 0) return Duration.zero;
return Duration(seconds: (remainingBytes / speed).round());
}
/// Check if this progress update includes artwork paths
bool get hasArtworkPaths => thumbPath != null;
DownloadProgress copyWith({
String? globalKey,
DownloadStatus? status,
int? progress,
int? downloadedBytes,
int? totalBytes,
double? speed,
String? errorMessage,
String? currentFile,
String? thumbPath,
}) {
return DownloadProgress(
globalKey: globalKey ?? this.globalKey,
status: status ?? this.status,
progress: progress ?? this.progress,
downloadedBytes: downloadedBytes ?? this.downloadedBytes,
totalBytes: totalBytes ?? this.totalBytes,
speed: speed ?? this.speed,
errorMessage: errorMessage ?? this.errorMessage,
currentFile: currentFile ?? this.currentFile,
thumbPath: thumbPath ?? this.thumbPath,
);
}
}
+9
View File
@@ -0,0 +1,9 @@
enum DownloadStatus {
queued,
downloading,
paused,
completed,
failed,
cancelled,
partial, // Some episodes downloaded, but not all (for shows/seasons)
}
+8
View File
@@ -201,3 +201,11 @@ class PlexMarker {
return posMs >= startTimeOffset && posMs <= endTimeOffset;
}
}
/// Combined chapters and markers fetched in a single API call
class PlaybackExtras {
final List<PlexChapter> chapters;
final List<PlexMarker> markers;
PlaybackExtras({required this.chapters, required this.markers});
}
+20 -17
View File
@@ -55,9 +55,8 @@ class PlexMetadata with MultiServerFields {
@JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName;
// Transient field for clear logo (extracted from Image array)
String? _clearLogo;
String? get clearLogo => _clearLogo;
// Clear logo URL (extracted from Image array, but serialized for offline storage)
final String? clearLogo;
/// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey;
@@ -103,6 +102,7 @@ class PlexMetadata with MultiServerFields {
this.librarySectionID,
this.serverId,
this.serverName,
this.clearLogo,
});
/// Create a copy of this metadata with optional field overrides
@@ -147,8 +147,9 @@ class PlexMetadata with MultiServerFields {
int? librarySectionID,
String? serverId,
String? serverName,
String? clearLogo,
}) {
final copy = PlexMetadata(
return PlexMetadata(
ratingKey: ratingKey ?? this.ratingKey,
key: key ?? this.key,
guid: guid ?? this.guid,
@@ -189,32 +190,34 @@ class PlexMetadata with MultiServerFields {
librarySectionID: librarySectionID ?? this.librarySectionID,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
clearLogo: clearLogo ?? this.clearLogo,
);
// Preserve clearLogo
copy._clearLogo = _clearLogo;
return copy;
}
// Extract clearLogo from Image array in raw JSON
void _extractClearLogo(Map<String, dynamic> json) {
if (!json.containsKey('Image')) return;
/// Extract clearLogo from Image array in raw JSON
static String? _extractClearLogoFromJson(Map<String, dynamic> json) {
if (!json.containsKey('Image')) return null;
final images = json['Image'] as List?;
if (images == null) return;
if (images == null) return null;
for (var image in images) {
if (image is Map && image['type'] == 'clearLogo') {
_clearLogo = image['url'] as String?;
return;
return image['url'] as String?;
}
}
return null;
}
// Custom factory that extracts clearLogo
/// Create from JSON with clearLogo extracted from Image array
factory PlexMetadata.fromJsonWithImages(Map<String, dynamic> json) {
final metadata = PlexMetadata.fromJson(json);
metadata._extractClearLogo(json);
return metadata;
// Extract clearLogo before parsing
final clearLogoUrl = _extractClearLogoFromJson(json);
// Add it to the json so it gets parsed
if (clearLogoUrl != null) {
json['clearLogo'] = clearLogoUrl;
}
return PlexMetadata.fromJson(json);
}
// Helper to get the display title (show name for episodes/seasons, title otherwise)
+2
View File
@@ -47,6 +47,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
clearLogo: json['clearLogo'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
@@ -89,4 +90,5 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'clearLogo': instance.clearLogo,
};
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../services/multi_server_manager.dart';
/// Tracks offline mode status based on network connectivity and server reachability.
class OfflineModeProvider extends ChangeNotifier {
final MultiServerManager _serverManager;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
bool _hasNetworkConnection = true;
bool _hasServerConnection = false;
bool _isInitialized = false;
OfflineModeProvider(this._serverManager);
/// Whether the app is currently in offline mode
/// Offline = no network OR no servers reachable
bool get isOffline => !_hasNetworkConnection || !_hasServerConnection;
/// Whether there is network connectivity (WiFi, mobile data, etc.)
bool get hasNetworkConnection => _hasNetworkConnection;
/// Whether at least one Plex server is reachable
bool get hasServerConnection => _hasServerConnection;
/// Initialize the provider and start monitoring
Future<void> initialize() async {
if (_isInitialized) return;
_isInitialized = true;
// Check initial connectivity
final connectivityResult = await Connectivity().checkConnectivity();
_hasNetworkConnection = !connectivityResult.contains(
ConnectivityResult.none,
);
// Monitor connectivity changes
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((
results,
) {
final wasOffline = isOffline;
_hasNetworkConnection = !results.contains(ConnectivityResult.none);
if (wasOffline != isOffline) {
notifyListeners();
}
});
// Monitor server status from MultiServerManager
_serverStatusSubscription = _serverManager.statusStream.listen((statusMap) {
final wasOffline = isOffline;
_hasServerConnection = statusMap.values.any((isOnline) => isOnline);
if (wasOffline != isOffline) {
notifyListeners();
}
});
// Check initial server status
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
notifyListeners();
}
/// Force a refresh of connectivity status
Future<void> refresh() async {
final connectivityResult = await Connectivity().checkConnectivity();
_hasNetworkConnection = !connectivityResult.contains(
ConnectivityResult.none,
);
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
notifyListeners();
}
@override
void dispose() {
_connectivitySubscription?.cancel();
_serverStatusSubscription?.cancel();
super.dispose();
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/foundation.dart';
import '../models/plex_metadata.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/plex_api_cache.dart';
import 'download_provider.dart';
/// Provider for offline watch status UI state.
///
/// Provides:
/// - Effective watch status (local changes + cached server data)
/// - Offline "OnDeck" calculation for shows
/// - Manual mark watched/unwatched while offline
class OfflineWatchProvider extends ChangeNotifier {
final OfflineWatchSyncService _syncService;
final DownloadProvider _downloadProvider;
// ignore: unused_field - reserved for future cached metadata lookup
final PlexApiCache _apiCache;
OfflineWatchProvider({
required OfflineWatchSyncService syncService,
required DownloadProvider downloadProvider,
required PlexApiCache apiCache,
}) : _syncService = syncService,
_downloadProvider = downloadProvider,
_apiCache = apiCache {
// Listen to sync service changes to update UI
_syncService.addListener(_onSyncServiceChanged);
}
void _onSyncServiceChanged() {
notifyListeners();
}
/// Whether a sync is in progress
bool get isSyncing => _syncService.isSyncing;
/// Get count of pending sync items
Future<int> getPendingSyncCount() => _syncService.getPendingSyncCount();
/// Get the effective watch status for a media item.
///
/// Priority:
/// 1. Local offline action (if exists)
/// 2. Cached server data from API cache
/// 3. Metadata from download provider
///
/// Returns true if watched, false otherwise.
Future<bool> isWatched(String globalKey) async {
// First check local offline action
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
if (localStatus != null) {
return localStatus;
}
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey);
if (metadata != null) {
return metadata.isWatched;
}
return false;
}
/// Check watch status synchronously using cached metadata.
///
/// This is useful for UI that can't await, but may not reflect
/// the most recent local actions.
bool isWatchedSync(PlexMetadata metadata) {
// Note: This doesn't check local actions synchronously
// because that would require async database access.
// For real-time accuracy, use isWatched() instead.
return metadata.isWatched;
}
/// Get the effective view offset (resume position) for a media item.
///
/// Priority:
/// 1. Local offline progress (if exists)
/// 2. Metadata from download provider
///
/// Returns null if no position is available.
Future<int?> getViewOffset(String globalKey) async {
// First check local offline progress
final localOffset = await _syncService.getLocalViewOffset(globalKey);
if (localOffset != null) {
return localOffset;
}
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey);
return metadata?.viewOffset;
}
/// Find the next unwatched downloaded episode for a show.
///
/// This is the "offline OnDeck" calculation - finds the first
/// episode that hasn't been watched (or is in progress).
///
/// Episodes are sorted by season number, then episode number.
///
/// Returns the next unwatched episode, or the first episode if all watched.
Future<PlexMetadata?> getNextUnwatchedEpisode(String showRatingKey) async {
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
showRatingKey,
);
if (episodes.isEmpty) return null;
// Sort by season, then episode number
episodes.sort((a, b) {
final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
if (seasonCompare != 0) return seasonCompare;
return (a.index ?? 0).compareTo(b.index ?? 0);
});
// Find first unwatched episode
for (final episode in episodes) {
final watched = await isWatched(episode.globalKey);
if (!watched) {
return episode;
}
}
// All episodes watched - return first episode for replay
return episodes.first;
}
/// Find the next unwatched downloaded episode synchronously.
///
/// This uses cached metadata without checking local offline actions.
/// For real-time accuracy, use getNextUnwatchedEpisode() instead.
PlexMetadata? getNextUnwatchedEpisodeSync(String showRatingKey) {
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
showRatingKey,
);
if (episodes.isEmpty) return null;
// Sort by season, then episode number
episodes.sort((a, b) {
final seasonCompare = (a.parentIndex ?? 0).compareTo(b.parentIndex ?? 0);
if (seasonCompare != 0) return seasonCompare;
return (a.index ?? 0).compareTo(b.index ?? 0);
});
// Find first unwatched episode (using metadata's isWatched)
for (final episode in episodes) {
if (!episode.isWatched) {
return episode;
}
}
// All episodes watched - return first episode for replay
return episodes.first;
}
/// Mark an item as watched while offline.
///
/// This queues the action for sync when online.
Future<void> markAsWatched({
required String serverId,
required String ratingKey,
}) async {
await _syncService.queueMarkWatched(
serverId: serverId,
ratingKey: ratingKey,
);
notifyListeners();
}
/// Mark an item as unwatched while offline.
///
/// This queues the action for sync when online.
Future<void> markAsUnwatched({
required String serverId,
required String ratingKey,
}) async {
await _syncService.queueMarkUnwatched(
serverId: serverId,
ratingKey: ratingKey,
);
notifyListeners();
}
/// Get downloaded episodes for a show with their watch status.
///
/// Returns a list of (episode, isWatched) pairs.
Future<List<(PlexMetadata episode, bool isWatched)>>
getEpisodesWithWatchStatus(String showRatingKey) async {
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
showRatingKey,
);
final results = <(PlexMetadata, bool)>[];
for (final episode in episodes) {
final watched = await isWatched(episode.globalKey);
results.add((episode, watched));
}
return results;
}
/// Trigger a manual sync of pending items.
Future<void> syncNow() async {
await _syncService.syncPendingItems();
}
@override
void dispose() {
_syncService.removeListener(_onSyncServiceChanged);
super.dispose();
}
}
+365
View File
@@ -0,0 +1,365 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/plex_metadata.dart';
import '../../providers/download_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../providers/settings_provider.dart';
import '../../services/gamepad_service.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focusable_tab_chip.dart';
import '../../widgets/focusable_media_card.dart';
import '../../widgets/media_grid_delegate.dart';
import '../../widgets/download_tree_view.dart';
import '../main_screen.dart';
import '../../i18n/strings.g.dart';
class DownloadsScreen extends StatefulWidget {
const DownloadsScreen({super.key});
@override
State<DownloadsScreen> createState() => DownloadsScreenState();
}
class DownloadsScreenState extends State<DownloadsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Focus nodes for tab chips
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
/// When true, suppress auto-focus in tabs (used when navigating via tab bar)
bool _suppressAutoFocus = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(_onTabChanged);
// Register L1/R1 callbacks for tab navigation
GamepadService.onL1Pressed = _goToPreviousTab;
GamepadService.onR1Pressed = _goToNextTab;
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_queueTabChipFocusNode.dispose();
_tvShowsTabChipFocusNode.dispose();
_moviesTabChipFocusNode.dispose();
// Clear L1/R1 callbacks
GamepadService.onL1Pressed = null;
GamepadService.onR1Pressed = null;
super.dispose();
}
void _goToPreviousTab() {
if (_tabController.index > 0) {
setState(() {
_suppressAutoFocus = true;
_tabController.index = _tabController.index - 1;
});
_getTabChipFocusNode(_tabController.index).requestFocus();
}
}
void _goToNextTab() {
if (_tabController.index < _tabController.length - 1) {
setState(() {
_suppressAutoFocus = true;
_tabController.index = _tabController.index + 1;
});
_getTabChipFocusNode(_tabController.index).requestFocus();
}
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) {
// Rebuild to update chip selection state
setState(() {});
}
}
/// Get the focus node for a tab chip by index
FocusNode _getTabChipFocusNode(int index) {
switch (index) {
case 0:
return _queueTabChipFocusNode;
case 1:
return _tvShowsTabChipFocusNode;
case 2:
return _moviesTabChipFocusNode;
default:
return _queueTabChipFocusNode;
}
}
/// Focus the currently selected tab chip in the tab bar.
/// Called when BACK is pressed in tab content.
void focusTabBar() {
setState(() {
_suppressAutoFocus = true;
});
final focusNode = _getTabChipFocusNode(_tabController.index);
focusNode.requestFocus();
}
/// Handle BACK from tab bar - navigate to sidenav
void _onTabBarBack() {
final focusScope = MainScreenFocusScope.of(context);
focusScope?.focusSidebar();
}
/// Focus the first item in the currently active tab
void _focusCurrentTab() {
// Re-enable auto-focus since user is navigating into tab content
setState(() {
_suppressAutoFocus = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Focus will be handled by the tab content
});
}
Widget _buildTabChip(String label, int index) {
final isSelected = _tabController.index == index;
const tabCount = 3; // Queue, TV Shows, Movies
return FocusableTabChip(
label: label,
isSelected: isSelected,
focusNode: _getTabChipFocusNode(index),
onSelect: () {
if (isSelected) {
// Already selected - navigate to tab content
_focusCurrentTab();
} else {
// Switch to this tab
setState(() {
_tabController.index = index;
});
}
},
onNavigateLeft: index > 0
? () {
final newIndex = index - 1;
setState(() {
_suppressAutoFocus = true;
_tabController.index = newIndex;
});
_getTabChipFocusNode(newIndex).requestFocus();
}
: null,
onNavigateRight: index < tabCount - 1
? () {
final newIndex = index + 1;
setState(() {
_suppressAutoFocus = true;
_tabController.index = newIndex;
});
_getTabChipFocusNode(newIndex).requestFocus();
}
: null,
onNavigateDown: _focusCurrentTab,
onBack: _onTabBarBack,
);
}
/// Build the app bar title - either tabs on desktop or simple title on mobile
Widget _buildAppBarTitle() {
// On desktop/TV with side nav, show tabs in app bar
if (PlatformDetector.shouldUseSideNavigation(context)) {
return Row(
children: [
_buildTabChip(t.downloads.manage, 0),
const SizedBox(width: 8),
_buildTabChip(t.downloads.tvShows, 1),
const SizedBox(width: 8),
_buildTabChip(t.downloads.movies, 2),
],
);
}
// On mobile, show simple title
return Text(t.downloads.title);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
DesktopSliverAppBar(
title: _buildAppBarTitle(),
floating: true,
pinned: true,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
scrolledUnderElevation: 0,
),
SliverFillRemaining(
child: Column(
children: [
// Tab selector chips (only on mobile - desktop has them in app bar)
if (!PlatformDetector.shouldUseSideNavigation(context))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildTabChip(t.downloads.manage, 0),
const SizedBox(width: 8),
_buildTabChip(t.downloads.tvShows, 1),
const SizedBox(width: 8),
_buildTabChip(t.downloads.movies, 2),
],
),
),
),
// Tab content
Expanded(
child: TabBarView(
controller: _tabController,
children: [
Consumer2<DownloadProvider, MultiServerProvider>(
builder: (context, downloadProvider, serverProvider, _) {
// Helper to get client from globalKey (serverId:ratingKey)
getClient(String globalKey) {
final serverId = globalKey.split(':').first;
return serverProvider.serverManager.getClient(serverId);
}
return DownloadTreeView(
downloads: downloadProvider.downloads,
metadata: downloadProvider.metadata,
onPause: downloadProvider.pauseDownload,
onResume: (globalKey) {
final client = getClient(globalKey);
if (client != null) {
downloadProvider.resumeDownload(globalKey, client);
}
},
onRetry: (globalKey) {
final client = getClient(globalKey);
if (client != null) {
downloadProvider.retryDownload(globalKey, client);
}
},
onCancel: downloadProvider.cancelDownload,
onDelete: downloadProvider.deleteDownload,
);
},
),
_DownloadsGridContent(
type: DownloadType.tvShows,
suppressAutoFocus: _suppressAutoFocus,
onBack: focusTabBar,
),
_DownloadsGridContent(
type: DownloadType.movies,
suppressAutoFocus: _suppressAutoFocus,
onBack: focusTabBar,
),
],
),
),
],
),
),
],
),
);
}
}
enum DownloadType { manage, tvShows, movies }
/// Grid content for TV Shows and Movies tabs
class _DownloadsGridContent extends StatelessWidget {
final DownloadType type;
final bool suppressAutoFocus;
final VoidCallback? onBack;
const _DownloadsGridContent({
required this.type,
required this.suppressAutoFocus,
this.onBack,
});
@override
Widget build(BuildContext context) {
return Consumer2<DownloadProvider, SettingsProvider>(
builder: (context, downloadProvider, settingsProvider, _) {
final List<PlexMetadata> items = type == DownloadType.tvShows
? downloadProvider.downloadedShows
: downloadProvider.downloadedMovies;
if (items.isEmpty) {
return _buildEmptyState(context);
}
return GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
density: settingsProvider.libraryDensity,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return FocusableMediaCard(
item: item,
onBack: onBack,
isOffline: true, // Downloaded content works without server
);
},
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_outlined,
size: 80,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 24),
Text(
t.downloads.noDownloads,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
t.downloads.noDownloadsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
+204 -46
View File
@@ -12,6 +12,8 @@ import '../providers/multi_server_provider.dart';
import '../providers/server_state_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/playback_state_provider.dart';
import '../services/offline_watch_sync_service.dart';
import '../providers/offline_mode_provider.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../utils/desktop_window_padding.dart';
@@ -19,6 +21,7 @@ import '../widgets/side_navigation_rail.dart';
import 'discover_screen.dart';
import 'libraries/libraries_screen.dart';
import 'search_screen.dart';
import 'downloads/downloads_screen.dart';
import 'settings/settings_screen.dart';
/// Provides access to the main screen's focus control.
@@ -46,22 +49,29 @@ class MainScreenFocusScope extends InheritedWidget {
}
class MainScreen extends StatefulWidget {
final PlexClient client;
final PlexClient? client;
final bool isOfflineMode;
const MainScreen({super.key, required this.client});
const MainScreen({super.key, this.client, this.isOfflineMode = false});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> with RouteAware {
int _currentIndex = 0;
late int _currentIndex;
String? _selectedLibraryGlobalKey;
late final List<Widget> _screens;
/// Whether the app is in offline mode (no server connection)
bool _isOffline = false;
OfflineModeProvider? _offlineModeProvider;
late List<Widget> _screens;
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
final GlobalKey<State<DownloadsScreen>> _downloadsKey = GlobalKey();
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
@@ -77,28 +87,25 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
@override
void initState() {
super.initState();
_isOffline = widget.isOfflineMode;
_screens = [
DiscoverScreen(
key: _discoverKey,
onBecameVisible: _onDiscoverBecameVisible,
),
LibrariesScreen(
key: _librariesKey,
onLibraryOrderChanged: _onLibraryOrderChanged,
),
SearchScreen(key: _searchKey),
SettingsScreen(key: _settingsKey),
];
// Start on Downloads tab when in offline mode
// In offline mode: visual index 0 = Downloads (screen 3), 1 = Settings (screen 4)
// In online mode: indices match directly
_currentIndex = _isOffline ? 0 : 0;
// Set up data invalidation callback for profile switching
_screens = _buildScreens(_isOffline);
// Set up data invalidation callback for profile switching (skip in offline mode)
WidgetsBinding.instance.addPostFrameCallback((_) async {
// Initialize UserProfileProvider to ensure it's ready after sign-in
final userProfileProvider = context.userProfile;
await userProfileProvider.initialize();
if (!_isOffline) {
// Initialize UserProfileProvider to ensure it's ready after sign-in
final userProfileProvider = context.userProfile;
await userProfileProvider.initialize();
// Set up data invalidation callback for profile switching
userProfileProvider.setDataInvalidationCallback(_invalidateAllScreens);
// Set up data invalidation callback for profile switching
userProfileProvider.setDataInvalidationCallback(_invalidateAllScreens);
}
// Focus content initially (replaces autofocus which caused focus stealing issues)
if (!_isSidebarFocused) {
@@ -110,17 +117,99 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Listen for offline/online transitions to refresh navigation & screens
final provider = context.read<OfflineModeProvider?>();
if (provider != null && provider != _offlineModeProvider) {
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
_offlineModeProvider = provider;
_offlineModeProvider!.addListener(_handleOfflineStatusChanged);
_handleOfflineStatusChanged();
}
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
_sidebarFocusScope.dispose();
_contentFocusScope.dispose();
super.dispose();
}
List<Widget> _buildScreens(bool offline) {
// In offline mode, only show Downloads and Settings
// In online mode, show all 5 screens
if (offline) {
return [
DownloadsScreen(key: _downloadsKey),
SettingsScreen(key: _settingsKey),
];
}
return [
DiscoverScreen(
key: _discoverKey,
onBecameVisible: _onDiscoverBecameVisible,
),
LibrariesScreen(
key: _librariesKey,
onLibraryOrderChanged: _onLibraryOrderChanged,
),
SearchScreen(key: _searchKey),
DownloadsScreen(key: _downloadsKey),
SettingsScreen(key: _settingsKey),
];
}
int _normalizeIndexForMode(int current, bool offline) {
if (offline) {
// Only two tabs exist offline: 0 = Downloads, 1 = Settings
if (current <= 0) return 0;
if (current == 1) return 1;
return 0;
}
// Map offline indices back to online equivalents when reconnecting
if (current == 0) return 3; // Downloads tab
if (current == 1) return 4; // Settings tab
if (current < 0) return 0;
if (current > 4) return 0;
return current;
}
void _handleOfflineStatusChanged() {
final newOffline =
_offlineModeProvider?.isOffline ?? widget.isOfflineMode;
if (newOffline == _isOffline) return;
setState(() {
_isOffline = newOffline;
_screens = _buildScreens(_isOffline);
_selectedLibraryGlobalKey =
_isOffline ? null : _selectedLibraryGlobalKey;
_currentIndex = _normalizeIndexForMode(_currentIndex, _isOffline);
});
// Refresh sidebar focus after rebuilding navigation
WidgetsBinding.instance.addPostFrameCallback((_) {
_sideNavKey.currentState?.focusActiveItem();
});
// Ensure profile provider is initialized when coming back online
if (!_isOffline) {
final userProfileProvider = context.userProfile;
userProfileProvider.initialize().then((_) {
userProfileProvider.setDataInvalidationCallback(
_invalidateAllScreens,
);
});
}
}
void _focusSidebar() {
setState(() => _isSidebarFocused = true);
_sidebarFocusScope.requestFocus();
@@ -169,7 +258,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
@override
void didPush() {
// Called when this route has been pushed (initial navigation)
if (_currentIndex == 0) {
if (_currentIndex == 0 && !_isOffline) {
_onDiscoverBecameVisible();
}
}
@@ -177,7 +266,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
@override
void didPopNext() {
// Called when returning to this route from a child route (e.g., from video player)
if (_currentIndex == 0) {
if (_currentIndex == 0 && !_isOffline) {
_onDiscoverBecameVisible();
}
}
@@ -221,6 +310,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
appLogger.d(
'Reconnected to $connectedCount/${servers.length} servers after profile switch',
);
// Trigger watch state sync now that servers are connected
if (connectedCount > 0 && context.mounted) {
context.read<OfflineWatchSyncService>().onServersConnected();
}
}
// Reset other provider states
@@ -254,6 +348,10 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
setState(() {
_currentIndex = index;
});
// Skip screen-specific logic in offline mode (only Downloads and Settings available)
if (_isOffline) return;
// Notify discover screen when it becomes visible via tab switch
if (index == 0) {
_onDiscoverBecameVisible();
@@ -315,6 +413,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
key: _sideNavKey,
selectedIndex: _currentIndex,
selectedLibraryKey: _selectedLibraryGlobalKey,
isOfflineMode: _isOffline,
onDestinationSelected: (index) {
_selectTab(index);
_focusContent();
@@ -344,33 +443,92 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
);
}
// In offline mode, only show Downloads and Settings
final destinations = _isOffline
? [
NavigationDestination(
icon: const Icon(Icons.download_outlined),
selectedIcon: const Icon(Icons.download),
label: t.navigation.downloads,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
]
: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.navigation.home,
),
NavigationDestination(
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library),
label: t.navigation.libraries,
),
NavigationDestination(
icon: const Icon(Icons.search),
selectedIcon: const Icon(Icons.search),
label: t.navigation.search,
),
NavigationDestination(
icon: const Icon(Icons.download_outlined),
selectedIcon: const Icon(Icons.download),
label: t.navigation.downloads,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
];
return Scaffold(
body: IndexedStack(index: _currentIndex, children: _screens),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _selectTab,
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.navigation.home,
),
NavigationDestination(
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library),
label: t.navigation.libraries,
),
NavigationDestination(
icon: const Icon(Icons.search),
selectedIcon: const Icon(Icons.search),
label: t.navigation.search,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
],
destinations: destinations,
),
);
}
}
/// Placeholder widget shown for network-dependent screens when offline
class _OfflinePlaceholder extends StatelessWidget {
const _OfflinePlaceholder();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
t.messages.youAreOffline,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
t.messages.offlineFeatureUnavailable,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
File diff suppressed because it is too large Load Diff
+252 -50
View File
@@ -1,7 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/plex_client.dart';
import '../focus/key_event_utils.dart';
import '../focus/input_mode_tracker.dart';
import '../models/download_status.dart';
import '../providers/download_provider.dart';
import '../services/download_storage_service.dart';
import '../widgets/plex_optimized_image.dart';
import '../models/plex_metadata.dart';
import '../utils/provider_extensions.dart';
@@ -15,8 +21,13 @@ import '../i18n/strings.g.dart';
class SeasonDetailScreen extends StatefulWidget {
final PlexMetadata season;
final bool isOffline;
const SeasonDetailScreen({super.key, required this.season});
const SeasonDetailScreen({
super.key,
required this.season,
this.isOffline = false,
});
@override
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
@@ -24,17 +35,20 @@ class SeasonDetailScreen extends StatefulWidget {
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
with ItemUpdatable {
late final PlexClient _client;
PlexClient? _client;
@override
PlexClient get client => _client;
PlexClient get client => _client!;
List<PlexMetadata> _episodes = [];
bool _isLoadingEpisodes = false;
bool _watchStateChanged = false;
/// Get the correct PlexClient for this season's server
PlexClient _getClientForSeason(BuildContext context) {
PlexClient? _getClientForSeason(BuildContext context) {
if (widget.isOffline || widget.season.serverId == null) {
return null;
}
return context.getClientForServer(widget.season.serverId!);
}
@@ -53,9 +67,15 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
_isLoadingEpisodes = true;
});
if (widget.isOffline) {
// Load episodes from downloads
_loadEpisodesFromDownloads();
return;
}
try {
// Episodes are automatically tagged with server info by PlexClient
final episodes = await _client.getChildren(widget.season.ratingKey);
final episodes = await _client!.getChildren(widget.season.ratingKey);
setState(() {
_episodes = episodes;
@@ -68,6 +88,28 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
}
}
/// Load episodes from downloaded content
void _loadEpisodesFromDownloads() {
final downloadProvider = context.read<DownloadProvider>();
// Get all downloaded episodes for the show (grandparentRatingKey)
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(
widget.season.parentRatingKey ?? '',
);
// Filter to only this season's episodes
final seasonEpisodes =
allEpisodes
.where((ep) => ep.parentIndex == widget.season.index)
.toList()
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
setState(() {
_episodes = seasonEpisodes;
_isLoadingEpisodes = false;
});
}
@override
Future<void> updateItem(String ratingKey) async {
_watchStateChanged = true;
@@ -125,17 +167,40 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final episode = _episodes[index];
// Get local poster path for offline mode
String? localPosterPath;
if (widget.isOffline && episode.serverId != null) {
final downloadProvider = context.read<DownloadProvider>();
final globalKey =
'${episode.serverId}:${episode.ratingKey}';
// Get the artwork reference and convert to local file path
final artworkRef = downloadProvider.getArtworkPaths(
globalKey,
);
localPosterPath = artworkRef?.getLocalPath(
DownloadStorageService.instance,
episode.serverId!,
);
}
return _EpisodeCard(
episode: episode,
client: _client,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
autofocus:
index == 0 && InputModeTracker.isKeyboardMode(context),
onTap: () async {
await navigateToVideoPlayer(context, metadata: episode);
// Refresh episodes when returning from video player
_loadEpisodes();
await navigateToVideoPlayer(
context,
metadata: episode,
isOffline: widget.isOffline,
);
// Refresh episodes when returning from video player (skip if offline)
if (!widget.isOffline) {
_loadEpisodes();
}
},
onRefresh: updateItem,
onRefresh: widget.isOffline ? null : updateItem,
);
}, childCount: _episodes.length),
),
@@ -149,17 +214,21 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
/// Episode card widget
class _EpisodeCard extends StatelessWidget {
final PlexMetadata episode;
final PlexClient client;
final PlexClient? client;
final VoidCallback onTap;
final Future<void> Function(String) onRefresh;
final Future<void> Function(String)? onRefresh;
final bool autofocus;
final bool isOffline;
final String? localPosterPath;
const _EpisodeCard({
required this.episode,
required this.client,
this.client,
required this.onTap,
required this.onRefresh,
this.onRefresh,
this.autofocus = false,
this.isOffline = false,
this.localPosterPath,
});
Widget _buildEpisodeMetaRow(BuildContext context) {
@@ -173,7 +242,8 @@ class _EpisodeCard extends StatelessWidget {
fontSize: 12,
),
),
if (episode.duration != null && episode.isWatched) ...[
// Hide watch status when offline (not tracked)
if (!isOffline && episode.duration != null && episode.isWatched) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
@@ -198,7 +268,9 @@ class _EpisodeCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Hide progress when offline (not tracked)
final hasProgress =
!isOffline &&
episode.viewOffset != null &&
episode.duration != null &&
episode.viewOffset! > 0;
@@ -236,7 +308,19 @@ class _EpisodeCard extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
child: AspectRatio(
aspectRatio: 16 / 9,
child: episode.thumb != null
child: isOffline && localPosterPath != null
? Image.file(
File(localPosterPath!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.movie, size: 32),
),
)
: episode.thumb != null
? PlexOptimizedImage.thumb(
client: client,
imagePath: episode.thumb,
@@ -323,43 +407,161 @@ class _EpisodeCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode number and title
Row(
children: [
if (episode.index != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'E${episode.index}',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w600,
// Episode number and title with download status
Consumer<DownloadProvider>(
builder: (context, downloadProvider, _) {
// Build download status icon based on state
Widget? downloadStatusIcon;
// Only show download status in online mode
if (!isOffline && episode.serverId != null) {
final globalKey =
'${episode.serverId}:${episode.ratingKey}';
final progress = downloadProvider.getProgress(
globalKey,
);
final isQueueing = downloadProvider.isQueueing(
globalKey,
);
// Helper to get status-specific muted color
Color getMutedColor(Color baseColor) {
return Color.lerp(
tokens(context).textMuted,
baseColor,
0.3, // 30% of the status color, 70% muted
)!;
}
if (isQueueing) {
// Queueing state - building queue
downloadStatusIcon = SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: tokens(context).textMuted,
),
);
} else if (progress?.status ==
DownloadStatus.queued) {
// Queued state - waiting to download
downloadStatusIcon = Icon(
Icons.schedule,
size: 12,
color: getMutedColor(Colors.orange),
);
} else if (progress?.status ==
DownloadStatus.downloading) {
// Downloading state - active download with radial progress
downloadStatusIcon = SizedBox(
width: 14,
height: 14,
child: Stack(
alignment: Alignment.center,
children: [
// Background circle
CircularProgressIndicator(
value: 1.0,
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(
getMutedColor(
Colors.blue,
).withOpacity(0.3),
),
),
// Progress circle
CircularProgressIndicator(
value: progress?.progressPercent,
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(
getMutedColor(Colors.blue),
),
),
],
),
);
} else if (progress?.status ==
DownloadStatus.paused) {
// Paused state - download paused
downloadStatusIcon = Icon(
Icons.pause_circle_outline,
size: 12,
color: getMutedColor(Colors.amber),
);
} else if (progress?.status ==
DownloadStatus.failed) {
// Failed state - download failed
downloadStatusIcon = Icon(
Icons.error_outline,
size: 12,
color: getMutedColor(Colors.red),
);
} else if (progress?.status ==
DownloadStatus.cancelled) {
// Cancelled state - download cancelled
downloadStatusIcon = Icon(
Icons.cancel_outlined,
size: 12,
color: getMutedColor(Colors.grey),
);
} else if (progress?.status ==
DownloadStatus.completed) {
// Completed state - download complete
downloadStatusIcon = Icon(
Icons.file_download_done,
size: 12,
color: getMutedColor(Colors.green),
);
}
// Note: No icon shown if not downloaded (null)
}
return Row(
children: [
// Episode number badge
if (episode.index != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'E${episode.index}',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
// Download status icon (if present)
if (downloadStatusIcon != null) ...[
const SizedBox(width: 6),
downloadStatusIcon,
],
const SizedBox(width: 8),
// Episode title
Expanded(
child: Text(
episode.title,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
episode.title,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
],
);
},
),
// Summary
+186
View File
@@ -1,9 +1,14 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../i18n/strings.g.dart';
import '../../services/download_storage_service.dart';
import '../../services/saf_storage_service.dart';
import '../../providers/settings_provider.dart';
import '../../providers/theme_provider.dart';
import '../../services/keyboard_shortcuts_service.dart';
@@ -37,6 +42,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _autoSkipIntro = true;
bool _autoSkipCredits = true;
int _autoSkipDelay = 5;
bool _downloadOnWifiOnly = false;
// Update checking state
bool _isCheckingForUpdate = false;
@@ -63,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_autoSkipIntro = _settingsService.getAutoSkipIntro();
_autoSkipCredits = _settingsService.getAutoSkipCredits();
_autoSkipDelay = _settingsService.getAutoSkipDelay();
_downloadOnWifiOnly = _settingsService.getDownloadOnWifiOnly();
_isLoading = false;
});
}
@@ -85,6 +92,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
_buildVideoPlaybackSection(),
const SizedBox(height: 24),
_buildDownloadsSection(),
const SizedBox(height: 24),
_buildKeyboardShortcutsSection(),
const SizedBox(height: 24),
_buildAdvancedSection(),
@@ -333,6 +342,183 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildDownloadsSection() {
final storageService = DownloadStorageService.instance;
final isCustom = storageService.isUsingCustomPath();
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
t.settings.downloads,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
// Download location picker - not available on iOS
if (!Platform.isIOS)
FutureBuilder<String>(
future: storageService.getCurrentDownloadPathDisplay(),
builder: (context, snapshot) {
final currentPath = snapshot.data ?? '...';
return ListTile(
leading: const Icon(Icons.folder),
title: Text(
isCustom
? t.settings.downloadLocationCustom
: t.settings.downloadLocationDefault,
),
subtitle: Text(
currentPath,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showDownloadLocationDialog(),
);
},
),
SwitchListTile(
secondary: const Icon(Icons.wifi),
title: Text(t.settings.downloadOnWifiOnly),
subtitle: Text(t.settings.downloadOnWifiOnlyDescription),
value: _downloadOnWifiOnly,
onChanged: (value) async {
setState(() => _downloadOnWifiOnly = value);
await _settingsService.setDownloadOnWifiOnly(value);
},
),
],
),
);
}
Future<void> _showDownloadLocationDialog() async {
final storageService = DownloadStorageService.instance;
final isCustom = storageService.isUsingCustomPath();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.settings.downloads),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.settings.downloadLocationDescription),
const SizedBox(height: 16),
FutureBuilder<String>(
future: storageService.getCurrentDownloadPathDisplay(),
builder: (context, snapshot) {
return Text(
t.settings.currentPath(path: snapshot.data ?? '...'),
style: Theme.of(context).textTheme.bodySmall,
);
},
),
],
),
actions: [
if (isCustom)
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
await _resetDownloadLocation();
},
child: Text(t.settings.resetToDefault),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () async {
Navigator.pop(dialogContext);
await _selectDownloadLocation();
},
child: Text(t.settings.selectFolder),
),
],
),
);
}
Future<void> _selectDownloadLocation() async {
try {
String? selectedPath;
String pathType = 'file';
if (Platform.isAndroid) {
// Use SAF on Android
final safService = SafStorageService.instance;
selectedPath = await safService.pickDirectory();
if (selectedPath != null) {
pathType = 'saf';
}
} else {
// Use file_picker on desktop
final result = await FilePicker.platform.getDirectoryPath(
dialogTitle: t.settings.selectFolder,
);
selectedPath = result;
}
if (selectedPath != null) {
// Validate the path is writable (for non-SAF paths)
if (pathType == 'file') {
final dir = Directory(selectedPath);
final isWritable = await DownloadStorageService.instance
.isDirectoryWritable(dir);
if (!isWritable) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.downloadLocationInvalid)),
);
}
return;
}
}
// Save the setting
await _settingsService.setCustomDownloadPath(
selectedPath,
type: pathType,
);
await DownloadStorageService.instance.refreshCustomPath();
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.downloadLocationChanged)),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.downloadLocationSelectError)),
);
}
}
}
Future<void> _resetDownloadLocation() async {
await _settingsService.setCustomDownloadPath(null);
await DownloadStorageService.instance.refreshCustomPath();
if (mounted) {
setState(() {});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.settings.downloadLocationReset)));
}
}
Widget _buildKeyboardShortcutsSection() {
return Card(
child: Column(
+88 -23
View File
@@ -10,14 +10,17 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../mpv/mpv.dart';
import '../../services/plex_client.dart';
import '../services/plex_api_cache.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_media_info.dart';
import '../providers/download_provider.dart';
import '../providers/playback_state_provider.dart';
import '../services/episode_navigation_service.dart';
import '../services/media_controls_manager.dart';
import '../services/playback_initialization_service.dart';
import '../services/playback_progress_tracker.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/settings_service.dart';
import '../services/track_selection_service.dart';
import '../services/video_filter_manager.dart';
@@ -37,6 +40,7 @@ class VideoPlayerScreen extends StatefulWidget {
final SubtitleTrack? preferredSubtitleTrack;
final double? preferredPlaybackRate;
final int selectedMediaIndex;
final bool isOffline;
const VideoPlayerScreen({
super.key,
@@ -45,6 +49,7 @@ class VideoPlayerScreen extends StatefulWidget {
this.preferredSubtitleTrack,
this.preferredPlaybackRate,
this.selectedMediaIndex = 0,
this.isOffline = false,
});
@override
@@ -193,9 +198,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
// Re-enable wakelock since we're back in the video player
WakelockPlus.enable();
// Restore media metadata
final client = _getClientForMetadata(context);
if (_mediaControlsManager != null) {
// Restore media metadata (only in online mode - requires client for artwork URLs)
if (!widget.isOffline && _mediaControlsManager != null) {
final client = _getClientForMetadata(context);
_mediaControlsManager!.updateMetadata(
metadata: widget.metadata,
client: client,
@@ -213,7 +218,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
_updateMediaControlsPlaybackState();
appLogger.d('Media controls restored and wakelock re-enabled on app resume');
appLogger.d(
'Media controls restored and wakelock re-enabled on app resume',
);
}
break;
case AppLifecycleState.detached:
@@ -458,15 +465,30 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
Future<void> _initializeServices() async {
if (!mounted || player == null) return;
final client = _getClientForMetadata(context);
// Get client (null in offline mode)
final client = widget.isOffline ? null : _getClientForMetadata(context);
// Initialize progress tracker
_progressTracker = PlaybackProgressTracker(
client: client,
metadata: widget.metadata,
player: player!,
);
_progressTracker!.startTracking();
if (widget.isOffline) {
// Offline mode: queue progress updates for later sync
final offlineWatchService = context.read<OfflineWatchSyncService>();
_progressTracker = PlaybackProgressTracker(
client: null,
metadata: widget.metadata,
player: player!,
isOffline: true,
offlineWatchService: offlineWatchService,
);
_progressTracker!.startTracking();
} else if (client != null) {
// Online mode: send progress to server
_progressTracker = PlaybackProgressTracker(
client: client,
metadata: widget.metadata,
player: player!,
);
_progressTracker!.startTracking();
}
// Initialize media controls manager
_mediaControlsManager = MediaControlsManager();
@@ -508,7 +530,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
});
// Update media metadata
// Update media metadata (client can be null in offline mode - artwork won't be shown)
await _mediaControlsManager!.updateMetadata(
metadata: widget.metadata,
client: client,
@@ -548,6 +570,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
Future<void> _ensurePlayQueue() async {
if (!mounted) return;
// Skip play queue in offline mode (requires server connection)
if (widget.isOffline) return;
// Only create play queues for episodes
if (widget.metadata.type.toLowerCase() != 'episode') {
return;
@@ -618,6 +643,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
Future<void> _loadAdjacentEpisodes() async {
if (!mounted) return;
// Skip loading adjacent episodes in offline mode (requires server connection)
if (widget.isOffline) return;
try {
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
@@ -645,17 +673,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
if (!mounted) return;
try {
// Use server-specific client for this metadata
final client = _getClientForMetadata(context);
PlaybackInitializationResult result;
// Initialize playback service
final playbackService = PlaybackInitializationService(client: client);
// Get playback data (video URL and available versions)
final result = await playbackService.getPlaybackData(
metadata: widget.metadata,
selectedMediaIndex: widget.selectedMediaIndex,
);
if (widget.isOffline) {
// Offline mode: get video path from downloads without requiring server
result = await _startOfflinePlayback();
} else {
// Online mode: use server-specific client
final client = _getClientForMetadata(context);
final playbackService = PlaybackInitializationService(
client: client,
database: PlexApiCache.instance.database,
);
result = await playbackService.getPlaybackData(
metadata: widget.metadata,
selectedMediaIndex: widget.selectedMediaIndex,
preferOffline: true, // Use downloaded file if available
);
}
// Open video through Player
if (result.videoUrl != null) {
@@ -674,7 +709,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
});
// Initialize video filter manager with player and available versions
if (player != null && _availableVersions.isNotEmpty) {
if (player != null) {
_videoFilterManager = VideoFilterManager(
player: player!,
availableVersions: _availableVersions,
@@ -718,6 +753,36 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
}
}
/// Start playback for offline/downloaded content
Future<PlaybackInitializationResult> _startOfflinePlayback() async {
final downloadProvider = context.read<DownloadProvider>();
// Debug: log metadata info
appLogger.d(
'Offline playback - serverId: ${widget.metadata.serverId}, ratingKey: ${widget.metadata.ratingKey}',
);
final globalKey =
'${widget.metadata.serverId}:${widget.metadata.ratingKey}';
appLogger.d('Looking up video with globalKey: $globalKey');
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
if (videoPath == null) {
appLogger.e('Video file path not found for globalKey: $globalKey');
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
appLogger.d('Starting offline playback: $videoPath');
return PlaybackInitializationResult(
availableVersions: [],
videoUrl: 'file://$videoPath',
mediaInfo: null,
externalSubtitles: const [],
isOffline: true,
);
}
/// Cycle through BoxFit modes: contain → cover → fill → contain (for button)
void _cycleBoxFitMode() {
setState(() {
File diff suppressed because it is too large Load Diff
+710
View File
@@ -0,0 +1,710 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../models/plex_metadata.dart';
import 'settings_service.dart';
import 'saf_storage_service.dart';
class DownloadStorageService {
static DownloadStorageService? _instance;
static DownloadStorageService get instance =>
_instance ??= DownloadStorageService._();
DownloadStorageService._();
Directory? _baseDownloadsDir;
String? _artworkDirectoryPath;
// Custom path configuration
SettingsService? _settingsService;
String? _customDownloadPath;
String _customPathType = 'file';
/// Check if currently using SAF mode (Android only)
bool get isUsingSaf =>
Platform.isAndroid &&
_customPathType == 'saf' &&
_customDownloadPath != null;
/// Get the SAF base URI (only valid when isUsingSaf is true)
String? get safBaseUri => isUsingSaf ? _customDownloadPath : null;
/// Get artwork directory path (cached, synchronous after first call)
String? get artworkDirectoryPath => _artworkDirectoryPath;
/// Initialize with settings service (call during app startup)
Future<void> initialize(SettingsService settingsService) async {
_settingsService = settingsService;
_customDownloadPath = settingsService.getCustomDownloadPath();
_customPathType = settingsService.getCustomDownloadPathType();
// Reset cached directories to force recalculation
_baseDownloadsDir = null;
_artworkDirectoryPath = null;
}
/// Refresh custom path from settings (call when settings change)
Future<void> refreshCustomPath() async {
if (_settingsService != null) {
_customDownloadPath = _settingsService!.getCustomDownloadPath();
_customPathType = _settingsService!.getCustomDownloadPathType();
_baseDownloadsDir = null;
_artworkDirectoryPath = null;
}
}
/// Check if using custom download path
bool isUsingCustomPath() => _customDownloadPath != null;
/// Get current download path for display in settings
Future<String> getCurrentDownloadPathDisplay() async {
if (_customDownloadPath != null) {
return _customDownloadPath!;
}
final dir = await getDownloadsDirectory();
return dir.path;
}
/// Get default download path (for "Reset to Default" functionality)
Future<String> getDefaultDownloadPath() async {
final Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
return path.join(baseDir.path, 'downloads');
}
/// Check if a directory is writable
Future<bool> isDirectoryWritable(Directory dir) async {
try {
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Test write access with a temp file
final testFile = File(
path.join(
dir.path,
'.write_test_${DateTime.now().millisecondsSinceEpoch}',
),
);
await testFile.writeAsString('test');
await testFile.delete();
return true;
} catch (e) {
return false;
}
}
/// Initialize and get base downloads directory
Future<Directory> getDownloadsDirectory() async {
if (_baseDownloadsDir != null) return _baseDownloadsDir!;
// Check for custom path first (file type only - SAF handled differently)
if (_customDownloadPath != null && _customPathType == 'file') {
final customDir = Directory(_customDownloadPath!);
if (await isDirectoryWritable(customDir)) {
_baseDownloadsDir = customDir;
return _baseDownloadsDir!;
}
// Fall through to default if custom path is not writable
}
// Default path logic
final Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
// Desktop: macOS, Windows, Linux
baseDir = await getApplicationSupportDirectory();
}
_baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads'));
if (!await _baseDownloadsDir!.exists()) {
await _baseDownloadsDir!.create(recursive: true);
}
return _baseDownloadsDir!;
}
/// Get centralized artwork directory for offline artwork caching
/// This directory stores artwork files with hashed filenames for deduplication
Future<Directory> getArtworkDirectory() async {
// If custom download path is set, put artwork alongside downloads
if (_customDownloadPath != null && _customPathType == 'file') {
final customDir = Directory(_customDownloadPath!);
final parent = customDir.parent;
final artworkDir = Directory(path.join(parent.path, 'artwork'));
try {
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
_artworkDirectoryPath = artworkDir.path;
return artworkDir;
} catch (e) {
// Fall through to default if we can't create artwork dir
}
}
// Default: Get the app base directory directly (not downloads directory)
final Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
// Desktop: macOS, Windows, Linux
baseDir = await getApplicationSupportDirectory();
}
final artworkDir = Directory(path.join(baseDir.path, 'artwork'));
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
// Cache the path for synchronous access
_artworkDirectoryPath = artworkDir.path;
return artworkDir;
}
/// Get artwork file path from Plex thumb path (synchronous, requires initialization)
/// Returns path to cached artwork file using hash of the thumb URL
/// Example: artwork/a1b2c3d4e5f6.jpg
String getArtworkPathSync(String serverId, String thumbPath) {
if (_artworkDirectoryPath == null) {
throw StateError(
'Artwork directory not initialized. Call getArtworkDirectory() first.',
);
}
// Create hash from serverId:thumbPath for deduplication
final hash = _hashArtworkPath(serverId, thumbPath);
return path.join(_artworkDirectoryPath!, '$hash.jpg');
}
/// Get artwork file path from Plex thumb path (async version)
Future<String> getArtworkPathFromThumb(
String serverId,
String thumbPath,
) async {
final artworkDir = await getArtworkDirectory();
final hash = _hashArtworkPath(serverId, thumbPath);
return path.join(artworkDir.path, '$hash.jpg');
}
/// Check if artwork already exists (for deduplication)
Future<bool> artworkExists(String serverId, String thumbPath) async {
final artworkPath = await getArtworkPathFromThumb(serverId, thumbPath);
return File(artworkPath).exists();
}
/// Hash artwork path for filename using MD5 for stability across app restarts
String _hashArtworkPath(String serverId, String thumbPath) {
final combined = '$serverId:$thumbPath';
return md5.convert(utf8.encode(combined)).toString();
}
/// Get directory for a specific media item
Future<Directory> getMediaDirectory(String serverId, String ratingKey) async {
final baseDir = await getDownloadsDirectory();
final mediaDir = Directory(path.join(baseDir.path, serverId, ratingKey));
if (!await mediaDir.exists()) {
await mediaDir.create(recursive: true);
}
return mediaDir;
}
/// Get video file path
Future<String> getVideoFilePath(
String serverId,
String ratingKey,
String extension,
) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
return path.join(mediaDir.path, 'video.$extension');
}
/// Get artwork file path (poster, art, thumb)
Future<String> getArtworkPath(
String serverId,
String ratingKey,
String artworkType,
) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
return path.join(mediaDir.path, '$artworkType.jpg');
}
/// Get subtitles directory
Future<Directory> getSubtitlesDirectory(
String serverId,
String ratingKey,
) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles'));
if (!await subtitlesDir.exists()) {
await subtitlesDir.create(recursive: true);
}
return subtitlesDir;
}
/// Get subtitle file path
Future<String> getSubtitlePath(
String serverId,
String ratingKey,
int trackId,
String extension,
) async {
final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey);
return path.join(subtitlesDir.path, '$trackId.$extension');
}
// ============================================================
// USER-FRIENDLY PATH METHODS (for Files app visibility)
// ============================================================
/// Sanitize a filename by removing invalid filesystem characters
String _sanitizeFileName(String name) {
// Remove invalid filesystem characters: < > : " / \ | ? *
// Also remove leading/trailing whitespace and dots
return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '')
.replaceAll(RegExp(r'^\.+|\.+$'), '')
.trim();
}
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
Future<Directory> getMovieDirectory(PlexMetadata movie) async {
final baseDir = await getDownloadsDirectory();
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final movieFolder = year != null ? '$movieName ($year)' : movieName;
final dir = Directory(path.join(baseDir.path, 'Movies', movieFolder));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// Get movie video file path: .../Movie Name (YYYY)/Movie Name (YYYY).{ext}
Future<String> getMovieVideoPath(PlexMetadata movie, String extension) async {
final movieDir = await getMovieDirectory(movie);
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final fileName = year != null ? '$movieName ($year)' : movieName;
return path.join(movieDir.path, '$fileName.$extension');
}
/// Get movie artwork path: .../Movie Name (YYYY)/{artworkType}.jpg
Future<String> getMovieArtworkPath(
PlexMetadata movie,
String artworkType,
) async {
final movieDir = await getMovieDirectory(movie);
return path.join(movieDir.path, '$artworkType.jpg');
}
/// Get show directory: downloads/TV Shows/{Show Name} ({Year})/
/// [showYear]: Pass the show's premiere year explicitly (for episodes, the episode's
/// year may differ from the show's year). If not provided, uses metadata.year.
Future<Directory> getShowDirectory(
PlexMetadata metadata, {
int? showYear,
}) async {
final baseDir = await getDownloadsDirectory();
// For episodes, use grandparentTitle; for shows, use title
final showName = _sanitizeFileName(
metadata.grandparentTitle ?? metadata.title,
);
// Use explicit showYear if provided, otherwise fall back to metadata.year
final year = showYear ?? metadata.year;
final showFolder = year != null ? '$showName ($year)' : showName;
final dir = Directory(path.join(baseDir.path, 'TV Shows', showFolder));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// Get show artwork path: downloads/TV Shows/{Show}/poster.jpg
Future<String> getShowArtworkPath(
PlexMetadata metadata,
String artworkType, {
int? showYear,
}) async {
final showDir = await getShowDirectory(metadata, showYear: showYear);
return path.join(showDir.path, '$artworkType.jpg');
}
/// Get season directory: .../TV Shows/{Show}/Season {XX}/
/// [showYear]: Pass the show's premiere year (not episode or season year)
Future<Directory> getSeasonDirectory(
PlexMetadata metadata, {
int? showYear,
}) async {
final showDir = await getShowDirectory(metadata, showYear: showYear);
final seasonNum = (metadata.parentIndex ?? 0).toString().padLeft(2, '0');
final dir = Directory(path.join(showDir.path, 'Season $seasonNum'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// Get season artwork path: .../Season XX/poster.jpg
Future<String> getSeasonArtworkPath(
PlexMetadata metadata,
String artworkType, {
int? showYear,
}) async {
final seasonDir = await getSeasonDirectory(metadata, showYear: showYear);
return path.join(seasonDir.path, '$artworkType.jpg');
}
/// Get episode video file path: .../Season XX/S{XX}E{XX} - {Title}.{ext}
/// [showYear]: Pass the show's premiere year (not episode year)
Future<String> getEpisodeVideoPath(
PlexMetadata episode,
String extension, {
int? showYear,
}) async {
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
return path.join(
seasonDir.path,
'S${seasonNum}E$episodeNum - $episodeName.$extension',
);
}
/// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg
/// [showYear]: Pass the show's premiere year (not episode year)
Future<String> getEpisodeThumbnailPath(
PlexMetadata episode, {
int? showYear,
}) async {
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
return path.join(
seasonDir.path,
'S${seasonNum}E$episodeNum - $episodeName.jpg',
);
}
/// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/
/// [showYear]: Pass the show's premiere year (not episode year)
Future<Directory> getEpisodeSubtitlesDirectory(
PlexMetadata episode, {
int? showYear,
}) async {
final seasonDir = await getSeasonDirectory(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
final subsDir = Directory(
path.join(
seasonDir.path,
'S${seasonNum}E$episodeNum - ${episodeName}_subs',
),
);
if (!await subsDir.exists()) {
await subsDir.create(recursive: true);
}
return subsDir;
}
/// Get episode subtitle path
/// [showYear]: Pass the show's premiere year (not episode year)
Future<String> getEpisodeSubtitlePath(
PlexMetadata episode,
int trackId,
String extension, {
int? showYear,
}) async {
final subsDir = await getEpisodeSubtitlesDirectory(
episode,
showYear: showYear,
);
return path.join(subsDir.path, '$trackId.$extension');
}
/// Get subtitles directory for movie
Future<Directory> getMovieSubtitlesDirectory(PlexMetadata movie) async {
final movieDir = await getMovieDirectory(movie);
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final baseName = year != null ? '$movieName ($year)' : movieName;
final subsDir = Directory(path.join(movieDir.path, '${baseName}_subs'));
if (!await subsDir.exists()) {
await subsDir.create(recursive: true);
}
return subsDir;
}
/// Get movie subtitle path
Future<String> getMovieSubtitlePath(
PlexMetadata movie,
int trackId,
String extension,
) async {
final subsDir = await getMovieSubtitlesDirectory(movie);
return path.join(subsDir.path, '$trackId.$extension');
}
/// Delete all files for a media item
Future<void> deleteMediaFiles(String serverId, String ratingKey) async {
final mediaDir = await getMediaDirectory(serverId, ratingKey);
if (await mediaDir.exists()) {
await mediaDir.delete(recursive: true);
}
}
/// Convert an absolute file path to a relative path (for database storage)
/// This ensures paths remain valid across app reinstalls on iOS where
/// the container UUID can change.
/// Returns a path relative to the app's documents directory.
Future<String> toRelativePath(String absolutePath) async {
final Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
// If the path starts with the base directory, strip it
if (absolutePath.startsWith(baseDir.path)) {
// Remove the base path and any leading separator
var relative = absolutePath.substring(baseDir.path.length);
if (relative.startsWith('/') || relative.startsWith('\\')) {
relative = relative.substring(1);
}
return relative;
}
// Already relative or from a different base - return as-is
return absolutePath;
}
/// Convert a relative file path to an absolute path (for file operations)
/// Reconstructs the full path using the current app documents directory.
Future<String> toAbsolutePath(String relativePath) async {
// If it's already an absolute path, return as-is
if (path.isAbsolute(relativePath)) {
return relativePath;
}
final Directory baseDir;
if (Platform.isAndroid || Platform.isIOS) {
baseDir = await getApplicationDocumentsDirectory();
} else {
baseDir = await getApplicationSupportDirectory();
}
return path.join(baseDir.path, relativePath);
}
/// Convert a potentially absolute path (from old database entries) to absolute
/// This handles both old absolute paths and new relative paths
Future<String> ensureAbsolutePath(String storedPath) async {
if (path.isAbsolute(storedPath)) {
// Already absolute - check if file exists at this path
if (await File(storedPath).exists()) {
return storedPath;
}
// File doesn't exist at absolute path - try to reconstruct
// Extract the relative portion (everything after 'downloads/')
final downloadsIndex = storedPath.indexOf('downloads/');
if (downloadsIndex != -1) {
final relativePart = storedPath.substring(downloadsIndex);
return await toAbsolutePath(relativePart);
}
// Can't reconstruct, return original
return storedPath;
}
// Relative path - convert to absolute
return await toAbsolutePath(storedPath);
}
/// Calculate total storage used by downloads
Future<int> getTotalStorageUsed() async {
final baseDir = await getDownloadsDirectory();
return _calculateDirectorySize(baseDir);
}
Future<int> _calculateDirectorySize(Directory dir) async {
int size = 0;
if (!await dir.exists()) return size;
await for (var entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) {
try {
size += await entity.length();
} catch (_) {
// Ignore errors reading file size
}
}
}
return size;
}
/// Format bytes to human readable string
static String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
// ============================================================
// SAF (Storage Access Framework) SUPPORT FOR ANDROID
// ============================================================
/// Get temporary cache directory for initial downloads
/// Files are downloaded here first, then copied to SAF if using SAF mode
Future<Directory> getCacheDownloadDirectory() async {
final cacheDir = await getApplicationDocumentsDirectory();
final downloadCache = Directory(
path.join(cacheDir.path, '.download_cache'),
);
if (!await downloadCache.exists()) {
await downloadCache.create(recursive: true);
}
return downloadCache;
}
/// Get temporary file path for downloading (before copying to SAF)
Future<String> getTempDownloadPath(String fileName) async {
final cacheDir = await getCacheDownloadDirectory();
return path.join(cacheDir.path, fileName);
}
/// Copy a file from temp cache to SAF and return the SAF URI
/// Returns null if SAF is not available or copy fails
Future<String?> copyToSaf(
String tempFilePath,
List<String> pathComponents,
String fileName,
String mimeType,
) async {
if (!isUsingSaf || _customDownloadPath == null) return null;
final safService = SafStorageService.instance;
// Create nested directory structure in SAF
final targetDirUri = await safService.createNestedDirectories(
_customDownloadPath!,
pathComponents,
);
if (targetDirUri == null) {
return null;
}
// Copy the file to SAF
final safUri = await safService.copyFileToSaf(
tempFilePath,
targetDirUri,
fileName,
mimeType,
);
// Delete temp file after successful copy
if (safUri != null) {
try {
await File(tempFilePath).delete();
} catch (_) {
// Ignore cleanup errors
}
}
return safUri;
}
/// Get the MIME type for a file extension
String getMimeType(String extension) {
switch (extension.toLowerCase()) {
case 'mp4':
return 'video/mp4';
case 'mkv':
return 'video/x-matroska';
case 'm4v':
return 'video/x-m4v';
case 'avi':
return 'video/x-msvideo';
case 'ogv':
return 'video/ogg';
case 'webm':
return 'video/webm';
case 'srt':
return 'application/x-subrip';
case 'vtt':
return 'text/vtt';
case 'ass':
case 'ssa':
return 'text/x-ssa';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
default:
return 'application/octet-stream';
}
}
/// Get path components for SAF based on media type
/// Returns list of directory names to create under the SAF base
List<String> getMovieSafPathComponents(PlexMetadata movie) {
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final movieFolder = year != null ? '$movieName ($year)' : movieName;
return ['Movies', movieFolder];
}
/// Get path components for episode SAF storage
List<String> getEpisodeSafPathComponents(
PlexMetadata episode, {
int? showYear,
}) {
final showName = _sanitizeFileName(
episode.grandparentTitle ?? episode.title,
);
final year = showYear ?? episode.year;
final showFolder = year != null ? '$showName ($year)' : showName;
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
return ['TV Shows', showFolder, 'Season $seasonNum'];
}
/// Get SAF file name for a movie
String getMovieSafFileName(PlexMetadata movie, String extension) {
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final fileName = year != null ? '$movieName ($year)' : movieName;
return '$fileName.$extension';
}
/// Get SAF file name for an episode
String getEpisodeSafFileName(PlexMetadata episode, String extension) {
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
final episodeNum = (episode.index ?? 0).toString().padLeft(2, '0');
final episodeName = _sanitizeFileName(episode.title);
return 'S${seasonNum}E$episodeNum - $episodeName.$extension';
}
/// Check if a path is a SAF content URI
bool isSafUri(String storedPath) {
return storedPath.startsWith('content://');
}
/// Get a readable path for a stored path (handles both SAF URIs and file paths)
/// For SAF URIs, returns the URI as-is (content:// URIs work with media players)
/// For file paths, ensures the path is absolute
Future<String> getReadablePath(String storedPath) async {
if (isSafUri(storedPath)) {
// SAF content:// URIs are already readable by media players
return storedPath;
}
return await ensureAbsolutePath(storedPath);
}
}
@@ -0,0 +1,507 @@
import 'package:flutter/foundation.dart';
import '../database/app_database.dart';
import '../providers/offline_mode_provider.dart';
import '../utils/app_logger.dart';
import 'multi_server_manager.dart';
import 'plex_api_cache.dart';
import 'plex_client.dart';
/// Service for managing offline watch progress and syncing to Plex servers.
///
/// Handles:
/// - Queuing progress updates when offline
/// - Queuing manual watch/unwatch actions
/// - Auto-marking items as watched at 90% threshold
/// - Syncing queued actions when connectivity is restored
class OfflineWatchSyncService extends ChangeNotifier {
final AppDatabase _database;
final MultiServerManager _serverManager;
OfflineModeProvider? _offlineModeProvider;
VoidCallback? _offlineModeListener;
bool _isSyncing = false;
DateTime? _lastSyncTime;
bool _hasPerformedStartupSync = false;
/// Callback to refresh download provider metadata after sync
VoidCallback? onWatchStatesRefreshed;
/// Watch threshold - mark as watched when progress exceeds this percentage
static const double watchedThreshold = 0.90;
/// Minimum interval between syncs (10 minutes)
static const Duration minSyncInterval = Duration(minutes: 10);
/// Maximum sync attempts before giving up on an item
static const int maxSyncAttempts = 5;
OfflineWatchSyncService({
required AppDatabase database,
required MultiServerManager serverManager,
}) : _database = database,
_serverManager = serverManager;
/// Whether a sync is currently in progress
bool get isSyncing => _isSyncing;
/// Start monitoring for connectivity changes to auto-sync
void startConnectivityMonitoring(OfflineModeProvider offlineModeProvider) {
// Remove previous listener if any
if (_offlineModeProvider != null && _offlineModeListener != null) {
_offlineModeProvider!.removeListener(_offlineModeListener!);
}
_offlineModeProvider = offlineModeProvider;
_offlineModeListener = () {
if (!offlineModeProvider.isOffline) {
// We just came online - trigger bidirectional sync
appLogger.i(
'Connectivity restored - starting bidirectional watch sync',
);
_performBidirectionalSync();
}
};
offlineModeProvider.addListener(_offlineModeListener!);
// Don't sync on startup - servers aren't connected yet.
// Sync will happen when:
// - Connectivity is restored (listener triggers)
// - App resumes from background (onAppResumed)
}
/// Perform bidirectional sync: push local changes, then pull server states.
///
/// Push always happens immediately. Pull respects [minSyncInterval] unless [force] is true.
Future<void> _performBidirectionalSync({bool force = false}) async {
if (_serverManager.onlineClients.isEmpty) {
appLogger.d(
'Skipping watch sync - no connected servers available yet',
);
return;
}
// Always push local changes to server (never throttle outbound sync)
await syncPendingItems();
// Only throttle the pull from server
if (!force && _lastSyncTime != null) {
final elapsed = DateTime.now().difference(_lastSyncTime!);
if (elapsed < minSyncInterval) {
appLogger.d(
'Skipping server pull - last sync was ${elapsed.inMinutes}m ago (min: ${minSyncInterval.inMinutes}m)',
);
return;
}
}
// Pull latest states from server
await syncWatchStatesFromServer();
_lastSyncTime = DateTime.now();
}
/// Called when app becomes active - syncs if interval has passed.
void onAppResumed() {
if (_offlineModeProvider?.isOffline != true) {
appLogger.d('App resumed - checking if sync needed');
_performBidirectionalSync();
}
}
/// Called when servers connect on app startup.
///
/// Triggers the initial sync now that PlexClients are available.
/// Only runs once per app session.
void onServersConnected() {
if (_hasPerformedStartupSync) return;
_hasPerformedStartupSync = true;
if (_offlineModeProvider?.isOffline != true) {
appLogger.i('Servers connected - performing startup sync');
_performBidirectionalSync();
}
}
/// Queue a progress update for later sync.
///
/// This is called during offline playback to track the watch position.
/// If progress exceeds 90%, shouldMarkWatched is set to true.
Future<void> queueProgressUpdate({
required String serverId,
required String ratingKey,
required int viewOffset,
required int duration,
}) async {
final shouldMarkWatched = isWatchedByProgress(viewOffset, duration);
await _database.upsertProgressAction(
serverId: serverId,
ratingKey: ratingKey,
viewOffset: viewOffset,
duration: duration,
shouldMarkWatched: shouldMarkWatched,
);
appLogger.d(
'Queued offline progress: $serverId:$ratingKey at ${(viewOffset / 1000).toStringAsFixed(0)}s / ${(duration / 1000).toStringAsFixed(0)}s (${((viewOffset / duration) * 100).toStringAsFixed(1)}%)',
);
notifyListeners();
}
/// Queue a manual "mark as watched" action.
///
/// Removes any conflicting actions for the same item.
Future<void> queueMarkWatched({
required String serverId,
required String ratingKey,
}) async {
await _database.insertWatchAction(
serverId: serverId,
ratingKey: ratingKey,
actionType: 'watched',
);
appLogger.d('Queued offline mark watched: $serverId:$ratingKey');
notifyListeners();
}
/// Queue a manual "mark as unwatched" action.
///
/// Removes any conflicting actions for the same item.
Future<void> queueMarkUnwatched({
required String serverId,
required String ratingKey,
}) async {
await _database.insertWatchAction(
serverId: serverId,
ratingKey: ratingKey,
actionType: 'unwatched',
);
appLogger.d('Queued offline mark unwatched: $serverId:$ratingKey');
notifyListeners();
}
/// Check if an item should be considered watched based on progress percentage.
bool isWatchedByProgress(int viewOffset, int duration) {
if (duration == 0) return false;
return (viewOffset / duration) >= watchedThreshold;
}
/// Get the local watch status for a media item.
///
/// Returns:
/// - `true` if item was marked as watched locally or progress >= 90%
/// - `false` if item was marked as unwatched locally
/// - `null` if no local action exists (use cached server data)
Future<bool?> getLocalWatchStatus(String globalKey) async {
final action = await _database.getLatestWatchAction(globalKey);
if (action == null) return null;
switch (action.actionType) {
case 'watched':
return true;
case 'unwatched':
return false;
case 'progress':
// Check if progress exceeds threshold
return action.shouldMarkWatched;
default:
return null;
}
}
/// Get the local view offset (resume position) for a media item.
///
/// Returns the locally tracked position, or null if none exists.
Future<int?> getLocalViewOffset(String globalKey) async {
final action = await _database.getLatestWatchAction(globalKey);
if (action == null) return null;
// Only return offset for progress actions
if (action.actionType == 'progress') {
return action.viewOffset;
}
return null;
}
/// Get count of pending sync items.
Future<int> getPendingSyncCount() async {
return _database.getPendingSyncCount();
}
/// Sync all pending items to their respective servers.
///
/// Called automatically when connectivity is restored, or manually.
Future<void> syncPendingItems() async {
if (_isSyncing) {
appLogger.d('Sync already in progress, skipping');
return;
}
_isSyncing = true;
notifyListeners();
try {
final pendingActions = await _database.getPendingWatchActions();
if (pendingActions.isEmpty) {
appLogger.d('No pending watch actions to sync');
return;
}
appLogger.i('Syncing ${pendingActions.length} pending watch actions');
for (final action in pendingActions) {
// Delete items that have exceeded retry limit
if (action.syncAttempts >= maxSyncAttempts) {
appLogger.w(
'Deleting action ${action.id} - exceeded retry limit '
'(${action.syncAttempts} attempts). Last error: ${action.lastError}',
);
await _database.deleteWatchAction(action.id);
continue;
}
// Get the client for this server
final client = _serverManager.getClient(action.serverId);
if (client == null) {
// Check if this server still exists in the app
if (_serverManager.getServer(action.serverId) == null) {
appLogger.w(
'Deleting action ${action.id} - server ${action.serverId} no longer exists',
);
await _database.deleteWatchAction(action.id);
} else {
appLogger.w(
'No client available for server ${action.serverId}, will retry',
);
await _database.updateSyncAttempt(
action.id,
'Server not available',
);
}
continue;
}
// Check if server is online
if (!_serverManager.isServerOnline(action.serverId)) {
appLogger.d('Server ${action.serverId} is offline, skipping');
continue;
}
try {
await _syncAction(client, action);
// Success - delete the action from queue
await _database.deleteWatchAction(action.id);
appLogger.d(
'Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}',
);
} catch (e) {
appLogger.w('Failed to sync action ${action.id}: $e');
await _database.updateSyncAttempt(action.id, e.toString());
}
}
} finally {
_isSyncing = false;
notifyListeners();
}
}
/// Sync a single action to the server.
Future<void> _syncAction(
PlexClient client,
OfflineWatchProgressItem action,
) async {
switch (action.actionType) {
case 'watched':
await client.markAsWatched(action.ratingKey);
break;
case 'unwatched':
await client.markAsUnwatched(action.ratingKey);
break;
case 'progress':
// First, update the timeline with current position
if (action.viewOffset != null && action.duration != null) {
await client.updateProgress(
action.ratingKey,
time: action.viewOffset!,
state:
'stopped', // Use 'stopped' since we're syncing after the fact
duration: action.duration,
);
}
// If progress exceeded threshold, also mark as watched
if (action.shouldMarkWatched) {
await client.markAsWatched(action.ratingKey);
}
break;
}
}
/// Fetch latest watch states from server and update local cache.
///
/// Called when coming online or on app startup to pull any watch state
/// changes made on other devices.
///
/// Optimized to fetch episodes by season (one API call per season)
/// instead of one API call per episode.
Future<void> syncWatchStatesFromServer() async {
try {
// Get all downloaded items from database
final downloadedItems = await _database.getAllDownloadedMetadata();
if (downloadedItems.isEmpty) {
appLogger.d('No downloaded items to sync watch states for');
return;
}
appLogger.i(
'Syncing watch states from server for ${downloadedItems.length} items',
);
// Separate episodes (with season parent) from other items (movies, etc.)
// Structure: serverId -> seasonRatingKey -> Set<episodeRatingKey>
final episodesByServerAndSeason = <String, Map<String, Set<String>>>{};
// Structure: serverId -> List<ratingKey>
final nonEpisodeItems = <String, List<String>>{};
for (final item in downloadedItems) {
if (item.type == 'episode' && item.parentRatingKey != null) {
// Group episodes by server and season for batch fetching
episodesByServerAndSeason
.putIfAbsent(item.serverId, () => {})
.putIfAbsent(item.parentRatingKey!, () => {})
.add(item.ratingKey);
} else {
// Movies, or episodes without parent (fallback to individual fetch)
nonEpisodeItems
.putIfAbsent(item.serverId, () => [])
.add(item.ratingKey);
}
}
int syncedCount = 0;
int seasonCount = 0;
// Fetch episodes by season (batch) - one API call per season
for (final serverEntry in episodesByServerAndSeason.entries) {
final serverId = serverEntry.key;
final seasonMap = serverEntry.value;
final client = _serverManager.getClient(serverId);
if (client == null) {
appLogger.d('No client for server $serverId, skipping');
continue;
}
if (!_serverManager.isServerOnline(serverId)) {
appLogger.d('Server $serverId is offline, skipping');
continue;
}
for (final seasonEntry in seasonMap.entries) {
final seasonRatingKey = seasonEntry.key;
final downloadedEpisodeKeys = seasonEntry.value;
try {
// Fetch all episodes in this season with one API call
final seasonEpisodes = await client.getChildren(seasonRatingKey);
seasonCount++;
// Cache only the episodes we have downloaded
for (final episode in seasonEpisodes) {
if (downloadedEpisodeKeys.contains(episode.ratingKey)) {
await PlexApiCache.instance.put(
serverId,
'/library/metadata/${episode.ratingKey}',
{
'MediaContainer': {
'Metadata': [episode.toJson()],
},
},
);
syncedCount++;
}
}
} catch (e) {
appLogger.d(
'Failed to sync watch states for season $seasonRatingKey: $e',
);
}
}
}
// Fetch non-episode items individually (movies, etc.)
for (final entry in nonEpisodeItems.entries) {
final serverId = entry.key;
final ratingKeys = entry.value;
final client = _serverManager.getClient(serverId);
if (client == null) {
appLogger.d('No client for server $serverId, skipping');
continue;
}
if (!_serverManager.isServerOnline(serverId)) {
appLogger.d('Server $serverId is offline, skipping');
continue;
}
for (final ratingKey in ratingKeys) {
try {
final metadata = await client.getMetadataWithImages(ratingKey);
if (metadata != null) {
await PlexApiCache.instance.put(
serverId,
'/library/metadata/$ratingKey',
{
'MediaContainer': {
'Metadata': [metadata.toJson()],
},
},
);
syncedCount++;
}
} catch (e) {
appLogger.d('Failed to sync watch state for $ratingKey: $e');
}
}
}
final movieCount = nonEpisodeItems.values.fold(0, (a, b) => a + b.length);
appLogger.i(
'Synced watch states: $seasonCount seasons, $movieCount other items ($syncedCount total)',
);
// Notify download provider to refresh metadata from updated cache
if (syncedCount > 0) {
onWatchStatesRefreshed?.call();
}
notifyListeners();
} catch (e) {
appLogger.w('Error syncing watch states from server: $e');
}
}
/// Clear all pending watch actions (e.g., when logging out).
Future<void> clearAll() async {
await _database.clearAllWatchActions();
notifyListeners();
}
@override
void dispose() {
if (_offlineModeProvider != null && _offlineModeListener != null) {
_offlineModeProvider!.removeListener(_offlineModeListener!);
}
super.dispose();
}
}
@@ -1,25 +1,147 @@
import 'plex_client.dart';
import '../models/plex_media_info.dart';
import '../models/plex_metadata.dart';
import '../models/download_status.dart';
import '../mpv/mpv.dart';
import '../utils/app_logger.dart';
import '../i18n/strings.g.dart';
import '../database/app_database.dart';
import 'download_storage_service.dart';
import 'dart:io';
import 'package:drift/drift.dart';
/// Service responsible for fetching video playback data from the Plex server
class PlaybackInitializationService {
final PlexClient client;
final AppDatabase? database;
PlaybackInitializationService({required this.client});
PlaybackInitializationService({required this.client, this.database});
/// Check if content is available offline and return local path
///
/// Returns the local file path if the video is downloaded and completed.
/// Returns null if not available offline or database is not provided.
Future<String?> getOfflineVideoPath(String serverId, String ratingKey) async {
if (database == null) {
return null;
}
try {
// Query database for downloaded media with matching serverId and ratingKey
final query = database!.select(database!.downloadedMedia)
..where(
(tbl) =>
tbl.serverId.equals(serverId) & tbl.ratingKey.equals(ratingKey),
);
final downloadedItem = await query.getSingleOrNull();
// Return null if not found or not completed
if (downloadedItem == null ||
downloadedItem.status != DownloadStatus.completed.index) {
return null;
}
// Return null if no video file path
if (downloadedItem.videoFilePath == null) {
return null;
}
final storageService = DownloadStorageService.instance;
final storedPath = downloadedItem.videoFilePath!;
// Check if this is a SAF URI (content://)
if (storageService.isSafUri(storedPath)) {
// SAF URIs are already valid for media players
appLogger.d('Found offline video (SAF): $storedPath');
return storedPath;
}
// Convert relative path to absolute path (handles both old absolute and new relative paths)
final absolutePath = await storageService.ensureAbsolutePath(storedPath);
// Verify file exists
final file = File(absolutePath);
if (!await file.exists()) {
appLogger.w(
'Offline video file not found: $absolutePath (stored as: $storedPath)',
);
return null;
}
appLogger.d('Found offline video: $absolutePath');
return absolutePath;
} catch (e) {
appLogger.w('Error checking offline video path', error: e);
return null;
}
}
/// Fetch playback data for the given metadata
///
/// Returns a PlaybackInitializationResult with video URL and available versions
/// If [preferOffline] is true and offline content is available, uses local file
Future<PlaybackInitializationResult> getPlaybackData({
required PlexMetadata metadata,
required int selectedMediaIndex,
bool preferOffline = false,
}) async {
try {
// Get consolidated playback data (URL, media info, and versions) in a single API call
// Check for offline content first if preferOffline is enabled
String? offlineVideoPath;
if (preferOffline && database != null) {
offlineVideoPath = await getOfflineVideoPath(
client.serverId,
metadata.ratingKey,
);
}
// If offline video is available, use it
if (offlineVideoPath != null) {
appLogger.d('Using offline playback for ${metadata.ratingKey}');
// For offline playback, we still need to fetch media info for subtitles
// but use the local file path for video
try {
final playbackData = await client.getVideoPlaybackData(
metadata.ratingKey,
mediaIndex: selectedMediaIndex,
);
// Build list of external subtitle tracks
final externalSubtitles = _buildExternalSubtitles(
playbackData.mediaInfo,
);
// Return result with local file path (file:// prefix for file paths, keep SAF URIs as-is)
return PlaybackInitializationResult(
availableVersions: playbackData.availableVersions,
videoUrl: offlineVideoPath.contains('://')
? offlineVideoPath
: 'file://$offlineVideoPath',
mediaInfo: playbackData.mediaInfo,
externalSubtitles: externalSubtitles,
isOffline: true,
);
} catch (e) {
// If we can't fetch media info (e.g., no network), use offline-only mode
appLogger.w(
'Failed to fetch media info for offline video, using offline-only mode',
error: e,
);
return PlaybackInitializationResult(
availableVersions: [],
videoUrl: offlineVideoPath.contains('://')
? offlineVideoPath
: 'file://$offlineVideoPath',
mediaInfo: null,
externalSubtitles: const [],
isOffline: true,
);
}
}
// Fall back to network streaming
final playbackData = await client.getVideoPlaybackData(
metadata.ratingKey,
mediaIndex: selectedMediaIndex,
@@ -38,6 +160,7 @@ class PlaybackInitializationService {
videoUrl: playbackData.videoUrl,
mediaInfo: playbackData.mediaInfo,
externalSubtitles: externalSubtitles,
isOffline: false,
);
} catch (e) {
if (e is PlaybackException) {
@@ -106,12 +229,14 @@ class PlaybackInitializationResult {
final String? videoUrl;
final PlexMediaInfo? mediaInfo;
final List<SubtitleTrack> externalSubtitles;
final bool isOffline;
PlaybackInitializationResult({
required this.availableVersions,
this.videoUrl,
this.mediaInfo,
this.externalSubtitles = const [],
this.isOffline = false,
});
}
+77 -38
View File
@@ -3,20 +3,33 @@ import 'dart:async';
import '../mpv/mpv.dart';
import 'plex_client.dart';
import 'offline_watch_sync_service.dart';
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
/// Tracks playback progress and reports it to the Plex server.
///
/// Handles:
/// - Periodic timeline updates during playback
/// - Periodic timeline updates during playback (online) or queuing (offline)
/// - Resume position tracking
/// - State change reporting (playing, paused, stopped)
/// - Offline progress queuing for later sync
class PlaybackProgressTracker {
final PlexClient client;
/// Plex client for online progress updates (null when offline)
final PlexClient? client;
/// Metadata of the media being played
final PlexMetadata metadata;
/// Video player instance
final Player player;
/// Whether playback is in offline mode
final bool isOffline;
/// Service for queuing offline progress updates
final OfflineWatchSyncService? offlineWatchService;
/// Timer for periodic progress updates
Timer? _progressTimer;
@@ -27,12 +40,22 @@ class PlaybackProgressTracker {
required this.client,
required this.metadata,
required this.player,
this.isOffline = false,
this.offlineWatchService,
this.updateInterval = const Duration(seconds: 10),
});
}) : assert(
!isOffline || offlineWatchService != null,
'offlineWatchService is required when isOffline is true',
),
assert(
isOffline || client != null,
'client is required when isOffline is false',
);
/// Start tracking playback progress
///
/// Begins periodic timeline updates to the Plex server.
/// Begins periodic timeline updates to the Plex server (online)
/// or queuing progress updates locally (offline).
void startTracking() {
if (_progressTimer != null) {
appLogger.w('Progress tracking already started');
@@ -46,7 +69,7 @@ class PlaybackProgressTracker {
});
appLogger.d(
'Started progress tracking (interval: ${updateInterval.inSeconds}s)',
'Started progress tracking (interval: ${updateInterval.inSeconds}s, offline: $isOffline)',
);
}
@@ -59,7 +82,7 @@ class PlaybackProgressTracker {
appLogger.d('Stopped progress tracking');
}
/// Send progress update to Plex server
/// Send progress update to Plex server or queue locally
///
/// [state] can be 'playing', 'paused', or 'stopped'
Future<void> sendProgress(String state) async {
@@ -76,44 +99,60 @@ class PlaybackProgressTracker {
return;
}
// Build timeline query parameters
final queryParams = {
'ratingKey': metadata.ratingKey,
'key': '/library/metadata/${metadata.ratingKey}',
'state': state,
'time': position.inMilliseconds.toString(),
'duration': duration.inMilliseconds.toString(),
};
// Add playQueueItemID if available (for playlist/shuffle playback)
if (metadata.playQueueItemID != null) {
queryParams['playQueueItemID'] = metadata.playQueueItemID.toString();
if (isOffline) {
// Queue progress update for later sync
await _sendOfflineProgress(position, duration);
} else {
// Send progress to server immediately
await _sendOnlineProgress(state, position, duration);
}
// Send timeline update
await client
.updateProgress(
metadata.ratingKey,
time: position.inMilliseconds,
state: state,
duration: duration.inMilliseconds,
)
.catchError((error) {
// Silent handling - don't interrupt playback for progress errors
appLogger.d(
'Failed to update progress (non-critical)',
error: error,
);
});
appLogger.d(
'Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s',
);
} catch (e) {
appLogger.d('Failed to send progress update (non-critical)', error: e);
}
}
/// Send progress update to Plex server (online mode)
Future<void> _sendOnlineProgress(
String state,
Duration position,
Duration duration,
) async {
await client!.updateProgress(
metadata.ratingKey,
time: position.inMilliseconds,
state: state,
duration: duration.inMilliseconds,
);
appLogger.d(
'Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s',
);
}
/// Queue progress update locally (offline mode)
Future<void> _sendOfflineProgress(
Duration position,
Duration duration,
) async {
final serverId = metadata.serverId;
if (serverId == null) {
appLogger.w('Cannot queue offline progress: serverId is null');
return;
}
await offlineWatchService!.queueProgressUpdate(
serverId: serverId,
ratingKey: metadata.ratingKey,
viewOffset: position.inMilliseconds,
duration: duration.inMilliseconds,
);
final percent = (position.inMilliseconds / duration.inMilliseconds * 100);
appLogger.d(
'Offline progress queued: ${position.inSeconds}s / ${duration.inSeconds}s (${percent.toStringAsFixed(1)}%)',
);
}
/// Dispose resources
void dispose() {
stopTracking();
+142
View File
@@ -0,0 +1,142 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../database/app_database.dart';
/// Key-value cache for Plex API responses using Drift/SQLite.
/// Stores raw JSON responses keyed by serverId:endpoint format.
class PlexApiCache {
static PlexApiCache? _instance;
static PlexApiCache get instance {
if (_instance == null) {
throw StateError(
'PlexApiCache not initialized. Call PlexApiCache.initialize() first.',
);
}
return _instance!;
}
final AppDatabase _db;
PlexApiCache._(this._db);
/// Initialize the singleton with an AppDatabase instance
static void initialize(AppDatabase db) {
_instance = PlexApiCache._(db);
}
/// Get the database instance (for services that need direct database access)
AppDatabase get database => _db;
/// Build cache key from serverId and endpoint
String _buildKey(String serverId, String endpoint) {
return '$serverId:$endpoint';
}
/// Get cached response for an endpoint
Future<Map<String, dynamic>?> get(String serverId, String endpoint) async {
final key = _buildKey(serverId, endpoint);
final result = await (_db.select(
_db.apiCache,
)..where((t) => t.cacheKey.equals(key))).getSingleOrNull();
if (result != null) {
return jsonDecode(result.data) as Map<String, dynamic>;
}
return null;
}
/// Cache a response for an endpoint
Future<void> put(
String serverId,
String endpoint,
Map<String, dynamic> data,
) async {
final key = _buildKey(serverId, endpoint);
await _db
.into(_db.apiCache)
.insertOnConflictUpdate(
ApiCacheCompanion(
cacheKey: Value(key),
data: Value(jsonEncode(data)),
cachedAt: Value(DateTime.now()),
),
);
}
/// Delete all cached data for a server
Future<void> deleteForServer(String serverId) async {
await (_db.delete(
_db.apiCache,
)..where((t) => t.cacheKey.like('$serverId:%'))).go();
}
/// Delete cached data for a specific item (when removing a download)
Future<void> deleteForItem(String serverId, String ratingKey) async {
// Delete the metadata endpoint
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
final childrenKey = _buildKey(
serverId,
'/library/metadata/$ratingKey/children',
);
await (_db.delete(_db.apiCache)..where(
(t) =>
t.cacheKey.equals(metadataKey) | t.cacheKey.equals(childrenKey),
))
.go();
}
/// Mark an item as pinned for offline access
Future<void> pinForOffline(String serverId, String ratingKey) async {
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
await (_db.update(_db.apiCache)
..where((t) => t.cacheKey.equals(metadataKey)))
.write(const ApiCacheCompanion(pinned: Value(true)));
}
/// Unpin an item
Future<void> unpinForOffline(String serverId, String ratingKey) async {
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
await (_db.update(_db.apiCache)
..where((t) => t.cacheKey.equals(metadataKey)))
.write(const ApiCacheCompanion(pinned: Value(false)));
}
/// Check if an item is pinned for offline
Future<bool> isPinned(String serverId, String ratingKey) async {
final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey');
final result = await (_db.select(
_db.apiCache,
)..where((t) => t.cacheKey.equals(metadataKey))).getSingleOrNull();
return result?.pinned ?? false;
}
/// Get all pinned rating keys for a server
Future<Set<String>> getPinnedKeys(String serverId) async {
final results =
await (_db.select(_db.apiCache)..where(
(t) => t.cacheKey.like('$serverId:%') & t.pinned.equals(true),
))
.get();
final keys = <String>{};
for (final row in results) {
// Extract ratingKey from cache key like "serverId:/library/metadata/12345"
// Rating keys can be alphanumeric, not just numeric
final match = RegExp(
r'/library/metadata/([^/]+)$',
).firstMatch(row.cacheKey);
if (match != null) {
keys.add(match.group(1)!);
}
}
return keys;
}
/// Clear all cached data (useful for debugging/testing)
Future<void> clearAll() async {
await _db.delete(_db.apiCache).go();
}
}
+300 -49
View File
@@ -17,6 +17,7 @@ import '../models/plex_video_playback_data.dart';
import '../utils/endpoint_failover_interceptor.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
import 'plex_api_cache.dart';
/// Result of testing a connection, including success status and latency
class ConnectionTestResult {
@@ -38,6 +39,20 @@ class PlexClient {
/// Server name - all PlexMetadata items created by this client are tagged with this
final String? serverName;
/// API response cache for offline support
final PlexApiCache _cache = PlexApiCache.instance;
/// Whether to operate in offline mode (use cache only)
bool _offlineMode = false;
/// Set offline mode - when true, only cached responses are returned
void setOfflineMode(bool offline) {
_offlineMode = offline;
}
/// Get current offline mode state
bool get isOfflineMode => _offlineMode;
/// Custom response decoder that handles malformed UTF-8 gracefully
static String _lenientUtf8Decoder(
List<int> responseBytes,
@@ -249,17 +264,6 @@ class PlexClient {
return null;
}
/// Extract single PlexMetadata from response (returns first item or null)
/// Automatically tags the item with this client's serverId and serverName
PlexMetadata? _extractSingleMetadata(Response response) {
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJson(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName)
: null;
}
/// Generic helper to extract and map Directory list from response
List<T> _extractDirectoryList<T>(
Response response,
@@ -347,10 +351,21 @@ class PlexClient {
return _extractMetadataList(response);
}
/// Get metadata by rating key
Future<PlexMetadata?> getMetadata(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
return _extractSingleMetadata(response);
/// Parse list of PlexMetadata from a cached response
List<PlexMetadata> _parseMetadataListFromCachedResponse(
Map<String, dynamic> cached,
) {
if (cached['MediaContainer'] != null &&
cached['MediaContainer']['Metadata'] != null) {
return (cached['MediaContainer']['Metadata'] as List)
.map(
(json) => PlexMetadata.fromJson(
json,
).copyWith(serverId: serverId, serverName: serverName),
)
.toList();
}
return [];
}
/// Get the server's machine identifier
@@ -378,54 +393,146 @@ class PlexClient {
}
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
/// Uses cache when offline or as fallback on network error
/// Note: OnDeck data is not relevant for offline mode
/// Always fetches with chapters/markers but caches at base endpoint
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(
String ratingKey,
) async {
final response = await _dio.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeOnDeck': 1},
);
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
PlexMetadata? metadata;
PlexMetadata? onDeckEpisode;
// If offline mode, return from cache only (no OnDeck in offline)
if (_offlineMode) {
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cached);
return {'metadata': metadata, 'onDeckEpisode': null};
}
return {'metadata': null, 'onDeckEpisode': null};
}
final metadataJson = _getFirstMetadataJson(response);
// Online: try network first
try {
// Fetch with chapters/markers/onDeck
final response = await _dio.get(
'/library/metadata/$ratingKey',
queryParameters: {
'includeChapters': 1,
'includeMarkers': 1,
'includeOnDeck': 1,
},
);
// Log the parsed metadata JSON
if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName);
// Cache at base endpoint (single cache entry per item)
if (response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
metadataJson['OnDeck'] != null) {
final onDeckData = metadataJson['OnDeck'];
PlexMetadata? metadata;
PlexMetadata? onDeckEpisode;
// OnDeck can be either a Map with 'Metadata' key or direct metadata
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
final onDeckMetadata = onDeckData['Metadata'];
if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(
onDeckMetadata,
).copyWith(serverId: serverId, serverName: serverName);
final metadataJson = _getFirstMetadataJson(response);
if (metadataJson != null) {
metadata = PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName);
// Check if OnDeck is nested inside Metadata
if (metadataJson.containsKey('OnDeck') &&
metadataJson['OnDeck'] != null) {
final onDeckData = metadataJson['OnDeck'];
// OnDeck can be either a Map with 'Metadata' key or direct metadata
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
final onDeckMetadata = onDeckData['Metadata'];
if (onDeckMetadata != null) {
onDeckEpisode = PlexMetadata.fromJson(
onDeckMetadata,
).copyWith(serverId: serverId, serverName: serverName);
}
}
}
}
}
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
} catch (e) {
// Network failed - try cache as fallback (no OnDeck)
appLogger.w(
'Network request failed for metadata with OnDeck, trying cache',
error: e,
);
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cached);
return {'metadata': metadata, 'onDeckEpisode': null};
}
rethrow;
}
}
/// Get metadata by rating key with images (includes clearLogo)
/// Uses cache when offline or as fallback on network error
/// Always fetches with chapters/markers but caches at base endpoint
Future<PlexMetadata?> getMetadataWithImages(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName)
: null;
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
// If offline mode, return from cache only
if (_offlineMode) {
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
return _parseMetadataWithImagesFromCachedResponse(cached);
}
return null;
}
// Online: try network first
try {
// Always fetch with chapters/markers - they're included in cached response
final response = await _dio.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1},
);
// Cache at base endpoint (single cache entry per item)
if (response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? PlexMetadata.fromJsonWithImages(
metadataJson,
).copyWith(serverId: serverId, serverName: serverName)
: null;
} catch (e) {
// Network failed - try cache as fallback
appLogger.w(
'Network request failed for metadata with images, trying cache',
error: e,
);
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
return _parseMetadataWithImagesFromCachedResponse(cached);
}
rethrow;
}
}
/// Parse PlexMetadata with images from a cached response
PlexMetadata? _parseMetadataWithImagesFromCachedResponse(
Map<String, dynamic> cached,
) {
if (cached['MediaContainer'] != null &&
cached['MediaContainer']['Metadata'] != null &&
(cached['MediaContainer']['Metadata'] as List).isNotEmpty) {
return PlexMetadata.fromJsonWithImages(
cached['MediaContainer']['Metadata'][0],
).copyWith(serverId: serverId, serverName: serverName);
}
return null;
}
/// Set per-media language preferences (audio and subtitle)
@@ -614,9 +721,41 @@ class PlexClient {
}
/// Get children of a metadata item (e.g., seasons for a show, episodes for a season)
/// Uses cache when offline or as fallback on network error
Future<List<PlexMetadata>> getChildren(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey/children');
return _extractMetadataList(response);
final endpoint = '/library/metadata/$ratingKey/children';
// If offline mode, return from cache only
if (_offlineMode) {
final cached = await _cache.get(serverId, endpoint);
if (cached != null) {
return _parseMetadataListFromCachedResponse(cached);
}
return [];
}
// Online: try network first
try {
final response = await _dio.get(endpoint);
// Cache the successful response
if (response.data != null) {
await _cache.put(serverId, endpoint, response.data);
}
return _extractMetadataList(response);
} catch (e) {
// Network failed - try cache as fallback
appLogger.w(
'Network request failed for children, trying cache',
error: e,
);
final cached = await _cache.get(serverId, endpoint);
if (cached != null) {
return _parseMetadataListFromCachedResponse(cached);
}
rethrow;
}
}
/// Get all unwatched episodes for a TV show across all seasons
@@ -749,6 +888,118 @@ class PlexClient {
return [];
}
/// Get chapters and markers from cached metadata or fetch if needed
/// Uses same cache key as other metadata methods for consistency
Future<PlaybackExtras> getPlaybackExtras(String ratingKey) async {
// Use same cache key as other metadata methods
final cacheKey = '/library/metadata/$ratingKey';
// If offline mode, return from cache only
if (_offlineMode) {
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
return _parsePlaybackExtrasFromCachedResponse(cached);
}
return PlaybackExtras(chapters: [], markers: []);
}
// Online: check cache first (already has chapters/markers from other fetches)
appLogger.d('getPlaybackExtras: serverId=$serverId, cacheKey=$cacheKey');
final cached = await _cache.get(serverId, cacheKey);
if (cached != null) {
final chapters =
cached['MediaContainer']?['Metadata']?[0]?['Chapter'] as List?;
appLogger.d(
'getPlaybackExtras: cache hit, ${chapters?.length ?? 0} chapters',
);
return _parsePlaybackExtrasFromCachedResponse(cached);
}
appLogger.d('getPlaybackExtras: cache miss');
// Not in cache - fetch and cache
try {
final response = await _dio.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1},
);
// Cache at base endpoint
if (response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
return _parsePlaybackExtrasFromResponse(response);
} catch (e) {
// Network failed
appLogger.w('Network request failed for playback extras', error: e);
return PlaybackExtras(chapters: [], markers: []);
}
}
/// Parse PlaybackExtras from API response
PlaybackExtras _parsePlaybackExtrasFromResponse(Response response) {
final metadataJson = _getFirstMetadataJson(response);
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
}
/// Parse PlaybackExtras from cached response
PlaybackExtras _parsePlaybackExtrasFromCachedResponse(
Map<String, dynamic> cached,
) {
Map<String, dynamic>? metadataJson;
if (cached['MediaContainer'] != null &&
cached['MediaContainer']['Metadata'] != null &&
(cached['MediaContainer']['Metadata'] as List).isNotEmpty) {
metadataJson =
cached['MediaContainer']['Metadata'][0] as Map<String, dynamic>;
}
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
}
/// Parse PlaybackExtras from metadata JSON
PlaybackExtras _parsePlaybackExtrasFromMetadataJson(
Map<String, dynamic>? metadataJson,
) {
final chapters = <PlexChapter>[];
final markers = <PlexMarker>[];
if (metadataJson != null) {
// Parse chapters
if (metadataJson['Chapter'] != null) {
final chapterList = metadataJson['Chapter'] as List<dynamic>;
for (var chapter in chapterList) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['tag'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
// Parse markers
if (metadataJson['Marker'] != null) {
final markerList = metadataJson['Marker'] as List;
for (var marker in markerList) {
markers.add(
PlexMarker(
id: marker['id'] as int,
type: marker['type'] as String,
startTimeOffset: marker['startTimeOffset'] as int,
endTimeOffset: marker['endTimeOffset'] as int,
),
);
}
}
}
return PlaybackExtras(chapters: chapters, markers: markers);
}
/// Get detailed media info including chapters and tracks
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
Future<PlexMediaInfo?> getMediaInfo(
+233
View File
@@ -0,0 +1,233 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:saf_util/saf_util.dart';
import 'package:saf_util/saf_util_platform_interface.dart';
import 'package:saf_stream/saf_stream.dart';
/// Handles Storage Access Framework (SAF) operations for Android
class SafStorageService {
static SafStorageService? _instance;
static SafStorageService get instance => _instance ??= SafStorageService._();
SafStorageService._();
final SafUtil _safUtil = SafUtil();
final SafStream _safStream = SafStream();
/// Check if SAF is available (Android only)
bool get isAvailable => Platform.isAndroid;
/// Pick a directory using SAF
/// Returns the content:// URI or null if cancelled
Future<String?> pickDirectory() async {
if (!isAvailable) return null;
try {
// Pick directory with persistent write permission
final doc = await _safUtil.pickDirectory(
writePermission: true,
persistablePermission: true,
);
return doc?.uri;
} catch (e) {
debugPrint('SAF pickDirectory error: $e');
return null;
}
}
/// Check if we have persisted access to a URI
Future<bool> hasPersistedPermission(String contentUri) async {
if (!isAvailable) return false;
try {
return await _safUtil.hasPersistedPermission(
contentUri,
checkRead: true,
checkWrite: true,
);
} catch (e) {
debugPrint('SAF hasPersistedPermission error: $e');
return false;
}
}
/// Get document file info for a URI
Future<SafDocumentFile?> getDocumentFile(
String contentUri, {
bool isDir = true,
}) async {
if (!isAvailable) return null;
try {
return await _safUtil.documentFileFromUri(contentUri, isDir);
} catch (e) {
debugPrint('SAF getDocumentFile error: $e');
return null;
}
}
/// Create a subdirectory in a SAF directory
/// Returns the URI of the created directory
Future<String?> createDirectory(String parentUri, String name) async {
if (!isAvailable) return null;
try {
final result = await _safUtil.mkdirp(parentUri, [name]);
return result.uri;
} catch (e) {
debugPrint('SAF createDirectory error: $e');
return null;
}
}
/// List files in a SAF directory
Future<List<SafDocumentFile>> listDirectory(String contentUri) async {
if (!isAvailable) return [];
try {
return await _safUtil.list(contentUri);
} catch (e) {
debugPrint('SAF listDirectory error: $e');
return [];
}
}
/// Get a child file/directory in a SAF directory
Future<SafDocumentFile?> getChild(String parentUri, String name) async {
if (!isAvailable) return null;
try {
return await _safUtil.child(parentUri, [name]);
} catch (e) {
debugPrint('SAF getChild error: $e');
return null;
}
}
/// Delete a file or directory in SAF
Future<bool> delete(String contentUri, {bool isDir = false}) async {
if (!isAvailable) return false;
try {
await _safUtil.delete(contentUri, isDir);
return true;
} catch (e) {
debugPrint('SAF delete error: $e');
return false;
}
}
/// Get a display name for a SAF URI (for UI purposes)
Future<String?> getDisplayName(String contentUri) async {
if (!isAvailable) return null;
try {
final doc = await _safUtil.documentFileFromUri(contentUri, true);
return doc?.name;
} catch (e) {
debugPrint('SAF getDisplayName error: $e');
return null;
}
}
/// Create nested directories in a SAF directory
/// Returns the URI of the deepest directory
Future<String?> createNestedDirectories(
String parentUri,
List<String> pathComponents,
) async {
if (!isAvailable) return null;
try {
final result = await _safUtil.mkdirp(parentUri, pathComponents);
return result.uri;
} catch (e) {
debugPrint('SAF createNestedDirectories error: $e');
return null;
}
}
/// Copy a file from local storage to SAF directory using streaming
/// Returns the SAF URI of the copied file, or null on failure
Future<String?> copyFileToSaf(
String sourceFilePath,
String targetDirectoryUri,
String fileName,
String mimeType,
) async {
if (!isAvailable) return null;
try {
final sourceFile = File(sourceFilePath);
if (!await sourceFile.exists()) {
debugPrint('SAF copyFileToSaf: source file does not exist');
return null;
}
// Start a write stream session
final writeInfo = await _safStream.startWriteStream(
targetDirectoryUri,
fileName,
mimeType,
);
final sessionId = writeInfo.session;
final stream = sourceFile.openRead();
await for (final chunk in stream) {
await _safStream.writeChunk(sessionId, Uint8List.fromList(chunk));
}
// End the write stream
await _safStream.endWriteStream(sessionId);
debugPrint(
'SAF copyFileToSaf: successfully copied to $targetDirectoryUri/$fileName',
);
return writeInfo.fileResult.uri.toString();
} catch (e) {
debugPrint('SAF copyFileToSaf error: $e');
return null;
}
}
/// Write bytes directly to a SAF file
/// Returns the SAF URI of the created file, or null on failure
Future<String?> writeFileBytes(
String directoryUri,
String fileName,
String mimeType,
Uint8List bytes,
) async {
if (!isAvailable) return null;
try {
final result = await _safStream.writeFileBytes(
directoryUri,
fileName,
mimeType,
bytes,
);
return result.uri.toString();
} catch (e) {
debugPrint('SAF writeFileBytes error: $e');
return null;
}
}
/// Read bytes from a SAF file
Future<Uint8List?> readFileBytes(String fileUri) async {
if (!isAvailable) return null;
try {
return await _safStream.readFileBytes(fileUri);
} catch (e) {
debugPrint('SAF readFileBytes error: $e');
return null;
}
}
/// Check if a file exists in a SAF directory
Future<bool> fileExists(String parentUri, String fileName) async {
if (!isAvailable) return false;
try {
final child = await _safUtil.child(parentUri, [fileName]);
return child != null;
} catch (e) {
debugPrint('SAF fileExists error: $e');
return false;
}
}
/// Get the content URI for a file that should be readable by MediaStore/media players
/// For SAF files, this returns the same URI as input (content:// URIs are already readable)
String getReadableUri(String safUri) => safUri;
}
+41
View File
@@ -45,6 +45,9 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyAutoSkipIntro = 'auto_skip_intro';
static const String _keyAutoSkipCredits = 'auto_skip_credits';
static const String _keyAutoSkipDelay = 'auto_skip_delay';
static const String _keyCustomDownloadPath = 'custom_download_path';
static const String _keyCustomDownloadPathType = 'custom_download_path_type';
static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only';
SettingsService._();
@@ -843,6 +846,41 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds
}
// Custom Download Path
Future<void> setCustomDownloadPath(
String? path, {
String type = 'file',
}) async {
if (path == null) {
await prefs.remove(_keyCustomDownloadPath);
await prefs.remove(_keyCustomDownloadPathType);
} else {
await prefs.setString(_keyCustomDownloadPath, path);
await prefs.setString(_keyCustomDownloadPathType, type);
}
}
String? getCustomDownloadPath() {
return prefs.getString(_keyCustomDownloadPath);
}
String getCustomDownloadPathType() {
return prefs.getString(_keyCustomDownloadPathType) ?? 'file';
}
bool hasCustomDownloadPath() {
return prefs.containsKey(_keyCustomDownloadPath);
}
// Download on WiFi Only
Future<void> setDownloadOnWifiOnly(bool value) async {
await prefs.setBool(_keyDownloadOnWifiOnly, value);
}
bool getDownloadOnWifiOnly() {
return prefs.getBool(_keyDownloadOnWifiOnly) ?? false;
}
// Reset all settings to defaults
Future<void> resetAllSettings() async {
await Future.wait([
@@ -874,6 +912,9 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keySubtitleBackgroundOpacity),
prefs.remove(_keyAppLocale),
prefs.remove(_keyRememberTrackSelections),
prefs.remove(_keyCustomDownloadPath),
prefs.remove(_keyCustomDownloadPathType),
prefs.remove(_keyDownloadOnWifiOnly),
]);
}
+8 -1
View File
@@ -120,8 +120,9 @@ class PlexImageHelper {
/// Creates an optimized image URL for Plex content
/// Falls back to original URL if transcoding is not appropriate
/// If client is null (offline mode), returns empty string for relative paths
static String getOptimizedImageUrl({
required PlexClient client,
PlexClient? client,
required String? thumbPath,
required double maxWidth,
required double maxHeight,
@@ -140,6 +141,12 @@ class PlexImageHelper {
return basePath;
}
// If no client (offline mode), we can't build URLs for relative paths
// Images should already be cached from when they were originally loaded
if (client == null) {
return '';
}
// For art/backgrounds and clear logos, prefer the original image to avoid
// any aspect ratio changes from Plex photo transcoding.
if (imageType == ImageType.art || imageType == ImageType.logo) {
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/download_provider.dart';
import '../widgets/deletion_progress_dialog.dart';
class SmartDeletionHandler {
/// Execute deletion with smart progress dialog
/// Only shows dialog if deletion takes longer than delayMs
static Future<void> deleteWithProgress({
required BuildContext context,
required DownloadProvider provider,
required String globalKey,
int delayMs = 500,
}) async {
bool dialogShown = false;
bool deletionComplete = false;
// Start a timer to show dialog after delay
Future.delayed(Duration(milliseconds: delayMs), () {
if (!deletionComplete && context.mounted) {
dialogShown = true;
_showProgressDialog(context, provider, globalKey);
}
});
try {
// Start deletion
await provider.deleteDownload(globalKey);
deletionComplete = true;
// Close dialog if shown
if (dialogShown && context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
deletionComplete = true;
// Close dialog if shown
if (dialogShown && context.mounted) {
Navigator.of(context).pop();
}
rethrow; // Let caller handle error
}
}
/// Show progress dialog and listen to updates
static void _showProgressDialog(
BuildContext context,
DownloadProvider provider,
String globalKey,
) {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => Consumer<DownloadProvider>(
builder: (context, provider, child) {
final progress = provider.getDeletionProgress(globalKey);
// If no progress, show simple fallback
if (progress == null) {
return const AlertDialog(
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(width: 20),
Text('Deleting...'),
],
),
);
}
return DeletionProgressDialog(progress: progress);
},
),
);
}
}
+3
View File
@@ -21,6 +21,7 @@ import '../services/settings_service.dart';
/// loads the saved preference for the series/movie. Defaults to 0 if no preference exists.
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
/// useful for episode-to-episode navigation. Defaults to false.
/// - [isOffline]: If true, plays from downloaded content without requiring server connection.
///
/// Returns a Future that completes with a boolean indicating whether the content
/// was watched, or null if navigation was cancelled.
@@ -32,6 +33,7 @@ Future<bool?> navigateToVideoPlayer(
double? preferredPlaybackRate,
int? selectedMediaIndex,
bool usePushReplacement = false,
bool isOffline = false,
}) async {
// Extract navigator before any async operations
final navigator = Navigator.of(context);
@@ -60,6 +62,7 @@ Future<bool?> navigateToVideoPlayer(
preferredSubtitleTrack: preferredSubtitleTrack,
preferredPlaybackRate: preferredPlaybackRate,
selectedMediaIndex: mediaIndex,
isOffline: isOffline,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../models/deletion_progress.dart';
import '../i18n/strings.g.dart';
class DeletionProgressDialog extends StatelessWidget {
final DeletionProgress progress;
const DeletionProgressDialog({super.key, required this.progress});
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
return PopScope(
canPop: false, // Prevent back button dismissal
child: AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Circular progress indicator
const SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(),
),
const SizedBox(height: 24),
// Progress text
Text(
t.downloads.deletingWithProgress(
title: progress.itemTitle,
current: progress.currentItem,
total: progress.totalItems,
),
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Progress text
Text(
'Deleting ${progress.itemTitle}... (${progress.currentItem} of ${progress.totalItems})',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Progress bar
LinearProgressIndicator(value: progress.progressPercent),
const SizedBox(height: 8),
// Percentage text
Text(
'${progress.progressPercentInt}%',
style: Theme.of(context).textTheme.bodySmall,
),
// Optional: Current operation
if (progress.currentOperation != null) ...[
const SizedBox(height: 8),
Text(
progress.currentOperation!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
}
+363
View File
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import '../models/download_progress.dart';
import '../models/download_status.dart';
/// Represents a node type in the download tree
enum DownloadNodeType { show, season, episode, movie }
/// Node in the download tree hierarchy
class DownloadTreeNode {
final String id;
final DownloadNodeType type;
final String title;
final DownloadProgress? progress;
final List<DownloadTreeNode> children;
// Episode-specific
final int? episodeNumber;
final int? seasonNumber;
DownloadTreeNode({
required this.id,
required this.type,
required this.title,
this.progress,
this.children = const [],
this.episodeNumber,
this.seasonNumber,
});
/// Calculate aggregate progress for shows/seasons
int get aggregateProgress {
if (progress != null) return progress!.progress;
if (children.isEmpty) return 0;
int total = 0;
for (final child in children) {
total += child.aggregateProgress;
}
return (total / children.length).round();
}
/// Get the status - individual or aggregate
DownloadStatus get status {
if (progress != null) return progress!.status;
if (children.isEmpty) return DownloadStatus.queued;
// Aggregate status from children
final statuses = children.map((c) => c.status).toSet();
if (statuses.contains(DownloadStatus.downloading))
return DownloadStatus.downloading;
if (statuses.contains(DownloadStatus.queued)) return DownloadStatus.queued;
if (statuses.contains(DownloadStatus.paused)) return DownloadStatus.paused;
if (statuses.contains(DownloadStatus.failed)) return DownloadStatus.failed;
if (statuses.every((s) => s == DownloadStatus.completed))
return DownloadStatus.completed;
return DownloadStatus.queued;
}
}
/// Widget that renders a single node in the download tree with radial progress indicator
class DownloadTreeItem extends StatelessWidget {
final DownloadTreeNode node;
final int depth;
final bool isExpanded;
final VoidCallback? onToggle;
final VoidCallback? onPause;
final VoidCallback? onCancel;
final VoidCallback? onDelete;
final VoidCallback? onResume;
final VoidCallback? onRetry;
const DownloadTreeItem({
super.key,
required this.node,
required this.depth,
this.isExpanded = false,
this.onToggle,
this.onPause,
this.onCancel,
this.onDelete,
this.onResume,
this.onRetry,
});
@override
Widget build(BuildContext context) {
final indentation = depth * 24.0;
return Container(
padding: EdgeInsets.only(left: indentation),
child: InkWell(
onTap: node.children.isNotEmpty ? onToggle : null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
child: Row(
children: [
// Expand/collapse icon for parent nodes
if (node.children.isNotEmpty)
_buildExpandIcon()
else if (node.type == DownloadNodeType.episode)
const SizedBox(width: 24), // Spacing for episodes
// Radial progress indicator
_buildRadialProgress(context),
const SizedBox(width: 12),
// Title and metadata
Expanded(child: _buildTitleSection(context)),
// Action buttons
_buildActionButtons(context),
],
),
),
),
);
}
Widget _buildExpandIcon() {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(
isExpanded ? Icons.expand_more : Icons.chevron_right,
size: 24,
),
);
}
Widget _buildRadialProgress(BuildContext context) {
final size = _getProgressSize();
final progress = node.aggregateProgress;
final color = _getStatusColor(node.status);
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// Background circle
CircularProgressIndicator(
value: 1.0,
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(color.withOpacity(0.2)),
),
// Progress circle
CircularProgressIndicator(
value: progress / 100.0,
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
// Percentage text
Text(
'$progress%',
style: TextStyle(
fontSize: size > 30 ? 10 : 8,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
],
),
);
}
Widget _buildTitleSection(BuildContext context) {
switch (node.type) {
case DownloadNodeType.show:
return _buildShowTitle(context);
case DownloadNodeType.season:
return _buildSeasonTitle(context);
case DownloadNodeType.episode:
return _buildEpisodeTitle(context);
case DownloadNodeType.movie:
return _buildMovieTitle(context);
}
}
Widget _buildShowTitle(BuildContext context) {
final episodeCount = node.children.fold<int>(
0,
(sum, season) => sum + season.children.length,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
Text(
'$episodeCount episode${episodeCount != 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
);
}
Widget _buildSeasonTitle(BuildContext context) {
final seasonNum = node.seasonNumber ?? 1;
return Text(
'Season $seasonNum',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
);
}
Widget _buildEpisodeTitle(BuildContext context) {
final episodeNum = node.episodeNumber ?? 0;
final episodeLabel = episodeNum.toString().padLeft(2, '0');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'E$episodeLabel - ${node.title}',
style: const TextStyle(fontSize: 14),
),
if (node.progress != null && node.progress!.currentFile != null)
Text(
node.progress!.currentFile!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
);
}
Widget _buildMovieTitle(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
if (node.progress != null && node.progress!.currentFile != null)
Text(
node.progress!.currentFile!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
);
}
Widget _buildActionButtons(BuildContext context) {
// Only show action buttons for leaf nodes (episodes/movies)
if (node.type != DownloadNodeType.episode &&
node.type != DownloadNodeType.movie) {
return const SizedBox.shrink();
}
final status = node.status;
switch (status) {
case DownloadStatus.downloading:
return IconButton(
icon: const Icon(Icons.pause),
onPressed: onPause,
tooltip: 'Pause',
iconSize: 20,
);
case DownloadStatus.queued:
return IconButton(
icon: const Icon(Icons.close),
onPressed: onCancel,
tooltip: 'Cancel',
iconSize: 20,
);
case DownloadStatus.paused:
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: onResume,
tooltip: 'Resume',
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: onCancel,
tooltip: 'Cancel',
iconSize: 20,
),
],
);
case DownloadStatus.completed:
return IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
tooltip: 'Delete',
iconSize: 20,
);
case DownloadStatus.failed:
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: onRetry,
tooltip: 'Retry',
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
tooltip: 'Delete',
iconSize: 20,
),
],
);
case DownloadStatus.cancelled:
return IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
tooltip: 'Delete',
iconSize: 20,
);
}
}
double _getProgressSize() {
switch (node.type) {
case DownloadNodeType.show:
case DownloadNodeType.movie:
return 36.0;
case DownloadNodeType.season:
case DownloadNodeType.episode:
return 28.0;
}
}
Color _getStatusColor(DownloadStatus status) {
switch (status) {
case DownloadStatus.downloading:
return Colors.blue;
case DownloadStatus.queued:
return Colors.orange;
case DownloadStatus.completed:
return Colors.green;
case DownloadStatus.failed:
return Colors.red;
case DownloadStatus.paused:
return Colors.amber;
case DownloadStatus.cancelled:
return Colors.grey;
}
}
}
+639
View File
@@ -0,0 +1,639 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import '../models/download_progress.dart';
import '../models/download_status.dart';
import '../models/plex_metadata.dart';
/// Represents a node in the download tree
class DownloadTreeNode {
final String key;
final String title;
final DownloadNodeType type;
final double progress; // 0.0-1.0
final DownloadStatus status;
final List<DownloadTreeNode> children;
final PlexMetadata? metadata;
final DownloadProgress? downloadProgress;
const DownloadTreeNode({
required this.key,
required this.title,
required this.type,
this.progress = 0.0,
required this.status,
this.children = const [],
this.metadata,
this.downloadProgress,
});
/// Check if this node has children
bool get hasChildren => children.isNotEmpty;
/// Get the number of completed children
int get completedChildrenCount {
return children
.where((child) => child.status == DownloadStatus.completed)
.length;
}
/// Get the number of downloading children
int get downloadingChildrenCount {
return children
.where((child) => child.status == DownloadStatus.downloading)
.length;
}
}
/// Type of node in the download tree
enum DownloadNodeType { show, season, episode, movie }
/// Hierarchical tree view for downloads
/// Groups TV shows by show -> season -> episode
/// Movies appear at top level
class DownloadTreeView extends StatefulWidget {
final Map<String, DownloadProgress> downloads;
final Map<String, PlexMetadata> metadata;
final void Function(String globalKey)? onPause;
final void Function(String globalKey)? onResume;
final void Function(String globalKey)? onRetry;
final void Function(String globalKey)? onCancel;
final void Function(String globalKey)? onDelete;
const DownloadTreeView({
super.key,
required this.downloads,
required this.metadata,
this.onPause,
this.onResume,
this.onRetry,
this.onCancel,
this.onDelete,
});
@override
State<DownloadTreeView> createState() => _DownloadTreeViewState();
}
class _DownloadTreeViewState extends State<DownloadTreeView> {
final Set<String> _expandedNodes = {};
@override
Widget build(BuildContext context) {
final tree = _buildTree();
final flattenedNodes = _flattenTree(tree);
if (flattenedNodes.isEmpty) {
return const Center(child: Text('No downloads'));
}
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: flattenedNodes.length,
itemBuilder: (context, index) {
final item = flattenedNodes[index];
return _buildTreeItem(item.node, item.depth);
},
);
}
/// Build the download tree from flat download list
List<DownloadTreeNode> _buildTree() {
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
final List<DownloadTreeNode> movies = [];
// Group downloads
for (final entry in widget.downloads.entries) {
final globalKey = entry.key;
final download = entry.value;
final meta = widget.metadata[globalKey];
if (meta == null) continue;
if (meta.type.toLowerCase() == 'episode') {
// Group episodes by show
final showKey = meta.grandparentRatingKey ?? 'unknown';
showGroups.putIfAbsent(showKey, () => []);
showGroups[showKey]!.add(entry);
} else if (meta.type.toLowerCase() == 'movie') {
// Movies go at top level
movies.add(
DownloadTreeNode(
key: globalKey,
title: meta.title,
type: DownloadNodeType.movie,
progress: download.progressPercent,
status: download.status,
metadata: meta,
downloadProgress: download,
),
);
}
}
// Build show nodes
final List<DownloadTreeNode> shows = [];
for (final showEntry in showGroups.entries) {
final showKey = showEntry.key;
final episodes = showEntry.value;
if (episodes.isEmpty) continue;
// Get show metadata from first episode
final firstEpisode = widget.metadata[episodes.first.key];
final showTitle = firstEpisode?.grandparentTitle ?? 'Unknown Show';
// Group episodes by season
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups =
{};
for (final episode in episodes) {
final meta = widget.metadata[episode.key];
if (meta == null) continue;
final seasonKey = meta.parentRatingKey ?? 'unknown';
seasonGroups.putIfAbsent(seasonKey, () => []);
seasonGroups[seasonKey]!.add(episode);
}
// Build season nodes
final List<DownloadTreeNode> seasons = [];
for (final seasonEntry in seasonGroups.entries) {
final seasonKey = seasonEntry.key;
final seasonEpisodes = seasonEntry.value;
if (seasonEpisodes.isEmpty) continue;
// Get season metadata from first episode
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
final seasonTitle = firstEpisode?.parentTitle ?? 'Unknown Season';
final seasonNumber = firstEpisode?.parentIndex;
// Build episode nodes
final List<DownloadTreeNode> episodeNodes = [];
for (final episodeEntry in seasonEpisodes) {
final globalKey = episodeEntry.key;
final download = episodeEntry.value;
final meta = widget.metadata[globalKey];
if (meta == null) continue;
final episodeNumber = meta.index;
final episodeTitle = episodeNumber != null
? 'Episode $episodeNumber - ${meta.title}'
: meta.title;
episodeNodes.add(
DownloadTreeNode(
key: globalKey,
title: episodeTitle,
type: DownloadNodeType.episode,
progress: download.progressPercent,
status: download.status,
metadata: meta,
downloadProgress: download,
),
);
}
// Sort episodes by episode number only (not by status)
episodeNodes.sort((a, b) {
final aIndex = a.metadata?.index ?? 0;
final bIndex = b.metadata?.index ?? 0;
return aIndex.compareTo(bIndex);
});
// Calculate aggregate season progress
final seasonProgress = episodeNodes.isEmpty
? 0.0
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) /
episodeNodes.length;
final seasonStatus = _determineAggregateStatus(
episodeNodes.map((e) => e.status).toList(),
);
final displayTitle = seasonNumber != null
? 'Season $seasonNumber'
: seasonTitle;
seasons.add(
DownloadTreeNode(
key: '$showKey:$seasonKey',
title: displayTitle,
type: DownloadNodeType.season,
progress: seasonProgress,
status: seasonStatus,
children: episodeNodes,
),
);
}
// Sort seasons by season number
seasons.sort((a, b) {
final aSeasonNum =
widget.metadata[a.children.first.key]?.parentIndex ?? 0;
final bSeasonNum =
widget.metadata[b.children.first.key]?.parentIndex ?? 0;
return aSeasonNum.compareTo(bSeasonNum);
});
// Calculate aggregate show progress
final showProgress = seasons.isEmpty
? 0.0
: seasons.map((s) => s.progress).reduce((a, b) => a + b) /
seasons.length;
final showStatus = _determineAggregateStatus(
seasons.map((s) => s.status).toList(),
);
shows.add(
DownloadTreeNode(
key: showKey,
title: showTitle,
type: DownloadNodeType.show,
progress: showProgress,
status: showStatus,
children: seasons,
),
);
}
// Sort shows by status and title
shows.sort((a, b) {
final statusCompare = _compareByStatus(a.status, b.status);
if (statusCompare != 0) return statusCompare;
return a.title.compareTo(b.title);
});
// Sort movies by status and title
movies.sort((a, b) {
final statusCompare = _compareByStatus(a.status, b.status);
if (statusCompare != 0) return statusCompare;
return a.title.compareTo(b.title);
});
// Combine movies and shows
return [...movies, ...shows];
}
/// Determine aggregate status from child statuses
/// Priority: downloading > queued > paused > completed > failed
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
if (statuses.isEmpty) return DownloadStatus.queued;
if (statuses.any((s) => s == DownloadStatus.downloading)) {
return DownloadStatus.downloading;
}
if (statuses.any((s) => s == DownloadStatus.queued)) {
return DownloadStatus.queued;
}
if (statuses.any((s) => s == DownloadStatus.paused)) {
return DownloadStatus.paused;
}
if (statuses.any((s) => s == DownloadStatus.failed)) {
return DownloadStatus.failed;
}
return DownloadStatus.completed;
}
/// Compare statuses for sorting (downloading first, then queued, etc.)
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
const statusOrder = {
DownloadStatus.downloading: 0,
DownloadStatus.queued: 1,
DownloadStatus.paused: 2,
DownloadStatus.completed: 3,
DownloadStatus.failed: 4,
DownloadStatus.cancelled: 5,
};
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
}
/// Flatten the tree into a list of visible nodes with their depths
List<_FlatNode> _flattenTree(List<DownloadTreeNode> nodes, [int depth = 0]) {
final List<_FlatNode> result = [];
for (final node in nodes) {
result.add(_FlatNode(node: node, depth: depth));
// Add children if node is expanded
if (_expandedNodes.contains(node.key) && node.hasChildren) {
result.addAll(_flattenTree(node.children, depth + 1));
}
}
return result;
}
/// Toggle node expansion
void _toggleExpansion(String key) {
setState(() {
if (_expandedNodes.contains(key)) {
_expandedNodes.remove(key);
} else {
_expandedNodes.add(key);
}
});
}
/// Build a tree item widget
Widget _buildTreeItem(DownloadTreeNode node, int depth) {
final isExpanded = _expandedNodes.contains(node.key);
final canExpand = node.hasChildren;
return InkWell(
onTap: canExpand ? () => _toggleExpansion(node.key) : null,
child: Padding(
padding: EdgeInsets.only(left: depth * 16.0),
child: _buildNodeContent(node, isExpanded, canExpand),
),
);
}
/// Build the content for a node
Widget _buildNodeContent(
DownloadTreeNode node,
bool isExpanded,
bool canExpand,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Expand/collapse icon
if (canExpand)
Icon(isExpanded ? Icons.expand_more : Icons.chevron_right, size: 20)
else
const SizedBox(width: 20),
const SizedBox(width: 8),
// Status icon
_buildStatusIcon(node.status),
const SizedBox(width: 12),
// Title and info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: canExpand ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (canExpand) ...[
const SizedBox(height: 4),
Text(
_getNodeSummary(node),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
// Progress bar
if (node.status == DownloadStatus.downloading ||
node.status == DownloadStatus.queued) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: node.progress,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
if (node.downloadProgress != null) ...[
const SizedBox(height: 4),
Text(
'${(node.progress * 100).toStringAsFixed(1)}% - ${node.downloadProgress!.speedFormatted}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
],
],
),
),
// Actions
_buildActions(node),
],
),
);
}
/// Build status icon
Widget _buildStatusIcon(DownloadStatus status) {
IconData iconData;
Color? color;
switch (status) {
case DownloadStatus.downloading:
iconData = Icons.downloading;
color = Colors.blue;
break;
case DownloadStatus.queued:
iconData = Icons.schedule;
color = Colors.orange;
break;
case DownloadStatus.paused:
iconData = Icons.pause_circle_outline;
color = Colors.grey;
break;
case DownloadStatus.completed:
iconData = Icons.check_circle;
color = Colors.green;
break;
case DownloadStatus.failed:
iconData = Icons.error;
color = Colors.red;
break;
case DownloadStatus.cancelled:
iconData = Icons.cancel;
color = Colors.grey;
break;
case DownloadStatus.partial:
iconData = Icons.downloading;
color = Colors.orange;
break;
}
return Icon(iconData, size: 20, color: color);
}
/// Get summary text for container nodes (shows/seasons)
String _getNodeSummary(DownloadTreeNode node) {
final total = node.children.length;
final completed = node.completedChildrenCount;
return '$completed/$total completed';
}
/// Build action buttons for nodes
Widget _buildActions(DownloadTreeNode node) {
final globalKey = node.key;
final isContainer =
node.type == DownloadNodeType.show ||
node.type == DownloadNodeType.season;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// For individual items (episodes/movies)
if (!isContainer) ...[
// Pause button for downloading items
if (node.status == DownloadStatus.downloading &&
widget.onPause != null)
IconButton(
icon: const Icon(Icons.pause, size: 20),
onPressed: () => widget.onPause!(globalKey),
tooltip: 'Pause',
),
// Resume button for paused items
if (node.status == DownloadStatus.paused && widget.onResume != null)
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: () => widget.onResume!(globalKey),
tooltip: 'Resume',
),
// Cancel button for downloading/queued items
if ((node.status == DownloadStatus.downloading ||
node.status == DownloadStatus.queued) &&
widget.onCancel != null)
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => widget.onCancel!(globalKey),
tooltip: 'Cancel',
),
// Retry button for failed items
if (node.status == DownloadStatus.failed && widget.onRetry != null)
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () => widget.onRetry!(globalKey),
tooltip: t.downloads.retryDownload,
),
// Delete button for completed/failed/cancelled items
if ((node.status == DownloadStatus.completed ||
node.status == DownloadStatus.failed ||
node.status == DownloadStatus.cancelled) &&
widget.onDelete != null)
IconButton(
icon: const Icon(Icons.delete, size: 20),
onPressed: () => widget.onDelete!(globalKey),
tooltip: 'Delete',
),
],
// For container nodes (shows/seasons)
if (isContainer) ...[
// Pause all button - show if any children are downloading or queued
if ((node.status == DownloadStatus.downloading ||
node.status == DownloadStatus.queued) &&
widget.onPause != null)
IconButton(
icon: const Icon(Icons.pause, size: 20),
onPressed: () => _pauseAllChildren(node),
tooltip: 'Pause all',
),
// Resume all button - show if container is paused
if (node.status == DownloadStatus.paused && widget.onResume != null)
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: () => _resumeAllChildren(node),
tooltip: 'Resume all',
),
// Delete all button
if (widget.onDelete != null)
IconButton(
icon: const Icon(Icons.delete_sweep, size: 20),
onPressed: () => _deleteAllChildren(node),
tooltip: 'Delete all',
),
],
],
);
}
/// Pause all active (downloading and queued) children of a container node
void _pauseAllChildren(DownloadTreeNode node) {
final keys = _getActiveChildKeys(node);
for (final key in keys) {
widget.onPause?.call(key);
}
}
/// Resume all paused children of a container node
void _resumeAllChildren(DownloadTreeNode node) {
final keys = _getPausedChildKeys(node);
for (final key in keys) {
widget.onResume?.call(key);
}
}
/// Get all active (downloading or queued) child keys from a container node
List<String> _getActiveChildKeys(DownloadTreeNode node) {
final List<String> keys = [];
for (final child in node.children) {
if (child.hasChildren) {
keys.addAll(_getActiveChildKeys(child));
} else if (child.status == DownloadStatus.downloading ||
child.status == DownloadStatus.queued) {
keys.add(child.key);
}
}
return keys;
}
/// Get all paused child keys from a container node
List<String> _getPausedChildKeys(DownloadTreeNode node) {
final List<String> keys = [];
for (final child in node.children) {
if (child.hasChildren) {
keys.addAll(_getPausedChildKeys(child));
} else if (child.status == DownloadStatus.paused) {
keys.add(child.key);
}
}
return keys;
}
/// Delete all children of a container node
void _deleteAllChildren(DownloadTreeNode node) {
final allKeys = _getAllChildKeys(node);
for (final key in allKeys) {
widget.onDelete?.call(key);
}
}
/// Get all leaf node keys from a container node
List<String> _getAllChildKeys(DownloadTreeNode node) {
final List<String> keys = [];
for (final child in node.children) {
if (child.hasChildren) {
keys.addAll(_getAllChildKeys(child));
} else {
keys.add(child.key);
}
}
return keys;
}
}
/// Helper class to store a node with its depth in the flattened tree
class _FlatNode {
final DownloadTreeNode node;
final int depth;
const _FlatNode({required this.node, required this.depth});
}
+5
View File
@@ -20,6 +20,9 @@ class FocusableMediaCard extends StatefulWidget {
final bool isInContinueWatching;
final String? collectionId;
/// True for downloaded content without server access
final bool isOffline;
/// Optional external focus node for programmatic focus control.
/// If not provided, an internal focus node is created.
final FocusNode? focusNode;
@@ -43,6 +46,7 @@ class FocusableMediaCard extends StatefulWidget {
this.forceGridMode = false,
this.isInContinueWatching = false,
this.collectionId,
this.isOffline = false,
this.focusNode,
this.onNavigateUp,
this.onBack,
@@ -78,6 +82,7 @@ class _FocusableMediaCardState extends State<FocusableMediaCard> {
forceGridMode: widget.forceGridMode,
isInContinueWatching: widget.isInContinueWatching,
collectionId: widget.collectionId,
isOffline: widget.isOffline,
),
);
}
+4 -6
View File
@@ -227,10 +227,7 @@ class HubSectionState extends State<HubSection> {
final Map<int, GlobalKey<MediaCardState>> _mediaCardKeys = {};
GlobalKey<MediaCardState> _getMediaCardKey(int index) {
return _mediaCardKeys.putIfAbsent(
index,
() => GlobalKey<MediaCardState>(),
);
return _mediaCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
}
void _activateCurrentItem() {
@@ -346,8 +343,9 @@ class HubSectionState extends State<HubSection> {
child: _LockedHubItemWrapper(
isFocused: isItemFocused,
onTap: () => _onItemTapped(index),
onLongPress: () =>
_mediaCardKeys[index]?.currentState?.showContextMenu(),
onLongPress: () => _mediaCardKeys[index]
?.currentState
?.showContextMenu(),
child: MediaCard(
key: _getMediaCardKey(index),
item: item,
+63 -6
View File
@@ -3,7 +3,9 @@ import 'package:provider/provider.dart';
import '../../services/plex_client.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/download_storage_service.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
@@ -31,6 +33,7 @@ class MediaCard extends StatefulWidget {
final bool isInContinueWatching;
final String?
collectionId; // The collection ID if displaying within a collection
final bool isOffline; // True for downloaded content without server access
const MediaCard({
super.key,
@@ -43,6 +46,7 @@ class MediaCard extends StatefulWidget {
this.forceGridMode = false,
this.isInContinueWatching = false,
this.collectionId,
this.isOffline = false,
});
@override
@@ -164,6 +168,7 @@ class MediaCardState extends State<MediaCard> {
final result = await navigateToVideoPlayer(
context,
metadata: widget.item,
isOffline: widget.isOffline,
);
// Refresh parent screen if result indicates it's needed
if (result == true) {
@@ -184,7 +189,10 @@ class MediaCardState extends State<MediaCard> {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(metadata: widget.item),
builder: (context) => MediaDetailScreen(
metadata: widget.item,
isOffline: widget.isOffline,
),
),
);
// Refresh parent screen if result indicates it's needed
@@ -194,6 +202,25 @@ class MediaCardState extends State<MediaCard> {
}
}
/// Get the local poster path for offline mode
String? _getLocalPosterPath(BuildContext context) {
if (!widget.isOffline) return null;
if (widget.item is! PlexMetadata) return null;
final metadata = widget.item as PlexMetadata;
if (metadata.serverId == null) return null;
final downloadProvider = context.read<DownloadProvider>();
final globalKey = '${metadata.serverId}:${metadata.ratingKey}';
// Get artwork reference and resolve to local path using hash (includes serverId)
final artwork = downloadProvider.getArtworkPaths(globalKey);
return artwork?.getLocalPath(
DownloadStorageService.instance,
metadata.serverId!,
);
}
@override
Widget build(BuildContext context) {
final settingsProvider = context.watch<SettingsProvider>();
@@ -202,6 +229,7 @@ class MediaCardState extends State<MediaCard> {
: settingsProvider.viewMode;
final semanticLabel = _buildSemanticLabel();
final localPosterPath = _getLocalPosterPath(context);
final cardWidget = viewMode == ViewMode.grid
? _MediaCardGrid(
@@ -211,6 +239,8 @@ class MediaCardState extends State<MediaCard> {
semanticLabel: semanticLabel,
onTap: () => _handleTap(context),
onLongPress: _showContextMenu,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
)
: _MediaCardList(
item: widget.item,
@@ -218,6 +248,8 @@ class MediaCardState extends State<MediaCard> {
onTap: () => _handleTap(context),
onLongPress: _showContextMenu,
density: settingsProvider.libraryDensity,
isOffline: widget.isOffline,
localPosterPath: localPosterPath,
);
// Use context menu for both PlexMetadata and PlexPlaylist items
@@ -243,6 +275,8 @@ class _MediaCardGrid extends StatelessWidget {
final String semanticLabel;
final VoidCallback onTap;
final VoidCallback onLongPress;
final bool isOffline;
final String? localPosterPath;
const _MediaCardGrid({
required this.item,
@@ -251,6 +285,8 @@ class _MediaCardGrid extends StatelessWidget {
required this.semanticLabel,
required this.onTap,
required this.onLongPress,
this.isOffline = false,
this.localPosterPath,
});
@override
@@ -321,7 +357,12 @@ class _MediaCardGrid extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context, item),
child: _buildPosterImage(
context,
item,
isOffline: isOffline,
localPosterPath: localPosterPath,
),
),
_PosterOverlay(item: item),
],
@@ -336,6 +377,8 @@ class _MediaCardList extends StatelessWidget {
final VoidCallback onTap;
final VoidCallback onLongPress;
final LibraryDensity density;
final bool isOffline;
final String? localPosterPath;
const _MediaCardList({
required this.item,
@@ -343,6 +386,8 @@ class _MediaCardList extends StatelessWidget {
required this.onTap,
required this.onLongPress,
required this.density,
this.isOffline = false,
this.localPosterPath,
});
double get _posterWidth {
@@ -522,7 +567,12 @@ class _MediaCardList extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context, item),
child: _buildPosterImage(
context,
item,
isOffline: isOffline,
localPosterPath: localPosterPath,
),
),
_PosterOverlay(item: item),
],
@@ -629,7 +679,12 @@ PlexClient _getClientForItem(BuildContext context, dynamic item) {
return context.getClientForServer(serverId);
}
Widget _buildPosterImage(BuildContext context, dynamic item) {
Widget _buildPosterImage(
BuildContext context,
dynamic item, {
bool isOffline = false,
String? localPosterPath,
}) {
String? posterUrl;
IconData fallbackIcon = Icons.movie;
@@ -638,22 +693,24 @@ Widget _buildPosterImage(BuildContext context, dynamic item) {
fallbackIcon = Icons.playlist_play;
return PlexOptimizedImage.playlist(
client: _getClientForItem(context, item),
client: isOffline ? null : _getClientForItem(context, item),
imagePath: posterUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
localFilePath: localPosterPath,
);
} else if (item is PlexMetadata) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
return PlexOptimizedImage.poster(
client: _getClientForItem(context, item),
client: isOffline ? null : _getClientForItem(context, item),
imagePath: posterUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
localFilePath: localPosterPath,
);
}
+186 -13
View File
@@ -6,12 +6,16 @@ import '../services/play_queue_launcher.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../providers/multi_server_provider.dart';
import '../providers/download_provider.dart';
import '../providers/offline_mode_provider.dart';
import '../providers/offline_watch_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../utils/library_refresh_notifier.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../utils/smart_deletion_handler.dart';
import '../widgets/file_info_bottom_sheet.dart';
import '../widgets/focusable_bottom_sheet.dart';
import '../widgets/focusable_list_tile.dart';
@@ -264,6 +268,39 @@ class MediaContextMenuState extends State<MediaContextMenu> {
);
}
// Download options (for episodes, movies, shows, and seasons)
if (itemType == 'episode' ||
itemType == 'movie' ||
itemType == 'show' ||
itemType == 'season') {
final downloadProvider = Provider.of<DownloadProvider>(
context,
listen: false,
);
final globalKey = '${metadata.serverId}:${metadata.ratingKey}';
final isDownloaded = downloadProvider.isDownloaded(globalKey);
if (isDownloaded) {
// Show delete download option
menuActions.add(
_MenuAction(
value: 'delete_download',
icon: Icons.delete,
label: t.downloads.deleteDownload,
),
);
} else {
// Show download option
menuActions.add(
_MenuAction(
value: 'download',
icon: Icons.download,
label: t.downloads.downloadNow,
),
);
}
}
// Add to... (for episodes, movies, shows, and seasons)
if (itemType == 'episode' ||
itemType == 'movie' ||
@@ -320,21 +357,55 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (!context.mounted) return;
// Check if we're in offline mode for watch actions
final offlineModeProvider = context.read<OfflineModeProvider>();
final isOffline = offlineModeProvider.isOffline;
switch (selected) {
case 'watch':
await _executeAction(
context,
() => client.markAsWatched(metadata!.ratingKey),
t.messages.markedAsWatched,
);
if (isOffline && metadata?.serverId != null) {
// Offline mode: queue action for later sync
final offlineWatch = context.read<OfflineWatchProvider>();
await offlineWatch.markAsWatched(
serverId: metadata!.serverId!,
ratingKey: metadata.ratingKey,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.markedAsWatchedOffline)),
);
widget.onRefresh?.call(metadata.ratingKey);
}
} else {
await _executeAction(
context,
() => client.markAsWatched(metadata!.ratingKey),
t.messages.markedAsWatched,
);
}
break;
case 'unwatch':
await _executeAction(
context,
() => client.markAsUnwatched(metadata!.ratingKey),
t.messages.markedAsUnwatched,
);
if (isOffline && metadata?.serverId != null) {
// Offline mode: queue action for later sync
final offlineWatch = context.read<OfflineWatchProvider>();
await offlineWatch.markAsUnwatched(
serverId: metadata!.serverId!,
ratingKey: metadata.ratingKey,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.markedAsUnwatchedOffline)),
);
widget.onRefresh?.call(metadata.ratingKey);
}
} else {
await _executeAction(
context,
() => client.markAsUnwatched(metadata!.ratingKey),
t.messages.markedAsUnwatched,
);
}
break;
case 'remove_from_continue_watching':
@@ -410,6 +481,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
case 'delete':
await _handleDelete(context, isCollection, isPlaylist);
break;
case 'download':
await _handleDownload(context);
break;
case 'delete_download':
await _handleDeleteDownload(context);
break;
}
} finally {
_isContextMenuOpen = false;
@@ -451,7 +530,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final client = _getClientForItem();
try {
final metadata = await client.getMetadata(ratingKey);
final metadata = await client.getMetadataWithImages(ratingKey);
if (metadata != null && context.mounted) {
await Navigator.push(
context,
@@ -750,7 +829,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (sectionId == null) {
try {
appLogger.d(' - Fetching full metadata for: ${metadata.ratingKey}');
final fullMetadata = await client.getMetadata(metadata.ratingKey);
final fullMetadata = await client.getMetadataWithImages(
metadata.ratingKey,
);
if (fullMetadata != null) {
sectionId = fullMetadata.librarySectionID;
appLogger.d(' - Section ID from full metadata: $sectionId');
@@ -777,7 +858,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
appLogger.d(
' - Trying to get from parent: ${metadata.grandparentRatingKey}',
);
final parentMeta = await client.getMetadata(
final parentMeta = await client.getMetadataWithImages(
metadata.grandparentRatingKey!,
);
sectionId = parentMeta?.librarySectionID;
@@ -1149,6 +1230,98 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
}
/// Handle download action
Future<void> _handleDownload(BuildContext context) async {
final downloadProvider = Provider.of<DownloadProvider>(
context,
listen: false,
);
final metadata = widget.item as PlexMetadata;
final client = _getClientForItem();
try {
final count = await downloadProvider.queueDownload(metadata, client);
if (context.mounted) {
// Show appropriate message based on count
final message = count > 1
? t.downloads.episodesQueued(count: count)
: t.downloads.downloadQueued;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
} on CellularDownloadBlockedException {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.settings.cellularDownloadBlocked)),
);
}
} catch (e) {
appLogger.e('Failed to queue download', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
}
/// Handle delete download action
Future<void> _handleDeleteDownload(BuildContext context) async {
final downloadProvider = Provider.of<DownloadProvider>(
context,
listen: false,
);
final metadata = widget.item as PlexMetadata;
final globalKey = '${metadata.serverId}:${metadata.ratingKey}';
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.downloads.deleteDownload),
content: Text(t.downloads.deleteConfirm(title: metadata.title)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.common.delete),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
// Use smart deletion handler (shows progress only if >500ms)
await SmartDeletionHandler.deleteWithProgress(
context: context,
provider: downloadProvider,
globalKey: globalKey,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.downloads.downloadDeleted)));
// Refresh the view if needed
widget.onRefresh?.call(metadata.ratingKey);
}
} catch (e) {
appLogger.e('Failed to delete download', error: e);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
+39 -9
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../services/plex_client.dart';
@@ -5,7 +6,7 @@ import '../utils/plex_image_helper.dart';
import 'media_card.dart';
class PlexOptimizedImage extends StatelessWidget {
final PlexClient client;
final PlexClient? client;
final String? imagePath;
final double? width;
final double? height;
@@ -19,10 +20,11 @@ class PlexOptimizedImage extends StatelessWidget {
final Alignment alignment;
final IconData? fallbackIcon;
final ImageType imageType;
final String? localFilePath;
const PlexOptimizedImage._({
super.key,
required this.client,
this.client,
required this.imagePath,
this.width,
this.height,
@@ -36,12 +38,13 @@ class PlexOptimizedImage extends StatelessWidget {
this.alignment = Alignment.center,
this.fallbackIcon,
this.imageType = ImageType.poster,
this.localFilePath,
});
/// Generic constructor for optimized images.
const factory PlexOptimizedImage({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -55,12 +58,13 @@ class PlexOptimizedImage extends StatelessWidget {
Alignment alignment,
IconData? fallbackIcon,
ImageType imageType,
String? localFilePath,
}) = PlexOptimizedImage._;
/// Named constructor for poster images with default fallback icon.
const factory PlexOptimizedImage.poster({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -72,12 +76,13 @@ class PlexOptimizedImage extends StatelessWidget {
bool enableTranscoding,
String? cacheKey,
Alignment alignment,
String? localFilePath,
}) = PlexOptimizedImage._poster;
/// Named constructor for episode thumbnails.
const factory PlexOptimizedImage.thumb({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -89,12 +94,13 @@ class PlexOptimizedImage extends StatelessWidget {
bool enableTranscoding,
String? cacheKey,
Alignment alignment,
String? localFilePath,
}) = PlexOptimizedImage._thumb;
/// Named constructor for playlist images.
const factory PlexOptimizedImage.playlist({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -106,11 +112,12 @@ class PlexOptimizedImage extends StatelessWidget {
bool enableTranscoding,
String? cacheKey,
Alignment alignment,
String? localFilePath,
}) = PlexOptimizedImage._playlist;
const PlexOptimizedImage._poster({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -122,6 +129,7 @@ class PlexOptimizedImage extends StatelessWidget {
bool enableTranscoding = true,
String? cacheKey,
Alignment alignment = Alignment.center,
String? localFilePath,
}) : this._(
key: key,
client: client,
@@ -138,11 +146,12 @@ class PlexOptimizedImage extends StatelessWidget {
alignment: alignment,
fallbackIcon: Icons.movie,
imageType: ImageType.poster,
localFilePath: localFilePath,
);
const PlexOptimizedImage._thumb({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -154,6 +163,7 @@ class PlexOptimizedImage extends StatelessWidget {
bool enableTranscoding = true,
String? cacheKey,
Alignment alignment = Alignment.center,
String? localFilePath,
}) : this._(
key: key,
client: client,
@@ -170,11 +180,12 @@ class PlexOptimizedImage extends StatelessWidget {
alignment: alignment,
fallbackIcon: Icons.video_library,
imageType: ImageType.thumb,
localFilePath: localFilePath,
);
const PlexOptimizedImage._playlist({
Key? key,
required PlexClient client,
PlexClient? client,
required String? imagePath,
double? width,
double? height,
@@ -186,6 +197,7 @@ class PlexOptimizedImage extends StatelessWidget {
bool enableTranscoding = true,
String? cacheKey,
Alignment alignment = Alignment.center,
String? localFilePath,
}) : this._(
key: key,
client: client,
@@ -202,10 +214,28 @@ class PlexOptimizedImage extends StatelessWidget {
alignment: alignment,
fallbackIcon: Icons.playlist_play,
imageType: ImageType.poster,
localFilePath: localFilePath,
);
@override
Widget build(BuildContext context) {
// Check for local file first
if (localFilePath != null) {
final file = File(localFilePath!);
if (file.existsSync()) {
return Image.file(
file,
width: width,
height: height,
fit: fit,
filterQuality: filterQuality,
alignment: alignment,
errorBuilder: (context, error, stackTrace) =>
_buildErrorWidget(context, error),
);
}
}
double resolvedDimension(
double? explicit,
double constraintMax,
+70 -30
View File
@@ -70,7 +70,9 @@ class NavigationRailItem extends StatelessWidget {
: isSelected
? t.text.withValues(alpha: 0.1) // Just selected
: isFocused
? t.text.withValues(alpha: 0.12) // Just focused (more visible)
? t.text.withValues(
alpha: 0.12,
) // Just focused (more visible)
: null,
borderRadius: borderRadius,
),
@@ -96,6 +98,7 @@ class NavigationRailItem extends StatelessWidget {
class SideNavigationRail extends StatefulWidget {
final int selectedIndex;
final String? selectedLibraryKey;
final bool isOfflineMode;
final ValueChanged<int> onDestinationSelected;
final ValueChanged<String> onLibrarySelected;
@@ -103,6 +106,7 @@ class SideNavigationRail extends StatefulWidget {
super.key,
required this.selectedIndex,
this.selectedLibraryKey,
this.isOfflineMode = false,
required this.onDestinationSelected,
required this.onLibrarySelected,
});
@@ -120,12 +124,14 @@ class SideNavigationRailState extends State<SideNavigationRail> {
late FocusNode _homeFocusNode;
late FocusNode _librariesFocusNode;
late FocusNode _searchFocusNode;
late FocusNode _downloadsFocusNode;
late FocusNode _settingsFocusNode;
// Focus state tracking
bool _isHomeFocused = false;
bool _isLibrariesFocused = false;
bool _isSearchFocused = false;
bool _isDownloadsFocused = false;
bool _isSettingsFocused = false;
// Map to store library item focus nodes and states
@@ -138,6 +144,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_homeFocusNode = FocusNode(debugLabel: 'nav_home');
_librariesFocusNode = FocusNode(debugLabel: 'nav_libraries');
_searchFocusNode = FocusNode(debugLabel: 'nav_search');
_downloadsFocusNode = FocusNode(debugLabel: 'nav_downloads');
_settingsFocusNode = FocusNode(debugLabel: 'nav_settings');
_homeFocusNode.addListener(
@@ -155,6 +162,11 @@ class SideNavigationRailState extends State<SideNavigationRail> {
setState(() => _isSearchFocused = _searchFocusNode.hasFocus);
}),
);
_downloadsFocusNode.addListener(
() => _onFocusChange(_downloadsFocusNode, () {
setState(() => _isDownloadsFocused = _downloadsFocusNode.hasFocus);
}),
);
_settingsFocusNode.addListener(
() => _onFocusChange(_settingsFocusNode, () {
setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus);
@@ -173,6 +185,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_homeFocusNode.dispose();
_librariesFocusNode.dispose();
_searchFocusNode.dispose();
_downloadsFocusNode.dispose();
_settingsFocusNode.dispose();
for (final node in _libraryFocusNodes.values) {
node.dispose();
@@ -218,6 +231,9 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_searchFocusNode.requestFocus();
break;
case 3:
_downloadsFocusNode.requestFocus();
break;
case 4:
_settingsFocusNode.requestFocus();
break;
}
@@ -359,45 +375,69 @@ class SideNavigationRailState extends State<SideNavigationRail> {
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
// Home
// In offline mode, only show Downloads and Settings
if (!widget.isOfflineMode) ...[
// Home
_buildNavItem(
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: Translations.of(context).navigation.home,
isSelected: widget.selectedIndex == 0,
isFocused: _isHomeFocused,
onTap: () => widget.onDestinationSelected(0),
focusNode: _homeFocusNode,
),
const SizedBox(height: 8),
// Libraries section
_buildLibrariesSection(visibleLibraries, t),
const SizedBox(height: 8),
// Search
_buildNavItem(
icon: Icons.search,
selectedIcon: Icons.search,
label: Translations.of(context).navigation.search,
isSelected: widget.selectedIndex == 2,
isFocused: _isSearchFocused,
onTap: () => widget.onDestinationSelected(2),
focusNode: _searchFocusNode,
),
const SizedBox(height: 8),
],
// Downloads (index 0 in offline mode, 3 in online mode)
_buildNavItem(
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: Translations.of(context).navigation.home,
isSelected: widget.selectedIndex == 0,
isFocused: _isHomeFocused,
onTap: () => widget.onDestinationSelected(0),
focusNode: _homeFocusNode,
icon: Icons.download_outlined,
selectedIcon: Icons.download,
label: Translations.of(context).navigation.downloads,
isSelected: widget.isOfflineMode
? widget.selectedIndex == 0
: widget.selectedIndex == 3,
isFocused: _isDownloadsFocused,
onTap: () => widget.onDestinationSelected(
widget.isOfflineMode ? 0 : 3,
),
focusNode: _downloadsFocusNode,
),
const SizedBox(height: 8),
// Libraries section
_buildLibrariesSection(visibleLibraries, t),
const SizedBox(height: 8),
// Search
_buildNavItem(
icon: Icons.search,
selectedIcon: Icons.search,
label: Translations.of(context).navigation.search,
isSelected: widget.selectedIndex == 2,
isFocused: _isSearchFocused,
onTap: () => widget.onDestinationSelected(2),
focusNode: _searchFocusNode,
),
const SizedBox(height: 8),
// Settings
// Settings (index 1 in offline mode, 4 in online mode)
_buildNavItem(
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
label: Translations.of(context).navigation.settings,
isSelected: widget.selectedIndex == 3,
isSelected: widget.isOfflineMode
? widget.selectedIndex == 1
: widget.selectedIndex == 4,
isFocused: _isSettingsFocused,
onTap: () => widget.onDestinationSelected(3),
onTap: () => widget.onDestinationSelected(
widget.isOfflineMode ? 1 : 4,
),
focusNode: _settingsFocusNode,
),
],
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../mpv/mpv.dart';
import '../../../services/plex_client.dart';
import '../../../services/download_storage_service.dart';
import '../../../models/plex_media_info.dart';
import '../../../utils/duration_formatter.dart';
import '../../../utils/provider_extensions.dart';
@@ -67,9 +68,14 @@ class _ChapterSheetState extends State<ChapterSheet> {
super.dispose();
}
/// Get the correct PlexClient for the metadata's server
PlexClient _getClientForChapters(BuildContext context) {
return context.getClientForServer(widget.serverId!);
/// Get the PlexClient for chapters, or null if unavailable (offline mode)
PlexClient? _tryGetClientForChapters(BuildContext context) {
if (widget.serverId == null) return null;
try {
return context.getClientForServer(widget.serverId!);
} catch (_) {
return null;
}
}
@override
@@ -117,6 +123,15 @@ class _ChapterSheetState extends State<ChapterSheet> {
final chapter = widget.chapters[index];
final isCurrentChapter = currentChapterIndex == index;
// Get local file path for offline chapter thumbnails
final localThumbPath =
widget.serverId != null && chapter.thumb != null
? DownloadStorageService.instance.getArtworkPathSync(
widget.serverId!,
chapter.thumb!,
)
: null;
return FocusableListTile(
focusNode: index == 0 ? _initialFocusNode : null,
leading: chapter.thumb != null
@@ -125,8 +140,9 @@ class _ChapterSheetState extends State<ChapterSheet> {
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: PlexOptimizedImage.thumb(
client: _getClientForChapters(context),
client: _tryGetClientForChapters(context),
imagePath: chapter.thumb,
localFilePath: localThumbPath,
width: 60,
height: 34,
fit: BoxFit.cover,
+96 -20
View File
@@ -16,6 +16,7 @@ import 'package:window_manager/window_manager.dart';
import '../../mpv/mpv.dart';
import '../../services/plex_client.dart';
import '../../services/plex_api_cache.dart';
import '../../models/plex_media_info.dart';
import '../../models/plex_media_version.dart';
import '../../models/plex_metadata.dart';
@@ -26,6 +27,7 @@ import '../../utils/platform_detector.dart';
import '../../utils/player_utils.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/video_control_icons.dart';
import '../../utils/app_logger.dart';
import '../../i18n/strings.g.dart';
import '../../focus/input_mode_tracker.dart';
import 'widgets/track_chapter_controls.dart';
@@ -148,8 +150,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
leading: true,
trailing: true,
);
_loadChapters();
_loadMarkers();
_loadPlaybackExtras();
_loadSeekTimes();
_startHideTimer();
_initKeyboardService();
@@ -562,29 +563,103 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
Future<void> _loadChapters() async {
final client = _getClientForMetadata();
Future<void> _loadPlaybackExtras() async {
try {
appLogger.d(
'_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}',
);
final client = _getClientForMetadata();
appLogger.d(
'_loadPlaybackExtras: got client with serverId=${client.serverId}',
);
final chapters = await client.getChapters(widget.metadata.ratingKey);
if (mounted) {
setState(() {
_chapters = chapters;
_chaptersLoaded = true;
});
final extras = await client.getPlaybackExtras(widget.metadata.ratingKey);
appLogger.d(
'_loadPlaybackExtras: got ${extras.chapters.length} chapters',
);
if (mounted) {
setState(() {
_chapters = extras.chapters;
_markers = extras.markers;
_chaptersLoaded = true;
_markersLoaded = true;
});
}
} catch (e, stack) {
// Fallback: try to load from cache directly (for offline playback)
appLogger.d(
'_loadPlaybackExtras: client unavailable, trying cache fallback',
);
final serverId = widget.metadata.serverId;
if (serverId != null) {
final cacheKey = '/library/metadata/${widget.metadata.ratingKey}';
final cached = await PlexApiCache.instance.get(serverId, cacheKey);
if (cached != null) {
final extras = _parsePlaybackExtrasFromCache(cached);
appLogger.d(
'_loadPlaybackExtras: loaded ${extras.chapters.length} chapters from cache',
);
if (mounted) {
setState(() {
_chapters = extras.chapters;
_markers = extras.markers;
_chaptersLoaded = true;
_markersLoaded = true;
});
}
return;
}
}
appLogger.e('_loadPlaybackExtras failed', error: e, stackTrace: stack);
}
}
Future<void> _loadMarkers() async {
final client = _getClientForMetadata();
/// Parse PlaybackExtras from cached API response (for offline playback)
PlaybackExtras _parsePlaybackExtrasFromCache(Map<String, dynamic> cached) {
final chapters = <PlexChapter>[];
final markers = <PlexMarker>[];
final markers = await client.getMarkers(widget.metadata.ratingKey);
if (mounted) {
setState(() {
_markers = markers;
_markersLoaded = true;
});
Map<String, dynamic>? metadataJson;
if (cached['MediaContainer']?['Metadata'] != null &&
(cached['MediaContainer']['Metadata'] as List).isNotEmpty) {
metadataJson =
cached['MediaContainer']['Metadata'][0] as Map<String, dynamic>;
}
if (metadataJson != null) {
// Parse chapters
if (metadataJson['Chapter'] != null) {
for (var chapter in metadataJson['Chapter'] as List) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['tag'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
// Parse markers
if (metadataJson['Marker'] != null) {
for (var marker in metadataJson['Marker'] as List) {
markers.add(
PlexMarker(
id: marker['id'] as int,
type: marker['type'] as String,
startTimeOffset: marker['startTimeOffset'] as int,
endTimeOffset: marker['endTimeOffset'] as int,
),
);
}
}
}
return PlaybackExtras(chapters: chapters, markers: markers);
}
Widget _buildTrackChapterControlsWidget() {
@@ -830,7 +905,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
@override
Widget build(BuildContext context) {
// Use desktop controls for desktop platforms AND Android TV
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
final isMobile =
PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
return Focus(
focusNode: _focusNode,
@@ -10,6 +10,7 @@
#include <hotkey_manager_linux/hotkey_manager_linux_plugin.h>
#include <os_media_controls/os_media_controls_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
@@ -26,6 +27,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+1
View File
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
hotkey_manager_linux
os_media_controls
screen_retriever_linux
sqlite3_flutter_libs
url_launcher_linux
window_manager
)
@@ -7,6 +7,7 @@ import Foundation
import connectivity_plus
import device_info_plus
import file_picker
import gamepads_darwin
import hotkey_manager_macos
import macos_window_utils
@@ -16,12 +17,15 @@ import path_provider_foundation
import screen_retriever_macos
import shared_preferences_foundation
import sqflite_darwin
import sqlite3_flutter_libs
import url_launcher_macos
import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
@@ -31,6 +35,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+43
View File
@@ -3,6 +3,8 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- gamepads_darwin (0.1.1):
- FlutterMacOS
@@ -27,14 +29,42 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.51.1):
- sqlite3/common (= 3.51.1)
- sqlite3/common (3.51.1)
- sqlite3/dbstatvtab (3.51.1):
- sqlite3/common
- sqlite3/fts5 (3.51.1):
- sqlite3/common
- sqlite3/math (3.51.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.51.1):
- sqlite3/common
- sqlite3/rtree (3.51.1):
- sqlite3/common
- sqlite3/session (3.51.1):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.51.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- url_launcher_macos (0.0.1):
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
- window_manager (0.2.0):
- FlutterMacOS
DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
@@ -45,18 +75,23 @@ DEPENDENCIES:
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
SPEC REPOS:
trunk:
- HotKey
- sqlite3
EXTERNAL SOURCES:
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
FlutterMacOS:
:path: Flutter/ephemeral
gamepads_darwin:
@@ -77,14 +112,19 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
sqlite3_flutter_libs:
:path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
gamepads_darwin: 643b6a69e20ca678fae83781b7f7fc8f15f5d710
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
@@ -96,7 +136,10 @@ SPEC CHECKSUMS:
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
+2 -2
View File
@@ -8,7 +8,7 @@
PRODUCT_NAME = Plezy
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterApplication1
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2025 edde746
+129 -1
View File
@@ -161,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -225,8 +233,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
url: "https://pub.dev"
source: hosted
version: "0.3.5+1"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -305,6 +321,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
drift:
dependency: "direct main"
description:
name: drift
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
url: "https://pub.dev"
source: hosted
version: "2.28.2"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
url: "https://pub.dev"
source: hosted
version: "2.28.0"
duration:
dependency: "direct main"
description:
@@ -337,6 +369,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
fixnum:
dependency: transitive
description:
@@ -390,6 +430,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -921,6 +969,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
rxdart:
dependency: transitive
description:
@@ -929,6 +985,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
saf_stream:
dependency: "direct main"
description:
name: saf_stream
sha256: c05449997698c481a03e428162a999f93b1ee1bcc0349d651899a59f7b10230a
url: "https://pub.dev"
source: hosted
version: "0.12.3"
saf_util:
dependency: "direct main"
description:
name: saf_util
sha256: "219f983e5f17b28998335158cdc97add9d52af9884e38b5a43f10dcc070510ec"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
screen_retriever:
dependency: transitive
description:
@@ -1134,6 +1206,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d"
url: "https://pub.dev"
source: hosted
version: "0.5.41"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
url: "https://pub.dev"
source: hosted
version: "0.41.2"
stack_trace:
dependency: transitive
description:
@@ -1374,6 +1470,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.3"
workmanager:
dependency: "direct main"
description:
name: workmanager
sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
url: "https://pub.dev"
source: hosted
version: "0.9.0+3"
workmanager_android:
dependency: transitive
description:
name: workmanager_android
sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
url: "https://pub.dev"
source: hosted
version: "0.9.0+2"
workmanager_apple:
dependency: transitive
description:
name: workmanager_apple
sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
url: "https://pub.dev"
source: hosted
version: "0.9.1+2"
workmanager_platform_interface:
dependency: transitive
description:
name: workmanager_platform_interface
sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
url: "https://pub.dev"
source: hosted
version: "0.9.1+1"
xdg_directories:
dependency: transitive
description:
+8
View File
@@ -37,6 +37,13 @@ dependencies:
path_provider: ^2.1.0
path: ^1.9.0
gamepads: ^0.1.9
drift: ^2.28.2
sqlite3_flutter_libs: ^0.5.41
workmanager: ^0.9.0+3
crypto: ^3.0.7
file_picker: ^8.0.0
saf_util: ^0.11.0
saf_stream: ^0.12.2
dev_dependencies:
flutter_test:
@@ -47,6 +54,7 @@ dev_dependencies:
flutter_launcher_icons: ^0.14.4
slang_build_runner: ^3.31.0
dart_code_linter: ^3.1.1
drift_dev: ^2.14.0
flutter:
uses-material-design: true
@@ -11,6 +11,7 @@
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
#include <os_media_controls/os_media_controls_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
@@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
+1
View File
@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
hotkey_manager_windows
os_media_controls
screen_retriever_windows
sqlite3_flutter_libs
url_launcher_windows
window_manager
)
+2 -2
View File
@@ -89,11 +89,11 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "CompanyName", "com.edde746" "\0"
VALUE "FileDescription", "Plezy" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "Plezy" "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 edde746. All rights reserved." "\0"
VALUE "OriginalFilename", "Plezy.exe" "\0"
VALUE "ProductName", "Plezy" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"