refactor: strip obvious comments

This commit is contained in:
edde746
2026-05-04 22:40:18 +02:00
parent fdaff3687d
commit ed4be7b96d
162 changed files with 208 additions and 1440 deletions
-2
View File
@@ -38,7 +38,6 @@ enum OfflineActionType {
};
}
// Simplified database with API cache for offline support
@DriftDatabase(
tables: [
DownloadedMedia,
@@ -556,7 +555,6 @@ LazyDatabase _openConnection() {
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
// Ensure directory exists
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
-1
View File
@@ -123,7 +123,6 @@ extension DownloadDatabaseOperations on AppDatabase {
/// Get next item from queue (highest priority, oldest first)
/// Only returns items that are not paused
Future<DownloadQueueItem?> getNextQueueItem() async {
// Join with downloadedMedia to check status and filter out paused items
final query = select(
downloadQueue,
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
-1
View File
@@ -7,7 +7,6 @@ class ApiCache extends Table {
/// for Plex, "abc123:/Users/.../Items/..." for Jellyfin)
TextColumn get cacheKey => text()();
/// JSON response data
TextColumn get data => text()();
/// Whether this item is pinned for offline access
-21
View File
@@ -1,13 +1,9 @@
import 'package:flutter/services.dart';
/// Extension on KeyEvent for common event type checks.
extension KeyEventActionable on KeyEvent {
/// Whether this event should trigger an action (KeyDownEvent or KeyRepeatEvent).
/// Use this to filter out KeyUpEvents early in key handlers.
bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent;
}
/// Shared sets for keyboard key categories.
final _dpadDirectionKeys = {
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
@@ -31,35 +27,18 @@ final _backKeys = {
final _contextMenuKeys = {LogicalKeyboardKey.contextMenu, LogicalKeyboardKey.gameButtonX};
/// Extension methods for checking D-pad related keys.
extension DpadKeyExtension on LogicalKeyboardKey {
/// Whether this key is a D-pad directional key.
bool get isDpadDirection => _dpadDirectionKeys.contains(this);
/// Whether this key is a select/activate key.
bool get isSelectKey => _selectKeys.contains(this);
/// Whether this key is a back/cancel key.
bool get isBackKey => _backKeys.contains(this);
/// Whether this key is a context menu key.
bool get isContextMenuKey => _contextMenuKeys.contains(this);
/// Whether this key is a navigation key (dpad, select, back, context menu, tab).
/// Use this to distinguish navigation keys from typing/volume/media keys.
bool get isNavigationKey =>
isDpadDirection || isSelectKey || isBackKey || isContextMenuKey || this == LogicalKeyboardKey.tab;
/// Whether this key moves focus left.
bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft;
/// Whether this key moves focus right.
bool get isRightKey => this == LogicalKeyboardKey.arrowRight;
/// Whether this key moves focus up.
bool get isUpKey => this == LogicalKeyboardKey.arrowUp;
/// Whether this key moves focus down.
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
-9
View File
@@ -1,30 +1,21 @@
import 'package:flutter/material.dart';
import '../theme/mono_tokens.dart';
/// Focus styling constants for D-pad navigation.
class FocusTheme {
FocusTheme._();
/// Scale factor when an item is focused.
static const double focusScale = 1.02;
/// Border width for the focus indicator.
static const double focusBorderWidth = 2.5;
/// Default border radius (matches MonoTokens.radiusSm).
static const double defaultBorderRadius = 8.0;
/// Get the focus border color from the theme.
static Color getFocusBorderColor(BuildContext context) {
return Theme.of(context).colorScheme.primary;
}
/// Get the animation duration from MonoTokens.
static Duration getAnimationDuration(BuildContext context) {
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
}
/// Build the focus border decoration.
static BoxDecoration focusDecoration(
BuildContext context, {
required bool isFocused,
-28
View File
@@ -5,22 +5,13 @@ import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
/// Describes a single action button for use in [FocusableActionBar].
class FocusableAction {
/// Icon to display. Ignored when [child] is provided.
final IconData icon;
/// Icon color. Ignored when [child] is provided.
final Color? iconColor;
/// Icon fill weight (0.01.0). Defaults to 1.0. Ignored when [child] is provided.
final double iconFill;
final String? tooltip;
final VoidCallback? onPressed;
/// Optional custom child widget placed inside the focus container.
/// Overrides the default [IconButton] built from [icon]/[tooltip]/[onPressed].
final Widget? child;
const FocusableAction({
@@ -33,25 +24,6 @@ class FocusableAction {
});
}
/// A row of focusable action buttons for app bar [actions:].
///
/// Manages focus nodes, left/right D-pad navigation between buttons,
/// and the standard white-alpha background focus indicator internally.
///
/// Returns a single [Row] widget — place it inside the `actions:` list:
/// ```dart
/// CustomAppBar(
/// title: Text('Title'),
/// actions: [
/// FocusableActionBar(
/// actions: [
/// FocusableAction(icon: Symbols.refresh_rounded, onPressed: _refresh),
/// FocusableAction(icon: Symbols.upload_rounded, onPressed: _upload),
/// ],
/// ),
/// ],
/// )
/// ```
class FocusableActionBar extends StatefulWidget {
final List<FocusableAction> actions;
-17
View File
@@ -4,23 +4,6 @@ import 'focus_theme.dart';
import 'focusable_wrapper.dart';
import 'input_mode_tracker.dart';
/// A focusable button wrapper for D-pad navigation on TV.
///
/// Wraps any button widget with [FocusableWrapper] and adds a white overlay
/// + contrasting border when focused. Tracks focus state internally so callers
/// don't need manual state management.
///
/// ```dart
/// FocusableButton(
/// autofocus: true,
/// onPressed: _doSomething,
/// child: FilledButton.icon(
/// onPressed: _doSomething,
/// icon: Icon(Symbols.add_rounded),
/// label: Text('Create'),
/// ),
/// )
/// ```
class FocusableButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
-18
View File
@@ -6,27 +6,13 @@ import 'package:flutter/services.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// Callbacks for chip key event handling.
class ChipKeyCallbacks {
/// Called when SELECT key is pressed (short press when [onLongPress] is set).
final VoidCallback? onSelect;
/// Called when SELECT key is held for 500ms.
final VoidCallback? onLongPress;
/// Called when DOWN arrow is pressed.
final VoidCallback? onNavigateDown;
/// Called when UP arrow is pressed.
final VoidCallback? onNavigateUp;
/// Called when LEFT arrow is pressed.
final VoidCallback? onNavigateLeft;
/// Called when RIGHT arrow is pressed.
final VoidCallback? onNavigateRight;
/// Called when BACK key is pressed.
final VoidCallback? onBack;
const ChipKeyCallbacks({
@@ -168,7 +154,6 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.ignored;
}
// LEFT arrow - call callback if provided, otherwise propagate to parent
if (key.isLeftKey) {
if (callbacks.onNavigateLeft != null) {
callbacks.onNavigateLeft!();
@@ -178,7 +163,6 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.ignored;
}
// RIGHT arrow - call callback if provided, otherwise consume to prevent escape
if (key.isRightKey) {
if (callbacks.onNavigateRight != null) {
callbacks.onNavigateRight!();
@@ -187,13 +171,11 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
return KeyEventResult.handled;
}
// DOWN arrow
if (key.isDownKey) {
callbacks.onNavigateDown?.call();
return KeyEventResult.handled;
}
// UP arrow
if (key.isUpKey && callbacks.onNavigateUp != null) {
callbacks.onNavigateUp!();
return KeyEventResult.handled;
-5
View File
@@ -4,11 +4,6 @@ import 'dpad_navigator.dart';
import 'focusable_wrapper.dart';
import 'input_mode_tracker.dart';
/// A D-pad friendly slider that uses LEFT/RIGHT to adjust value
/// and lets UP/DOWN pass through for focus traversal.
///
/// Hides the thumb in keyboard mode when not focused, matching
/// the timeline slider pattern.
class FocusableSlider extends StatefulWidget {
final double value;
final double min;
+3 -10
View File
@@ -52,10 +52,7 @@ class BackKeyCoordinator {
}
}
/// Handle a BACK key press by running [onBack] on key up.
///
/// This consumes KeyDown/KeyRepeat to avoid duplicate actions from key repeat.
/// Optionally suppresses stray KeyUp events delivered to the next route after a pop.
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
@@ -92,12 +89,8 @@ KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event,
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
/// Handles a select key as a one-shot button activation.
///
/// Fires [onActivate] on the initial [KeyDownEvent] only.
/// Consumes all select key events (down, repeat, up) to prevent
/// unhandled events from reaching platform-level handling.
/// Returns [KeyEventResult.ignored] for non-select keys.
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) onActivate();
-2
View File
@@ -2,8 +2,6 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
/// Key-repeat timer for held dpad/keyboard inputs: fires immediately, then
/// every 100 ms after a 400 ms initial delay. Call [stopRepeat] in `dispose`.
mixin KeyRepeatHelper<T extends StatefulWidget> on State<T> {
static const _initialDelay = Duration(milliseconds: 400);
static const _repeatInterval = Duration(milliseconds: 100);
-1
View File
@@ -8,7 +8,6 @@ class HubFocusMemory {
static final Map<String, int> _perHubMemory = {};
static int _lastColumnHint = 0;
/// Remember the focused index for a specific hub
static void setForHub(String hubKey, int index) {
_perHubMemory[hubKey] = index;
_lastColumnHint = index;
-13
View File
@@ -150,7 +150,6 @@ Future<void> _bootstrapApp() async {
unawaited(LocaleSettings.setLocale(savedLocale));
// Needed for formatting dates in different locales
await initializeDateFormatting(savedLocale.languageCode, null);
// Configure image cache — keep budget modest to leave headroom for Skia decode buffers
@@ -176,7 +175,6 @@ Future<void> _bootstrapApp() async {
PipService();
}
// Configure macOS window with custom titlebar (depends on window manager)
futures.add(MacOSWindowService.setupCustomTitlebar());
// Hook Windows native fullscreen callback (no-op elsewhere).
@@ -216,15 +214,12 @@ Future<void> _bootstrapApp() async {
// and intercepts input events, so we must listen to re-dispatch them)
GamepadService.instance.start();
// Desktop-only services
if (PlatformDetector.isDesktopOS()) {
unawaited(DiscordRPCService.instance.initialize());
}
// Trakt scrobble service (all platforms)
await TraktScrobbleService.instance.initialize();
// Register bundled shader licenses
_registerShaderLicenses();
// In release mode, show a colored placeholder instead of a blank/white screen
@@ -380,7 +375,6 @@ void _registerShaderLicenses() {
});
}
// Global RouteObserver for tracking navigation
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
final rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -403,7 +397,6 @@ class MainApp extends StatefulWidget {
}
class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Initialize multi-server infrastructure
late final MultiServerManager _serverManager;
late final DataAggregationService _aggregationService;
late final AppDatabase _appDatabase;
@@ -428,7 +421,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
// On desktop, periodically check RSS and evict image cache if too high
if (PlatformDetector.isDesktopOS()) {
_memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
final rss = ProcessInfo.currentRss;
@@ -444,7 +436,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_aggregationService = DataAggregationService(_serverManager);
_appDatabase = AppDatabase();
// Initialize API cache with database
PlexApiCache.initialize(_appDatabase);
JellyfinApiCache.initialize(_appDatabase);
@@ -691,7 +682,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
},
dispose: (_, binder) => binder.dispose(),
),
// Offline mode provider - depends on MultiServerProvider
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
create: (_) {
final provider = OfflineModeProvider(_serverManager);
@@ -714,7 +704,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider;
},
),
// Offline watch sync service
ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
create: (context) {
final offlineModeProvider = context.read<OfflineModeProvider>();
@@ -764,7 +753,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider;
},
),
// Offline watch provider - depends on sync service and download provider
ChangeNotifierProxyProvider2<OfflineWatchSyncService, DownloadProvider, OfflineWatchProvider>(
create: (context) => OfflineWatchProvider(
syncService: _offlineWatchSyncService,
@@ -1097,7 +1085,6 @@ class _SetupScreenState extends State<SetupScreen> {
return;
}
// Populate per-server status from the registry for the splash list.
if (mounted) {
setState(() {
for (final conn in allConnections) {
-5
View File
@@ -96,7 +96,6 @@ abstract class LiveTvSupport {
/// locally.
Future<void> setFavoriteChannels(List<FavoriteChannel> channels);
// ── DVR setup/lifecycle ─────────────────────────────────────────
Future<LiveTvServerStatus> fetchLiveTvServerStatus();
Future<LiveTvDvr?> fetchDvr(String dvrId);
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
@@ -115,7 +114,6 @@ abstract class LiveTvSupport {
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId);
Future<void> cancelGuideReload(String dvrId);
// ── Grabber devices/tuners ──────────────────────────────────────
Future<List<MediaGrabber>> fetchGrabbers({String? protocol});
Future<List<MediaGrabberDevice>> fetchGrabberDevices();
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices();
@@ -136,7 +134,6 @@ abstract class LiveTvSupport {
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs);
String buildGrabberDeviceThumbUrl(String deviceId, int version);
// ── EPG setup/lineups ───────────────────────────────────────────
Future<List<LiveTvCountry>> fetchEpgCountries();
Future<List<LiveTvLanguage>> fetchEpgLanguages();
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId);
@@ -149,7 +146,6 @@ abstract class LiveTvSupport {
required String lineupGroupUri,
});
// ── Recording rules / scheduled grabs ───────────────────────────
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid);
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true});
Future<MediaSubscription?> fetchRecordingRule(
@@ -170,7 +166,6 @@ abstract class LiveTvSupport {
bool includeStorage = true,
});
// ── Providers and sessions ──────────────────────────────────────
Future<List<MediaProviderInfo>> fetchMediaProviders();
Future<void> registerMediaProvider(String url);
Future<void> refreshMediaProviders();
-11
View File
@@ -28,7 +28,6 @@ sealed class MediaItem {
/// for matching across servers and for Trakt-style external lookups.
final String? guid;
// ── Display metadata ─────────────────────────────────────────────
final String? title;
final String? titleSort;
final String? summary;
@@ -41,7 +40,6 @@ sealed class MediaItem {
final String? originallyAvailableAt;
final String? contentRating;
// ── Hierarchy (episodes/seasons) ─────────────────────────────────
final String? parentId;
final String? parentTitle;
final String? parentThumbPath;
@@ -52,13 +50,11 @@ sealed class MediaItem {
final String? grandparentThumbPath;
final String? grandparentArtPath;
// ── Artwork ──────────────────────────────────────────────────────
final String? thumbPath;
final String? artPath;
final String? clearLogoPath;
final String? backgroundSquarePath;
// ── Time / watch state ──────────────────────────────────────────
final int? durationMs;
/// Resume position in ms.
@@ -78,11 +74,9 @@ sealed class MediaItem {
final int? addedAt;
final int? updatedAt;
// ── Rating ───────────────────────────────────────────────────────
final double? rating;
final double? userRating;
// ── Tags / people ────────────────────────────────────────────────
final List<String>? genres;
final List<String>? directors;
final List<String>? writers;
@@ -94,15 +88,12 @@ sealed class MediaItem {
final List<String>? moods;
final List<MediaRole>? roles;
// ── Media files ──────────────────────────────────────────────────
final List<MediaVersion>? mediaVersions;
// ── Library reference ────────────────────────────────────────────
/// Backend-opaque library/section id this item belongs to.
final String? libraryId;
final String? libraryTitle;
// ── Per-item playback prefs ──────────────────────────────────────
/// Preferred audio language for this item — used by track-selection
/// fallback (Priority 3) on both backends. Plex persists changes via
/// [PlexClient.setMetadataPreferences]; Jellyfin populates it from the
@@ -110,11 +101,9 @@ sealed class MediaItem {
/// endpoint, so the value is read-only there.
final String? audioLanguage;
// ── Multi-server ─────────────────────────────────────────────────
final String? serverId;
final String? serverName;
// ── Escape hatch ─────────────────────────────────────────────────
/// Untyped fall-through for backend-specific fields not yet mapped onto a
/// typed accessor. Use sparingly; promote to typed fields when stable.
final Map<String, Object?>? raw;
-16
View File
@@ -62,13 +62,11 @@ import 'server_capabilities.dart';
enum HealthStatus { online, offline, authError }
abstract class MediaServerClient {
// ── Identity ─────────────────────────────────────────────────────
String get serverId;
String? get serverName;
MediaBackend get backend;
ServerCapabilities get capabilities;
// ── Lifecycle ────────────────────────────────────────────────────
/// Release HTTP resources and any other long-lived state. Idempotent.
void close();
@@ -96,7 +94,6 @@ abstract class MediaServerClient {
/// appropriate cache instance.
ApiCache get cache;
// ── Browse: libraries ────────────────────────────────────────────
Future<List<MediaLibrary>> fetchLibraries();
/// Page through items in [libraryId] using the neutral [query]. Backends
@@ -151,7 +148,6 @@ abstract class MediaServerClient {
/// children).
Future<void> refreshLibraryMetadata(String libraryId);
// ── Browse: items ────────────────────────────────────────────────
/// Fetch a single item by its backend-opaque id. Returns `null` when the
/// item no longer exists or the user can't see it.
Future<MediaItem?> fetchItem(String id);
@@ -204,7 +200,6 @@ abstract class MediaServerClient {
/// internally; the neutral name matches the Continue Watching UI surface.
Future<List<MediaItem>> fetchContinueWatching({int count = 20});
// ── Browse: hubs ─────────────────────────────────────────────────
/// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin
/// synthesizes `Latest` + `Resume` + `NextUp`).
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10});
@@ -223,7 +218,6 @@ abstract class MediaServerClient {
/// (Latest / Resume / NextUp) without the preview limit.
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit});
// ── Watch state ──────────────────────────────────────────────────
/// Mark [item] as watched. The full item is passed (not just an id) so
/// implementations can fire a [WatchStateEvent] on [WatchStateNotifier]
/// for UI invalidation — episode/season/show parent chain, library
@@ -242,7 +236,6 @@ abstract class MediaServerClient {
/// awaited call in `try/catch` and surface a snackbar on the catch arm.
Future<void> rate(MediaItem item, double rating);
// ── Playlists ────────────────────────────────────────────────────
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart});
/// Metadata only — items are fetched via [fetchPlaylistItems].
@@ -282,7 +275,6 @@ abstract class MediaServerClient {
/// the same caveats about backend tagging and the per-playlist id.
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item});
// ── Collections ──────────────────────────────────────────────────
/// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`;
/// Jellyfin queries `/Items?ParentId={libraryId}&IncludeItemTypes=BoxSet`.
/// Each result carries `kind == MediaKind.collection`.
@@ -322,7 +314,6 @@ abstract class MediaServerClient {
/// read [MediaItem.libraryId] for backends that need it (Plex).
Future<bool> deleteCollection(MediaItem collection);
// ── Item write ───────────────────────────────────────────────────
/// Permanently delete [item] from the library.
Future<bool> deleteMediaItem(MediaItem item);
@@ -332,7 +323,6 @@ abstract class MediaServerClient {
/// the server has no info to show.
Future<MediaFileInfo?> getFileInfo(MediaItem item);
// ── Images ───────────────────────────────────────────────────────
/// Resolve a backend-relative thumbnail path to a fully-qualified URL ready
/// for cached image providers. Returns an empty string for null/empty
/// inputs.
@@ -355,14 +345,12 @@ abstract class MediaServerClient {
/// engine alongside the URL.
Map<String, String> get streamHeaders;
// ── External IDs ─────────────────────────────────────────────────
/// External IDs (IMDb / TMDB / TVDB) for [itemId]. Plex hits
/// `/library/metadata/{id}?includeGuids=1`; Jellyfin reads the inline
/// `ProviderIds` map. Returns an empty [ExternalIds] when the server
/// has no external mapping for the item.
Future<ExternalIds> fetchExternalIds(String itemId);
// ── Hubs: extras ─────────────────────────────────────────────────
/// Chapters and intro/credits markers for [itemId]. Plex returns both in one
/// round trip; Jellyfin combines item-level chapters with best-effort native
/// media segments. Implementations may cache.
@@ -401,7 +389,6 @@ abstract class MediaServerClient {
/// `trickplayByWidth` map).
Future<ScrubPreviewSource?> createScrubPreviewSource({required MediaItem item, required MediaSourceInfo mediaSource});
// ── Playback progress ────────────────────────────────────────────
/// Watched threshold (0.01.0). An item is considered "watched" when
/// `position / duration` crosses this value. Plex reads it from the
/// server's `LibraryVideoPlayedThreshold` pref; Jellyfin doesn't expose
@@ -452,7 +439,6 @@ abstract class MediaServerClient {
String? mediaSourceId,
});
// ── Playback initialization ──────────────────────────────────────
/// Resolve the video URL, media info, and external subtitle list for
/// playback. Backends own the per-backend particulars: Plex runs the
/// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset]
@@ -465,13 +451,11 @@ abstract class MediaServerClient {
/// metadata, even when the caller intends to play a downloaded copy.
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options);
// ── Live TV ──────────────────────────────────────────────────────
/// Backend-neutral live-TV operations. Always returns a wrapper; consult
/// [LiveTvSupport.isAvailable] to find out whether the server actually
/// has live TV configured before calling other methods.
LiveTvSupport get liveTv;
// ── Downloads ────────────────────────────────────────────────────
/// Resolve the download URL for [item]'s primary video file along with
/// any external subtitle tracks that should be saved alongside it.
///
-5
View File
@@ -15,19 +15,14 @@ class MediaSort {
Map<String, dynamic> toJson() => _$MediaSortToJson(this);
/// Gets the full sort key with direction
/// If [descending] is true, returns the descKey or key:desc
/// Otherwise returns the key for ascending sort
String getSortKey({bool descending = false}) {
if (!descending) {
return key;
}
// Use descKey if available, otherwise append :desc to key
return descKey ?? '$key:desc';
}
/// Returns true if this sort's default direction is descending
bool get isDefaultDescending {
return defaultDirection?.toLowerCase() == 'desc';
}
-3
View File
@@ -71,9 +71,6 @@ mixin _TrackLabelMixin {
String? get displayTitle;
String? get language;
/// Builds a label from the given parts
/// If displayTitle is present, returns it
/// Otherwise, combines language and additional parts
String buildLabel(List<String> additionalParts) {
if (displayTitle != null && displayTitle!.isNotEmpty) {
return displayTitle!;
-3
View File
@@ -25,7 +25,6 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix);
}
/// Record that the item at [index] received focus.
void trackGridItemFocus(int index, bool hasFocus) {
if (hasFocus) {
lastFocusedGridIndex = index;
@@ -37,7 +36,6 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
bool get shouldRestoreGridFocus =>
lastFocusedGridIndex != null && lastFocusedGridContentVersion == gridContentVersion && lastFocusedGridIndex! >= 0;
/// Remove focus nodes for indices >= [itemCount].
void cleanupGridFocusNodes(int itemCount) {
final keysToRemove = gridItemFocusNodes.keys.where((i) => i >= itemCount).toList();
for (final key in keysToRemove) {
@@ -68,7 +66,6 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
}
}
/// Dispose all grid-item focus nodes.
void disposeGridFocusNodes() {
for (final node in gridItemFocusNodes.values) {
node.dispose();
-2
View File
@@ -7,10 +7,8 @@ mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T> {
/// Focus node for the first item (for programmatic focus)
late final FocusNode firstItemFocusNode;
/// Debug label for the focus node
String get focusNodeDebugLabel;
/// Number of items in the list/grid
int get itemCount;
@override
-1
View File
@@ -7,7 +7,6 @@ import '../utils/provider_extensions.dart';
/// Mixin providing common functionality for library tab screens
/// Provides server-specific client resolution for multi-server support
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
/// The library being displayed
MediaLibrary get library;
/// Get the [PlexClient] for this library's server. Throws if unavailable.
-4
View File
@@ -2,23 +2,19 @@ mixin Refreshable {
void refresh();
}
/// Mixin for screens that support full refresh (clearing all cached data)
mixin FullRefreshable {
void fullRefresh();
}
/// Mixin for screens with focusable tab content
mixin FocusableTab {
void focusActiveTabIfReady();
}
/// Mixin for screens with focusable search input
mixin SearchInputFocusable {
void focusSearchInput();
void setSearchQuery(String query);
}
/// Mixin for screens that can load a specific library by key
mixin LibraryLoadable {
void loadLibraryByKey(String libraryGlobalKey);
}
-2
View File
@@ -76,7 +76,6 @@ mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderSt
FocusNode getTabChipFocusNode(int index) => tabChipFocusNodes[index];
/// Focus the currently selected tab chip.
void focusTabBar() {
setState(() {
suppressAutoFocus = true;
@@ -84,7 +83,6 @@ mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderSt
getTabChipFocusNode(tabController.index).requestFocus();
}
/// Navigate back from the tab bar to the sidebar.
void onTabBarBack() {
MainScreenFocusScope.of(context)?.focusSidebar();
}
@@ -1,5 +1,4 @@
enum RemoteCommandType {
// Navigation
dpadUp,
dpadDown,
dpadLeft,
@@ -8,7 +7,6 @@ enum RemoteCommandType {
back,
contextMenu,
// Playback
play,
pause,
playPause,
@@ -20,13 +18,11 @@ enum RemoteCommandType {
skipIntro,
skipCredits,
// Volume
volumeUp,
volumeDown,
volumeMute,
volumeSet,
// Tab Navigation
tabNext,
tabPrevious,
tabDiscover,
@@ -35,7 +31,6 @@ enum RemoteCommandType {
tabDownloads,
tabSettings,
// Quick Actions
home,
search,
subtitles,
@@ -43,7 +38,6 @@ enum RemoteCommandType {
qualitySettings,
fullscreen,
// Session Management
ping,
pong,
deviceInfo,
-1
View File
@@ -41,7 +41,6 @@ class DownloadProgress {
String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes);
String get totalFormatted => ByteFormatter.formatBytes(totalBytes);
/// Check if this progress update includes artwork paths
bool get hasArtworkPaths => thumbPath != null;
DownloadProgress copyWith({
-1
View File
@@ -31,7 +31,6 @@ String favoriteChannelKey(String source, String id) => '$source\u0000$id';
String liveTvChannelScopeKey(LiveTvChannel channel) =>
'${channel.serverId ?? ''}\u0000${channel.liveDvrKey ?? ''}\u0000${channel.key}';
/// Represents a Live TV channel from the EPG
@JsonSerializable(createToJson: false)
class LiveTvChannel with MultiServerFields {
@JsonKey(readValue: _readChannelKey)
-6
View File
@@ -124,26 +124,21 @@ class LiveTvProgram {
);
}
/// Start time as DateTime
DateTime? get startTime => beginsAt != null ? DateTime.fromMillisecondsSinceEpoch(beginsAt! * 1000) : null;
/// End time as DateTime
DateTime? get endTime => endsAt != null ? DateTime.fromMillisecondsSinceEpoch(endsAt! * 1000) : null;
/// Duration in minutes
int get durationMinutes {
if (beginsAt == null || endsAt == null) return 0;
return ((endsAt! - beginsAt!) / 60).round();
}
/// Whether this program is currently airing
bool get isCurrentlyAiring {
if (beginsAt == null || endsAt == null) return false;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return now >= beginsAt! && now < endsAt!;
}
/// Progress through the program (0.0 to 1.0)
double get progress {
if (beginsAt == null || endsAt == null) return 0.0;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
@@ -152,7 +147,6 @@ class LiveTvProgram {
return (now - beginsAt!) / (endsAt! - beginsAt!);
}
/// Display title including series info for episodes
String get displayTitle {
if (grandparentTitle != null && index != null) {
final seasonEpisode = parentIndex != null ? 'S${parentIndex}E$index' : 'E$index';
-1
View File
@@ -1,4 +1,3 @@
/// Represents a saved preset of MPV configurations
class MpvPreset {
final String name;
final String text;
-1
View File
@@ -38,7 +38,6 @@ class PlayQueueResponse {
}
}
/// Get the index of the selected item in the current window
int? get selectedItemIndex {
if (items == null || playQueueSelectedItemID == null) return null;
return items!.indexWhere((item) => item is PlexMediaItem && item.playQueueItemId == playQueueSelectedItemID);
@@ -4,16 +4,12 @@ import '../../media/media_version.dart';
/// Consolidated data model containing all information needed for video playback.
/// This model combines data from multiple Plex API endpoints to reduce redundant requests.
class PlexVideoPlaybackData {
/// Direct video URL for playback
final String? videoUrl;
/// Media information including audio/subtitle tracks and chapters
final MediaSourceInfo? mediaInfo;
/// Available media versions/qualities for this content
final List<MediaVersion> availableVersions;
/// Markers for intro/credits skip functionality
final List<MediaMarker> markers;
PlexVideoPlaybackData({
@@ -23,9 +19,7 @@ class PlexVideoPlaybackData {
this.markers = const [],
});
/// Returns true if this playback data has a valid video URL
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
/// Returns true if media info is available
bool get hasMediaInfo => mediaInfo != null;
}
-12
View File
@@ -1,4 +1,3 @@
/// Shader preset types available in the app
enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom }
/// ArtCNN real-time model sizes.
@@ -52,7 +51,6 @@ enum Anime4KMode {
modeCA,
}
/// Configuration for Anime4K preset
class Anime4KConfig {
final Anime4KQuality quality;
final Anime4KMode mode;
@@ -78,7 +76,6 @@ class Anime4KConfig {
}
}
/// Configuration for ArtCNN preset
class ArtCNNConfig {
final ArtCNNModel model;
final ArtCNNVariant variant;
@@ -104,7 +101,6 @@ class ArtCNNConfig {
}
}
/// Configuration for NVScaler preset
class NVScalerConfig {
/// Whether to automatically skip NVScaler on HDR content
final bool autoHdrSkip;
@@ -127,7 +123,6 @@ class NVScalerConfig {
}
}
/// Represents a shader preset configuration
class ShaderPreset {
final String id;
final String name;
@@ -235,7 +230,6 @@ class ShaderPreset {
}
}
/// Get display name for the mode
String get modeDisplayName {
if (anime4kConfig != null) {
return _getModeName(anime4kConfig!.mode);
@@ -243,7 +237,6 @@ class ShaderPreset {
return '';
}
/// Get display name for the ArtCNN model
String get artcnnModelDisplayName {
if (artcnnConfig != null) {
return _getArtCNNModelName(artcnnConfig!.model);
@@ -251,26 +244,22 @@ class ShaderPreset {
return '';
}
/// Get all available preset options
static List<ShaderPreset> get allPresets {
return [
none,
nvscalerDefault,
// ArtCNN presets
artcnnPreset(ArtCNNModel.c4f16, ArtCNNVariant.neutral),
artcnnPreset(ArtCNNModel.c4f16, ArtCNNVariant.denoise),
artcnnPreset(ArtCNNModel.c4f16, ArtCNNVariant.denoiseSharpen),
artcnnPreset(ArtCNNModel.c4f32, ArtCNNVariant.neutral),
artcnnPreset(ArtCNNModel.c4f32, ArtCNNVariant.denoise),
artcnnPreset(ArtCNNModel.c4f32, ArtCNNVariant.denoiseSharpen),
// Anime4K Fast presets
anime4kPreset(Anime4KQuality.fast, Anime4KMode.modeA),
anime4kPreset(Anime4KQuality.fast, Anime4KMode.modeB),
anime4kPreset(Anime4KQuality.fast, Anime4KMode.modeC),
anime4kPreset(Anime4KQuality.fast, Anime4KMode.modeAA),
anime4kPreset(Anime4KQuality.fast, Anime4KMode.modeBB),
anime4kPreset(Anime4KQuality.fast, Anime4KMode.modeCA),
// Anime4K HQ presets
anime4kPreset(Anime4KQuality.hq, Anime4KMode.modeA),
anime4kPreset(Anime4KQuality.hq, Anime4KMode.modeB),
anime4kPreset(Anime4KQuality.hq, Anime4KMode.modeC),
@@ -280,7 +269,6 @@ class ShaderPreset {
];
}
/// Find a preset by its ID
static ShaderPreset? fromId(String id) {
try {
return allPresets.firstWhere((p) => p.id == id);
+2 -12
View File
@@ -5,10 +5,8 @@ import 'package:path_provider/path_provider.dart';
import '../utils/app_logger.dart';
/// Utility class for loading font assets for libass subtitle rendering.
///
/// Extracts font files from Flutter assets to the app's cache directory to ensure
/// comprehensive Unicode coverage (including CJK characters) for subtitles.
/// Extracts font files from Flutter assets to the cache directory for
/// comprehensive Unicode coverage (including CJK characters) for libass subtitles.
class SubtitleFontLoader {
static const String _fontAssetPath = 'assets/go-noto-current-regular.ttf';
static const String _fontName = 'Go Noto Current-Regular';
@@ -19,26 +17,21 @@ class SubtitleFontLoader {
/// instantiation.
static Future<String?>? _cachedFontDir;
/// Loads the subtitle font from assets to the cache directory.
/// Returns the directory path containing the font file.
static Future<String?> loadSubtitleFont() {
return _cachedFontDir ??= _loadSubtitleFontOnce();
}
static Future<String?> _loadSubtitleFontOnce() async {
try {
// Get the app's cache directory
final cacheDir = await getTemporaryDirectory();
final fontDir = Directory(path.join(cacheDir.path, 'subtitle_fonts'));
// Create fonts directory if it doesn't exist
if (!await fontDir.exists()) {
await fontDir.create(recursive: true);
}
final fontFile = File(path.join(fontDir.path, 'go-noto-current-regular.ttf'));
// Load font from assets and write to cache if it doesn't exist
if (!await fontFile.exists()) {
final fontData = await rootBundle.load(_fontAssetPath);
await fontFile.writeAsBytes(fontData.buffer.asUint8List());
@@ -46,15 +39,12 @@ class SubtitleFontLoader {
return fontDir.path;
} catch (e, st) {
// Return null if font loading fails - libass will fall back gracefully
appLogger.w('Failed to load subtitle font', error: e, stackTrace: st);
return null;
}
}
/// Returns the font name to be used with libass.
static String get fontName => _fontName;
/// Returns the font asset path.
static String get fontAssetPath => _fontAssetPath;
}
+3 -103
View File
@@ -1,15 +1,11 @@
/// Represents a contiguous buffered range in the demuxer cache.
class BufferRange {
final Duration start;
final Duration end;
const BufferRange({required this.start, required this.end});
}
/// A playback error emitted by the player.
///
/// [cause] is an optional machine-readable tag (e.g. `server-http-500`) set by
/// the native layer when it can classify the failure, letting the UI branch
/// without parsing [message].
/// [cause] is an optional machine-readable tag (e.g. `server-http-500`),
/// letting the UI branch without parsing [message].
class PlayerError {
/// Cause tag for a server-side HTTP 500 — shared-user bandwidth or
/// transcoding limit rejection set by the server owner.
@@ -23,63 +19,18 @@ class PlayerError {
String toString() => message;
}
/// Log level for player messages.
enum PlayerLogLevel {
/// No logging.
none,
enum PlayerLogLevel { none, fatal, error, warn, info, verbose, debug, trace }
/// Fatal errors only.
fatal,
/// Errors.
error,
/// Warnings.
warn,
/// Informational messages.
info,
/// Verbose output.
verbose,
/// Debug messages.
debug,
/// Trace-level output (very verbose).
trace,
}
/// Represents an audio track in the media.
class AudioTrack {
/// Unique identifier for the track.
final String id;
/// Human-readable title of the track.
final String? title;
/// Language code (e.g., 'eng', 'jpn').
final String? language;
/// Audio codec (e.g., 'aac', 'ac3', 'dts').
final String? codec;
/// Number of audio channels.
final int? channels;
/// Alias for channels (media_kit compatibility).
int? get channelsCount => channels;
/// Sample rate in Hz.
final int? sampleRate;
/// Bitrate in bits per second.
final int? bitrate;
/// Whether this is the default track.
final bool isDefault;
/// Whether this track is forced.
final bool isForced;
const AudioTrack({
@@ -94,13 +45,10 @@ class AudioTrack {
this.isForced = false,
});
/// Auto-select track.
static const auto = AudioTrack(id: 'auto', title: 'Auto');
/// Disable audio.
static const off = AudioTrack(id: 'no', title: 'Off');
/// Returns a display name for the track.
String get displayName {
if (title != null && title!.isNotEmpty) return title!;
if (language != null && language!.isNotEmpty) return language!;
@@ -118,30 +66,14 @@ class AudioTrack {
int get hashCode => id.hashCode;
}
/// Represents a subtitle track in the media.
class SubtitleTrack {
/// Unique identifier for the track.
final String id;
/// Human-readable title of the track.
final String? title;
/// Language code (e.g., 'eng', 'jpn').
final String? language;
/// Subtitle codec/format (e.g., 'subrip', 'ass', 'pgs').
final String? codec;
/// Whether this is the default track.
final bool isDefault;
/// Whether this track is forced (e.g., for foreign language segments).
final bool isForced;
/// Whether this is an external subtitle file.
final bool isExternal;
/// URI of external subtitle file (if isExternal is true).
final String? uri;
const SubtitleTrack({
@@ -155,18 +87,14 @@ class SubtitleTrack {
this.uri,
});
/// Create a subtitle track from an external URI.
factory SubtitleTrack.uri(String uri, {String? title, String? language}) {
return SubtitleTrack(id: 'external:$uri', title: title, language: language, isExternal: true, uri: uri);
}
/// Auto-select track.
static const auto = SubtitleTrack(id: 'auto', title: 'Auto');
/// Disable subtitles.
static const off = SubtitleTrack(id: 'no', title: 'Off');
/// Returns a display name for the track.
String get displayName {
if (title != null && title!.isNotEmpty) return title!;
if (language != null && language!.isNotEmpty) return language!;
@@ -185,17 +113,12 @@ class SubtitleTrack {
int get hashCode => id.hashCode;
}
/// Container for all available tracks in the media.
class Tracks {
/// Available audio tracks.
final List<AudioTrack> audio;
/// Available subtitle tracks.
final List<SubtitleTrack> subtitle;
const Tracks({this.audio = const [], this.subtitle = const []});
/// Creates a copy with the given fields replaced.
Tracks copyWith({List<AudioTrack>? audio, List<SubtitleTrack>? subtitle}) {
return Tracks(audio: audio ?? this.audio, subtitle: subtitle ?? this.subtitle);
}
@@ -207,15 +130,9 @@ class Tracks {
/// Sentinel value used to distinguish "not provided" from "explicitly set to null" in copyWith.
const _sentinel = Object();
/// Represents the currently selected tracks.
class TrackSelection {
/// Currently selected audio track.
final AudioTrack? audio;
/// Currently selected subtitle track.
final SubtitleTrack? subtitle;
/// Currently selected secondary subtitle track (mpv secondary-sid).
final SubtitleTrack? secondarySubtitle;
const TrackSelection({this.audio, this.subtitle, this.secondarySubtitle});
@@ -236,17 +153,12 @@ class TrackSelection {
String toString() => 'TrackSelection(audio: $audio, subtitle: $subtitle, secondarySubtitle: $secondarySubtitle)';
}
/// Represents an audio output device.
class AudioDevice {
/// Unique identifier for the device.
final String name;
/// Human-readable description of the device.
final String description;
const AudioDevice({required this.name, this.description = ''});
/// Default/auto audio device.
static const auto = AudioDevice(name: 'auto', description: 'Auto');
@override
@@ -260,15 +172,9 @@ class AudioDevice {
int get hashCode => name.hashCode;
}
/// A log entry from the player.
class PlayerLog {
/// The log level of this message.
final PlayerLogLevel level;
/// The prefix/category of the log message (e.g., 'cplayer', 'ffmpeg').
final String prefix;
/// The log message text.
final String text;
const PlayerLog({required this.level, required this.prefix, required this.text});
@@ -277,15 +183,9 @@ class PlayerLog {
String toString() => '[$prefix] ${level.name}: $text';
}
/// Represents a media source for the player.
class Media {
/// The URI of the media (file path, HTTP URL, etc.).
final String uri;
/// Optional HTTP headers for network requests.
final Map<String, String>? headers;
/// Optional start position for playback.
final Duration? start;
const Media(this.uri, {this.headers, this.start});
+2 -18
View File
@@ -3,8 +3,7 @@ import 'package:flutter/services.dart';
import '../../models.dart';
import '../player_base.dart';
/// Android implementation of [Player] using ExoPlayer.
/// Provides hardware-accelerated playback with ASS subtitle support via libass-android.
/// Android implementation using ExoPlayer with ASS subtitle support via libass-android.
class PlayerAndroid extends PlayerBase {
static const _methodChannel = MethodChannel('com.plezy/exo_player');
static const _eventChannel = EventChannel('com.plezy/exo_player/events');
@@ -12,7 +11,6 @@ class PlayerAndroid extends PlayerBase {
int? _bufferSizeBytes;
bool _tunnelingEnabled = true;
/// Stored subtitle track ID when subtitles are hidden via sub-visibility.
String? _hiddenSubtitleTrackId;
@override
@@ -32,7 +30,6 @@ class PlayerAndroid extends PlayerBase {
@override
void handlePlayerEvent(String name, Map? data) {
// Handle Android-specific events
if (name == 'backend-switched') {
// Native player switched from ExoPlayer to MPV due to unsupported format.
// Clear stale ExoPlayer tracks so applyTrackSelectionWhenReady waits for
@@ -42,7 +39,6 @@ class PlayerAndroid extends PlayerBase {
return;
}
// Delegate to base class for common events
super.handlePlayerEvent(name, data);
}
@@ -170,7 +166,6 @@ class PlayerAndroid extends PlayerBase {
@override
Future<void> setProperty(String name, String value) async {
if (disposed) return;
// ExoPlayer doesn't use MPV properties, but we handle common ones
switch (name) {
case 'pause':
if (value == 'yes') {
@@ -196,14 +191,12 @@ class PlayerAndroid extends PlayerBase {
break;
case 'sub-visibility':
if (value == 'no') {
// Store current subtitle track and disable
final current = state.track.subtitle;
if (current != null && current.id != 'no') {
_hiddenSubtitleTrackId = current.id;
await selectSubtitleTrack(SubtitleTrack.off);
}
} else {
// Restore previously hidden subtitle track
final storedId = _hiddenSubtitleTrackId;
if (storedId != null) {
_hiddenSubtitleTrackId = null;
@@ -218,7 +211,6 @@ class PlayerAndroid extends PlayerBase {
}
break;
default:
// Forward unknown properties to Kotlin for MPV fallback
await invoke('setMpvProperty', {'name': name, 'value': value});
}
}
@@ -226,7 +218,6 @@ class PlayerAndroid extends PlayerBase {
@override
Future<String?> getProperty(String name) async {
if (disposed) return null;
// Return state-based values for common properties
switch (name) {
case 'pause':
return state.playing ? 'no' : 'yes';
@@ -244,12 +235,10 @@ class PlayerAndroid extends PlayerBase {
final stats = await getStats();
final mode = stats['dvConversionDebugMode'];
return mode?.toString().toLowerCase();
// Video frame rate - query from ExoPlayer stats
case 'container-fps':
final fpsStats = await getStats();
final fps = fpsStats['videoFps'];
return fps?.toString();
// Video dimensions - query from ExoPlayer stats
case 'width':
case 'dwidth':
final stats = await getStats();
@@ -265,8 +254,6 @@ class PlayerAndroid extends PlayerBase {
}
}
/// Get all playback stats from ExoPlayer.
/// Returns a map with video/audio codec info, buffer state, and performance metrics.
Future<Map<String, dynamic>> getStats() async {
if (disposed) return {};
try {
@@ -277,8 +264,7 @@ class PlayerAndroid extends PlayerBase {
}
}
/// Get the device's large heap size in MB (Android only).
/// Returns 0 if unavailable.
/// Returns the device's large heap size in MB, or 0 if unavailable (Android only).
static Future<int> getHeapSize() async {
try {
final result = await _methodChannel.invokeMethod<int>('getHeapSize');
@@ -288,7 +274,6 @@ class PlayerAndroid extends PlayerBase {
}
}
/// Get the current player type ('exoplayer' or 'mpv' if fallback is active).
Future<String> getPlayerType() async {
if (disposed) return 'unknown';
try {
@@ -302,7 +287,6 @@ class PlayerAndroid extends PlayerBase {
@override
Future<void> command(List<String> args) async {
if (disposed) return;
// Handle MPV commands by translating to ExoPlayer equivalents
if (args.isEmpty) return;
switch (args.first) {
+2 -6
View File
@@ -1,9 +1,5 @@
import '../player_native.dart';
/// Linux implementation of [Player].
///
/// Uses libmpv with FlTextureGL — video is rendered to an offscreen FBO
/// Uses libmpv with FlTextureGL — video rendered to an offscreen FBO
/// and composited GPU-side via Flutter's Texture widget.
class PlayerLinux extends PlayerNative {
// textureId is set during initialize() via PlayerNative
}
class PlayerLinux extends PlayerNative {}
+3 -6
View File
@@ -1,14 +1,11 @@
import '../player_native.dart';
import '../video_rect_support.dart';
/// Windows implementation of [Player].
///
/// Uses libmpv via platform channels with native window embedding.
/// The mpv video window is positioned behind the Flutter window,
/// with transparent regions allowing the video to show through.
/// Uses libmpv with native window embedding behind the Flutter window.
class PlayerWindows extends PlayerNative with VideoRectSupport {
// Native window embedding, not a Flutter texture.
@override
int? get textureId => null; // Uses native window embedding, not Flutter texture
int? get textureId => null;
@override
Future<void> setVideoRect({
-18
View File
@@ -47,22 +47,16 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
/// Whether the player has been initialized.
/// Subclasses should set this to true after initialization.
@protected
bool initialized = false;
/// Whether the player has been disposed.
@override
bool get disposed => _disposed;
/// The method channel for platform communication.
MethodChannel get methodChannel;
/// The event channel for receiving platform events.
EventChannel get eventChannel;
/// The log prefix for this player (e.g., 'MPV', 'ExoPlayer').
String get logPrefix;
PlayerBase() {
@@ -100,8 +94,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
);
}
/// Observes a property on the native player and assigns it a compact propId
/// for efficient event channel communication.
@protected
Future<void> observeProperty(String name, String format) async {
final propId = _nextPropId++;
@@ -125,8 +117,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
}
/// Handle a property change event from the platform.
/// Subclasses can override this to handle platform-specific properties.
void handlePropertyChange(String name, dynamic value) {
if (_disposed) return;
switch (name) {
@@ -338,8 +328,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
}
/// Handle a player event from the platform.
/// Subclasses can override this to handle platform-specific events.
void handlePlayerEvent(String name, Map? data) {
if (_disposed) return;
switch (name) {
@@ -384,7 +372,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
}
/// Parse a log level string to [PlayerLogLevel].
PlayerLogLevel parseLogLevel(String level) {
return switch (level) {
'fatal' => PlayerLogLevel.fatal,
@@ -398,7 +385,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
};
}
/// Parse a track list from the platform into [Tracks] and selected track IDs.
({Tracks tracks, String? selectedAudioId, String? selectedSubtitleId}) parseTrackList(List trackList) {
final audioTracks = <AudioTrack>[];
final subtitleTracks = <SubtitleTrack>[];
@@ -450,7 +436,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
);
}
/// Update the selected audio track.
void updateSelectedAudioTrack(dynamic trackId) {
final id = trackId?.toString();
AudioTrack? selectedTrack;
@@ -463,7 +448,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
trackController.add(_state.track);
}
/// Update the selected subtitle track.
void updateSelectedSubtitleTrack(dynamic trackId) {
final id = trackId?.toString();
SubtitleTrack? selectedTrack;
@@ -476,7 +460,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
trackController.add(_state.track);
}
/// Update the selected secondary subtitle track.
void updateSelectedSecondarySubtitleTrack(dynamic trackId) {
final id = trackId?.toString();
SubtitleTrack? selectedTrack;
@@ -505,7 +488,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
seekableController.add(seekable);
}
/// Safe method channel invocation — no-ops if player is disposed.
@protected
Future<T?> invoke<T>(String method, [dynamic args]) async {
if (_disposed) return null;
+2 -9
View File
@@ -87,8 +87,6 @@ class PlayerNative extends PlayerBase {
}
}
/// 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 {
try {
return await invoke<int>('openContentFd', {'uri': contentUri});
@@ -108,26 +106,21 @@ class PlayerNative extends PlayerBase {
await _ensureInitialized();
setSeekable(false);
// Show the video layer
await setVisible(true);
// Set HTTP headers for Plex authentication and profile
if (media.headers != null && media.headers!.isNotEmpty) {
final headerList = media.headers!.entries.map((e) => '${e.key}: ${e.value}').toList();
await setProperty('http-header-fields', headerList.join(','));
}
// Set start position if provided (must be set before loading file)
// 'start' must be set before loadfile.
if (media.start != null && media.start!.inSeconds > 0) {
await setProperty('start', media.start!.inSeconds.toString());
} else {
// Reset start position if not resuming
await setProperty('start', 'none');
}
// Set pause BEFORE loadfile to prevent decoder from starting immediately.
// This is important for adding external subtitles before playback begins,
// avoiding a race condition that can freeze the video decoder on Android (issue #226).
// Prevents race condition that can freeze the video decoder on Android (issue #226).
if (!play) {
await setProperty('pause', 'yes');
}
-36
View File
@@ -1,59 +1,24 @@
import '../models.dart';
/// Immutable snapshot of the current player state.
///
/// This class provides synchronous access to the player's current state.
/// For reactive updates, use [PlayerStreams].
class PlayerState {
/// Whether playback is currently active.
final bool playing;
/// Whether the media has completed playback.
final bool completed;
/// Whether the player is currently buffering.
final bool buffering;
/// Current playback position.
final Duration position;
/// Total duration of the media.
final Duration duration;
/// Whether the current media item can be seeked.
final bool seekable;
/// Amount of media buffered ahead of current position.
final Duration buffer;
/// Current volume level (0.0 to 100.0).
final double volume;
/// Current playback rate (1.0 = normal speed).
final double rate;
/// Available tracks in the media.
final Tracks tracks;
/// Currently selected tracks.
final TrackSelection track;
/// Audio delay/sync offset in seconds.
final double audioDelay;
/// Subtitle delay/sync offset in seconds.
final double subtitleDelay;
/// Whether audio passthrough is currently enabled.
final bool audioPassthrough;
/// Current audio output device.
final AudioDevice audioDevice;
/// Available audio output devices.
final List<AudioDevice> audioDevices;
/// Seekable buffered ranges from the demuxer cache.
final List<BufferRange> bufferRanges;
const PlayerState({
@@ -76,7 +41,6 @@ class PlayerState {
this.bufferRanges = const [],
});
/// Creates a copy with the given fields replaced.
PlayerState copyWith({
bool? playing,
bool? completed,
@@ -3,12 +3,7 @@ import 'dart:async';
import '../models.dart';
import 'player_streams.dart';
/// Mixin providing stream controllers for player state changes.
///
/// This mixin contains the 16 stream controllers used by both
/// [PlayerAndroid] and [PlayerNative] implementations.
mixin PlayerStreamControllersMixin {
// Stream controllers
final playingController = StreamController<bool>.broadcast();
final completedController = StreamController<bool>.broadcast();
final bufferingController = StreamController<bool>.broadcast();
@@ -28,7 +23,6 @@ mixin PlayerStreamControllersMixin {
final playbackRestartController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast();
/// Creates a [PlayerStreams] instance from the stream controllers.
PlayerStreams createStreams() {
return PlayerStreams(
playing: playingController.stream,
@@ -52,7 +46,6 @@ mixin PlayerStreamControllersMixin {
);
}
/// Closes all stream controllers.
Future<void> closeStreamControllers() async {
await playingController.close();
await completedController.close();
-11
View File
@@ -1,17 +1,6 @@
import 'player.dart';
/// Mixin for players that support video rect positioning.
///
/// Players that render video behind the Flutter view (e.g., using
/// native window embedding or GtkGLArea) implement this mixin to
/// receive layout updates from the [Video] widget.
mixin VideoRectSupport on Player {
/// Updates the video rendering area.
///
/// Called by the [Video] widget when the layout changes.
///
/// [left], [top], [right], [bottom] define the rect in physical pixels.
/// [devicePixelRatio] is the device's pixel ratio for scaling.
Future<void> setVideoRect({
required int left,
required int top,
-11
View File
@@ -20,13 +20,8 @@ import 'player/video_rect_support.dart';
/// )
/// ```
class Video extends StatefulWidget {
/// The player instance.
final Player player;
/// Builder for custom video controls overlay.
final Widget Function(BuildContext context)? controls;
/// Background color shown behind the video.
final Color backgroundColor;
const Video({super.key, required this.player, this.controls, this.backgroundColor = Colors.black});
@@ -74,15 +69,11 @@ class _VideoState extends State<Video> {
}
Widget _buildVideoSurface() {
// For players that use Flutter's texture pipeline (Linux FlTextureGL),
// render directly via the Texture widget.
final textureId = widget.player.textureId;
if (textureId != null) {
return Texture(textureId: textureId);
}
// For players that support video rect positioning (Windows),
// communicate layout changes to the native side.
if (widget.player is VideoRectSupport) {
return LayoutBuilder(
builder: (context, constraints) {
@@ -106,7 +97,6 @@ class _VideoState extends State<Video> {
final newRect = Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
// Only update if the rect has changed significantly
if (_lastRect != null &&
(newRect.left - _lastRect!.left).abs() < 1 &&
(newRect.top - _lastRect!.top).abs() < 1 &&
@@ -117,7 +107,6 @@ class _VideoState extends State<Video> {
_lastRect = newRect;
// Update the native video rect
(widget.player as VideoRectSupport).setVideoRect(
left: (position.dx * dpr).toInt(),
top: (position.dy * dpr).toInt(),
+3 -19
View File
@@ -27,25 +27,9 @@ bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBo
return preVerified || !hasBoundOnce;
}
/// Wires the active [Profile] into [MultiServerManager] + [MultiServerProvider].
///
/// Both kinds bind connections in two layers:
///
/// 1. **Parent (plex_home only)**: Plex Home profiles have an implicit
/// parent [PlexAccountConnection] (referenced by `parentConnectionId`,
/// not stored in the join table). The binder reuses the cached
/// `/home/users/{uuid}/switch` token if available, otherwise mints one
/// via [pinPrompt] when Plex's `protected` flag is set.
/// 2. **Join rows**: every [ProfileConnection] row for the profile —
/// borrowed Plex accounts (each with its own `userToken`) and Jellyfin
/// servers. The same path runs for local Plezy profiles, which only
/// have join rows.
///
/// After both layers, servers not in the bound set are removed from
/// [MultiServerManager], and the bound id set is pushed into
/// [MultiServerProvider]. An empty bound set is propagated as `{}` so a
/// profile with no connections shows nothing — falling back to "all
/// visible" would leak servers attached to other profiles.
/// An empty bound set is propagated as `{}` so a profile with no connections
/// shows nothing — falling back to "all visible" would leak servers attached
/// to other profiles.
class ActiveProfileBinder {
ActiveProfileBinder({
required this.activeProfile,
-5
View File
@@ -7,11 +7,6 @@ import '../utils/initials_palette.dart';
import '../widgets/app_icon.dart';
import 'profile.dart';
/// Round avatar for a [Profile]. Plex Home users with an `avatarThumbUrl`
/// render the network image; locals (and Plex Home users without a thumb)
/// fall back to the first initial on a deterministic colour. A small lock
/// badge overlays PIN-protected profiles. A neutral fill is used while the
/// active profile is still loading at app start.
class ProfileAvatar extends StatelessWidget {
final Profile? profile;
final double size;
@@ -443,9 +443,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
@visibleForTesting
List<String> get debugCryptoConnectionIds => _authContexts.map((context) => context.connectionId).toList();
// ── Host Server ──
/// Start the host server and begin LAN broadcasting. Idempotent.
Future<void> startHostServer() async {
if (_peerService?.isServerRunning == true) return;
if (!isCryptoReady) {
@@ -505,9 +502,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
safeNotifyListeners();
}
// ── Client: Discovery ──
/// Start listening for host beacons. Returns a stream of discovered hosts.
Stream<List<DiscoveredHost>>? discoverHosts() {
if (!isCryptoReady) {
appLogger.w('CompanionRemote: Cannot discover — crypto not initialized');
@@ -606,8 +600,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
}
}
// ── Peer service listeners ──
void _setupPeerServiceListeners() {
_commandSubscription = _peerService!.onCommandReceived.listen(
(command) {
@@ -704,8 +696,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
_statusSubscription = null;
}
// ── Commands ──
void sendCommand(RemoteCommandType type, {Map<String, dynamic>? data}) {
if (_peerService == null || !isConnected) {
appLogger.w('CompanionRemote: Cannot send command - not connected');
@@ -716,8 +706,6 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
_peerService!.sendCommand(RemoteCommand(type: type, data: data));
}
// ── Reconnection ──
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
appLogger.w('CompanionRemote: Max reconnect attempts reached');
-16
View File
@@ -5,9 +5,6 @@ import '../models/shader_preset.dart';
import '../services/settings_service.dart';
import '../services/shader_asset_loader.dart';
/// Provider for managing shader preset state.
///
/// Persists the selected shader preset so it is restored across sessions.
class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
SettingsService? _settingsService;
ValueNotifier<String>? _savedPresetListenable;
@@ -62,31 +59,18 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
super.dispose();
}
/// Whether the provider has finished initializing
bool get initialized => _initialized;
/// The persisted shader preset
ShaderPreset get savedPreset => _savedPreset;
/// The currently active shader preset
ShaderPreset get currentPreset => _currentPreset;
/// All available shader presets (built-in + custom)
List<ShaderPreset> get allPresets => [...ShaderPreset.allPresets, ..._customPresets];
/// Custom shader presets only
List<ShaderPreset> get customPresets => _customPresets;
/// Whether any shader is currently enabled
bool get isShaderEnabled => _currentPreset.type != ShaderPresetType.none;
/// Find a preset by its ID, searching both built-in and custom presets.
ShaderPreset? findPresetById(String id) {
return ShaderPreset.fromId(id) ??
_customPresets.cast<ShaderPreset?>().firstWhere((p) => p!.id == id, orElse: () => null);
}
/// Apply and persist a shader preset
Future<void> setPreset(ShaderPreset preset) async {
final service = _settingsService ?? await SettingsService.getInstance();
await service.write(SettingsService.globalShaderPreset, preset.id);
-2
View File
@@ -106,8 +106,6 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
if (_themeModeListenable == null) _syncThemeMode(mode);
}
/// Re-read the theme mode from SharedPreferences. Used after imports or
/// resets that change persisted settings outside this provider.
Future<void> reload() async {
await _initializeSettings();
final service = _settingsService;
-7
View File
@@ -81,8 +81,6 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
safeNotifyListeners();
}
// ───── Connect / disconnect ─────
Future<bool> connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect<MalSession>(
service: TrackerService.mal,
alreadyConnected: isMalConnected,
@@ -146,9 +144,6 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_rebindSimkl();
});
// ───── Connect machinery ─────
/// Guard-if-busy, set in-flight flag, run the shared pipeline, clear flag.
Future<bool> _runConnect<T>({
required TrackerService service,
required bool alreadyConnected,
@@ -223,8 +218,6 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
// ───── Tracker rebinding ─────
void _rebindAll() {
_rebindMal();
_rebindAnilist();
-4
View File
@@ -1232,7 +1232,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Hub title skeleton
Container(
width: 200,
height: 24,
@@ -1242,7 +1241,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
),
const SizedBox(height: 16),
// Hub items skeleton
SizedBox(
height: 200,
child: ListView.builder(
@@ -1349,9 +1347,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
semanticLabel: '${_isAutoScrollPaused ? t.common.play : t.common.pause} auto-scroll',
),
),
// Spacer to separate indicators from button
const SizedBox(width: 8),
// Page indicators (limited to 5 dots)
...() {
final range = _getVisibleDotRange();
return List.generate(range.end - range.start + 1, (i) {
-7
View File
@@ -75,16 +75,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
@override
void initState() {
super.initState();
// Start with items already loaded in the hub
_items = widget.hub.items;
_filteredItems = widget.hub.items;
// Load more items if available
if (widget.hub.more) {
_loadMoreItems();
}
// Load sorts based on the library type
_loadSorts();
// Auto-focus first grid item in keyboard mode after first frame
autoFocusFirstItemAfterLoad();
}
@@ -206,7 +202,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
},
onClear: () {
setState(() {
// Reset to no sorting (original order)
_selectedSort = null;
_isSortDescending = false;
});
@@ -261,11 +256,9 @@ class _HubDetailScreenState extends State<HubDetailScreen>
}
void _handleItemRefresh(String ratingKey) {
// Refresh the specific item in the list
setState(() {
final index = _items.indexWhere((item) => item.id == ratingKey);
if (index != -1) {
// The item will be refreshed by the MediaCard itself
appLogger.d('Item refresh requested for: $ratingKey');
}
});
@@ -201,7 +201,6 @@ 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 {
@@ -435,7 +434,6 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Hub header
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
@@ -545,7 +543,6 @@ class _LiveTvPosterCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
SizedBox(
width: double.infinity,
height: posterHeight,
@@ -561,14 +558,12 @@ class _LiveTvPosterCard extends StatelessWidget {
),
),
const SizedBox(height: 4),
// Title
Text(
metadata.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
),
// Subtitle
if (metadata.displaySubtitle != null)
Text(
metadata.displaySubtitle!,
@@ -88,8 +88,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
// Drag handle (if reorderable)
// Wrapped in GestureDetector to consume long-press and prevent context menu
if (widget.canReorder)
GestureDetector(
// ignore: no-empty-block - consumes long-press to prevent context menu on drag
-4
View File
@@ -42,7 +42,6 @@ class _SearchScreenState extends State<SearchScreen>
super.initState();
_searchDebounce = debounce(_performSearch, const Duration(milliseconds: 500));
_searchController.addListener(_onSearchChanged);
// Focus the search input when the screen is shown
FocusUtils.requestFocusAfterBuild(this, _searchFocusNode);
}
@@ -98,7 +97,6 @@ class _SearchScreenState extends State<SearchScreen>
throw Exception('No servers available');
}
// Search across all connected servers
final neutral = await multiServerProvider.aggregationService.searchAcrossServers(query);
if (mounted) {
setState(() {
@@ -119,7 +117,6 @@ class _SearchScreenState extends State<SearchScreen>
@override
void refresh() {
// Re-run the current search if there is one
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
@@ -227,7 +224,6 @@ class _SearchScreenState extends State<SearchScreen>
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
onPressed: () {
_searchController.clear();
// State update handled by listener
},
)
: null,
@@ -84,21 +84,18 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
body: GestureDetector(
behavior: HitTestBehavior.translucent, // Allow taps to pass through to controls
onScaleStart: (details) {
// Initialize pinch gesture tracking (mobile only)
if (!isMobile) return;
if (_videoFilterManager != null) {
_videoFilterManager!.isPinching = false;
}
},
onScaleUpdate: (details) {
// Track if this is a pinch gesture (2+ fingers) on mobile
if (!isMobile) return;
if (details.pointerCount >= 2 && _videoFilterManager != null) {
_videoFilterManager!.isPinching = true;
}
},
onScaleEnd: (details) {
// Only toggle if we detected a pinch gesture on mobile
if (!isMobile) return;
if (_videoFilterManager != null && _videoFilterManager!.isPinching) {
_toggleContainCover();
@@ -110,7 +107,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
// macOS PiP placeholder — video is in PiP window, show background with icon
// Placed before Video so controls render on top
if (Platform.isMacOS) const VideoPlayerMacPipPlaceholder(),
// Video player
Center(
child: LayoutBuilder(
builder: (context, constraints) {
@@ -4,11 +4,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Future<void> _playNext() async {
if (_nextEpisode == null || _isLoadingNext) return;
// Cancel auto-play timer if running
_autoPlayTimer?.cancel();
_dismissStillWatching();
// Notify Watch Together of episode change before navigating
_notifyWatchTogetherMediaChange(metadata: _nextEpisode);
_setPlayerState(() {
@@ -46,12 +44,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// Set flag to skip orientation restoration in dispose()
_isReplacingWithVideo = true;
// Clear Discord Rich Presence + Trakt scrobble before switching episodes
unawaited(DiscordRPCService.instance.stopPlayback());
unawaited(TraktScrobbleService.instance.stopPlayback());
unawaited(TrackerCoordinator.instance.stopPlayback());
// If player isn't available, navigate without preserving settings
if (player == null) {
if (mounted) {
unawaited(
@@ -69,7 +65,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// Capture current state atomically to avoid race conditions
final currentPlayer = player;
if (currentPlayer == null) {
// Player already disposed, navigate without preserving settings
if (mounted) {
unawaited(
navigateToVideoPlayer(
@@ -87,15 +82,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final currentSubtitleTrack = currentPlayer.state.track.subtitle;
final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
// Pause and stop current playback
unawaited(currentPlayer.pause());
await _sendStoppedProgressOnce();
_progressTracker?.stopTracking();
// Ensure the native player is fully disposed before creating the next one
await disposePlayerForNavigation();
// Navigate to the episode using pushReplacement to destroy current player
if (mounted) {
unawaited(
navigateToVideoPlayer(
@@ -246,9 +238,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_trackManager!.applyTrackSelectionWhenReady();
}
// Wire progress tracker, media-controls metadata, and the
// Discord/Trakt/Tracker scrobblers — same helper as the initial
// start flow, so any future change lands in both paths together.
// Same helper as the initial start flow, so any future change lands in
// both paths together.
_wirePerItemPlaybackServices(
metadata: episodeMetadata,
mediaClient: mediaClient,
@@ -11,7 +11,6 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
// Skip play queue for live TV (would interfere with tuner session)
if (widget.isLive) return;
// Only create play queues for episodes
if (!_currentMetadata.isEpisode) {
return;
}
@@ -26,7 +25,6 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
final playbackState = context.read<PlaybackStateProvider>();
// Determine the show's rating key
// For episodes, grandparentId points to the show
final showRatingKey = _currentMetadata.grandparentId;
if (showRatingKey == null) {
@@ -50,19 +48,15 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
playbackState.clearShuffle();
}
// Create a new sequential play queue for the show
appLogger.d('Creating sequential play queue for show $showRatingKey');
final playQueue = await client.createShowPlayQueue(
showRatingKey: showRatingKey,
shuffle: 0, // Sequential order
shuffle: 0,
startingEpisodeKey: _currentMetadata.id,
);
if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) {
// Initialize playback state with the play queue
await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey);
// Set the client for loading more items
playbackState.setPlayQueueWindowFetcher(client.getPlayQueue);
appLogger.d('Sequential play queue created with ${playQueue.items!.length} items');
@@ -83,7 +77,6 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
}
try {
// Load adjacent episodes using the service
final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes(
context: context,
metadata: _currentMetadata,
@@ -129,7 +122,6 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
return aDate.compareTo(bDate);
});
// Find current episode in the sorted list
final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id);
if (currentIdx == -1) return;
+1 -6
View File
@@ -495,8 +495,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
switch (state) {
case AppLifecycleState.inactive:
_recordLifecycleState('inactive');
// App is inactive (notification shade, split-screen, etc.)
// Don't pause - user may still be watching
break;
case AppLifecycleState.hidden:
_recordLifecycleState('hidden');
@@ -507,10 +505,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_recordLifecycleState('paused', action: 'skipped_for_pip');
break;
}
// Clear media controls when app truly goes to background
// (we don't support background playback)
// We don't support background playback
_mediaControlsManager?.clear();
// Disable wakelock when app goes to background
_setWakelock(false);
_recordLifecycleState('paused', action: 'backgrounded');
break;
@@ -520,7 +516,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
break;
case AppLifecycleState.detached:
_recordLifecycleState('detached');
// No action needed for this state
break;
}
}
@@ -37,7 +37,6 @@ class AmbientLightingService {
if (!isSupported) return;
try {
// Write static shader (only needs to happen once)
_shaderPath ??= await _writeShaderToTemp(_generateShader());
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
-12
View File
@@ -66,7 +66,6 @@ abstract class ApiCache {
return '$serverId:$endpoint';
}
/// Get cached response for an endpoint.
Future<Map<String, dynamic>?> get(String serverId, String endpoint) async {
final key = _buildKey(serverId, endpoint);
final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull();
@@ -76,7 +75,6 @@ abstract class ApiCache {
return null;
}
/// Cache a response for an endpoint.
Future<void> put(String serverId, String endpoint, Map<String, dynamic> data) async {
final key = _buildKey(serverId, endpoint);
final encoded = await tryIsolateRun(() => jsonEncode(data));
@@ -87,7 +85,6 @@ abstract class ApiCache {
);
}
/// Delete all cached data for a server.
Future<void> deleteForServer(String serverId) async {
await (_db.delete(_db.apiCache)..where((t) => t.cacheKey.like('$serverId:%'))).go();
}
@@ -100,7 +97,6 @@ abstract class ApiCache {
)..where((t) => t.cacheKey.equals(key))).write(const ApiCacheCompanion(pinned: Value(true)));
}
/// Unpin a previously pinned endpoint.
Future<void> unpin(String serverId, String endpoint) async {
final key = _buildKey(serverId, endpoint);
await (_db.update(
@@ -108,7 +104,6 @@ abstract class ApiCache {
)..where((t) => t.cacheKey.equals(key))).write(const ApiCacheCompanion(pinned: Value(false)));
}
/// Whether the endpoint is pinned for offline.
Future<bool> isPinned(String serverId, String endpoint) async {
final key = _buildKey(serverId, endpoint);
final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull();
@@ -135,14 +130,12 @@ abstract class ApiCache {
)..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(true)));
}
/// Inverse of [pinByKeyPattern].
Future<void> unpinByKeyPattern(String pattern) async {
await (_db.update(
_db.apiCache,
)..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(false)));
}
/// True when at least one pinned row matches [pattern].
Future<bool> hasPinnedMatching(String pattern) async {
final rows = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.like(pattern) & t.pinned.equals(true))).get();
return rows.isNotEmpty;
@@ -182,11 +175,6 @@ abstract class ApiCache {
return out;
}
// ── Backend-shape operations ──────────────────────────────────────
// These are implemented by the per-backend subclasses so callers can
// dispatch via [forBackend(backend).getMetadata(...)] without an outer
// `switch (backend)` at every call site.
/// Fetch and parse cached [MediaItem] for [itemId] on [serverId]. Returns
/// `null` when the item isn't cached.
Future<MediaItem?> getMetadata(String serverId, String itemId);
-1
View File
@@ -16,7 +16,6 @@ enum PeerErrorType {
unknown,
}
/// Represents an error in a peer service.
class PeerError {
final PeerErrorType type;
final String message;
@@ -18,10 +18,8 @@ abstract class BaseSharedPreferencesService {
late SharedPreferencesWithCache _cache;
/// Protected constructor for subclasses
BaseSharedPreferencesService();
/// Access to the underlying preferences cache.
SharedPreferencesWithCache get prefs => _cache;
/// Initialize the preferences instance.
@@ -133,9 +131,7 @@ abstract class BaseSharedPreferencesService {
///
/// Override this method to perform any setup that requires access to
/// SharedPreferences (e.g., registering values with other services).
Future<void> onInit() async {
// Default implementation does nothing
}
Future<void> onInit() async {}
}
/// Typed preference declaration. Pair with [BaseSharedPreferencesService.read]
-8
View File
@@ -60,7 +60,6 @@ class DiscordRPCService {
DiscordRPCService._();
/// Check if Discord RPC is available on this platform
static bool get isAvailable {
if (!PlatformDetector.isDesktopOS()) {
return false;
@@ -86,7 +85,6 @@ class DiscordRPCService {
}
}
/// Enable or disable Discord RPC
Future<void> setEnabled(bool enabled) async {
if (_isEnabled == enabled) return;
@@ -172,7 +170,6 @@ class DiscordRPCService {
}
}
/// Stop showing presence when playback ends
Future<void> stopPlayback() async {
_currentMetadata = null;
_currentClient = null;
@@ -185,7 +182,6 @@ class DiscordRPCService {
}
}
/// Clear the presence
Future<void> clearPresence() async {
try {
unawaited(_rpc?.clearPresence());
@@ -200,8 +196,6 @@ class DiscordRPCService {
await _disconnect();
}
// Private methods
Future<void> _connect() async {
if (_rpc != null) return;
@@ -311,7 +305,6 @@ class DiscordRPCService {
final imageUrl = client.thumbnailUrl(thumbPath);
if (imageUrl.isEmpty) return null;
// Fetch image data
final imageBytes = await httpClient.getBytes(
imageUrl,
headers: client.streamHeaders,
@@ -319,7 +312,6 @@ class DiscordRPCService {
);
if (imageBytes.isEmpty) return null;
// Upload to Litterbox
final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl))
..fields['reqtype'] = 'fileupload'
..fields['time'] = '1h'
-5
View File
@@ -33,7 +33,6 @@ class DisplayModeService {
bool anyChange = false;
// Refresh rate matching.
if (_settings.read(SettingsService.matchRefreshRate) && fps != null && fps > 0) {
try {
final success = await _matchRefreshRate(fps);
@@ -43,7 +42,6 @@ class DisplayModeService {
}
}
// Dynamic range matching.
if (_settings.read(SettingsService.matchDynamicRange) && sigPeak != null && sigPeak > 1.0) {
try {
final success = await _enableSystemHDR();
@@ -61,7 +59,6 @@ class DisplayModeService {
return Duration.zero;
}
/// Restore all display settings to their original state.
Future<void> restoreAll() async {
if (!Platform.isWindows) return;
@@ -94,7 +91,6 @@ class DisplayModeService {
final currentHeight = currentMode['height'] as int;
final currentRate = currentMode['refreshRate'] as int;
// Find best matching rate from available modes.
final modes = await _channel.invokeListMethod<Map>('getDisplayModes');
if (modes == null || modes.isEmpty) return false;
@@ -137,7 +133,6 @@ class DisplayModeService {
static int _findBestRefreshRate(double videoFps, List<Map> modes, int currentWidth, int currentHeight) {
if (videoFps <= 0) return 0;
// Collect unique refresh rates at current resolution.
final rates = <int>{};
for (final mode in modes) {
final w = mode['width'] as int;
@@ -467,7 +467,6 @@ class DownloadManagerService {
continue;
}
// Check if background_downloader still has this task
Task? bgTask;
if (item.bgTaskId != null) {
bgTask = await FileDownloader().taskForId(item.bgTaskId!);
@@ -480,7 +479,6 @@ class DownloadManagerService {
await _database.updateBgTaskId(item.globalKey, null);
await _database.addToQueue(mediaGlobalKey: item.globalKey);
}
// If bgTask exists, background_downloader is still handling it
}
}
} catch (e) {
@@ -5,7 +5,6 @@ import '../utils/platform_detector.dart';
import 'macos_window_service.dart';
import 'native_window_service.dart';
/// Global manager for tracking fullscreen state across the app
class FullscreenStateManager extends ChangeNotifier with WindowListener {
static final FullscreenStateManager _instance = FullscreenStateManager._internal();
@@ -91,7 +90,6 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
}
}
/// Start monitoring fullscreen state
void startMonitoring() {
if (!_shouldMonitor() || _isListening) return;
@@ -103,7 +101,6 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
}
}
/// Stop monitoring fullscreen state
void stopMonitoring() {
if (_isListening) {
windowManager.removeListener(this);
@@ -115,7 +112,6 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
return PlatformDetector.isDesktopOS();
}
// WindowListener callbacks for Windows/Linux
@override
void onWindowEnterFullScreen() {
setFullscreen(true);
+1 -4
View File
@@ -153,7 +153,6 @@ class GamepadService with WindowListener {
GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard})
: _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows);
/// Get the singleton instance.
static GamepadService get instance {
_instance ??= GamepadService._();
return _instance!;
@@ -166,7 +165,6 @@ class GamepadService with WindowListener {
void start() async {
appLogger.i('GamepadService: Starting on ${Platform.operatingSystem}');
// List connected gamepads
try {
final gamepads = await Gamepad.instance.listGamepads();
appLogger.i('GamepadService: Found ${gamepads.length} gamepad(s)');
@@ -193,7 +191,6 @@ class GamepadService with WindowListener {
appLogger.i('GamepadService: Listening for gamepad events');
}
/// Stop listening to gamepad events.
void stop() {
_stopDirectionRepeat();
_unregisterNativeKeyHandler();
@@ -325,8 +322,8 @@ class GamepadService with WindowListener {
_pressedButtons.remove(event.button);
if (_suppressedButtons.remove(event.button)) return;
// D-pad release — stop repeat
switch (event.button) {
// D-pad release — stop repeat
case GamepadButton.dpadUp:
case GamepadButton.dpadDown:
case GamepadButton.dpadLeft:
-14
View File
@@ -17,7 +17,6 @@ class InAppReviewService {
Future<SharedPreferencesWithCache> _getPrefs() => BaseSharedPreferencesService.sharedCache();
// SharedPreferences keys
static const String _keyQualifyingSessionsCount = 'review_qualifying_sessions_count';
static const String _keyLastPromptTime = 'review_last_prompt_time';
@@ -26,11 +25,8 @@ class InAppReviewService {
static const Duration _minimumSessionDuration = Duration(minutes: 5);
static const Duration _promptCooldown = Duration(days: 60);
// Session tracking
DateTime? _sessionStartTime;
/// Check if in-app review is enabled via build flag
/// Only enabled on mobile platforms (iOS and Android)
static bool get isEnabled {
if (!Platform.isIOS && !Platform.isAndroid) {
return false;
@@ -38,7 +34,6 @@ class InAppReviewService {
return const bool.fromEnvironment('ENABLE_IN_APP_REVIEW', defaultValue: false);
}
/// Start tracking a new session
void startSession() {
if (!isEnabled) return;
_sessionStartTime = DateTime.now();
@@ -63,14 +58,12 @@ class InAppReviewService {
}
}
/// Increment the qualifying sessions counter
Future<void> _incrementQualifyingSessions() async {
final prefs = await _getPrefs();
final currentCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0;
await prefs.setInt(_keyQualifyingSessionsCount, currentCount + 1);
}
/// Get the current qualifying sessions count
Future<int> _getQualifyingSessionsCount() async {
final prefs = await _getPrefs();
return prefs.getInt(_keyQualifyingSessionsCount) ?? 0;
@@ -80,14 +73,12 @@ class InAppReviewService {
Future<bool> _shouldRequestReview() async {
final prefs = await _getPrefs();
// Check session count
final sessionCount = await _getQualifyingSessionsCount();
if (sessionCount < _requiredSessions) {
appLogger.d('In-app review: Not enough sessions ($sessionCount/$_requiredSessions)');
return false;
}
// Check cooldown
final lastPromptString = prefs.getString(_keyLastPromptTime);
if (lastPromptString != null) {
final lastPrompt = DateTime.parse(lastPromptString);
@@ -102,7 +93,6 @@ class InAppReviewService {
return true;
}
/// Request a review if conditions are met
Future<void> maybeRequestReview() async {
if (!isEnabled) return;
@@ -110,25 +100,21 @@ class InAppReviewService {
if (!shouldRequest) return;
try {
// Check if in-app review is available on this device
final isAvailable = await _inAppReview.isAvailable();
if (!isAvailable) {
appLogger.d('In-app review: Not available on this device');
return;
}
// Request the review
await _inAppReview.requestReview();
appLogger.i('In-app review: Review prompt shown');
// Record that we showed the prompt and reset session count
await _recordPromptShown();
} catch (e) {
appLogger.e('In-app review: Error requesting review', error: e);
}
}
/// Record that the review prompt was shown
Future<void> _recordPromptShown() async {
final prefs = await _getPrefs();
await prefs.setString(_keyLastPromptTime, DateTime.now().toIso8601String());
-2
View File
@@ -61,7 +61,6 @@ class JellyfinApiCache extends ApiCache {
await Future.wait([pinByKeyPattern(_itemPattern(serverId, itemId)), pin(serverId, endpoint)]);
}
/// Unpin a previously pinned item.
Future<void> unpinForOffline(String serverId, String itemId) async {
final endpoint = mediaSegmentsEndpoint(itemId);
await Future.wait([unpinByKeyPattern(_itemPattern(serverId, itemId)), unpin(serverId, endpoint)]);
@@ -73,7 +72,6 @@ class JellyfinApiCache extends ApiCache {
/// [ApiCache.isPinned]'s identical Dart signature.
Future<bool> isPinnedItemId(String serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId));
/// Get all pinned Jellyfin item ids for a server.
Future<Set<String>> getPinnedItemIds(String serverId) => extractPinnedIds(serverId, _itemKeyPattern);
/// Fetch and parse a [MediaItem] from cache.
+1 -2
View File
@@ -317,8 +317,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
if (approved != true) return null;
// Exchange the approved secret for an access token. Response shape
// matches /Users/AuthenticateByName.
// Exchange the approved secret for an access token.
final exchangeClient = _buildHttpClient(
baseUrl: normalised,
headers: {'Authorization': authHeader, 'Content-Type': 'application/json'},
-8
View File
@@ -183,8 +183,6 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items) =>
items.map(_mapItem).whereType<MediaItem>().toList();
// ── Identity ─────────────────────────────────────────────────────
@override
String get serverId => connection.serverMachineId;
@@ -205,8 +203,6 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
@override
double get watchedThreshold => 0.9;
// ── Lifecycle ────────────────────────────────────────────────────
@override
void close() => _http.close();
@@ -839,8 +835,6 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
throwIfHttpError(response);
}
// ── Browse: items ────────────────────────────────────────────────
/// Jellyfin has no single-round-trip equivalent of Plex's
/// `?includeOnDeck=1`. We approximate it for shows by chaining a second
/// request to `/Shows/NextUp` filtered by `seriesId`. NextUp's defaults
@@ -1097,8 +1091,6 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count);
}
// ── Browse: hubs ─────────────────────────────────────────────────
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10}) async {
// Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the
-2
View File
@@ -278,8 +278,6 @@ class JellyfinMappers {
);
}
// ── private helpers ──────────────────────────────────────────────
static MediaKind _libraryKindFromCollectionType(String? collectionType, String? type) {
final ct = collectionType?.toLowerCase();
if (ct != null) {
@@ -132,7 +132,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
super.dispose();
}
// Format HotKey for display
String formatHotkey(HotKey? hotKey) {
if (hotKey == null) return 'No shortcut set';
@@ -169,7 +168,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
return modifiers.isEmpty ? keyName : '${modifiers.join(' + ')} + $keyName';
}
// Handle keyboard input for video player
KeyEventResult handleVideoPlayerKeyEvent(
KeyEvent event,
Player player,
@@ -189,7 +187,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
}) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Handle back navigation keys (Escape)
if (event.logicalKey == LogicalKeyboardKey.escape) {
onBack?.call();
return KeyEventResult.handled;
@@ -201,19 +198,15 @@ class KeyboardShortcutsService extends ChangeNotifier {
final isAltPressed = HardwareKeyboard.instance.isAltPressed;
final isMetaPressed = HardwareKeyboard.instance.isMetaPressed;
// Check each hotkey
for (final entry in _hotkeys.entries) {
final action = entry.key;
final hotkey = entry.value;
// Check if the physical key matches
if (physicalKey != hotkey.key) continue;
// Check if modifiers match
final requiredModifiers = hotkey.modifiers ?? [];
bool modifiersMatch = true;
// Check each required modifier
for (final modifier in requiredModifiers) {
switch (modifier) {
case HotKeyModifier.shift:
@@ -384,7 +377,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
}
}
// Get human-readable action names
String getActionDisplayName(String action) {
switch (action) {
case 'play_pause':
-14
View File
@@ -38,8 +38,6 @@ class MacOSWindowService {
static bool _delegateEnabled = false;
static final List<MacOSWindowDelegate> _delegates = [];
// MARK: - Private Helpers
static Future<void> _invoke(String method, [Map<String, dynamic>? args]) async {
if (!Platform.isMacOS) return;
await _channel.invokeMethod(method, args);
@@ -64,8 +62,6 @@ class MacOSWindowService {
}
}
// MARK: - Initialization
/// Initialize the window service and set up the titlebar.
///
/// Note: The initial window configuration (transparent titlebar, toolbar,
@@ -99,32 +95,22 @@ class MacOSWindowService {
}
}
/// Add a delegate to receive window events.
static void addWindowDelegate(MacOSWindowDelegate delegate) {
if (!_delegates.contains(delegate)) {
_delegates.add(delegate);
}
}
/// Remove a previously added delegate.
static void removeWindowDelegate(MacOSWindowDelegate delegate) {
_delegates.remove(delegate);
}
// MARK: - Traffic Light Buttons
/// Show or hide all traffic light buttons (close, miniaturize, zoom).
static Future<void> setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible});
// MARK: - Fullscreen
/// Enter fullscreen mode.
static Future<void> enterFullscreen() => _invoke('enterFullscreen');
/// Exit fullscreen mode.
static Future<void> exitFullscreen() => _invoke('exitFullscreen');
/// Check if the window is in fullscreen mode.
static Future<bool> isFullscreen() async {
if (!Platform.isMacOS) return false;
return await _channel.invokeMethod<bool>('isFullscreen') ?? false;
-7
View File
@@ -42,7 +42,6 @@ class MediaControlsManager {
/// self-authenticated image URL).
Future<void> updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async {
try {
// Build artwork URL if client is available
String? artworkUrl;
if (client != null && metadata.thumbPath != null) {
try {
@@ -53,7 +52,6 @@ class MediaControlsManager {
}
}
// Update OS media controls
await OsMediaControls.setMetadata(
MediaMetadata(
title: metadata.title ?? '',
@@ -86,12 +84,10 @@ class MediaControlsManager {
_throttledUpdate.cancel();
await _doUpdatePlaybackState(params);
} else {
// Use throttled update
_throttledUpdate([params]);
}
}
/// Internal method to actually perform the playback state update
Future<void> _doUpdatePlaybackState(_PlaybackStateParams params) async {
try {
await OsMediaControls.setPlaybackState(
@@ -176,12 +172,10 @@ class MediaControlsManager {
if (metadata.isEpisode) {
final parts = <String>[];
// Add show name
if (metadata.grandparentTitle != null) {
parts.add(metadata.grandparentTitle!);
}
// Add season/episode info
if (metadata.parentIndex != null && metadata.index != null) {
parts.add('S${metadata.parentIndex} E${metadata.index}');
} else if (metadata.parentTitle != null) {
@@ -190,7 +184,6 @@ class MediaControlsManager {
return parts.join('');
} else if (metadata.isMovie) {
// For movies, use director or studio
if (metadata.year != null) {
return metadata.year.toString();
}
-6
View File
@@ -24,13 +24,10 @@ import 'storage_service.dart';
class MultiServerManager {
FutureOr<void> Function(JellyfinConnection connection)? onJellyfinConnectionUpdated;
/// Map of serverId (clientIdentifier) to active client instances.
final Map<String, MediaServerClient> _clients = {};
/// Map of serverId to server info
final Map<String, PlexServer> _plexServers = {};
/// Map of serverId to online status
final Map<String, bool> _serverStatus = {};
/// Servers whose last health probe rejected the auth token (HTTP 401/403).
@@ -42,7 +39,6 @@ class MultiServerManager {
/// Stream controller for server status changes
final _statusController = StreamController<Map<String, bool>>.broadcast();
/// Stream of server status changes
Stream<Map<String, bool>> get statusStream => _statusController.stream;
/// Servers whose authentication has failed (token rejected). A re-auth flow
@@ -97,10 +93,8 @@ class MultiServerManager {
/// Jellyfin-only profiles.
List<String> get serverIds => _clients.keys.toList();
/// Get all online server IDs
List<String> get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
/// Get all offline server IDs
List<String> get offlineServerIds => _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();
/// Get client for specific server.
@@ -114,9 +114,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
await _adoptLegacyWatchActionsForProfile(profileId, availableProfileCount: _availableProfileCount);
}
/// Start monitoring for connectivity changes to auto-sync
void startConnectivityMonitoring(OfflineModeSource source) {
// Remove previous listener if any
if (_offlineModeSource != null && _offlineModeListener != null) {
_offlineModeSource!.removeListener(_offlineModeListener!);
}
@@ -241,19 +239,12 @@ class OfflineWatchSyncService extends ChangeNotifier {
return clientScopeId;
}
/// Queue a manual "mark as watched" action.
///
/// Removes any conflicting actions for the same item.
Future<String?> queueMarkWatched({required String serverId, required String itemId}) =>
_queueWatchStatusAction(serverId: serverId, itemId: itemId, actionType: OfflineActionType.watched.id);
/// Queue a manual "mark as unwatched" action.
///
/// Removes any conflicting actions for the same item.
Future<String?> queueMarkUnwatched({required String serverId, required String itemId}) =>
_queueWatchStatusAction(serverId: serverId, itemId: itemId, actionType: OfflineActionType.unwatched.id);
/// Internal helper to queue watch/unwatch actions.
Future<String?> _queueWatchStatusAction({
required String serverId,
required String itemId,
@@ -379,7 +370,6 @@ class OfflineWatchSyncService extends ChangeNotifier {
return null;
}
/// Get count of pending sync items.
Future<int> getPendingSyncCount() async {
await _adoptLegacyWatchActionsForActiveProfile();
final profileId = _activeProfileId;
-1
View File
@@ -10,7 +10,6 @@ class PipService {
/// PiP is only implemented natively on Android, iOS, and macOS.
static bool get _isAvailable => Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
// Singleton instance
static final PipService _instance = PipService._internal();
factory PipService() => _instance;
-5
View File
@@ -112,7 +112,6 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
final selectedKey = (!shuffle && startItem != null) ? '/library/metadata/${startItem.id}' : null;
if (facts.isCollection) {
// Get machine identifier (fetch if not cached in config)
final machineId = client.config.machineIdentifier ?? await client.getMachineIdentifier();
if (machineId == null) {
@@ -278,22 +277,18 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
if (!context.mounted) return const PlayQueueError('Context not mounted');
// Set up playback state
final playbackState = context.read<PlaybackStateProvider>();
playbackState.setPlayQueueWindowFetcher(client.getPlayQueue);
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey);
if (!context.mounted) return const PlayQueueError('Context not mounted');
// Determine which item to navigate to
var itemToPlay = selectedItem ?? playQueue.items!.first;
// Copy server info if needed
if (copyServerInfo && serverId != null) {
itemToPlay = itemToPlay.copyWith(serverId: serverId, serverName: serverName);
}
// Navigate to video player
await navigateToVideoPlayer(context, metadata: itemToPlay);
return const PlayQueueSuccess();
-3
View File
@@ -47,13 +47,11 @@ class PlexApiCache extends ApiCache {
)..where((t) => t.cacheKey.equals(metadataKey) | t.cacheKey.equals(childrenKey))).go();
}
/// Mark an item as pinned for offline access.
@override
Future<void> pinForOffline(String serverId, String ratingKey) async {
return pin(serverId, '/library/metadata/$ratingKey');
}
/// Unpin an item.
Future<void> unpinForOffline(String serverId, String ratingKey) async {
return unpin(serverId, '/library/metadata/$ratingKey');
}
@@ -69,7 +67,6 @@ class PlexApiCache extends ApiCache {
// Rating keys can be alphanumeric, not just numeric.
static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$');
/// Get all pinned rating keys for a server.
Future<Set<String>> getPinnedKeys(String serverId) => extractPinnedIds(serverId, _metadataKeyPattern);
/// Fetch and parse a [MediaItem] from cache.
+164 -172
View File
@@ -16,54 +16,51 @@ PlexRoleDto _$PlexRoleDtoFromJson(Map<String, dynamic> json) => PlexRoleDto(
count: flexibleInt(json['count']),
);
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) =>
PlexMediaVersionDto(
id: _flexibleIntOrZero(json['id']),
videoResolution: readStringField(json, 'videoResolution') as String?,
videoCodec: readStringField(json, 'videoCodec') as String?,
bitrate: flexibleInt(json['bitrate']),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
container: readStringField(json, 'container') as String?,
partKey: _readPartKey(json, 'partKey') as String,
accessible: _readPartAccessible(json, 'accessible') as bool?,
exists: _readPartExists(json, 'exists') as bool?,
);
PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) => PlexMediaVersionDto(
id: _flexibleIntOrZero(json['id']),
videoResolution: readStringField(json, 'videoResolution') as String?,
videoCodec: readStringField(json, 'videoCodec') as String?,
bitrate: flexibleInt(json['bitrate']),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
container: readStringField(json, 'container') as String?,
partKey: _readPartKey(json, 'partKey') as String,
accessible: _readPartAccessible(json, 'accessible') as bool?,
exists: _readPartExists(json, 'exists') as bool?,
);
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) =>
PlexLibraryDto(
key: readStringField(json, 'key') as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String? ?? '',
agent: json['agent'] as String?,
scanner: json['scanner'] as String?,
language: json['language'] as String?,
uuid: json['uuid'] as String?,
updatedAt: flexibleInt(json['updatedAt']),
createdAt: flexibleInt(json['createdAt']),
hidden: flexibleInt(json['hidden']),
);
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) => PlexLibraryDto(
key: readStringField(json, 'key') as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String? ?? '',
agent: json['agent'] as String?,
scanner: json['scanner'] as String?,
language: json['language'] as String?,
uuid: json['uuid'] as String?,
updatedAt: flexibleInt(json['updatedAt']),
createdAt: flexibleInt(json['createdAt']),
hidden: flexibleInt(json['hidden']),
);
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) =>
PlexPlaylistDto(
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
key: json['key'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String?,
smart: json['smart'] as bool? ?? false,
playlistType: json['playlistType'] as String? ?? '',
duration: flexibleInt(json['duration']),
leafCount: flexibleInt(json['leafCount']),
composite: json['composite'] as String?,
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
viewCount: flexibleInt(json['viewCount']),
content: json['content'] as String?,
guid: json['guid'] as String?,
thumb: json['thumb'] as String?,
);
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) => PlexPlaylistDto(
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
key: json['key'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String?,
smart: json['smart'] as bool? ?? false,
playlistType: json['playlistType'] as String? ?? '',
duration: flexibleInt(json['duration']),
leafCount: flexibleInt(json['leafCount']),
composite: json['composite'] as String?,
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
viewCount: flexibleInt(json['viewCount']),
content: json['content'] as String?,
guid: json['guid'] as String?,
thumb: json['thumb'] as String?,
);
PlexHubDto _$PlexHubDtoFromJson(Map<String, dynamic> json) => PlexHubDto(
hubKey: readStringField(json, 'key') as String? ?? '',
@@ -75,130 +72,125 @@ PlexHubDto _$PlexHubDtoFromJson(Map<String, dynamic> json) => PlexHubDto(
items: _hubItemsFromJson(_readHubItems(json, 'items')),
);
PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) =>
PlexMetadataDto(
ratingKey: _readMetadataRatingKey(json, 'ratingKey') as String? ?? '',
key: json['key'] as String?,
guid: json['guid'] as String?,
studio: json['studio'] as String?,
type: json['type'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
userRating: (json['userRating'] as num?)?.toDouble(),
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?,
art: json['art'] as String?,
duration: flexibleInt(json['duration']),
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumb: json['grandparentThumb'] as String?,
grandparentArt: json['grandparentArt'] as String?,
grandparentRatingKey:
readStringField(json, 'grandparentRatingKey') as String?,
parentTitle: json['parentTitle'] as String?,
parentThumb: json['parentThumb'] as String?,
parentRatingKey: readStringField(json, 'parentRatingKey') as String?,
parentIndex: flexibleInt(json['parentIndex']),
index: flexibleInt(json['index']),
grandparentTheme: json['grandparentTheme'] as String?,
viewOffset: flexibleInt(json['viewOffset']),
viewCount: flexibleInt(json['viewCount']),
leafCount: flexibleInt(json['leafCount']),
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
childCount: flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRoleDto.fromJson(e as Map<String, dynamic>))
.toList(),
mediaVersions: (json['Media'] as List<dynamic>?)
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
.toList(),
genre: _tagListFromJson(json['Genre']),
director: _tagListFromJson(json['Director']),
writer: _tagListFromJson(json['Writer']),
producer: _tagListFromJson(json['Producer']),
country: _tagListFromJson(json['Country']),
collection: _tagListFromJson(json['Collection']),
label: _tagListFromJson(json['Label']),
style: _tagListFromJson(json['Style']),
mood: _tagListFromJson(json['Mood']),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
playlistItemID: flexibleInt(json['playlistItemID']),
playQueueItemID: flexibleInt(json['playQueueItemID']),
librarySectionID: flexibleInt(json['librarySectionID']),
librarySectionTitle: json['librarySectionTitle'] as String?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
editionTitle: json['editionTitle'] as String?,
subtype: json['subtype'] as String?,
extraType: flexibleInt(json['extraType']),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
PlexMetadataDto _$PlexMetadataDtoFromJson(Map<String, dynamic> json) => PlexMetadataDto(
ratingKey: _readMetadataRatingKey(json, 'ratingKey') as String? ?? '',
key: json['key'] as String?,
guid: json['guid'] as String?,
studio: json['studio'] as String?,
type: json['type'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
contentRating: json['contentRating'] as String?,
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
userRating: (json['userRating'] as num?)?.toDouble(),
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?,
art: json['art'] as String?,
duration: flexibleInt(json['duration']),
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumb: json['grandparentThumb'] as String?,
grandparentArt: json['grandparentArt'] as String?,
grandparentRatingKey: readStringField(json, 'grandparentRatingKey') as String?,
parentTitle: json['parentTitle'] as String?,
parentThumb: json['parentThumb'] as String?,
parentRatingKey: readStringField(json, 'parentRatingKey') as String?,
parentIndex: flexibleInt(json['parentIndex']),
index: flexibleInt(json['index']),
grandparentTheme: json['grandparentTheme'] as String?,
viewOffset: flexibleInt(json['viewOffset']),
viewCount: flexibleInt(json['viewCount']),
leafCount: flexibleInt(json['leafCount']),
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
childCount: flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRoleDto.fromJson(e as Map<String, dynamic>)).toList(),
mediaVersions: (json['Media'] as List<dynamic>?)
?.map((e) => PlexMediaVersionDto.fromJson(e as Map<String, dynamic>))
.toList(),
genre: _tagListFromJson(json['Genre']),
director: _tagListFromJson(json['Director']),
writer: _tagListFromJson(json['Writer']),
producer: _tagListFromJson(json['Producer']),
country: _tagListFromJson(json['Country']),
collection: _tagListFromJson(json['Collection']),
label: _tagListFromJson(json['Label']),
style: _tagListFromJson(json['Style']),
mood: _tagListFromJson(json['Mood']),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
playlistItemID: flexibleInt(json['playlistItemID']),
playQueueItemID: flexibleInt(json['playQueueItemID']),
librarySectionID: flexibleInt(json['librarySectionID']),
librarySectionTitle: json['librarySectionTitle'] as String?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
editionTitle: json['editionTitle'] as String?,
subtype: json['subtype'] as String?,
extraType: flexibleInt(json['extraType']),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': ?instance.key,
'guid': ?instance.guid,
'studio': ?instance.studio,
'type': ?instance.type,
'title': ?instance.title,
'titleSort': ?instance.titleSort,
'contentRating': ?instance.contentRating,
'summary': ?instance.summary,
'rating': ?instance.rating,
'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating,
'year': ?instance.year,
'originallyAvailableAt': ?instance.originallyAvailableAt,
'thumb': ?instance.thumb,
'art': ?instance.art,
'duration': ?instance.duration,
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'lastViewedAt': ?instance.lastViewedAt,
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumb': ?instance.grandparentThumb,
'grandparentArt': ?instance.grandparentArt,
'grandparentRatingKey': ?instance.grandparentRatingKey,
'parentTitle': ?instance.parentTitle,
'parentThumb': ?instance.parentThumb,
'parentRatingKey': ?instance.parentRatingKey,
'parentIndex': ?instance.parentIndex,
'index': ?instance.index,
'grandparentTheme': ?instance.grandparentTheme,
'viewOffset': ?instance.viewOffset,
'viewCount': ?instance.viewCount,
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
'playlistItemID': ?instance.playlistItemID,
'playQueueItemID': ?instance.playQueueItemID,
'librarySectionID': ?instance.librarySectionID,
'librarySectionTitle': ?instance.librarySectionTitle,
'ratingImage': ?instance.ratingImage,
'audienceRatingImage': ?instance.audienceRatingImage,
'tagline': ?instance.tagline,
'originalTitle': ?instance.originalTitle,
'editionTitle': ?instance.editionTitle,
'subtype': ?instance.subtype,
'extraType': ?instance.extraType,
'primaryExtraKey': ?instance.primaryExtraKey,
'clearLogo': ?instance.clearLogo,
'backgroundSquare': ?instance.backgroundSquare,
};
Map<String, dynamic> _$PlexMetadataDtoToJson(PlexMetadataDto instance) => <String, dynamic>{
'ratingKey': instance.ratingKey,
'key': ?instance.key,
'guid': ?instance.guid,
'studio': ?instance.studio,
'type': ?instance.type,
'title': ?instance.title,
'titleSort': ?instance.titleSort,
'contentRating': ?instance.contentRating,
'summary': ?instance.summary,
'rating': ?instance.rating,
'audienceRating': ?instance.audienceRating,
'userRating': ?instance.userRating,
'year': ?instance.year,
'originallyAvailableAt': ?instance.originallyAvailableAt,
'thumb': ?instance.thumb,
'art': ?instance.art,
'duration': ?instance.duration,
'addedAt': ?instance.addedAt,
'updatedAt': ?instance.updatedAt,
'lastViewedAt': ?instance.lastViewedAt,
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumb': ?instance.grandparentThumb,
'grandparentArt': ?instance.grandparentArt,
'grandparentRatingKey': ?instance.grandparentRatingKey,
'parentTitle': ?instance.parentTitle,
'parentThumb': ?instance.parentThumb,
'parentRatingKey': ?instance.parentRatingKey,
'parentIndex': ?instance.parentIndex,
'index': ?instance.index,
'grandparentTheme': ?instance.grandparentTheme,
'viewOffset': ?instance.viewOffset,
'viewCount': ?instance.viewCount,
'leafCount': ?instance.leafCount,
'viewedLeafCount': ?instance.viewedLeafCount,
'childCount': ?instance.childCount,
'audioLanguage': ?instance.audioLanguage,
'subtitleLanguage': ?instance.subtitleLanguage,
'subtitleMode': ?instance.subtitleMode,
'playlistItemID': ?instance.playlistItemID,
'playQueueItemID': ?instance.playQueueItemID,
'librarySectionID': ?instance.librarySectionID,
'librarySectionTitle': ?instance.librarySectionTitle,
'ratingImage': ?instance.ratingImage,
'audienceRatingImage': ?instance.audienceRatingImage,
'tagline': ?instance.tagline,
'originalTitle': ?instance.originalTitle,
'editionTitle': ?instance.editionTitle,
'subtype': ?instance.subtype,
'extraType': ?instance.extraType,
'primaryExtraKey': ?instance.primaryExtraKey,
'clearLogo': ?instance.clearLogo,
'backgroundSquare': ?instance.backgroundSquare,
};
-5
View File
@@ -64,7 +64,6 @@ class ShaderAssetLoader {
final fileName = path.basename(assetPath);
final subDir = path.dirname(assetPath);
// Create subdirectory if needed
final targetDir = Directory(path.join(shaderDir, subDir));
if (!await targetDir.exists()) {
await targetDir.create(recursive: true);
@@ -114,7 +113,6 @@ class ShaderAssetLoader {
final quality = config.quality;
final mode = config.mode;
// Get quality-specific shader variants
String restoreVariant;
String upscaleVariant;
@@ -262,15 +260,12 @@ class ShaderAssetLoader {
/// Call this at startup to avoid extraction delay during playback.
static Future<void> preloadShaders() async {
try {
// Extract NVScaler
await _extractShader(_nvscalerShader);
// Extract all ArtCNN shaders
for (final shaderPath in _artcnnShaders.values) {
await _extractShader(shaderPath);
}
// Extract all Anime4K shaders
for (final shaderPath in _anime4kShaders.values) {
await _extractShader(shaderPath);
}
-7
View File
@@ -18,7 +18,6 @@ class ShaderService {
ShaderService(this._player);
/// The currently applied shader preset
ShaderPreset get currentPreset => _currentPreset;
/// Check if the player is MPV (shaders are MPV-only)
@@ -35,7 +34,6 @@ class ShaderService {
}
try {
// Handle NVScaler HDR auto-skip
if (preset.type == ShaderPresetType.nvscaler && preset.nvscalerConfig?.autoHdrSkip == true) {
final isHdr = await _isHdrContent();
if (isHdr) {
@@ -47,7 +45,6 @@ class ShaderService {
}
}
// Get shader paths for the preset
final shaderPaths = await ShaderAssetLoader.getShadersForPreset(preset);
if (shaderPaths.isEmpty) {
@@ -58,10 +55,8 @@ class ShaderService {
return;
}
// Clear existing shaders first
await _clearShaders();
// Apply new shader chain
for (final shaderPath in shaderPaths) {
await _player.command(['change-list', 'glsl-shaders', 'append', shaderPath]);
}
@@ -78,7 +73,6 @@ class ShaderService {
}
}
/// Clear all currently applied shaders.
Future<void> _clearShaders() async {
try {
await _player.command(['change-list', 'glsl-shaders', 'clr', '']);
@@ -128,7 +122,6 @@ class ShaderService {
}
}
/// Disable all shaders.
Future<void> disable() async {
await applyPreset(ShaderPreset.none);
}
-8
View File
@@ -24,7 +24,6 @@ class SleepTimerService extends ChangeNotifier {
/// Emits when the timer fires and wants to show a "still watching?" prompt
Stream<void> get onPrompt => _promptController.stream;
/// Whether a timer is currently active
bool get isActive => _timer != null && _timer!.isActive;
/// The time when the timer will complete
@@ -43,11 +42,7 @@ class SleepTimerService extends ChangeNotifier {
return remaining.isNegative ? Duration.zero : remaining;
}
/// Start a sleep timer with the specified duration
/// [duration] - How long until the timer completes
/// [onComplete] - Callback to execute when timer completes
void startTimer(Duration duration, VoidCallback onComplete) {
// Cancel any existing timer
cancelTimer();
_originalDuration = duration;
@@ -57,7 +52,6 @@ class SleepTimerService extends ChangeNotifier {
appLogger.d('Sleep timer started: ${duration.inMinutes} minutes');
// Create a periodic timer to update remaining time
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
final remaining = remainingTime;
@@ -66,7 +60,6 @@ class SleepTimerService extends ChangeNotifier {
_stopTimerOnly();
_promptController.add(null);
} else {
// Notify listeners to update UI
notifyListeners();
}
});
@@ -88,7 +81,6 @@ class SleepTimerService extends ChangeNotifier {
}
}
/// Restart the timer with the original user-selected duration
void restartTimer() {
if (_originalDuration != null && _onTimerComplete != null) {
final duration = _originalDuration!;
+1 -6
View File
@@ -17,7 +17,6 @@ class UpdateService {
static const String _githubRepo = 'edde746/plezy';
static const String _feedUrl = 'https://cdn.jsdelivr.net/gh/edde746/plezy@appcast/appcast.xml';
// SharedPreferences keys
static const String _keySkippedVersion = 'update_skipped_version';
static const String _keyLastCheckTime = 'update_last_check_time';
@@ -100,19 +99,16 @@ class UpdateService {
}
}
/// Skip a specific version
static Future<void> skipVersion(String version) async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(_keySkippedVersion, version);
}
/// Get the skipped version
static Future<String?> getSkippedVersion() async {
final prefs = await BaseSharedPreferencesService.sharedCache();
return prefs.getString(_keySkippedVersion);
}
/// Clear skipped version
static Future<void> clearSkippedVersion() async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.remove(_keySkippedVersion);
@@ -132,7 +128,6 @@ class UpdateService {
return timeSinceLastCheck >= _checkCooldown;
}
/// Update the last check timestamp
static Future<void> _updateLastCheckTime() async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String());
@@ -246,7 +241,7 @@ class UpdateService {
if (newPart < currentPart) return false;
}
return false; // Versions are equal
return false;
} catch (e) {
_logger.e('Error comparing versions: $e');
return false;
-4
View File
@@ -63,7 +63,6 @@ class VideoFilterManager {
/// Current BoxFit mode (0=contain, 1=cover, 2=fill)
int get boxFitMode => _boxFitMode;
/// Current player size
Size? get playerSize => _playerSize;
/// Cycle through BoxFit modes: contain → cover → fill → contain (for button)
@@ -114,12 +113,10 @@ class VideoFilterManager {
/// Whether ambient lighting was active before entering PiP
bool get hadAmbientLightingBeforePip => _prePipAmbientLighting == true;
/// Clear the pre-PiP ambient lighting flag after restore
void clearPipAmbientLightingFlag() {
_prePipAmbientLighting = null;
}
/// Update player size when layout changes
void updatePlayerSize(Size size) {
// Check if size actually changed to avoid unnecessary updates
if (_playerSize == null ||
@@ -169,7 +166,6 @@ class VideoFilterManager {
/// subsequent calls within 50ms are debounced.
void debouncedUpdateVideoFilter() => _debouncedUpdateVideoFilter();
/// Clean up resources
void dispose() {
_debouncedUpdateVideoFilter.cancel();
}
-5
View File
@@ -5,7 +5,6 @@ import '../mpv/mpv.dart';
import '../services/pip_service.dart';
import '../utils/app_logger.dart';
/// Manages video Picture-in-Picture mode
class VideoPIPManager {
final Player player;
@@ -22,7 +21,6 @@ class VideoPIPManager {
_playerSize = size;
}
/// Access PiP state from the service
ValueNotifier<bool> get isPipActive => PipService().isPipActive;
/// Get current video dimensions (display or storage or fallback to viewport)
@@ -60,8 +58,6 @@ class VideoPIPManager {
return (width, height);
}
/// Toggle native PiP
/// Returns a tuple of (success, error message) for error handling
Future<(bool success, String? error)> togglePIP() async {
final supported = await PipService.isSupported();
if (!supported) return (false, 'PiP not supported on this device');
@@ -84,7 +80,6 @@ class VideoPIPManager {
return await PipService.enter(width: dims.$1, height: dims.$2);
}
/// Update auto-PiP readiness on the native side
Future<void> updateAutoPipState({required bool isPlaying}) async {
if (!isPlaying) {
await PipService.setAutoPipReady(ready: false);
-1
View File
@@ -118,7 +118,6 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
iconColor: c.text,
textColor: c.text,
),
// minimal bottom bar
navigationBarTheme: NavigationBarThemeData(
backgroundColor: c.bg,
elevation: 0,
-1
View File
@@ -1,7 +1,6 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// Helper function to access MonoTokens from context
MonoTokens tokens(BuildContext context) => Theme.of(context).extension<MonoTokens>()!;
@immutable
+1 -16
View File
@@ -35,17 +35,12 @@ class LogEntry {
/// Estimate the memory size of this log entry in bytes
int get estimatedSize {
int size = 0;
// DateTime: ~8 bytes
size += 8;
// Level enum: ~4 bytes
size += 4;
// Message string: 2 bytes per character (UTF-16)
size += message.length * 2;
// Error string: 2 bytes per character if present
if (error != null) {
size += error.toString().length * 2;
}
// Stack trace string: 2 bytes per character if present
if (stackTrace != null) {
size += stackTrace.toString().length * 2;
}
@@ -58,25 +53,21 @@ class LogEntry {
/// Storage is handled by [MemoryAwareLogPrinter.log()] — this class only
/// forwards formatted lines to the console via the default [ConsoleOutput].
class MemoryLogOutput extends LogOutput {
static const int maxLogSizeBytes = 5 * 1024 * 1024; // 5 MB
static const int maxLogSizeBytes = 5 * 1024 * 1024;
static final ListQueue<LogEntry> _logs = ListQueue<LogEntry>();
static int _currentSize = 0;
static final _consoleOutput = ConsoleOutput();
/// Get all stored logs (newest first)
static List<LogEntry> getLogs() => _logs.toList().reversed.toList();
/// Clear all stored logs
static void clearLogs() {
_logs.clear();
_currentSize = 0;
}
/// Get current log buffer size in bytes
static int getCurrentSize() => _currentSize;
/// Get current log buffer size in MB
static double getCurrentSizeMB() => _currentSize / (1024 * 1024);
@override
@@ -115,7 +106,6 @@ class MemoryAwareLogPrinter extends LogPrinter {
MemoryLogOutput._currentSize -= removed.estimatedSize;
}
// Delegate a redacted event to the wrapped printer for console output.
return _wrappedPrinter.log(
LogEvent(event.level, message, time: event.time, error: error, stackTrace: event.stackTrace),
);
@@ -136,7 +126,6 @@ class ProductionFilter extends LogFilter {
}
}
/// Global filter instance
final _productionFilter = ProductionFilter();
/// Centralized logger instance for the application.
@@ -161,13 +150,9 @@ Logger appLogger = Logger(
void setLoggerLevel(bool debugEnabled) {
final newLevel = debugEnabled ? Level.debug : Level.info;
// Update the filter level
_productionFilter.setLevel(newLevel);
// Recreate the logger instance with the new level
// This ensures it works in release mode where Logger.level might be optimized away
appLogger = Logger(printer: MemoryAwareLogPrinter(SimplePrinter()), filter: _productionFilter, level: newLevel);
// Also set the static level for consistency
Logger.level = newLevel;
}
-4
View File
@@ -5,10 +5,6 @@
class CodecUtils {
CodecUtils._();
/// Maps Plex subtitle codec names to file extensions.
///
/// Returns the appropriate file extension for a given subtitle codec.
/// Defaults to 'srt' for unknown or null codecs.
static String getSubtitleExtension(String? codec) {
if (codec == null) return 'srt';
-6
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
/// Content type constants used throughout the app
class ContentTypes {
ContentTypes._();
@@ -21,17 +20,13 @@ class ContentTypes {
static const Set<String> playableTypes = {movie, episode, clip, track};
}
/// Utility class for content type checking and filtering
class ContentTypeHelper {
ContentTypeHelper._();
/// Checks if the given type is music content (artist, album, or track)
static bool isMusicContent(String type) => ContentTypes.musicTypes.contains(type.toLowerCase());
/// Checks if the given type is video content (movie, show, episode, or season)
static bool isVideoContent(String type) => ContentTypes.videoTypes.contains(type.toLowerCase());
/// Checks if the given [MediaLibrary] is a music library.
static bool isMusicLibrary(dynamic lib) {
if (lib == null) return false;
try {
@@ -43,7 +38,6 @@ class ContentTypeHelper {
}
}
/// Returns the appropriate icon for a given library type
static IconData getLibraryIcon(String type) {
switch (type.toLowerCase()) {
case ContentTypes.movie:
-3
View File
@@ -60,10 +60,8 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
DeletionNotifier._internal();
/// Filter for events affecting a specific server
Stream<DeletionEvent> forServer(String serverId) => stream.where((e) => e.serverId == serverId);
/// Filter for events affecting a specific item or its children
Stream<DeletionEvent> forItem(String itemId) => stream.where((e) => e.affectsItem(itemId));
/// Emit a deletion event with logging
@@ -73,7 +71,6 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
super.notify(event);
}
/// Helper to emit a deletion event from a [MediaItem].
void notifyDeletedItem({required MediaItem item, bool isDownloadOnly = false}) {
notify(
DeletionEvent(
-6
View File
@@ -43,12 +43,10 @@ class DesktopAppBarHelper {
rightPadding = DesktopWindowPadding.mobileRight;
}
// If no platform-specific padding needed, return original actions
if (rightPadding == null) {
return actions;
}
// Add padding to keep actions away from edge
return actions != null ? [...actions, SizedBox(width: rightPadding)] : [SizedBox(width: rightPadding)];
}
@@ -61,7 +59,6 @@ class DesktopAppBarHelper {
return leading;
}
// Skip left padding when side navigation scope is present in widget tree
if (context != null && SideNavigationScope.isPresent(context)) {
if (includeGestureDetector) {
return GestureDetector(
@@ -120,7 +117,6 @@ class DesktopAppBarHelper {
return null;
}
// Skip extra width when side navigation scope is present in widget tree
if (context != null && SideNavigationScope.isPresent(context)) {
return null;
}
@@ -165,8 +161,6 @@ class DesktopTitleBarPadding extends StatelessWidget {
return child;
}
// Skip left padding when side navigation scope is present in widget tree
// (side nav already handles the traffic lights area)
if (SideNavigationScope.isPresent(context)) {
final right = rightPadding ?? 0.0;
if (right == 0.0) {
-2
View File
@@ -10,8 +10,6 @@ import '../widgets/dialog_action_button.dart';
import '../widgets/focusable_list_tile.dart';
import 'focus_utils.dart';
/// Utility functions for showing common dialogs
const _buttonPadding = EdgeInsets.symmetric(horizontal: 18, vertical: 14);
const _buttonShape = StadiumBorder();
-1
View File
@@ -102,7 +102,6 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
maxCount = customCount;
}
// For unwatched-based options on shows, offer sync vs one-time download
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
final syncChoice = await showOptionPickerDialog<_SyncChoice>(
context,
-1
View File
@@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart';
/// Utility class for common focus operations
class FocusUtils {
FocusUtils._();
-22
View File
@@ -10,7 +10,6 @@ String padNumber(int number, int width) {
return number.toString().padLeft(width, '0');
}
/// Utility class for formatting byte sizes and speeds
class ByteFormatter {
ByteFormatter._();
@@ -19,9 +18,6 @@ class ByteFormatter {
static const int _gb = _mb * 1024;
/// Format bytes to human-readable string (e.g., "1.5 GB", "256.3 MB")
///
/// [bytes] The number of bytes to format
/// [decimals] Number of decimal places (default: 1 for KB/MB, 2 for GB)
static String formatBytes(int bytes, {int? decimals}) {
if (bytes < _kb) return '$bytes B';
if (bytes < _mb) {
@@ -34,8 +30,6 @@ class ByteFormatter {
}
/// Format speed in bytes per second to human-readable string
///
/// [bytesPerSecond] The speed in bytes per second
static String formatSpeed(double bytesPerSecond) {
if (bytesPerSecond < _kb) {
return '${bytesPerSecond.toStringAsFixed(0)} B/s';
@@ -47,8 +41,6 @@ class ByteFormatter {
}
/// Format bitrate in kbps to human-readable string
///
/// [kbps] The bitrate in kilobits per second
static String formatBitrate(int kbps) {
if (kbps < 1000) return '$kbps kbps';
return '${(kbps / 1000).toStringAsFixed(1)} Mbps';
@@ -63,17 +55,13 @@ class ByteFormatter {
String formatDurationTextual(int milliseconds, {bool abbreviated = true}) {
final duration = Duration(milliseconds: milliseconds);
// Get the appropriate locale for the duration package
final durationLocale = _getDurationLocale();
// Format with abbreviated or full units (h, m) but no seconds
return prettyDuration(
duration,
abbreviated: abbreviated,
locale: durationLocale,
delimiter: abbreviated ? ' ' : ', ',
spacer: '',
// Configure to show only hours and minutes
tersity: DurationTersity.minute,
);
}
@@ -87,14 +75,12 @@ String formatDurationWithSeconds(Duration duration) {
// Get the appropriate locale for the duration package
final durationLocale = _getDurationLocale();
// Format with abbreviated units (h, m, s) including seconds
return prettyDuration(
duration,
abbreviated: true,
locale: durationLocale,
delimiter: ' ',
spacer: '',
// Show all non-zero units
tersity: DurationTersity.second,
);
}
@@ -105,7 +91,6 @@ String formatDurationWithSeconds(Duration duration) {
///
/// Used for: video controls, chapters, episode durations.
String formatDurationTimestamp(Duration duration) {
// Handle negative durations
final isNegative = duration.isNegative;
final absoluteDuration = duration.abs();
@@ -130,13 +115,11 @@ String formatSyncOffset(double offsetMs) {
final durationLocale = _getDurationLocale();
if (absMs >= 10000) {
// For values >= 10s, show decimal seconds (e.g., "+15.1s")
final seconds = (offsetMs.abs() / 1000).toStringAsFixed(1);
final unit = durationLocale.second(1, true);
return '$sign$seconds$unit';
}
// For values < 10s, show milliseconds (e.g., "+7300ms")
final unit = durationLocale.millisecond(1, true);
return '$sign$absMs$unit';
}
@@ -144,7 +127,6 @@ String formatSyncOffset(double offsetMs) {
/// Gets the duration package locale based on the current app locale.
/// Falls back to English if the locale is not supported by the duration package.
DurationLocale _getDurationLocale() {
// Get the current locale from slang's LocaleSettings
final appLocale = LocaleSettings.currentLocale;
final languageCode = appLocale.languageCode;
@@ -154,7 +136,6 @@ DurationLocale _getDurationLocale() {
try {
return DurationLocale.fromLanguageCode(languageCode) ?? const EnglishDurationLocale();
} catch (e) {
// Fallback to English if language code is not supported
return const EnglishDurationLocale();
}
}
@@ -188,7 +169,6 @@ String formatFinishTime(Duration remaining, {double rate = 1.0, required bool is
return formatClockTime(finishTime, is24Hour: is24Hour);
}
/// Takes a list of strings and returns one long string with each item in the list concatenated by a bullet
String toBulletedString(List<String> parts) {
return parts.join(' · ');
}
@@ -207,10 +187,8 @@ String formatPlaybackRate(double rate, {bool normalAtOne = false}) {
/// If there is any error, `dateString` is returned as is
String formatFullDate(String dateString) {
try {
// Parse the date
final date = DateTime.parse(dateString);
// Create a DateFormat with the full date pattern for the current locale
final formatter = DateFormat.yMMMMd(LocaleSettings.currentLocale.languageCode);
return formatter.format(date);
-1
View File
@@ -1,4 +1,3 @@
/// Builds a globalKey string from [serverId] and [ratingKey].
String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKey';
/// Separator used by profile-owned rows whose public media identity is still
-3
View File
@@ -3,7 +3,6 @@ import '../services/settings_service.dart' show LibraryDensity;
import 'layout_constants.dart';
import 'platform_detector.dart';
/// Utility class for calculating consistent grid sizes across the app
class GridSizeCalculator {
static double _lerp(double min, double max, double t) => min + (max - min) * t;
@@ -68,12 +67,10 @@ class GridSizeCalculator {
return availableWidth / columns;
}
/// Check if the given index is in the first row of a grid with given column count.
static bool isFirstRow(int index, int columnCount) {
return index < columnCount;
}
/// Check if the given index is in the first column of a grid with given column count.
static bool isFirstColumn(int index, int columnCount) {
return index % columnCount == 0;
}
-2
View File
@@ -9,10 +9,8 @@ mixin HierarchicalEventMixin {
/// The id of the affected item (Plex ratingKey, Jellyfin GUID, …).
String get itemId;
/// Composite key: serverId:itemId.
String get globalKey;
/// Server this item belongs to.
String get serverId;
/// Parent chain for hierarchical matching.

Some files were not shown because too many files have changed in this diff Show More