Skip to content

Commit 2d70695

Browse files
committed
feat: re-do os media controls
1 parent 260681a commit 2d70695

File tree

13 files changed

+189
-654
lines changed

13 files changed

+189
-654
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
<!-- Internet access permissions -->
44
<uses-permission android:name="android.permission.INTERNET"/>
55

6-
<!-- Audio service permissions -->
7-
<uses-permission android:name="android.permission.WAKE_LOCK"/>
6+
<!-- Media session permissions for OS media controls -->
87
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
98
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
109

@@ -45,23 +44,6 @@
4544
<meta-data
4645
android:name="flutterEmbedding"
4746
android:value="2" />
48-
49-
<!-- Audio service -->
50-
<service android:name="com.ryanheise.audioservice.AudioService"
51-
android:foregroundServiceType="mediaPlayback"
52-
android:exported="true" tools:ignore="Instantiatable">
53-
<intent-filter>
54-
<action android:name="android.media.browse.MediaBrowserService" />
55-
</intent-filter>
56-
</service>
57-
58-
<!-- Media button receiver -->
59-
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
60-
android:exported="true" tools:ignore="Instantiatable">
61-
<intent-filter>
62-
<action android:name="android.intent.action.MEDIA_BUTTON" />
63-
</intent-filter>
64-
</receiver>
6547
</application>
6648
<!-- Required to query activities that can process text, see:
6749
https://developer.android.com/training/package-visibility and
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package com.edde746.plezy
22

3-
import com.ryanheise.audioservice.AudioServiceActivity
3+
import io.flutter.embedding.android.FlutterActivity
44

5-
class MainActivity : AudioServiceActivity()
5+
class MainActivity : FlutterActivity()

ios/Podfile.lock

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
PODS:
2-
- audio_service (0.0.1):
3-
- Flutter
4-
- FlutterMacOS
5-
- audio_session (0.0.1):
6-
- Flutter
72
- Flutter (1.0.0)
83
- media_kit_libs_ios_video (1.0.4):
94
- Flutter
105
- media_kit_video (0.0.1):
116
- Flutter
7+
- os_media_controls (0.0.1):
8+
- Flutter
129
- package_info_plus (0.4.5):
1310
- Flutter
1411
- path_provider_foundation (0.0.1):
@@ -28,11 +25,10 @@ PODS:
2825
- Flutter
2926

3027
DEPENDENCIES:
31-
- audio_service (from `.symlinks/plugins/audio_service/darwin`)
32-
- audio_session (from `.symlinks/plugins/audio_session/ios`)
3328
- Flutter (from `Flutter`)
3429
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
3530
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
31+
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
3632
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
3733
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
3834
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -42,16 +38,14 @@ DEPENDENCIES:
4238
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
4339

4440
EXTERNAL SOURCES:
45-
audio_service:
46-
:path: ".symlinks/plugins/audio_service/darwin"
47-
audio_session:
48-
:path: ".symlinks/plugins/audio_session/ios"
4941
Flutter:
5042
:path: Flutter
5143
media_kit_libs_ios_video:
5244
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
5345
media_kit_video:
5446
:path: ".symlinks/plugins/media_kit_video/ios"
47+
os_media_controls:
48+
:path: ".symlinks/plugins/os_media_controls/ios"
5549
package_info_plus:
5650
:path: ".symlinks/plugins/package_info_plus/ios"
5751
path_provider_foundation:
@@ -68,11 +62,10 @@ EXTERNAL SOURCES:
6862
:path: ".symlinks/plugins/wakelock_plus/ios"
6963

7064
SPEC CHECKSUMS:
71-
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
72-
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
7365
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
7466
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
7567
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
68+
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
7669
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
7770
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
7871
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb

ios/Runner/AppDelegate.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Flutter
22
import UIKit
3+
import AVFoundation
34

45
@main
56
@objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,18 @@ import UIKit
89
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
910
) -> Bool {
1011
GeneratedPluginRegistrant.register(with: self)
12+
13+
// Configure audio session for media playback
14+
do {
15+
let session = AVAudioSession.sharedInstance()
16+
try session.setCategory(.playback, mode: .default)
17+
try session.setActive(true)
18+
} catch {
19+
print("Failed to configure audio session: \(error)")
20+
}
21+
22+
application.beginReceivingRemoteControlEvents()
23+
1124
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
1225
}
1326
}

lib/screens/video_player_screen.dart

Lines changed: 141 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
44
import 'package:media_kit/media_kit.dart';
55
import 'package:media_kit_video/media_kit_video.dart';
66
import 'package:provider/provider.dart';
7+
import 'package:os_media_controls/os_media_controls.dart';
78
import '../models/plex_metadata.dart';
89
import '../models/plex_user_profile.dart';
910
import '../providers/plex_client_provider.dart';
@@ -14,7 +15,6 @@ import '../widgets/video_controls/video_controls.dart';
1415
import '../utils/language_codes.dart';
1516
import '../utils/app_logger.dart';
1617
import '../services/settings_service.dart';
17-
import '../services/audio_service_manager.dart';
1818
import '../utils/orientation_helper.dart';
1919
import '../utils/video_player_navigation.dart';
2020
import '../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

Comments
 (0)