@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
44import 'package:media_kit/media_kit.dart' ;
55import 'package:media_kit_video/media_kit_video.dart' ;
66import 'package:provider/provider.dart' ;
7+ import 'package:os_media_controls/os_media_controls.dart' ;
78import '../models/plex_metadata.dart' ;
89import '../models/plex_user_profile.dart' ;
910import '../providers/plex_client_provider.dart' ;
@@ -14,7 +15,6 @@ import '../widgets/video_controls/video_controls.dart';
1415import '../utils/language_codes.dart' ;
1516import '../utils/app_logger.dart' ;
1617import '../services/settings_service.dart' ;
17- import '../services/audio_service_manager.dart' ;
1818import '../utils/orientation_helper.dart' ;
1919import '../utils/video_player_navigation.dart' ;
2020import '../utils/platform_detector.dart' ;
@@ -56,6 +56,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
5656 StreamSubscription <String >? _errorSubscription;
5757 StreamSubscription <bool >? _playingSubscription;
5858 StreamSubscription <bool >? _completedSubscription;
59+ StreamSubscription <Duration >? _positionSubscription;
60+ StreamSubscription <dynamic >? _mediaControlSubscription;
5961 bool _isReplacingWithVideo =
6062 false ; // Flag to skip orientation restoration during video-to-video navigation
6163
@@ -215,14 +217,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
215217 // Listen to MPV errors
216218 _errorSubscription = player! .stream.error.listen (_onPlayerError);
217219
220+ // Listen to position updates for media controls
221+ _positionSubscription = player! .stream.position.listen ((_) {
222+ _updateMediaControlsPosition ();
223+ });
224+
225+ // Initialize OS media controls
226+ _initializeMediaControls ();
227+
218228 // Start periodic progress updates
219229 _startProgressTracking ();
220230
221231 // Load next/previous episodes
222232 _loadAdjacentEpisodes ();
223-
224- // Initialize audio service for OS media controls
225- _initializeAudioService ();
226233 } catch (e) {
227234 appLogger.e ('Failed to initialize player' , error: e);
228235 if (mounted) {
@@ -279,52 +286,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
279286 _nextEpisode = next;
280287 _previousEpisode = previous;
281288 });
282-
283- // Update audio handler navigation availability
284- _updateAudioHandlerNavigation ();
285289 }
286290 } catch (e) {
287291 // Silently handle errors
288292 }
289293 }
290294
291- Future <void > _initializeAudioService () async {
292- try {
293- if (player == null ) return ;
294-
295- final clientProvider = context.plexClient;
296- final client = clientProvider.client;
297- if (client == null ) return ;
298-
299- final audioManager = AudioServiceManager .instance;
300-
301- // Initialize audio service (will only happen once)
302- await audioManager.initialize (
303- player: player! ,
304- plexServerUrl: client.config.baseUrl,
305- authToken: client.config.token ?? '' ,
306- onSkipToNext: _nextEpisode != null ? _playNext : null ,
307- onSkipToPrevious: _previousEpisode != null ? _playPrevious : null ,
308- );
309-
310- // Update media item with current content
311- audioManager.updateMediaItem (widget.metadata);
312-
313- appLogger.d ('Audio service initialized/updated successfully' );
314- } catch (e) {
315- appLogger.e ('Failed to initialize audio service' , error: e);
316- // Continue playback even if audio service fails
317- }
318- }
319-
320- void _updateAudioHandlerNavigation () {
321- final audioManager = AudioServiceManager .instance;
322- audioManager.updateNavigation (
323- onNext: _nextEpisode != null ? _playNext : null ,
324- onPrevious: _previousEpisode != null ? _playPrevious : null ,
325- );
326- }
327-
328295 Future <void > _startPlayback () async {
329296 try {
330297 final clientProvider = context.plexClient;
@@ -519,17 +486,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
519486 _completedSubscription? .cancel ();
520487 _logSubscription? .cancel ();
521488 _errorSubscription? .cancel ();
489+ _positionSubscription? .cancel ();
490+ _mediaControlSubscription? .cancel ();
522491
523- // Handle audio service based on navigation intent
524- if (_isReplacingWithVideo) {
525- // Switching to another episode: pause but keep notification visible
526- // The new VideoPlayerScreen will update the notification
527- AudioServiceManager .instance.pause ();
528- } else {
529- // Truly exiting player: clear notification but keep singleton alive
530- // This allows controls to reappear when playing again
531- AudioServiceManager .instance.clearNotification ();
532- }
492+ // Clear OS media controls completely
493+ OsMediaControls .clear ();
533494
534495 // Send final stopped state
535496 _sendProgress ('stopped' );
@@ -1123,6 +1084,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
11231084 void _onPlayingStateChanged (bool isPlaying) {
11241085 // Send timeline update when playback state changes
11251086 _sendProgress (isPlaying ? 'playing' : 'paused' );
1087+
1088+ // Update OS media controls playback state
1089+ _updateMediaControlsPlaybackState ();
11261090 }
11271091
11281092 void _sendProgress (String state) {
@@ -1185,6 +1149,129 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
11851149 appLogger.e ('[MPV ERROR] $error ' );
11861150 }
11871151
1152+ // OS Media Controls Integration
1153+
1154+ void _initializeMediaControls () async {
1155+ // Listen to media control events
1156+ _mediaControlSubscription = OsMediaControls .controlEvents.listen ((event) {
1157+ if (event is PlayEvent ) {
1158+ player? .play ();
1159+ } else if (event is PauseEvent ) {
1160+ player? .pause ();
1161+ } else if (event is SeekEvent ) {
1162+ player? .seek (event.position);
1163+ } else if (event is NextTrackEvent ) {
1164+ if (_nextEpisode != null ) {
1165+ _playNext ();
1166+ }
1167+ } else if (event is PreviousTrackEvent ) {
1168+ if (_previousEpisode != null ) {
1169+ _playPrevious ();
1170+ }
1171+ }
1172+ });
1173+
1174+ // Enable/disable next/previous track controls based on content type
1175+ final isEpisode = widget.metadata.type.toLowerCase () == 'episode' ;
1176+ if (isEpisode) {
1177+ // Enable next/previous track controls for episodes
1178+ await OsMediaControls .enableControls ([
1179+ MediaControl .next,
1180+ MediaControl .previous,
1181+ ]);
1182+ } else {
1183+ // Disable next/previous track controls for movies
1184+ await OsMediaControls .disableControls ([
1185+ MediaControl .next,
1186+ MediaControl .previous,
1187+ ]);
1188+ }
1189+
1190+ // Set initial metadata
1191+ await _updateMediaMetadata ();
1192+ }
1193+
1194+ Future <void > _updateMediaMetadata () async {
1195+ if (! mounted) {
1196+ appLogger.w ('Cannot update media metadata: widget not mounted' );
1197+ return ;
1198+ }
1199+
1200+ final metadata = widget.metadata;
1201+ final clientProvider = context.plexClient;
1202+ final client = clientProvider.client;
1203+
1204+ // Get artwork URL
1205+ String ? artworkUrl;
1206+ if (client == null ) {
1207+ appLogger.w ('Cannot get artwork URL for media controls: Plex client is null' );
1208+ } else {
1209+ final thumbUrl = metadata.type.toLowerCase () == 'episode'
1210+ ? metadata.grandparentThumb ?? metadata.thumb
1211+ : metadata.thumb;
1212+
1213+ if (thumbUrl != null ) {
1214+ try {
1215+ artworkUrl = client.getThumbnailUrl (thumbUrl);
1216+ appLogger.d ('Artwork URL for media controls: $artworkUrl ' );
1217+ } catch (e) {
1218+ appLogger.w ('Failed to get artwork URL for media controls' , error: e);
1219+ }
1220+ } else {
1221+ appLogger.d ('No thumbnail URL available for media controls' );
1222+ }
1223+ }
1224+
1225+ // Build title/artist based on content type
1226+ String title = metadata.title;
1227+ String ? artist;
1228+ String ? album;
1229+
1230+ if (metadata.type.toLowerCase () == 'episode' ) {
1231+ title = metadata.title;
1232+ artist = metadata.grandparentTitle; // Show name
1233+ if (metadata.parentIndex != null ) {
1234+ album = 'Season ${metadata .parentIndex }' ;
1235+ }
1236+ }
1237+
1238+ await OsMediaControls .setMetadata (MediaMetadata (
1239+ title: title,
1240+ artist: artist,
1241+ album: album,
1242+ duration: metadata.duration != null
1243+ ? Duration (milliseconds: metadata.duration! )
1244+ : null ,
1245+ artworkUrl: artworkUrl,
1246+ ));
1247+
1248+ // Set initial playback state
1249+ _updateMediaControlsPlaybackState ();
1250+ }
1251+
1252+ void _updateMediaControlsPlaybackState () {
1253+ if (player == null ) return ;
1254+
1255+ OsMediaControls .setPlaybackState (MediaPlaybackState (
1256+ state: player! .state.playing ? PlaybackState .playing : PlaybackState .paused,
1257+ position: player! .state.position,
1258+ speed: player! .state.rate,
1259+ ));
1260+ }
1261+
1262+ void _updateMediaControlsPosition () {
1263+ if (player == null ) return ;
1264+
1265+ // Only update if playing to avoid excessive updates
1266+ if (player! .state.playing) {
1267+ OsMediaControls .setPlaybackState (MediaPlaybackState (
1268+ state: PlaybackState .playing,
1269+ position: player! .state.position,
1270+ speed: player! .state.rate,
1271+ ));
1272+ }
1273+ }
1274+
11881275 Future <void > _playNext () async {
11891276 if (_nextEpisode == null || _isLoadingNext) return ;
11901277
0 commit comments