import 'package:os_media_controls/os_media_controls.dart'; import 'package:rate_limiter/rate_limiter.dart'; import '../media/media_server_client.dart'; import '../media/media_item.dart'; import '../media/media_item_types.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 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; bool _updatesSuspended = false; 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. Backend-neutral — /// the [MediaServerClient.thumbnailUrl] adapter handles per-backend URL /// shape (Plex's `/photo/:/transcode` proxy vs. Jellyfin's /// self-authenticated image URL). Future updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async { if (_updatesSuspended) return; try { String? artworkUrl; if (client != null && metadata.thumbPath != null) { try { artworkUrl = client.thumbnailUrl(metadata.thumbPath!); appLogger.d('Artwork URL for media controls: $artworkUrl'); } catch (e) { appLogger.w('Failed to build artwork URL', error: e); } } 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 updatePlaybackState({ required bool isPlaying, required Duration position, required double speed, bool force = false, }) async { if (_updatesSuspended) return; 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 { _throttledUpdate([params]); } } Future _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 setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async { if (_updatesSuspended) return; try { final controlsToEnable = []; final controlsToDisable = []; 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 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); } } void suspendUpdates() { if (_updatesSuspended) return; _updatesSuspended = true; _throttledUpdate.cancel(); appLogger.d('Media controls updates suspended'); } void resumeUpdates() { if (!_updatesSuspended) return; _updatesSuspended = false; appLogger.d('Media controls updates resumed'); } /// 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(MediaItem metadata) { if (metadata.isEpisode) { final parts = []; if (metadata.grandparentTitle != null) { parts.add(metadata.grandparentTitle!); } 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) { 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}); }