Files
plezy/lib/services/media_controls_manager.dart
T
edde746 4f736c555b fix: make PlexMetadata type/key/title nullable for folder items
Plex API returns Directory entries without type field, causing
TypeError crash in fromJson. Made fields nullable and migrated
callers to use mediaType enum and displayTitle getter instead
of raw string access.
2026-03-14 05:40:14 +01:00

209 lines
6.7 KiB
Dart

import 'package:os_media_controls/os_media_controls.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'plex_client.dart';
import '../models/plex_metadata.dart';
import '../utils/content_utils.dart';
import '../utils/app_logger.dart';
/// Manages OS media controls integration for video playback.
///
/// Handles:
/// - Metadata updates (title, artwork, etc.)
/// - Playback state updates (playing/paused, position, speed)
/// - Control event streaming (play, pause, next, previous, seek)
/// - Position update throttling to prevent excessive API calls
class MediaControlsManager {
/// Stream of control events from OS media controls
Stream<dynamic> get controlEvents => OsMediaControls.controlEvents;
/// Throttled playback state update (1 second interval, leading + trailing)
late final Throttle _throttledUpdate;
/// Cached control enabled state to avoid redundant platform calls
bool? _lastCanGoNext;
bool? _lastCanGoPrevious;
bool? _lastCanSeek;
MediaControlsManager() {
_throttledUpdate = throttle(
_doUpdatePlaybackState,
const Duration(seconds: 1),
leading: true,
trailing: true, // Send final position at end of throttle window
);
}
/// Update media metadata displayed in OS media controls
///
/// This includes title, artist, artwork, and duration.
Future<void> updateMetadata({required PlexMetadata metadata, PlexClient? client, Duration? duration}) async {
try {
// Build artwork URL if client is available
String? artworkUrl;
if (client != null && metadata.thumb != null) {
try {
artworkUrl = client.getThumbnailUrl(metadata.thumb!);
appLogger.d('Artwork URL for media controls: $artworkUrl');
} catch (e) {
appLogger.w('Failed to build artwork URL', error: e);
}
}
// Update OS media controls
await OsMediaControls.setMetadata(
MediaMetadata(
title: metadata.title!,
artist: _buildArtist(metadata),
artworkUrl: artworkUrl,
duration: duration,
),
);
appLogger.d('Updated media controls metadata: ${metadata.title}');
} catch (e) {
appLogger.w('Failed to update media controls metadata', error: e);
}
}
/// Update playback state in OS media controls
///
/// Updates the current playing state, position, and playback speed.
/// Position updates are throttled to avoid excessive API calls.
Future<void> updatePlaybackState({
required bool isPlaying,
required Duration position,
required double speed,
bool force = false,
}) async {
final params = _PlaybackStateParams(isPlaying: isPlaying, position: position, speed: speed);
if (force) {
// Cancel any pending throttled update to prevent stale state from overwriting
_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(
MediaPlaybackState(
state: params.isPlaying ? PlaybackState.playing : PlaybackState.paused,
position: params.position,
speed: params.speed,
),
);
} catch (e) {
appLogger.w('Failed to update media controls playback state', error: e);
}
}
/// Enable or disable next/previous track controls
///
/// This should be called based on content type and playback mode.
/// For example:
/// - Episodes: Enable both if there are adjacent episodes
/// - Playlist items: Enable based on playlist position
/// - Movies: Usually disabled
Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {
try {
final controlsToEnable = <MediaControl>[];
final controlsToDisable = <MediaControl>[];
if (canGoPrevious != _lastCanGoPrevious) {
(canGoPrevious ? controlsToEnable : controlsToDisable).add(MediaControl.previous);
}
if (canGoNext != _lastCanGoNext) {
(canGoNext ? controlsToEnable : controlsToDisable).add(MediaControl.next);
}
if (canSeek != _lastCanSeek) {
(canSeek ? controlsToEnable : controlsToDisable).add(MediaControl.seek);
}
if (controlsToEnable.isEmpty && controlsToDisable.isEmpty) return;
if (controlsToEnable.isNotEmpty) {
await OsMediaControls.enableControls(controlsToEnable);
}
if (controlsToDisable.isNotEmpty) {
await OsMediaControls.disableControls(controlsToDisable);
}
_lastCanGoNext = canGoNext;
_lastCanGoPrevious = canGoPrevious;
_lastCanSeek = canSeek;
appLogger.d('Media controls updated - Previous: $canGoPrevious, Next: $canGoNext, Seek: $canSeek');
} catch (e) {
appLogger.w('Failed to set media controls enabled state', error: e);
}
}
/// Clear all media controls
///
/// Should be called when playback stops or screen is disposed.
Future<void> clear() async {
try {
await OsMediaControls.clear();
_throttledUpdate.cancel();
_lastCanGoNext = null;
_lastCanGoPrevious = null;
_lastCanSeek = null;
appLogger.d('Media controls cleared');
} catch (e) {
appLogger.w('Failed to clear media controls', error: e);
}
}
/// Dispose resources
void dispose() {
_throttledUpdate.cancel();
}
/// Build artist string from metadata
///
/// For episodes: "Show Name - Season X Episode Y"
/// For movies: Director or studio
/// For other content: Fallback to year or empty
String _buildArtist(PlexMetadata metadata) {
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) {
parts.add(metadata.parentTitle!);
}
return parts.join(' • ');
} else if (metadata.isMovie) {
// For movies, use director or studio
// Note: These fields may need to be added to PlexMetadata model
if (metadata.year != null) {
return metadata.year.toString();
}
}
return '';
}
}
/// Parameters for playback state update (used with throttle)
class _PlaybackStateParams {
final bool isPlaying;
final Duration position;
final double speed;
const _PlaybackStateParams({required this.isPlaying, required this.position, required this.speed});
}