chore: clean up redundant comments

This commit is contained in:
edde746
2026-05-01 05:02:26 +02:00
parent 117d1b8882
commit 9bd5732f2b
25 changed files with 8 additions and 544 deletions
-12
View File
@@ -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();
-18
View File
@@ -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;
-14
View File
@@ -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) {
-4
View File
@@ -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;
-44
View File
@@ -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:
-20
View File
@@ -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;
-32
View File
@@ -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;
-4
View File
@@ -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);
-8
View File
@@ -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++) ...[
-28
View File
@@ -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',
-12
View File
@@ -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),
-14
View File
@@ -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),
+7 -72
View File
@@ -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;
+1 -60
View File
@@ -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);
-48
View File
@@ -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.
-24
View File
@@ -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)');
}