refactor: strip obvious comments
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.0–1.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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.0–1.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.
|
||||
///
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
/// Represents a saved preset of MPV configurations
|
||||
class MpvPreset {
|
||||
final String name;
|
||||
final String text;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Utility class for common focus operations
|
||||
class FocusUtils {
|
||||
FocusUtils._();
|
||||
|
||||
|
||||
@@ -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,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,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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user