diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9988d4941..64f920264 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -205,6 +205,14 @@ This change eventually prevents building the app with Flutter v3.3.3. android:resource="@xml/app_widget_info" /> + + + + + + dart + analysisStreamHandler = AnalysisStreamHandler().apply { + EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this) + } + errorStreamHandler = ErrorStreamHandler().apply { + EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) + } + val mediaCommandHandler = MediaCommandStreamHandler().apply { + EventChannel(messenger, MediaCommandStreamHandler.CHANNEL).setStreamHandler(this) + } + // dart -> platform -> dart // - need Context analysisHandler = AnalysisHandler(this, ::onAnalysisCompleted) @@ -83,7 +94,7 @@ open class MainActivity : FlutterActivity() { MethodChannel(messenger, HomeWidgetHandler.CHANNEL).setMethodCallHandler(HomeWidgetHandler(this)) MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(this)) MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this)) - MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(MediaSessionHandler(this)) + MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(MediaSessionHandler(this, mediaCommandHandler)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) @@ -128,16 +139,6 @@ open class MainActivity : FlutterActivity() { } } - // notification: platform -> dart - analysisStreamHandler = AnalysisStreamHandler().apply { - EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this) - } - - // notification: platform -> dart - errorStreamHandler = ErrorStreamHandler().apply { - EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { setupShortcuts() } @@ -431,7 +432,7 @@ open class MainActivity : FlutterActivity() { } } - var errorStreamHandler: ErrorStreamHandler? = null + private var errorStreamHandler: ErrorStreamHandler? = null suspend fun notifyError(error: String) { Log.e(LOG_TAG, "notifyError error=$error") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt index e96903269..327a9eded 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt @@ -2,20 +2,16 @@ package deckers.thibault.aves.channel.calls import android.content.ComponentName import android.content.Context -import android.content.Intent import android.media.session.PlaybackState import android.net.Uri import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat -import android.util.Log -import android.view.KeyEvent import androidx.media.session.MediaButtonReceiver import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend +import deckers.thibault.aves.channel.streams.MediaCommandStreamHandler import deckers.thibault.aves.utils.FlutterUtils -import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.getParcelableExtraCompat import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -24,10 +20,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -class MediaSessionHandler(private val context: Context) : MethodCallHandler { +class MediaSessionHandler(private val context: Context, private val mediaCommandHandler: MediaCommandStreamHandler) : MethodCallHandler { private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val sessions = HashMap() + private var session: MediaSessionCompat? = null override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { @@ -77,77 +73,41 @@ class MediaSessionHandler(private val context: Context) : MethodCallHandler { .setActions(actions) .build() - var session = sessions[uri] - if (session == null) { - val mbrIntent = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE) - val mbrName = ComponentName(context, MediaButtonReceiver::class.java) - session = MediaSessionCompat(context, "aves-$uri", mbrName, mbrIntent) - sessions[uri] = session - - val metadata = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMillis) - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri.toString()) - .build() - session.setMetadata(metadata) - - val callback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { - override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - val keyEvent = mediaButtonEvent.getParcelableExtraCompat(Intent.EXTRA_KEY_EVENT) ?: return false - Log.d(LOG_TAG, "TLAD onMediaButtonEvent keyEvent=$keyEvent") - return super.onMediaButtonEvent(mediaButtonEvent) - } - - override fun onPlay() { - super.onPlay() - Log.d(LOG_TAG, "TLAD onPlay uri=$uri") - } - - override fun onPause() { - super.onPause() - Log.d(LOG_TAG, "TLAD onPause uri=$uri") - } - - override fun onStop() { - super.onStop() - Log.d(LOG_TAG, "TLAD onStop uri=$uri") - } - - override fun onSeekTo(pos: Long) { - super.onSeekTo(pos) - Log.d(LOG_TAG, "TLAD onSeekTo uri=$uri pos=$pos") + FlutterUtils.runOnUiThread { + if (session == null) { + val mbrIntent = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE) + val mbrName = ComponentName(context, MediaButtonReceiver::class.java) + session = MediaSessionCompat(context, "aves", mbrName, mbrIntent).apply { + setCallback(mediaCommandHandler) } } - FlutterUtils.runOnUiThread { - session.setCallback(callback) + session!!.apply { + val metadata = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMillis) + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri.toString()) + .build() + setMetadata(metadata) + setPlaybackState(playbackState) + if (!isActive) { + isActive = true + } } } - session.setPlaybackState(playbackState) - - if (!session.isActive) { - session.isActive = true - } - result.success(null) } - private fun release(call: MethodCall, result: MethodChannel.Result) { - val uri = call.argument("uri")?.let { Uri.parse(it) } - - if (uri == null) { - result.error("release-args", "missing arguments", null) - return + private fun release(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + session?.let { + it.release() + session = null } - - sessions[uri]?.release() - result.success(null) } companion object { - private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/media_session" const val STATE_STOPPED = "stopped" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt index cd30bf1a7..c2e9d364d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ActivityResultStreamHandler.kt @@ -199,7 +199,9 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any activity.startActivityForResult(intent, MainActivity.PICK_COLLECTION_FILTERS_REQUEST) } - override fun onCancel(arguments: Any?) {} + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } private fun success(result: Any?) { handler.post { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt index 2e199a30d..3cbaad3e2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/AnalysisStreamHandler.kt @@ -1,5 +1,7 @@ package deckers.thibault.aves.channel.streams +import android.util.Log +import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -13,13 +15,16 @@ class AnalysisStreamHandler : EventChannel.StreamHandler { this.eventSink = eventSink } - override fun onCancel(arguments: Any?) {} + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } fun notifyCompletion() { eventSink?.success(true) } companion object { + private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/analysis_events" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt index 1896fd0ef..a71e16b88 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt @@ -1,6 +1,8 @@ package deckers.thibault.aves.channel.streams +import android.util.Log import deckers.thibault.aves.utils.FlutterUtils +import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -14,7 +16,9 @@ class ErrorStreamHandler : EventChannel.StreamHandler { this.eventSink = eventSink } - override fun onCancel(arguments: Any?) {} + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } suspend fun notifyError(error: String) { FlutterUtils.runOnUiThread { @@ -23,6 +27,7 @@ class ErrorStreamHandler : EventChannel.StreamHandler { } companion object { + private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/error" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt index e1734338c..a5626196b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt @@ -1,5 +1,7 @@ package deckers.thibault.aves.channel.streams +import android.util.Log +import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -13,13 +15,16 @@ class IntentStreamHandler : EventChannel.StreamHandler { this.eventSink = eventSink } - override fun onCancel(arguments: Any?) {} + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } fun notifyNewIntent(intentData: MutableMap?) { eventSink?.success(intentData) } companion object { + private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/new_intent_stream" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt new file mode 100644 index 000000000..b81815622 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaCommandStreamHandler.kt @@ -0,0 +1,76 @@ +package deckers.thibault.aves.channel.streams + +import android.os.Handler +import android.os.Looper +import android.support.v4.media.session.MediaSessionCompat +import android.util.Log +import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.LogUtils +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class MediaCommandStreamHandler : EventChannel.StreamHandler, MediaSessionCompat.Callback() { + // cannot use `lateinit` because we cannot guarantee + // its initialization in `onListen` at the right time + private var eventSink: EventSink? = null + private var handler: Handler? = null + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + handler = Handler(Looper.getMainLooper()) + } + + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } + + private fun success(fields: FieldMap) { + handler?.post { + try { + eventSink?.success(fields) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } + } + + // media session callback + + override fun onPlay() { + super.onPlay() + success(hashMapOf(KEY_COMMAND to COMMAND_PLAY)) + } + + override fun onPause() { + super.onPause() + success(hashMapOf(KEY_COMMAND to COMMAND_PAUSE)) + } + + override fun onStop() { + super.onStop() + success(hashMapOf(KEY_COMMAND to COMMAND_STOP)) + } + + override fun onSeekTo(pos: Long) { + super.onSeekTo(pos) + success( + hashMapOf( + KEY_COMMAND to COMMAND_SEEK, + KEY_POSITION to pos, + ) + ) + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + const val CHANNEL = "deckers.thibault/aves/media_command" + + const val KEY_COMMAND = "command" + const val KEY_POSITION = "position" + + const val COMMAND_PLAY = "play" + const val COMMAND_PAUSE = "pause" + const val COMMAND_STOP = "stop" + const val COMMAND_SEEK = "seek" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreChangeStreamHandler.kt index 7fdcddffc..05ede13b6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreChangeStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreChangeStreamHandler.kt @@ -41,7 +41,9 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel handler = Handler(Looper.getMainLooper()) } - override fun onCancel(arguments: Any?) {} + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } fun dispose() { context.contentResolver.unregisterContentObserver(contentObserver) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt index c61c18ad7..77a753bfc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt @@ -79,7 +79,9 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S handler = Handler(Looper.getMainLooper()) } - override fun onCancel(arguments: Any?) {} + override fun onCancel(arguments: Any?) { + Log.i(LOG_TAG, "onCancel arguments=$arguments") + } fun dispose() { context.contentResolver.unregisterContentObserver(contentObserver) diff --git a/lib/services/media/media_session_service.dart b/lib/services/media/media_session_service.dart index 4914e9237..c6cb94b8a 100644 --- a/lib/services/media/media_session_service.dart +++ b/lib/services/media/media_session_service.dart @@ -1,18 +1,33 @@ import 'dart:async'; +import 'package:aves/services/common/optional_event_channel.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; abstract class MediaSessionService { + Stream get mediaCommands; + Future update(AvesVideoController controller); - Future release(String uri); + Future release(); } class PlatformMediaSessionService implements MediaSessionService { static const _platformObject = MethodChannel('deckers.thibault/aves/media_session'); + final EventChannel _mediaCommandChannel = const OptionalEventChannel('deckers.thibault/aves/media_command'); + final StreamController _streamController = StreamController.broadcast(); + + PlatformMediaSessionService() { + _mediaCommandChannel.receiveBroadcastStream().listen((event) => _onMediaCommand(event as Map?)); + } + + @override + Stream get mediaCommands => _streamController.stream.where((event) => event is MediaCommandEvent).cast(); + @override Future update(AvesVideoController controller) async { final entry = controller.entry; @@ -31,11 +46,9 @@ class PlatformMediaSessionService implements MediaSessionService { } @override - Future release(String uri) async { + Future release() async { try { - await _platformObject.invokeMethod('release', { - 'uri': uri, - }); + await _platformObject.invokeMethod('release'); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } @@ -54,4 +67,52 @@ class PlatformMediaSessionService implements MediaSessionService { return 'stopped'; } } + + void _onMediaCommand(Map? fields) { + if (fields == null) return; + final command = fields['command'] as String?; + MediaCommandEvent? event; + switch (command) { + case 'play': + event = const MediaCommandEvent(MediaCommand.play); + break; + case 'pause': + event = const MediaCommandEvent(MediaCommand.pause); + break; + case 'stop': + event = const MediaCommandEvent(MediaCommand.stop); + break; + case 'seek': + final position = fields['position'] as int?; + if (position != null) { + event = MediaSeekCommandEvent(MediaCommand.stop, position: position); + } + break; + } + if (event != null) { + _streamController.add(event); + } + } +} + +enum MediaCommand { play, pause, stop, seek } + +@immutable +class MediaCommandEvent extends Equatable { + final MediaCommand command; + + @override + List get props => [command]; + + const MediaCommandEvent(this.command); +} + +@immutable +class MediaSeekCommandEvent extends MediaCommandEvent { + final int position; + + @override + List get props => [...super.props, position]; + + const MediaSeekCommandEvent(super.command, {required this.position}); } diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 2484943b5..5dc2c3d74 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -686,7 +686,7 @@ class _EntryViewerStackState extends State with EntryViewContr if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { await windowService.keepScreenOn(false); } - + await mediaSessionService.release(); await AvesApp.showSystemUI(); AvesApp.setSystemUIStyle(context); if (!settings.useTvLayout) { diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index fc544af7b..9c9ee8032 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -30,7 +30,6 @@ abstract class AvesVideoController { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); - await mediaSessionService.release(entry.uri); entry.visualChangeNotifier.removeListener(onVisualChanged); await _savePlaybackState(); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 301eef9e8..287a8caea 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -6,6 +6,8 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/media_session_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -107,6 +109,7 @@ class _EntryPageViewState extends State with SingleTickerProvider _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); if (entry.isVideo) { + _subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand)); _videoCoverStreamListener = ImageStreamListener((image, _) => _videoCoverInfoNotifier.value = image); _videoCoverStream = videoCoverUriImage.resolve(ImageConfiguration.empty); _videoCoverStream!.addListener(_videoCoverStreamListener); @@ -430,6 +433,28 @@ class _EntryPageViewState extends State with SingleTickerProvider const ToggleOverlayNotification().dispatch(context); } + void _onMediaCommand(MediaCommandEvent event) { + final videoController = context.read().getController(entry); + if (videoController == null) return; + + switch (event.command) { + case MediaCommand.play: + videoController.play(); + break; + case MediaCommand.pause: + videoController.pause(); + break; + case MediaCommand.stop: + videoController.pause(); + break; + case MediaCommand.seek: + if (event is MediaSeekCommandEvent) { + videoController.seekTo(event.position); + } + break; + } + } + void _onViewStateChanged(MagnifierState v) { _viewStateNotifier.value = _viewStateNotifier.value.copyWith( position: v.position,