chore: clean up redundant comments
This commit is contained in:
@@ -215,10 +215,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Offline Watch Progress Operations
|
||||
// ============================================================
|
||||
|
||||
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
||||
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
||||
}
|
||||
@@ -446,10 +442,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
return delete(offlineWatchProgress).go();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sync Rules Operations
|
||||
// ============================================================
|
||||
|
||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||
final query = select(syncRules);
|
||||
if (profileId != null) {
|
||||
@@ -550,10 +542,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).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();
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../profiles/profile.dart';
|
||||
|
||||
/// Extension methods on AppDatabase for download operations
|
||||
extension DownloadDatabaseOperations on AppDatabase {
|
||||
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
|
||||
if (profileId.isEmpty) return;
|
||||
@@ -76,7 +75,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new download into the database.
|
||||
Future<void> insertDownload({
|
||||
required String serverId,
|
||||
String? clientScopeId,
|
||||
@@ -104,7 +102,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Add item to download queue
|
||||
Future<void> addToQueue({
|
||||
required String mediaGlobalKey,
|
||||
int priority = 0,
|
||||
@@ -143,14 +140,12 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return result?.readTable(downloadQueue);
|
||||
}
|
||||
|
||||
/// Update download status
|
||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
||||
}
|
||||
|
||||
/// Update download progress
|
||||
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
@@ -161,7 +156,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Update video file path
|
||||
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
@@ -171,16 +165,13 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Update artwork paths
|
||||
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||
}
|
||||
|
||||
/// Update download error and increment retry count
|
||||
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
||||
// Get current retry count to increment it
|
||||
final existing = await getDownloadedMedia(globalKey);
|
||||
final currentCount = existing?.retryCount ?? 0;
|
||||
|
||||
@@ -189,31 +180,26 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear download error and reset retry count (for retry)
|
||||
Future<void> clearDownloadError(String globalKey) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from queue
|
||||
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||
}
|
||||
|
||||
/// Get downloaded media item
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Delete a download
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a season
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
|
||||
String seasonKey, {
|
||||
String? serverId,
|
||||
@@ -229,7 +215,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a show
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(
|
||||
String showKey, {
|
||||
String? serverId,
|
||||
@@ -245,7 +230,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get all downloaded items for a specific server
|
||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||
}
|
||||
@@ -268,14 +252,12 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return column.equals(clientScopeId);
|
||||
}
|
||||
|
||||
/// Update the background_downloader task ID for a download
|
||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
||||
}
|
||||
|
||||
/// Get the background_downloader task ID for a download
|
||||
Future<String?> getBgTaskId(String globalKey) async {
|
||||
final item = await getDownloadedMedia(globalKey);
|
||||
return item?.bgTaskId;
|
||||
|
||||
@@ -146,11 +146,9 @@ Future<void> main() async {
|
||||
}
|
||||
|
||||
Future<void> _bootstrapApp() async {
|
||||
// Initialize settings first to get saved locale
|
||||
final settings = await SettingsService.getInstance();
|
||||
final savedLocale = settings.read(SettingsService.appLocale);
|
||||
|
||||
// Initialize localization with saved locale
|
||||
unawaited(LocaleSettings.setLocale(savedLocale));
|
||||
|
||||
// Needed for formatting dates in different locales
|
||||
@@ -165,10 +163,8 @@ Future<void> _bootstrapApp() async {
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 100MB
|
||||
}
|
||||
|
||||
// Initialize services in parallel where possible
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
// Initialize window_manager for desktop platforms
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
futures.add(windowManager.ensureInitialized());
|
||||
}
|
||||
@@ -178,7 +174,6 @@ Future<void> _bootstrapApp() async {
|
||||
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
// Initialize PiP service to listen for PiP state changes (Android only).
|
||||
PipService();
|
||||
}
|
||||
|
||||
@@ -188,21 +183,17 @@ Future<void> _bootstrapApp() async {
|
||||
// Hook Windows native fullscreen callback (no-op elsewhere).
|
||||
NativeWindowService.initialize();
|
||||
|
||||
// Initialize storage service
|
||||
futures.add(StorageService.getInstance());
|
||||
|
||||
// Wait for all parallel services to complete
|
||||
await Future.wait(futures);
|
||||
|
||||
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
||||
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
||||
// is available — keeps the deprecated legacy slots out of runtime paths.
|
||||
|
||||
// Initialize logger level based on debug setting
|
||||
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
|
||||
setLoggerLevel(debugEnabled);
|
||||
|
||||
// Log app version and git commit at startup
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
|
||||
String renderer = '';
|
||||
@@ -211,10 +202,8 @@ Future<void> _bootstrapApp() async {
|
||||
}
|
||||
appLogger.i('Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer');
|
||||
|
||||
// Initialize download storage service with settings
|
||||
await DownloadStorageService.instance.initialize(settings);
|
||||
|
||||
// Start global fullscreen state monitoring
|
||||
FullscreenStateManager().startMonitoring();
|
||||
|
||||
// Apply "start in fullscreen" preference on Windows/Linux. macOS is
|
||||
@@ -236,8 +225,6 @@ Future<void> _bootstrapApp() async {
|
||||
// Trakt scrobble service (all platforms)
|
||||
await TraktScrobbleService.instance.initialize();
|
||||
|
||||
// DTD service is available for MCP tooling connection if needed
|
||||
|
||||
// Register bundled shader licenses
|
||||
_registerShaderLicenses();
|
||||
|
||||
@@ -788,7 +775,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
|
||||
},
|
||||
),
|
||||
// Existing providers
|
||||
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
||||
create: (_) => UserProfileProvider(),
|
||||
update: (context, activeProfile, connections, previous) {
|
||||
|
||||
@@ -79,8 +79,6 @@ class ExternalPlayer {
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
// --- Launch helpers ---
|
||||
|
||||
Future<bool> _launchWithUrl(String url) {
|
||||
return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
@@ -146,8 +144,6 @@ Future<bool> _launchCustom(String value, String url, CustomPlayerType type) asyn
|
||||
}
|
||||
}
|
||||
|
||||
// --- Known Players ---
|
||||
|
||||
class KnownPlayers {
|
||||
static final systemDefault = ExternalPlayer(id: 'system_default', name: 'System Default', launch: _launchWithUrl);
|
||||
|
||||
|
||||
@@ -30,10 +30,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
@override
|
||||
bool get supportsSecondarySubtitles => false;
|
||||
|
||||
// ============================================
|
||||
// Platform-Specific Event Handling
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
void handlePlayerEvent(String name, Map? data) {
|
||||
// Handle Android-specific events
|
||||
@@ -50,10 +46,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
super.handlePlayerEvent(name, data);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
// Memoizes the in-flight init Future so concurrent callers share one
|
||||
// `invoke('initialize')`. ExoPlayer's native handleInitialize is
|
||||
// synchronous and would mask a Dart-side race anyway, but we mirror the
|
||||
@@ -100,10 +92,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback Control
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> open(
|
||||
Media media, {
|
||||
@@ -154,10 +142,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
await runSeek(() => invoke('seek', {'positionMs': position.inMilliseconds}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Track Selection
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||
await invoke('selectAudioTrack', {'trackId': track.id});
|
||||
@@ -173,10 +157,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Volume and Rate
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {
|
||||
await invoke('setVolume', {'volume': volume});
|
||||
@@ -187,10 +167,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('setRate', {'rate': rate});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MPV Properties (Compatibility Layer)
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async {
|
||||
if (disposed) return;
|
||||
@@ -352,10 +328,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Subtitle Styling (ExoPlayer Native)
|
||||
// ============================================
|
||||
|
||||
/// Apply subtitle styling to the native ExoPlayer layer.
|
||||
///
|
||||
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
|
||||
@@ -385,10 +357,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Box-fit / video scaling mode
|
||||
// ============================================
|
||||
|
||||
/// Apply the box-fit mode to the native ExoPlayer layer.
|
||||
/// Maps to AspectRatioFrameLayout resize mode: 0=FIT, 1=ZOOM, 2=FILL.
|
||||
Future<void> setBoxFitMode(int mode) async {
|
||||
@@ -396,10 +364,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('setBoxFitMode', {'mode': mode});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Frame Rate Matching
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
|
||||
if (disposed || !initialized) return false;
|
||||
@@ -423,10 +387,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('updateFrame');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Audio Focus
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioFocus() async {
|
||||
if (disposed) return false;
|
||||
@@ -440,10 +400,6 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('abandonAudioFocus');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Log Level
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setLogLevel(String level) async {
|
||||
if (disposed) return;
|
||||
|
||||
@@ -53,10 +53,6 @@ abstract class Player {
|
||||
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
|
||||
String get playerType;
|
||||
|
||||
// ============================================
|
||||
// Playback Control
|
||||
// ============================================
|
||||
|
||||
/// Open a media source for playback.
|
||||
///
|
||||
/// [media] - The media source to open.
|
||||
@@ -78,10 +74,6 @@ abstract class Player {
|
||||
/// Seek to a specific position.
|
||||
Future<void> seek(Duration position);
|
||||
|
||||
// ============================================
|
||||
// Track Selection
|
||||
// ============================================
|
||||
|
||||
/// Select an audio track.
|
||||
Future<void> selectAudioTrack(AudioTrack track);
|
||||
|
||||
@@ -107,10 +99,6 @@ abstract class Player {
|
||||
/// [select] - Whether to select this track immediately.
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false});
|
||||
|
||||
// ============================================
|
||||
// Volume and Rate
|
||||
// ============================================
|
||||
|
||||
/// Set the playback volume.
|
||||
///
|
||||
/// [volume] - Volume level from 0.0 (muted) to 100.0 (max).
|
||||
@@ -126,10 +114,6 @@ abstract class Player {
|
||||
/// [device] - The audio device to use.
|
||||
Future<void> setAudioDevice(AudioDevice device);
|
||||
|
||||
// ============================================
|
||||
// MPV Properties (Advanced)
|
||||
// ============================================
|
||||
|
||||
/// Set an MPV property by name.
|
||||
///
|
||||
/// Common properties:
|
||||
@@ -162,30 +146,18 @@ abstract class Player {
|
||||
/// [args] - Command and arguments as a list of strings.
|
||||
Future<void> command(List<String> args);
|
||||
|
||||
// ============================================
|
||||
// Subtitle Fonts
|
||||
// ============================================
|
||||
|
||||
/// Configure subtitle fonts for libass rendering.
|
||||
///
|
||||
/// Extracts a comprehensive Unicode font (Go Noto) to the cache directory
|
||||
/// and sets `sub-fonts-dir` and `sub-font` properties.
|
||||
Future<void> configureSubtitleFonts();
|
||||
|
||||
// ============================================
|
||||
// Passthrough Mode (Audio)
|
||||
// ============================================
|
||||
|
||||
/// Enable or disable audio passthrough mode.
|
||||
///
|
||||
/// When enabled, supported audio codecs (AC3, DTS, etc.) will be
|
||||
/// passed through to the audio device without decoding.
|
||||
Future<void> setAudioPassthrough(bool enabled);
|
||||
|
||||
// ============================================
|
||||
// Visibility (macOS Metal Layer)
|
||||
// ============================================
|
||||
|
||||
/// Show or hide the video rendering layer.
|
||||
///
|
||||
/// On macOS, this controls the Metal layer visibility.
|
||||
@@ -201,10 +173,6 @@ abstract class Player {
|
||||
/// On other platforms, this is a no-op.
|
||||
Future<void> updateFrame();
|
||||
|
||||
// ============================================
|
||||
// Frame Rate Matching (Android)
|
||||
// ============================================
|
||||
|
||||
/// Set the video frame rate for display refresh rate matching.
|
||||
///
|
||||
/// On Android, this hints the system to adjust the display refresh rate
|
||||
@@ -231,10 +199,6 @@ abstract class Player {
|
||||
/// On other platforms, this is a no-op.
|
||||
Future<void> clearVideoFrameRate();
|
||||
|
||||
// ============================================
|
||||
// Audio Focus (Android)
|
||||
// ============================================
|
||||
|
||||
/// Request audio focus before starting playback.
|
||||
///
|
||||
/// On Android, this notifies the system that the app wants to play audio,
|
||||
@@ -252,10 +216,6 @@ abstract class Player {
|
||||
/// On other platforms, this is a no-op.
|
||||
Future<void> abandonAudioFocus();
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
/// Whether the player has been disposed.
|
||||
bool get disposed;
|
||||
|
||||
@@ -264,10 +224,6 @@ abstract class Player {
|
||||
/// After calling this, the player instance should not be used.
|
||||
Future<void> dispose();
|
||||
|
||||
// ============================================
|
||||
// Factory
|
||||
// ============================================
|
||||
|
||||
/// Creates a new player instance.
|
||||
///
|
||||
/// Returns a platform-specific implementation:
|
||||
|
||||
@@ -510,10 +510,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
return methodChannel.invokeMethod<T>(method, args);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Default Implementations
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> playOrPause() async {
|
||||
if (_disposed) return;
|
||||
@@ -576,10 +572,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||
Future<void> setLogLevel(String level) async {}
|
||||
|
||||
// ============================================
|
||||
// Subtitle Fonts
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> configureSubtitleFonts() async {
|
||||
try {
|
||||
@@ -596,10 +588,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Seek helpers
|
||||
// ============================================
|
||||
|
||||
/// Run a backend-specific seek call, swallowing the common "not ready" errors
|
||||
/// the native channel throws when the engine was torn down mid-seek.
|
||||
@protected
|
||||
@@ -615,10 +603,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Debug helpers
|
||||
// ============================================
|
||||
|
||||
/// Injects the log + error events that would fire when the server rejects the
|
||||
/// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by
|
||||
/// the in-player debug button to preview the end-to-end detection path
|
||||
@@ -635,10 +619,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -33,10 +33,6 @@ class PlayerNative extends PlayerBase {
|
||||
/// but as JSON strings on Android/Windows.
|
||||
static final String _nodeFormat = (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node';
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
// Memoizes the in-flight init Future so concurrent callers (e.g. the
|
||||
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
|
||||
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
|
||||
@@ -91,10 +87,6 @@ class PlayerNative extends PlayerBase {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback Control
|
||||
// ============================================
|
||||
|
||||
/// Opens a content:// URI via the platform channel and returns the raw FD number.
|
||||
/// Returns null if the call fails.
|
||||
Future<int?> _openContentFd(String contentUri) async {
|
||||
@@ -174,10 +166,6 @@ class PlayerNative extends PlayerBase {
|
||||
await runSeek(() => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Track Selection
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||
await setProperty('aid', track.id);
|
||||
@@ -201,10 +189,6 @@ class PlayerNative extends PlayerBase {
|
||||
await command(args);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Volume and Rate
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {
|
||||
await setProperty('volume', volume.toString());
|
||||
@@ -220,10 +204,6 @@ class PlayerNative extends PlayerBase {
|
||||
await setProperty('audio-device', device.name);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MPV Properties
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async {
|
||||
if (disposed) return;
|
||||
@@ -245,10 +225,6 @@ class PlayerNative extends PlayerBase {
|
||||
await invoke('command', {'args': args});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Log Level
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setLogLevel(String level) async {
|
||||
if (disposed) return;
|
||||
@@ -256,10 +232,6 @@ class PlayerNative extends PlayerBase {
|
||||
await invoke('setLogLevel', {'level': level});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Passthrough
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
if (enabled) {
|
||||
@@ -271,10 +243,6 @@ class PlayerNative extends PlayerBase {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Platform-Specific Overrides
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
if (disposed || !initialized) return;
|
||||
|
||||
@@ -1488,10 +1488,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return deletedTitles;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sync Rules
|
||||
// ============================================================
|
||||
|
||||
/// All sync rules for the active profile (profile-scoped globalKey -> SyncRuleItem).
|
||||
Map<String, SyncRuleItem> get syncRules => Map.unmodifiable(_syncRules);
|
||||
|
||||
|
||||
@@ -387,10 +387,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
@override
|
||||
void focusActiveTabIfReady() => _focusCurrentTab();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab chips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
String _getTabLabel(LiveTvTab tab) {
|
||||
return switch (tab) {
|
||||
LiveTvTab.guide => t.liveTv.guide,
|
||||
@@ -398,10 +394,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<Widget> _buildTabChipItems() {
|
||||
return [
|
||||
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
|
||||
|
||||
@@ -267,10 +267,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus key handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
@@ -415,10 +411,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
LiveTvProgram? _findCurrentProgram(int channelIndex) {
|
||||
if (channelIndex < 0 || channelIndex >= widget.channels.length) return null;
|
||||
final channel = widget.channels[channelIndex];
|
||||
@@ -502,10 +494,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -760,10 +748,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time navigation bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) {
|
||||
final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index;
|
||||
if (!isFocused) return child;
|
||||
@@ -840,10 +824,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time header & now indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildTimeHeader(ThemeData theme) {
|
||||
final is24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
|
||||
final slots = <Widget>[];
|
||||
@@ -872,10 +852,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
return Row(children: slots);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel column
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final client = multiServer.getClientForServer(channel.serverId ?? '');
|
||||
@@ -918,10 +894,6 @@ class GuideTabState extends State<GuideTab> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Program grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildProgramRow(
|
||||
LiveTvChannel channel,
|
||||
List<LiveTvProgram> programs,
|
||||
|
||||
@@ -200,10 +200,8 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub section — horizontal scrolling row of poster cards (always 2:3 aspect)
|
||||
// Uses locked focus pattern: single Focus node at hub level, visual index in state.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _LiveTvHubSection extends StatefulWidget {
|
||||
final LiveTvHubResult hub;
|
||||
@@ -509,10 +507,6 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Poster card — always 2:3, shows poster image + title + subtitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _LiveTvPosterCard extends StatelessWidget {
|
||||
final LiveTvHubEntry entry;
|
||||
final double width;
|
||||
|
||||
@@ -396,8 +396,6 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
|
||||
return options.first.label;
|
||||
}
|
||||
|
||||
// ===== Field visibility =====
|
||||
|
||||
bool get _showSortTitle => _mediaType != MediaKind.season;
|
||||
bool get _showOriginalTitle => _mediaType == MediaKind.movie || _mediaType == MediaKind.show;
|
||||
bool get _showReleaseDate => _mediaType != MediaKind.season;
|
||||
@@ -840,8 +838,6 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Language option lists =====
|
||||
|
||||
// Plex locale codes for metadata agent language.
|
||||
const _plexLocaleCodes = [
|
||||
'ar-SA',
|
||||
|
||||
@@ -118,10 +118,6 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _TvPinInput — unified 4-digit PIN input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _TvPinInput extends StatefulWidget {
|
||||
final ValueChanged<String> onSubmit;
|
||||
final VoidCallback onCancel;
|
||||
@@ -205,8 +201,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
if (pin != null) widget.onSubmit(pin);
|
||||
}
|
||||
|
||||
// -- D-pad / keyboard handling (TV + desktop) --
|
||||
|
||||
void _incrementDigit() {
|
||||
setState(() {
|
||||
_digits[_activeIndex] = ((_digits[_activeIndex] ?? -1) + 1) % 10;
|
||||
@@ -324,8 +318,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// -- Mobile input handling --
|
||||
|
||||
void _onMobileDigitChanged(int index, String value) {
|
||||
if (value.isEmpty) {
|
||||
// Backspace
|
||||
@@ -437,10 +429,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _DigitBox — single digit display box for TV/desktop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _DigitBox extends StatelessWidget {
|
||||
final int? digit;
|
||||
final bool isActive;
|
||||
|
||||
@@ -54,7 +54,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// --- Display ---
|
||||
SettingsSectionHeader(t.settings.display),
|
||||
_buildThemeSelector(),
|
||||
_buildLanguageSelector(),
|
||||
@@ -64,13 +63,11 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
_buildShowEpisodeNumberOnCards(),
|
||||
_buildShowSeasonPostersOnTabs(),
|
||||
|
||||
// --- Home Screen ---
|
||||
SettingsSectionHeader(t.settings.homeScreen),
|
||||
_buildShowHeroSection(),
|
||||
_buildUseGlobalHubs(),
|
||||
_buildShowServerNameOnHubs(),
|
||||
|
||||
// --- Navigation ---
|
||||
SettingsSectionHeader(t.settings.navigation),
|
||||
if (Platform.isAndroid) _buildForceTvMode(),
|
||||
if (PlatformDetector.shouldUseSideNavigation(context)) _buildAlwaysKeepSidebarOpen(),
|
||||
@@ -78,13 +75,11 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
if (!PlatformDetector.shouldUseSideNavigation(context)) _buildShowNavBarLabels(),
|
||||
_buildShowUnwatchedCount(),
|
||||
|
||||
// --- Window (Windows/Linux only) ---
|
||||
if (Platform.isWindows || Platform.isLinux) ...[
|
||||
SettingsSectionHeader(t.settings.window),
|
||||
_buildStartInFullscreen(),
|
||||
],
|
||||
|
||||
// --- Content ---
|
||||
SettingsSectionHeader(t.settings.content),
|
||||
_buildLiveTvDefaultFavorites(),
|
||||
_buildHideSpoilers(),
|
||||
@@ -98,8 +93,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Display section ---
|
||||
|
||||
Widget _buildThemeSelector() {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
@@ -251,8 +244,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Home Screen section ---
|
||||
|
||||
Widget _buildShowHeroSection() => _buildBoolToggle(
|
||||
icon: Symbols.featured_play_list_rounded,
|
||||
title: t.settings.showHeroSection,
|
||||
@@ -277,8 +268,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
setter: (p, v) => p.setShowServerNameOnHubs(v),
|
||||
);
|
||||
|
||||
// --- Navigation section ---
|
||||
|
||||
Widget _buildAlwaysKeepSidebarOpen() => _buildBoolToggle(
|
||||
icon: Symbols.dock_to_left_rounded,
|
||||
title: t.settings.alwaysKeepSidebarOpen,
|
||||
@@ -311,8 +300,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
setter: (p, v) => p.setShowUnwatchedCount(v),
|
||||
);
|
||||
|
||||
// --- Content section ---
|
||||
|
||||
Widget _buildLiveTvDefaultFavorites() => _buildBoolToggle(
|
||||
icon: Symbols.star_rounded,
|
||||
title: t.settings.liveTvDefaultFavorites,
|
||||
@@ -400,8 +387,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
String _getLanguageDisplayName(AppLocale locale) {
|
||||
switch (locale) {
|
||||
case AppLocale.en:
|
||||
|
||||
@@ -123,7 +123,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// --- Player ---
|
||||
SettingsSectionHeader(t.settings.player),
|
||||
if (Platform.isAndroid) _buildPlayerBackendSelector(),
|
||||
_buildExternalPlayerTile(),
|
||||
@@ -139,12 +138,10 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
_buildBufferSizeSelector(),
|
||||
_buildDefaultQualityTile(),
|
||||
|
||||
// --- Subtitles & Config ---
|
||||
SettingsSectionHeader(t.settings.subtitlesAndConfig),
|
||||
_buildSubtitleStylingTile(),
|
||||
if (!Platform.isAndroid || !_useExoPlayer) _buildMpvConfigTile(),
|
||||
|
||||
// --- Seek & Timing ---
|
||||
SettingsSectionHeader(t.settings.seekAndTiming),
|
||||
_buildSmallSkipDuration(),
|
||||
_buildLargeSkipDuration(),
|
||||
@@ -152,14 +149,12 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
_buildDefaultSleepTimer(),
|
||||
_buildMaxVolume(),
|
||||
|
||||
// --- Behavior ---
|
||||
SettingsSectionHeader(t.settings.behavior),
|
||||
if (DiscordRPCService.isAvailable) _buildDiscordRPC(),
|
||||
if (PlatformDetector.shouldActAsRemoteHost(context)) _buildCompanionRemoteServer(),
|
||||
_buildRememberTrackSelections(),
|
||||
if (!isMobile) _buildClickVideoTogglesPlayback(),
|
||||
|
||||
// --- Auto-Skip ---
|
||||
SettingsSectionHeader(t.settings.autoSkip),
|
||||
_buildAutoSkipIntro(),
|
||||
_buildAutoSkipCredits(),
|
||||
@@ -174,8 +169,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Player section ---
|
||||
|
||||
Widget _buildPlayerBackendSelector() {
|
||||
return SegmentedSetting<bool>(
|
||||
icon: Symbols.play_circle_rounded,
|
||||
@@ -369,8 +362,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Subtitles & Config section ---
|
||||
|
||||
Widget _buildSubtitleStylingTile() {
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
|
||||
@@ -395,8 +386,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Seek & Timing section ---
|
||||
|
||||
Widget _buildSmallSkipDuration() {
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
|
||||
@@ -515,8 +504,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Behavior section ---
|
||||
|
||||
Widget _buildDiscordRPC() {
|
||||
return SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.chat_rounded, fill: 1),
|
||||
@@ -570,8 +557,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Auto-Skip section ---
|
||||
|
||||
Widget _buildAutoSkipIntro() {
|
||||
return SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1),
|
||||
|
||||
@@ -168,40 +168,28 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// --- Donate (non-store builds only) ---
|
||||
if (DonationService.isEnabled) _buildDonateTile(),
|
||||
|
||||
// --- Appearance (navigation tile) ---
|
||||
_buildAppearanceTile(),
|
||||
|
||||
// --- Playback (navigation tile) ---
|
||||
_buildPlaybackTile(),
|
||||
|
||||
// --- Trackers (unified hub: Trakt + MAL + AniList + Simkl) ---
|
||||
_buildTrackersTile(),
|
||||
|
||||
// --- Connections (Jellyfin servers) ---
|
||||
_buildConnectionsSection(),
|
||||
|
||||
// --- Profiles (kids mode / multi-user) ---
|
||||
_buildProfilesSection(),
|
||||
|
||||
// --- Downloads (inline) ---
|
||||
if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(),
|
||||
|
||||
// --- Keyboard Shortcuts (inline, conditional) ---
|
||||
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
|
||||
|
||||
// --- Advanced (inline) ---
|
||||
_buildAdvancedSection(),
|
||||
|
||||
// --- Updates (conditional) ---
|
||||
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()],
|
||||
|
||||
// --- Backup (hidden on Apple TV — no file picker / storage) ---
|
||||
if (!PlatformDetector.isAppleTV()) _buildBackupSection(),
|
||||
|
||||
// --- About ---
|
||||
ListTile(
|
||||
focusNode: _focusTracker.get(_kAbout),
|
||||
leading: const AppIcon(Symbols.info_rounded, fill: 1),
|
||||
@@ -615,8 +603,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dialogs ---
|
||||
|
||||
Future<void> _showDownloadLocationDialog() async {
|
||||
final storageService = DownloadStorageService.instance;
|
||||
final isCustom = storageService.isUsingCustomPath();
|
||||
|
||||
@@ -182,7 +182,6 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// --- Text ---
|
||||
SettingsSectionHeader(t.subtitlingStyling.text),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
|
||||
@@ -279,7 +278,6 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
// --- Border ---
|
||||
SettingsSectionHeader(t.subtitlingStyling.border),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.border_style_rounded, fill: 1),
|
||||
@@ -319,7 +317,6 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
}),
|
||||
),
|
||||
|
||||
// --- Background ---
|
||||
SettingsSectionHeader(t.subtitlingStyling.background),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.opacity_rounded, fill: 1),
|
||||
|
||||
@@ -262,12 +262,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
StreamSubscription<double>? _mediaControlsRateSubscription;
|
||||
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
|
||||
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
|
||||
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
bool _isReplacingWithVideo = false;
|
||||
bool _isDisposingForNavigation = false;
|
||||
bool _isHandlingBack = false;
|
||||
ScrubPreviewSource? _scrubPreviewSource;
|
||||
|
||||
// Live TV channel navigation
|
||||
int _liveChannelIndex = -1;
|
||||
String? _liveChannelName;
|
||||
MediaServerClient? _liveClient;
|
||||
@@ -287,7 +286,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// the Jellyfin started/progress/stopped transition.
|
||||
JellyfinLiveSessionTracker _jellyfinLiveSession = JellyfinLiveSessionTracker();
|
||||
|
||||
// Live TV time-shift
|
||||
CaptureBuffer? _captureBuffer;
|
||||
int? _programBeginsAt;
|
||||
double _streamStartEpoch = 0;
|
||||
@@ -299,16 +297,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
int _liveStreamFallbackLevel = 0;
|
||||
bool _isRetryingLiveStream = false;
|
||||
|
||||
// Auto-play next episode
|
||||
Timer? _autoPlayTimer;
|
||||
int _autoPlayCountdown = 5;
|
||||
bool _completionTriggered = false;
|
||||
|
||||
// Play Next dialog focus nodes (for TV D-pad navigation)
|
||||
late final FocusNode _playNextCancelFocusNode;
|
||||
late final FocusNode _playNextConfirmFocusNode;
|
||||
|
||||
// "Still watching?" prompt (sleep timer)
|
||||
bool _showStillWatchingPrompt = false;
|
||||
int _stillWatchingCountdown = 30;
|
||||
Timer? _stillWatchingTimer;
|
||||
@@ -345,7 +340,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled) ||
|
||||
(Platform.isAndroid && _androidAutoPipTransitionInFlight);
|
||||
|
||||
// Services
|
||||
MediaControlsManager? _mediaControlsManager;
|
||||
PlaybackProgressTracker? _progressTracker;
|
||||
VideoFilterManager? _videoFilterManager;
|
||||
@@ -354,10 +348,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
AmbientLightingService? _ambientLightingService;
|
||||
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
|
||||
|
||||
// Watch Together provider reference (stored early to use in dispose)
|
||||
WatchTogetherProvider? _watchTogetherProvider;
|
||||
|
||||
// Companion remote state (stored early for use in dispose)
|
||||
CompanionRemoteProvider? _companionRemoteProvider;
|
||||
VoidCallback? _savedOnHome;
|
||||
|
||||
@@ -373,12 +365,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
|
||||
|
||||
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
|
||||
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
|
||||
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false); // Track if navigating away (for black overlay)
|
||||
final ValueNotifier<bool> _controlsVisible = ValueNotifier<bool>(
|
||||
true,
|
||||
); // Track if video controls are visible (for popup positioning)
|
||||
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<bool> _controlsVisible = ValueNotifier<bool>(true);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -388,17 +378,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_activeId = widget.metadata.id;
|
||||
_activeMediaIndex = widget.selectedMediaIndex;
|
||||
|
||||
// Transcode session identifiers — reused across quality/version/audio
|
||||
// switches so the server-side transcode session is preserved.
|
||||
// Reused across quality/version/audio switches so the server-side
|
||||
// transcode session is preserved.
|
||||
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier();
|
||||
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier();
|
||||
_selectedAudioStreamId = widget.selectedAudioStreamId;
|
||||
_effectiveIsOffline = widget.isOffline;
|
||||
// Quality preset is resolved later when the SettingsProvider is available;
|
||||
// see _resolveQualityPreset() called from _initializePlayer.
|
||||
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
||||
|
||||
// Initialize live TV channel tracking
|
||||
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
|
||||
_liveChannelName = widget.liveChannelName;
|
||||
_liveClient = widget.liveClient;
|
||||
@@ -411,11 +398,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: widget.liveSessionIdentifier);
|
||||
}
|
||||
|
||||
// Initialize Play Next dialog focus nodes
|
||||
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
||||
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
|
||||
|
||||
// Initialize "Still watching?" dialog focus nodes
|
||||
_stillWatchingPauseFocusNode = FocusNode(debugLabel: 'StillWatchingPause');
|
||||
_stillWatchingContinueFocusNode = FocusNode(debugLabel: 'StillWatchingContinue');
|
||||
|
||||
@@ -437,7 +422,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
appLogger.d('Preferred subtitle track: $subtitleDesc');
|
||||
}
|
||||
|
||||
// Update current item in playback state provider
|
||||
try {
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
@@ -459,22 +443,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Provider might not be available yet during initialization
|
||||
appLogger.d('Deferred playback state update (provider not ready)', error: e);
|
||||
}
|
||||
|
||||
// Register app lifecycle observer
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Wire companion remote playback callbacks
|
||||
_setupCompanionRemoteCallbacks();
|
||||
|
||||
// Show "Still watching?" prompt when sleep timer fires
|
||||
_sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) {
|
||||
if (mounted) _showStillWatchingDialog();
|
||||
});
|
||||
|
||||
// Initialize player asynchronously with buffer size from settings
|
||||
_initializePlayer();
|
||||
}
|
||||
|
||||
@@ -682,7 +661,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (mounted) {
|
||||
setState(() => _playerInitializationError = null);
|
||||
}
|
||||
// Load buffer size from settings
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
_videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled);
|
||||
_autoPipEnabled = settingsService.read(SettingsService.autoPip);
|
||||
@@ -692,14 +670,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final debugLoggingEnabled = settingsService.read(SettingsService.enableDebugLogging);
|
||||
final useExoPlayer = settingsService.read(SettingsService.useExoPlayer);
|
||||
|
||||
// Initialize Windows display mode service.
|
||||
if (Platform.isWindows) {
|
||||
_displayModeService = DisplayModeService(settingsService, FullscreenStateManager());
|
||||
await _displayModeService!.syncWithNative();
|
||||
FullscreenStateManager().addListener(_onFullscreenChanged);
|
||||
}
|
||||
|
||||
// Create player (on Android, uses ExoPlayer by default, MPV as fallback)
|
||||
player = Player(useExoPlayer: useExoPlayer);
|
||||
_playerBackendLabel = player!.playerType;
|
||||
|
||||
@@ -771,7 +747,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (bufferSizeMB > 0) {
|
||||
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
||||
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
||||
// Set back-buffer to 1/4 of forward buffer
|
||||
final backBytes = bufferSizeBytes ~/ 4;
|
||||
await player!.setProperty('demuxer-max-back-bytes', backBytes.toString());
|
||||
}
|
||||
@@ -791,7 +766,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
autoBackMB = 48;
|
||||
}
|
||||
if (bufferSizeMB == 0) {
|
||||
// Auto mode: cap both forward and back buffer based on heap
|
||||
int autoForwardMB;
|
||||
if (heapMB <= 256) {
|
||||
autoForwardMB = 32;
|
||||
@@ -813,7 +787,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await player!.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
||||
await player!.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
|
||||
|
||||
// Subtitle styling
|
||||
await player!.setProperty('sub-font-size', settingsService.read(SettingsService.subtitleFontSize).toString());
|
||||
await player!.setProperty('sub-color', settingsService.read(SettingsService.subtitleTextColor));
|
||||
await player!.setProperty('sub-border-size', settingsService.read(SettingsService.subtitleBorderSize).toString());
|
||||
@@ -833,7 +806,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await player!.setProperty('sub-ass-video-aspect-override', '1');
|
||||
await player!.setProperty('sub-pos', settingsService.read(SettingsService.subtitlePosition).toString());
|
||||
|
||||
// Platform-specific settings
|
||||
if (Platform.isIOS) {
|
||||
await player!.setProperty('audio-exclusive', 'yes');
|
||||
}
|
||||
@@ -851,26 +823,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
await player!.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
// Apply audio sync offset
|
||||
final audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset);
|
||||
if (audioSyncOffset != 0) {
|
||||
final offsetSeconds = audioSyncOffset / 1000.0;
|
||||
await player!.setProperty('audio-delay', offsetSeconds.toString());
|
||||
}
|
||||
|
||||
// Apply subtitle sync offset
|
||||
final subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset);
|
||||
if (subtitleSyncOffset != 0) {
|
||||
final offsetSeconds = subtitleSyncOffset / 1000.0;
|
||||
await player!.setProperty('sub-delay', offsetSeconds.toString());
|
||||
}
|
||||
|
||||
// Apply audio normalization (loudnorm filter)
|
||||
if (settingsService.read(SettingsService.audioNormalization)) {
|
||||
await player!.setProperty('af', 'loudnorm=I=-14:TP=-3:LRA=4');
|
||||
}
|
||||
|
||||
// Apply custom MPV config entries
|
||||
final customMpvConfig = SettingsService.parseMpvConfigText(settingsService.read(SettingsService.mpvConfigText));
|
||||
for (final entry in customMpvConfig.entries) {
|
||||
try {
|
||||
@@ -881,15 +849,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
// Set max volume limit for volume boost
|
||||
final maxVolume = settingsService.read(SettingsService.maxVolume);
|
||||
await player!.setProperty('volume-max', maxVolume.toString());
|
||||
|
||||
// Apply saved volume (clamped to max volume)
|
||||
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
|
||||
unawaited(player!.setVolume(savedVolume));
|
||||
|
||||
// Notify that player is ready
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlayerInitialized = true;
|
||||
@@ -906,7 +871,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
appLogger.d('Wakelock enabled for video playback');
|
||||
}
|
||||
|
||||
// Get the video URL and start playback
|
||||
await _startPlayback();
|
||||
|
||||
// Set fullscreen mode and orientation based on rotation lock setting
|
||||
@@ -941,7 +905,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (_positionSubscription != null) _positionSubscription!.cancel(),
|
||||
]);
|
||||
|
||||
// Listen to playback state changes
|
||||
_playingSubscription = player!.streams.playing.listen(_onPlayingStateChanged);
|
||||
|
||||
// Listen to completion. When mpv emits completed=false (file-loaded after a
|
||||
@@ -955,7 +918,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_onVideoCompleted(done);
|
||||
});
|
||||
|
||||
// Listen to MPV errors
|
||||
_errorSubscription = player!.streams.error.listen(_onPlayerError);
|
||||
|
||||
// warn is included so we can catch ffmpeg's "HTTP error 500" line in
|
||||
@@ -964,12 +926,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||
.listen(_onPlayerLog);
|
||||
|
||||
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
|
||||
if (Platform.isAndroid && useExoPlayer) {
|
||||
_backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
||||
}
|
||||
|
||||
// Listen to buffering state
|
||||
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
|
||||
_isBuffering.value = isBuffering;
|
||||
});
|
||||
@@ -994,7 +954,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to playback restart to detect first frame ready
|
||||
_playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async {
|
||||
_lastLogError = null;
|
||||
_sawServer500 = false;
|
||||
@@ -1003,12 +962,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_hasFirstFrame.value = true;
|
||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player')));
|
||||
|
||||
// Apply frame rate matching on Android if enabled
|
||||
if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) {
|
||||
await _applyFrameRateMatching();
|
||||
}
|
||||
|
||||
// Apply Windows display mode matching (refresh rate, HDR)
|
||||
if (Platform.isWindows && _displayModeService != null) {
|
||||
await _applyWindowsDisplayMatching();
|
||||
}
|
||||
@@ -1016,7 +973,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_trackManager?.onPlaybackRestart();
|
||||
});
|
||||
|
||||
// Listen to position for completion detection (fallback for unreliable MPV events)
|
||||
int? lastObservedPositionMs;
|
||||
_positionSubscription = player!.streams.position.listen((position) {
|
||||
// Fallback for cases where playbackRestart doesn't fire (observed on
|
||||
@@ -2387,10 +2343,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister app lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
// Clean up companion remote playback callbacks
|
||||
_cleanupCompanionRemoteCallbacks();
|
||||
|
||||
// Notify Watch Together guests that host is exiting the player
|
||||
@@ -2403,10 +2357,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_watchTogetherProvider!.notifyHostExitedPlayer();
|
||||
}
|
||||
|
||||
// Detach from Watch Together session
|
||||
_detachFromWatchTogetherSession();
|
||||
|
||||
// Dispose value notifiers
|
||||
_isBuffering.dispose();
|
||||
_hasFirstFrame.dispose();
|
||||
_isExiting.dispose();
|
||||
@@ -2422,14 +2374,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_sendLiveTimeline('stopped');
|
||||
_stopLiveTimelineUpdates();
|
||||
|
||||
// Remove PiP state listener, clear callbacks, disable auto-PiP, and dispose video filter manager
|
||||
_videoPIPManager?.isPipActive.removeListener(_onPipStateChanged);
|
||||
_videoPIPManager?.onBeforeEnterPip = null;
|
||||
_videoPIPManager?.disableAutoPip();
|
||||
PipService.onAutoPipEntering = null;
|
||||
_videoFilterManager?.dispose();
|
||||
|
||||
// Release cached scrub-thumbnail data (BIF or trickplay)
|
||||
_scrubPreviewSource?.dispose();
|
||||
|
||||
// Mark sleep timer for restart if truly exiting (not episode transition)
|
||||
@@ -2437,7 +2387,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
SleepTimerService().markNeedsRestart();
|
||||
}
|
||||
|
||||
// Cancel stream subscriptions
|
||||
_playingSubscription?.cancel();
|
||||
_completedSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
@@ -2455,34 +2404,26 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_mediaControlsSeekableSubscription?.cancel();
|
||||
_serverStatusSubscription?.cancel();
|
||||
|
||||
// Cancel auto-play timer
|
||||
_autoPlayTimer?.cancel();
|
||||
|
||||
// Cancel still watching timer
|
||||
_stillWatchingTimer?.cancel();
|
||||
|
||||
// Dispose Play Next dialog focus nodes
|
||||
_playNextCancelFocusNode.dispose();
|
||||
_playNextConfirmFocusNode.dispose();
|
||||
|
||||
// Dispose "Still watching?" dialog focus nodes
|
||||
_stillWatchingPauseFocusNode.dispose();
|
||||
_stillWatchingContinueFocusNode.dispose();
|
||||
|
||||
// Dispose screen-level focus node
|
||||
_screenFocusNode.removeListener(_onScreenFocusChanged);
|
||||
_screenFocusNode.dispose();
|
||||
|
||||
// Clear media controls and dispose manager
|
||||
_mediaControlsManager?.clear();
|
||||
_mediaControlsManager?.dispose();
|
||||
|
||||
// Clear Discord Rich Presence + send Trakt stop scrobble
|
||||
DiscordRPCService.instance.stopPlayback();
|
||||
TraktScrobbleService.instance.stopPlayback();
|
||||
TrackerCoordinator.instance.stopPlayback();
|
||||
|
||||
// Clean up Windows display mode service
|
||||
if (Platform.isWindows && _displayModeService != null) {
|
||||
FullscreenStateManager().removeListener(_onFullscreenChanged);
|
||||
}
|
||||
@@ -2502,7 +2443,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
player!.abandonAudioFocus();
|
||||
}
|
||||
|
||||
// Disable wakelock when leaving the video player
|
||||
_setWakelock(false);
|
||||
appLogger.d('Wakelock disabled');
|
||||
|
||||
@@ -2513,10 +2453,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Restore orientation based on cached device type (no context needed)
|
||||
try {
|
||||
if (_isPhone) {
|
||||
// Phone: portrait only
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||
} else {
|
||||
// Tablet/Desktop: all orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
@@ -2820,7 +2758,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
bool _isSwitchingChannel = false;
|
||||
|
||||
/// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous)
|
||||
/// Start periodic timeline heartbeats for live TV transcode session.
|
||||
void _startLiveTimelineUpdates() {
|
||||
final generation = ++_liveTimelineGeneration;
|
||||
@@ -3203,8 +3140,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
});
|
||||
}
|
||||
|
||||
// -- "Still watching?" prompt --
|
||||
|
||||
void _showStillWatchingDialog() {
|
||||
// Don't show if auto-play dialog is already visible
|
||||
if (_showPlayNextDialog) return;
|
||||
|
||||
@@ -30,8 +30,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
typedef MediaClientResolver = MediaServerClient? Function(String serverId, {String? clientScopeId});
|
||||
|
||||
/// Context for a download that's been enqueued with background_downloader.
|
||||
/// Carries metadata needed between enqueue and completion callback.
|
||||
class _DownloadContext {
|
||||
final MediaItem metadata;
|
||||
final DownloadQueueItem queueItem;
|
||||
@@ -59,15 +57,12 @@ class DownloadManagerService {
|
||||
final DownloadStorageService _storageService;
|
||||
final MediaServerHttpClient _http;
|
||||
|
||||
// Stream controller for download progress updates
|
||||
final _progressController = StreamController<DownloadProgress>.broadcast();
|
||||
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
||||
|
||||
// Stream controller for deletion progress updates
|
||||
final _deletionProgressController = StreamController<DeletionProgress>.broadcast();
|
||||
Stream<DeletionProgress> get deletionProgressStream => _deletionProgressController.stream;
|
||||
|
||||
// Context for downloads enqueued in this session
|
||||
final Map<String, _DownloadContext> _pendingDownloadContext = {};
|
||||
|
||||
// Items recovered with video complete but supplementary downloads missing
|
||||
@@ -80,7 +75,6 @@ class DownloadManagerService {
|
||||
|
||||
OfflineModeSource? _offlineSource;
|
||||
|
||||
// background_downloader state
|
||||
bool _fileDownloaderInitialized = false;
|
||||
static const _downloadGroup = 'video_downloads';
|
||||
static const _maxAppRetries = 3;
|
||||
@@ -112,8 +106,6 @@ class DownloadManagerService {
|
||||
int _consecutiveQueueFailures = 0;
|
||||
static const _maxConsecutiveFailures = 3;
|
||||
|
||||
/// Public method to check if downloads should be blocked due to cellular-only setting
|
||||
/// Can be used by DownloadProvider to show user-friendly error
|
||||
static Future<bool> shouldBlockDownloadOnCellular() async {
|
||||
final List<ConnectivityResult> connectivity;
|
||||
try {
|
||||
@@ -149,7 +141,6 @@ class DownloadManagerService {
|
||||
_storageService = storageService,
|
||||
_http = http ?? httpClient;
|
||||
|
||||
/// Register a callback to resolve the correct [MediaServerClient] for a given serverId.
|
||||
void setClientResolver(MediaClientResolver resolver) {
|
||||
_clientResolver = resolver;
|
||||
}
|
||||
@@ -383,10 +374,9 @@ class DownloadManagerService {
|
||||
progressBar: true,
|
||||
);
|
||||
|
||||
// Configure native holding queue: max 1 concurrent (Plex server limitation)
|
||||
// Plex servers can reject concurrent media downloads.
|
||||
await FileDownloader().configure(globalConfig: (Config.holdingQueue, (1, 1, 1)));
|
||||
|
||||
// Track tasks for persistence across app restarts
|
||||
await FileDownloader().trackTasks();
|
||||
// Deliver status updates from iOS background-to-foreground transitions
|
||||
await FileDownloader().resumeFromBackground();
|
||||
@@ -679,7 +669,6 @@ class DownloadManagerService {
|
||||
]);
|
||||
}
|
||||
|
||||
/// Queue a download for a media item
|
||||
Future<void> queueDownload({
|
||||
required MediaItem metadata,
|
||||
required MediaServerClient client,
|
||||
@@ -690,7 +679,6 @@ class DownloadManagerService {
|
||||
}) async {
|
||||
final globalKey = metadata.globalKey;
|
||||
|
||||
// Check if already downloading or completed
|
||||
final existing = await _database.getDownloadedMedia(globalKey);
|
||||
if (existing != null &&
|
||||
(existing.status == DownloadStatus.downloading.index || existing.status == DownloadStatus.completed.index)) {
|
||||
@@ -698,7 +686,6 @@ class DownloadManagerService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
await _database.insertDownload(
|
||||
serverId: metadata.serverId!,
|
||||
clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId,
|
||||
@@ -716,7 +703,6 @@ class DownloadManagerService {
|
||||
// cache is warm and falls back to the existing entry on network error.
|
||||
await _pinMetadataForOffline(client, metadata);
|
||||
|
||||
// Add to queue
|
||||
await _database.addToQueue(
|
||||
mediaGlobalKey: globalKey,
|
||||
priority: priority,
|
||||
@@ -726,7 +712,6 @@ class DownloadManagerService {
|
||||
|
||||
_emitProgress(globalKey, DownloadStatus.queued, 0);
|
||||
|
||||
// Start processing if not already
|
||||
unawaited(_processQueue(client));
|
||||
}
|
||||
|
||||
@@ -1219,7 +1204,6 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
// Store video path in DB
|
||||
await _database.updateVideoFilePath(globalKey, storedPath);
|
||||
appLogger.d('Video download completed for $globalKey');
|
||||
|
||||
@@ -1229,7 +1213,6 @@ class DownloadManagerService {
|
||||
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
||||
final showYear = ctx?.showYear;
|
||||
|
||||
// Get queue item settings (still in drift at this point)
|
||||
final queueItem =
|
||||
ctx?.queueItem ??
|
||||
await (_database.select(
|
||||
@@ -1278,7 +1261,6 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve metadata from cache using a globalKey
|
||||
Future<MediaItem?> _resolveMetadata(String globalKey) async {
|
||||
final parsed = parseGlobalKey(globalKey);
|
||||
if (parsed == null) return null;
|
||||
@@ -1292,7 +1274,6 @@ class DownloadManagerService {
|
||||
return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year;
|
||||
}
|
||||
|
||||
/// Re-derive the SAF file URI from metadata (for recovery when context is lost)
|
||||
Future<String?> _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear) async {
|
||||
final safBaseUri = _storageService.safBaseUri;
|
||||
if (safBaseUri == null) return null;
|
||||
@@ -1334,8 +1315,6 @@ class DownloadManagerService {
|
||||
return _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: clientScopeId);
|
||||
}
|
||||
|
||||
/// Download artwork for a media item using hash-based storage
|
||||
/// Downloads all artwork types: thumb/poster, clearLogo, and background art
|
||||
Future<void> _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async {
|
||||
if (metadata.serverId == null) return;
|
||||
|
||||
@@ -1348,7 +1327,6 @@ class DownloadManagerService {
|
||||
await _downloadSingleArtwork(serverId, spec);
|
||||
}
|
||||
|
||||
// Store thumb reference in database (primary artwork for display)
|
||||
final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!);
|
||||
await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath);
|
||||
|
||||
@@ -1605,17 +1583,14 @@ class DownloadManagerService {
|
||||
await _database.removeFromQueue(globalKey);
|
||||
}
|
||||
|
||||
/// Delete a downloaded item and its files
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
_cancelDownloadTimers(globalKey);
|
||||
// Cancel if actively downloading via background_downloader
|
||||
final bgTaskId = await _database.getBgTaskId(globalKey);
|
||||
if (bgTaskId != null) {
|
||||
await FileDownloader().cancelTaskWithId(bgTaskId);
|
||||
}
|
||||
_pendingDownloadContext.remove(globalKey);
|
||||
|
||||
// Delete files from storage
|
||||
final parsed = parseGlobalKey(globalKey);
|
||||
if (parsed == null) {
|
||||
await _database.deleteDownload(globalKey);
|
||||
@@ -1636,24 +1611,18 @@ class DownloadManagerService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine total items to delete
|
||||
final totalItems = await _getTotalItemsToDelete(metadata, serverId, clientScopeId: clientScopeId);
|
||||
|
||||
// Emit initial progress
|
||||
_emitDeletionProgress(
|
||||
DeletionProgress(globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems),
|
||||
);
|
||||
|
||||
// Delete files from storage (with progress updates)
|
||||
await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId);
|
||||
|
||||
// Delete from API cache
|
||||
await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId);
|
||||
|
||||
// Delete from database
|
||||
await _database.deleteDownload(globalKey);
|
||||
|
||||
// Emit completion
|
||||
_emitDeletionProgress(
|
||||
DeletionProgress(
|
||||
globalKey: globalKey,
|
||||
@@ -1664,7 +1633,6 @@ class DownloadManagerService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Emit deletion progress update
|
||||
void _emitDeletionProgress(DeletionProgress progress) {
|
||||
if (_disposed) return;
|
||||
_deletionProgressController.add(progress);
|
||||
@@ -1687,12 +1655,10 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete media files using metadata to find correct paths
|
||||
Future<void> _deleteMediaFilesWithMetadata(String serverId, String ratingKey, {String? clientScopeId}) async {
|
||||
try {
|
||||
final gk = buildGlobalKey(serverId, ratingKey);
|
||||
final downloadRecord = await _database.getDownloadedMedia(gk);
|
||||
// Get metadata from API cache
|
||||
final scopeId = clientScopeId ?? downloadRecord?.clientScopeId;
|
||||
final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId);
|
||||
|
||||
@@ -1777,7 +1743,6 @@ class DownloadManagerService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a set of all chapter thumb paths used by OTHER items on this server
|
||||
final otherItems = await _database.getDownloadsByServerId(serverId);
|
||||
final inUseThumbPaths = <String>{};
|
||||
for (final item in otherItems) {
|
||||
@@ -1801,7 +1766,6 @@ class DownloadManagerService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get artwork file path and delete
|
||||
final artworkPath = await _storageService.getArtworkPathFromThumb(serverId, thumbPath);
|
||||
if (await _deleteFileIfExists(File(artworkPath), 'chapter thumbnail')) {
|
||||
deletedCount++;
|
||||
@@ -1819,7 +1783,6 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete episode files
|
||||
Future<void> _deleteEpisodeFiles(MediaItem episode, String serverId, {String? clientScopeId}) async {
|
||||
try {
|
||||
final parentMetadata = episode.grandparentId != null
|
||||
@@ -1827,7 +1790,6 @@ class DownloadManagerService {
|
||||
: null;
|
||||
final showYear = parentMetadata?.year;
|
||||
|
||||
// Delete video file
|
||||
final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear);
|
||||
final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.'));
|
||||
final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt);
|
||||
@@ -1837,21 +1799,17 @@ class DownloadManagerService {
|
||||
await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download');
|
||||
}
|
||||
|
||||
// Delete thumbnail
|
||||
final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear);
|
||||
await _deleteFileIfExists(File(thumbPath), 'episode thumbnail');
|
||||
|
||||
// Delete subtitles directory
|
||||
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
||||
if (await subsDir.exists()) {
|
||||
await subsDir.delete(recursive: true);
|
||||
appLogger.i('Deleted episode subtitles: ${subsDir.path}');
|
||||
}
|
||||
|
||||
// Delete chapter thumbnails (with reference counting)
|
||||
await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId);
|
||||
|
||||
// Clean up parent directories if empty
|
||||
await _cleanupEmptyDirectories(episode, showYear);
|
||||
|
||||
// Safety net: verify the actual DB-recorded file is gone
|
||||
@@ -1861,7 +1819,6 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete season files
|
||||
Future<void> _deleteSeasonFiles(MediaItem season, String serverId, {String? clientScopeId}) async {
|
||||
try {
|
||||
final parentMetadata = season.parentId != null
|
||||
@@ -1869,7 +1826,6 @@ class DownloadManagerService {
|
||||
: null;
|
||||
final showYear = parentMetadata?.year;
|
||||
|
||||
// Get all episodes in this season
|
||||
final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId);
|
||||
|
||||
appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}');
|
||||
@@ -1946,10 +1902,8 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete show files
|
||||
Future<void> _deleteShowFiles(MediaItem show, String serverId, {String? clientScopeId}) async {
|
||||
try {
|
||||
// Get all episodes in this show
|
||||
final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId);
|
||||
|
||||
appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}');
|
||||
@@ -1971,7 +1925,6 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete movie files
|
||||
Future<void> _deleteMovieFiles(MediaItem movie, String serverId, {String? clientScopeId}) async {
|
||||
try {
|
||||
final movieDir = await _storageService.getMovieDirectory(movie);
|
||||
@@ -1980,7 +1933,6 @@ class DownloadManagerService {
|
||||
appLogger.i('Deleted movie directory: ${movieDir.path}');
|
||||
}
|
||||
|
||||
// Delete chapter thumbnails (with reference counting)
|
||||
await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId);
|
||||
|
||||
// Safety net: verify the actual DB-recorded file is gone
|
||||
@@ -2161,7 +2113,6 @@ class DownloadManagerService {
|
||||
appLogger.w('Safety net: video still exists after metadata deletion, deleting: $videoPath');
|
||||
await videoFile.delete();
|
||||
|
||||
// Clean up .part file and subtitles directory alongside video
|
||||
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
||||
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
||||
final subsDir = Directory(subsPath);
|
||||
@@ -2238,29 +2189,24 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if season artwork is in use
|
||||
Future<bool> _isSeasonArtworkInUse(MediaItem episode, int? _) async {
|
||||
final seasonKey = episode.parentId;
|
||||
if (seasonKey == null) return false;
|
||||
|
||||
final otherEpisodes = await _database.getEpisodesBySeason(seasonKey);
|
||||
|
||||
// Check if any episodes besides this one
|
||||
return otherEpisodes.any((e) => e.globalKey != episode.globalKey);
|
||||
}
|
||||
|
||||
/// Check if show artwork is in use
|
||||
Future<bool> _isShowArtworkInUse(MediaItem metadata, int? _) async {
|
||||
final showKey = metadata.grandparentId ?? metadata.parentId ?? metadata.id;
|
||||
|
||||
// Use targeted query instead of full table scan
|
||||
final showEpisodes = await _database.getEpisodesByShow(showKey);
|
||||
|
||||
// Check if any episodes belong to this show besides the current item
|
||||
return showEpisodes.any((item) => item.globalKey != metadata.globalKey);
|
||||
}
|
||||
|
||||
/// Find file with any extension
|
||||
Future<File?> _findFileWithAnyExtension(String pathWithoutExt) async {
|
||||
final dir = Directory(path.dirname(pathWithoutExt));
|
||||
final baseName = path.basename(pathWithoutExt);
|
||||
@@ -2293,10 +2239,8 @@ class DownloadManagerService {
|
||||
final videoDeleted = await _deleteFileIfExists(videoFile, 'video file');
|
||||
|
||||
if (videoDeleted) {
|
||||
// Delete .part file from interrupted downloads
|
||||
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
||||
|
||||
// Delete subtitle directory
|
||||
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
||||
final subsDir = Directory(subsPath);
|
||||
if (await subsDir.exists()) {
|
||||
@@ -2304,7 +2248,6 @@ class DownloadManagerService {
|
||||
appLogger.i('Deleted subtitles: $subsPath');
|
||||
}
|
||||
|
||||
// Clean up empty parent directories
|
||||
await _cleanupEmptyParentDirectories(videoFile.parent);
|
||||
}
|
||||
}
|
||||
@@ -2324,12 +2267,10 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all downloaded media items (for loading persisted data)
|
||||
Future<List<DownloadedMediaItem>> getAllDownloads() {
|
||||
return _database.select(_database.downloadedMedia).get();
|
||||
}
|
||||
|
||||
/// Get a specific downloaded media item by globalKey
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return _database.getDownloadedMedia(globalKey);
|
||||
}
|
||||
|
||||
@@ -38,34 +38,26 @@ class 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.read(SettingsService.customDownloadPath);
|
||||
_customPathType = settingsService.read(SettingsService.customDownloadPathType) ?? 'file';
|
||||
// Reset cached directories to force recalculation
|
||||
_baseDownloadsDir = null;
|
||||
_artworkDirectoryPath = null;
|
||||
|
||||
// Eagerly initialize artwork directory for sync access
|
||||
await getArtworkDirectory();
|
||||
}
|
||||
|
||||
/// Refresh custom path from settings (call when settings change)
|
||||
Future<void> refreshCustomPath() async {
|
||||
if (_settingsService != null) {
|
||||
_customDownloadPath = _settingsService!.read(SettingsService.customDownloadPath);
|
||||
@@ -93,10 +85,8 @@ class DownloadStorageService {
|
||||
return 'S${season}E$ep - $episodeName';
|
||||
}
|
||||
|
||||
/// 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!;
|
||||
@@ -105,7 +95,6 @@ class DownloadStorageService {
|
||||
return dir.path;
|
||||
}
|
||||
|
||||
/// Check if a directory is writable
|
||||
Future<bool> isDirectoryWritable(Directory dir) async {
|
||||
try {
|
||||
if (!await dir.exists()) {
|
||||
@@ -121,11 +110,9 @@ class DownloadStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
@@ -135,7 +122,6 @@ class DownloadStorageService {
|
||||
// Fall through to default if custom path is not writable
|
||||
}
|
||||
|
||||
// Default path logic
|
||||
final baseDir = await _getBaseAppDir();
|
||||
_baseDownloadsDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'downloads')));
|
||||
return _baseDownloadsDir!;
|
||||
@@ -176,7 +162,6 @@ class DownloadStorageService {
|
||||
/// Example: artwork/a1b2c3d4e5f6.jpg
|
||||
String? getArtworkPathSync(String serverId, String thumbPath) {
|
||||
if (_artworkDirectoryPath == null) return null;
|
||||
// Create hash from serverId:thumbPath for deduplication
|
||||
final hash = _hashArtworkPath(serverId, thumbPath);
|
||||
return path.join(_artworkDirectoryPath!, '$hash.jpg');
|
||||
}
|
||||
@@ -189,7 +174,6 @@ class DownloadStorageService {
|
||||
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();
|
||||
@@ -201,19 +185,16 @@ class DownloadStorageService {
|
||||
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();
|
||||
return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey)));
|
||||
}
|
||||
|
||||
/// 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 subtitles directory
|
||||
Future<Directory> getSubtitlesDirectory(String serverId, String ratingKey) async {
|
||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||
final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles'));
|
||||
@@ -223,16 +204,11 @@ class DownloadStorageService {
|
||||
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: < > : " / \ | ? *
|
||||
@@ -263,7 +239,6 @@ class DownloadStorageService {
|
||||
return year != null ? '$sanitized ($year)' : sanitized;
|
||||
}
|
||||
|
||||
/// Get the folder name for a movie: "Movie Name (YYYY)"
|
||||
String _getMovieFolderName(MediaItem movie) {
|
||||
return _formatTitleWithYear(movie.title!, movie.year);
|
||||
}
|
||||
@@ -276,7 +251,6 @@ class DownloadStorageService {
|
||||
return _formatTitleWithYear(title, year);
|
||||
}
|
||||
|
||||
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
|
||||
Future<Directory> getMovieDirectory(MediaItem movie) async {
|
||||
final baseDir = await getDownloadsDirectory();
|
||||
final movieFolder = _getMovieFolderName(movie);
|
||||
@@ -336,21 +310,18 @@ class DownloadStorageService {
|
||||
return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')));
|
||||
}
|
||||
|
||||
/// Get episode subtitle path
|
||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||
Future<String> getEpisodeSubtitlePath(MediaItem 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(MediaItem movie) async {
|
||||
final movieDir = await getMovieDirectory(movie);
|
||||
final baseName = _getMovieFolderName(movie);
|
||||
return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs')));
|
||||
}
|
||||
|
||||
/// Get movie subtitle path
|
||||
Future<String> getMovieSubtitlePath(MediaItem movie, int trackId, String extension) async {
|
||||
final subsDir = await getMovieSubtitlesDirectory(movie);
|
||||
return path.join(subsDir.path, '$trackId.$extension');
|
||||
@@ -375,14 +346,12 @@ class DownloadStorageService {
|
||||
}
|
||||
if (result != absolutePath) return result;
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -463,13 +432,10 @@ class DownloadStorageService {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// Get path components for SAF based on media type
|
||||
/// Returns list of directory names to create under the SAF base
|
||||
List<String> getMovieSafPathComponents(MediaItem movie) {
|
||||
return ['Movies', _getMovieFolderName(movie)];
|
||||
}
|
||||
|
||||
/// Get path components for episode SAF storage
|
||||
List<String> getEpisodeSafPathComponents(MediaItem episode, {int? showYear}) {
|
||||
final showFolder = _getShowFolderName(episode, showYear: showYear);
|
||||
final seasonNum = padNumber(episode.parentIndex ?? 0, 2);
|
||||
@@ -489,12 +455,10 @@ class DownloadStorageService {
|
||||
return ['TV Shows', showFolder, 'Season $seasonNum'];
|
||||
}
|
||||
|
||||
/// Get SAF file name for a movie
|
||||
String getMovieSafFileName(MediaItem movie, String extension) {
|
||||
return '${_getMovieFolderName(movie)}.$extension';
|
||||
}
|
||||
|
||||
/// Get SAF file name for an episode
|
||||
String getEpisodeSafFileName(MediaItem episode, String extension) {
|
||||
final fileName = _formatEpisodeFileName(episode);
|
||||
return '$fileName.$extension';
|
||||
@@ -503,7 +467,6 @@ class DownloadStorageService {
|
||||
/// Get the extension-less episode filename used for SAF lookups.
|
||||
String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode);
|
||||
|
||||
/// Check if a path is a SAF content URI
|
||||
bool isSafUri(String storedPath) {
|
||||
return storedPath.startsWith('content://');
|
||||
}
|
||||
@@ -513,7 +476,6 @@ class DownloadStorageService {
|
||||
/// 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);
|
||||
|
||||
@@ -372,7 +372,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse libraries from the library provider
|
||||
final libraries = <PlexLibraryDto>[];
|
||||
final epg = <({String identifier, String gridEndpoint})>[];
|
||||
|
||||
@@ -554,11 +553,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return ConnectionTestResult(success: true, latencyMs: avgLatency);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Parsing Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Extract MediaContainer from API response
|
||||
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
|
||||
if (response.data is Map && response.data.containsKey('MediaContainer')) {
|
||||
return response.data['MediaContainer'];
|
||||
@@ -566,15 +560,11 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Tag a PlexMetadataDto with this client's serverId and serverName
|
||||
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
|
||||
metadata.copyWith(serverId: serverId, serverName: serverName);
|
||||
|
||||
/// Create and tag a PlexMetadataDto from JSON
|
||||
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
|
||||
|
||||
/// Extract list of PlexMetadataDto from response
|
||||
/// Automatically tags all items with this client's serverId and serverName
|
||||
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
@@ -583,7 +573,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Extract first metadata JSON from response (returns raw Map or null)
|
||||
Map<String, dynamic>? _getFirstMetadataJson(MediaServerResponse response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) {
|
||||
@@ -592,7 +581,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Generic helper to extract and map Directory list from response
|
||||
List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Directory'] != null) {
|
||||
@@ -601,7 +589,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Extract PlexLibraryDto list from response with auto-tagging
|
||||
List<PlexLibraryDto> _extractLibraryList(MediaServerResponse response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Directory'] != null) {
|
||||
@@ -616,7 +603,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Extract PlexPlaylistDto list from response with auto-tagging
|
||||
List<PlexPlaylistDto> _extractPlaylistList(MediaServerResponse response) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
@@ -631,11 +617,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get server identity
|
||||
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||
final response = await _getWithFailover('/identity');
|
||||
return response.data;
|
||||
@@ -981,8 +962,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
'Failed to select streams',
|
||||
);
|
||||
}
|
||||
// Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée)
|
||||
// Ici, on retourne true par défaut si rien n'est fait
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1256,13 +1235,10 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) {
|
||||
final mediaList = metadataJson['Media'] as List;
|
||||
|
||||
// Parse available media versions first (convert via the internal
|
||||
// mapper so PlaybackInitializationResult sees neutral MediaVersion).
|
||||
availableVersions = mediaList
|
||||
.map((media) => PlexMappers.mediaVersionFromJson(media as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Ensure the requested index is valid
|
||||
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
||||
mediaIndex = 0;
|
||||
}
|
||||
@@ -1281,15 +1257,11 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
final partKey = part['key'] as String?;
|
||||
|
||||
if (partKey != null) {
|
||||
// Get video URL
|
||||
videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token);
|
||||
|
||||
// Parse streams using shared parser
|
||||
final streams = walkStreams(part['Stream'] as List<dynamic>?, _streamReader);
|
||||
// Parse chapters using helper
|
||||
final chapters = _parseChapters(metadataJson);
|
||||
|
||||
// Create media info
|
||||
mediaInfo = MediaSourceInfo(
|
||||
videoUrl: videoUrl,
|
||||
audioTracks: streams.audioTracks,
|
||||
@@ -1941,10 +1913,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Metadata Editing Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Update metadata fields for a media item
|
||||
Future<bool> updateMetadata({
|
||||
required int sectionId,
|
||||
@@ -2099,10 +2067,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Collection Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get all collections for a library section
|
||||
/// Returns collections as PlexMetadataDto objects with type="collection"
|
||||
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
||||
@@ -2255,10 +2219,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Play Queue Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
|
||||
/// MediaItem-typed entries.
|
||||
PlayQueueResponse _parsePlayQueueResponse(dynamic data) {
|
||||
@@ -2496,10 +2456,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
return _getPlaylists(playlistType: playlistType);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Library Management Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Scan/refresh a library section to detect new files
|
||||
Future<void> scanLibrary(String sectionId) async {
|
||||
await _getWithFailover('/library/sections/$sectionId/refresh');
|
||||
@@ -2521,10 +2477,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
||||
await _getWithFailover('/library/sections/$sectionId/analyze');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live TV / DVR Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get all DVR devices configured on this server
|
||||
Future<List<LiveTvDvr>> getDvrs() async {
|
||||
return _wrapListApiCall<LiveTvDvr>(() => _http.get('/livetv/dvrs'), (response) {
|
||||
|
||||
@@ -269,8 +269,6 @@ class SettingsExportService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- File operations -----------------------------------------------------
|
||||
|
||||
static Future<String> _defaultFileName() async {
|
||||
final now = DateTime.now();
|
||||
final y = now.year.toString().padLeft(4, '0');
|
||||
|
||||
@@ -10,9 +10,6 @@ import '../utils/future_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
|
||||
// ============================================================================
|
||||
// Track Matching Utilities
|
||||
// ============================================================================
|
||||
// These functions match MPV tracks to Plex tracks by properties (language,
|
||||
// codec, title, etc.) instead of list index, since the two may be ordered
|
||||
// differently.
|
||||
|
||||
@@ -82,10 +82,6 @@ class MediaServerHttpClient {
|
||||
Duration connectTimeout;
|
||||
Duration receiveTimeout;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public request methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<MediaServerResponse> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
@@ -190,10 +186,6 @@ class MediaServerHttpClient {
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core send implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<MediaServerResponse> _send(
|
||||
String method,
|
||||
String path, {
|
||||
@@ -260,10 +252,6 @@ class MediaServerHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URI building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a full URI from [baseUrl] + [path] + [queryParameters].
|
||||
/// Use this from callers that need to construct URLs with the client's
|
||||
/// current (possibly failover-switched) base, rather than reading
|
||||
@@ -307,10 +295,6 @@ class MediaServerHttpClient {
|
||||
|
||||
static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set the request body, choosing encoding based on the body type.
|
||||
void _setBody(http.Request request, Object? body) {
|
||||
if (body == null) return;
|
||||
@@ -338,10 +322,6 @@ class MediaServerHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Decode the response body: lenient UTF-8, then JSON parse if applicable.
|
||||
/// Large payloads are decoded in a background isolate.
|
||||
Future<dynamic> _decodeBody(List<int> bytes, Map<String, String> headers) async {
|
||||
@@ -375,10 +355,6 @@ class MediaServerHttpClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _logResponse(String method, Uri uri, int statusCode, int ms) {
|
||||
appLogger.d('$method ${LogRedactionManager.redact(uri.toString())} → $statusCode (${ms}ms)');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user