feat: downloads
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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};
|
||||
}
|
||||
@@ -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))();
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%)';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
paused,
|
||||
completed,
|
||||
failed,
|
||||
cancelled,
|
||||
partial, // Some episodes downloaded, but not all (for shows/seasons)
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user