diff --git a/docs/api-reference/flutter-readium.md b/docs/api-reference/flutter-readium.md index 6bb63978..35438771 100644 --- a/docs/api-reference/flutter-readium.md +++ b/docs/api-reference/flutter-readium.md @@ -95,4 +95,5 @@ final results = await FlutterReadium().searchInPublication('query'); | `onReaderStatusChanged` | `ReadiumReaderStatus` | Loading, ready, closed, error. See [enums.dart](../../flutter_readium_platform_interface/lib/src/enums.dart) | | `onTextLocatorChanged` | `Locator` | Visual reader position changes. See [locator.md](./locator.md) | | `onTimebasedPlayerStateChanged` | `ReadiumTimebasedState` | Audio/TTS playback state. See [timebased_state.dart](../../flutter_readium_platform_interface/lib/src/timebased_state.dart) | +| `onExternalPlaybackCommand` | `ReadiumExternalPlaybackCommand` | System media-control commands such as headphones / Control Center play and pause. See [external_playback_command.dart](../../flutter_readium_platform_interface/lib/src/external_playback_command.dart) | | `onErrorEvent` | `ReadiumError` | Non-fatal errors. See [readium_exceptions.dart](../../flutter_readium_platform_interface/lib/src/exceptions/readium_exceptions.dart) | diff --git a/docs/api-reference/streams-events.md b/docs/api-reference/streams-events.md index 6c0883f4..3180922c 100644 --- a/docs/api-reference/streams-events.md +++ b/docs/api-reference/streams-events.md @@ -40,6 +40,28 @@ reader.onTimebasedPlayerStateChanged.listen((state) { When `state == TimebasedState.failure` during TTS, `state.ttsErrorType` is non-null. +## onExternalPlaybackCommand + +Emits a `ReadiumExternalPlaybackCommand` when playback controls are received from +system media controls, such as headphones, iOS Control Center, or the Android +media session / notification. + +```dart +reader.onExternalPlaybackCommand.listen((command) { + final action = command.action; // ExternalPlaybackCommandAction + final position = command.position; // Duration? +}); +``` + +This stream reports user/system control intent. Use +`onTimebasedPlayerStateChanged` for the resulting playback state and progress. +The possible actions are `play`, `pause`, `togglePlayPause`, `seekForward`, +`seekBackward`, `seekTo`, `next`, `previous`, and `unknown`. For `seekTo`, `position` contains +the requested position relative to the configured `controlPanelTimebase`. + +Android and iOS produce these events. The Web implementation exposes the stream +for API compatibility but does not currently emit events. + ## onReaderStatusChanged Emits `ReadiumReaderStatus` for reader lifecycle events. diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index c1884041..029fed1d 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -71,20 +71,22 @@ Navigation methods (`goForward`, `goBackward`, `goToLocator`, `goToProgression`) ## Event streams -The plugin communicates state changes through four streams: +The plugin communicates state changes and external control events through different streams: ```dart final reader = FlutterReadium(); reader.onTextLocatorChanged.listen((locator) { /* position updated */ }); reader.onTimebasedPlayerStateChanged.listen((state) { /* audio/TTS state */ }); +reader.onExternalPlaybackCommand.listen((command) { /* system media-control intent */ }); reader.onReaderStatusChanged.listen((status) { /* loading, ready, closed, reachedEndOfPublication, error */ }); reader.onErrorEvent.listen((error) { /* non-fatal errors */ }); ``` Always cancel subscriptions in `dispose()` to avoid leaks. -See guides for [Saving Progress](../guides/saving-progress.md) or [Error Handling](../guides/error-handling.md) for more details. +See guides for [Audiobook Playback](../guides/audiobook-playback.md), +[Saving Progress](../guides/saving-progress.md), or [Error Handling](../guides/error-handling.md) for more details. ## Decorations diff --git a/docs/guides/audiobook-playback.md b/docs/guides/audiobook-playback.md index 5606e351..0455fa0d 100644 --- a/docs/guides/audiobook-playback.md +++ b/docs/guides/audiobook-playback.md @@ -63,6 +63,35 @@ _sub = reader.onTimebasedPlayerStateChanged.listen((state) { }); ``` +## External playback commands + +On Android and iOS, observe commands received from system media controls such +as headphones, the media notification, or Control Center: + +```dart +_sub = reader.onExternalPlaybackCommand.listen((command) { + final position = command.position; + + switch (command.action) { + case ExternalPlaybackCommandAction.play: // record play intent + case ExternalPlaybackCommandAction.pause: // record pause intent + case ExternalPlaybackCommandAction.togglePlayPause: // record toggle intent + case ExternalPlaybackCommandAction.seekForward: // record forward seek intent + case ExternalPlaybackCommandAction.seekBackward: // record backward seek intent + case ExternalPlaybackCommandAction.seekTo: // requested position is in `position` + case ExternalPlaybackCommandAction.next: // record next intent + case ExternalPlaybackCommandAction.previous: // record previous intent + default: break; + } +}); +``` + +The native playback integration already performs the requested operation, so +do not call `play`, `pause`, or seek methods again from this listener. For a +`seekTo` command, `position` is relative to the configured +`controlPanelTimebase`. The Web implementation does not currently emit these +events. + ## Saving and restoring position ```dart diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index 97a18901..d0c982a2 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- `onExternalPlaybackCommand` stream for play/pause/seek commands received from + system media controls such as headphones, iOS Control Center, and Android + media-session controls. + ## [0.4.0] - 2026-08-17 ### Added diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PluginMediaService.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PluginMediaService.kt index 1e8117b3..b275df6f 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PluginMediaService.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PluginMediaService.kt @@ -34,6 +34,8 @@ import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import dk.nota.flutterreadium.events.ExternalPlaybackCommandAction +import dk.nota.flutterreadium.events.ReadiumExternalPlaybackCommand import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -144,11 +146,21 @@ class PluginMediaService : ): ListenableFuture { // Handle custom command buttons from player notification. if (customCommand.customAction == NotificationPlayerCustomCommandButton.REWIND.customAction) { + ReadiumReader.emitExternalPlaybackCommand( + ReadiumExternalPlaybackCommand( + action = ExternalPlaybackCommandAction.SeekBackward, + ), + ) CoroutineScope(Dispatchers.Main).async { ReadiumReader.previous() } } if (customCommand.customAction == NotificationPlayerCustomCommandButton.FORWARD.customAction) { + ReadiumReader.emitExternalPlaybackCommand( + ReadiumExternalPlaybackCommand( + action = ExternalPlaybackCommandAction.SeekForward, + ), + ) CoroutineScope(Dispatchers.Main).async { ReadiumReader.next() } @@ -211,7 +223,13 @@ class PluginMediaService : if (secs != null && secs.isFinite() && secs > 0) (secs * 1000L).toLong() else null }?.takeIf { it.size == (ReadiumReader.currentPublication?.readingOrder?.size ?: 0) } - val pluginForwardingPlayer = PluginSimpleBasePlayer(player, ReadiumReader.audioPreferences, publicationChapterDurationsMs) + val pluginForwardingPlayer = + PluginSimpleBasePlayer( + player, + ReadiumReader.audioPreferences, + publicationChapterDurationsMs, + ReadiumReader::emitExternalPlaybackCommand, + ) val mediaSession = MediaSession @@ -404,6 +422,7 @@ class PluginSimpleBasePlayer( val preferences: FlutterAudioPreferences, /** Chapter durations from the publication manifest (seconds → ms). Null = chapter mode or durations unavailable. */ private val manifestChapterDurationsMs: List? = null, + private val onExternalPlaybackCommand: ((ReadiumExternalPlaybackCommand) -> Unit)? = null, ) : ForwardingSimpleBasePlayer(player) { private data class PublicationSeekTarget( val mediaItemIndex: Int, @@ -556,6 +575,29 @@ class PluginSimpleBasePlayer( return null } + private fun emitExternalPlaybackCommand( + action: ExternalPlaybackCommandAction, + positionMs: Long? = null, + ) { + onExternalPlaybackCommand?.invoke( + ReadiumExternalPlaybackCommand( + action = action, + position = positionMs, + ), + ) + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + emitExternalPlaybackCommand( + if (playWhenReady) { + ExternalPlaybackCommandAction.Play + } else { + ExternalPlaybackCommandAction.Pause + }, + ) + return super.handleSetPlayWhenReady(playWhenReady) + } + override fun handleSeek( mediaItemIndex: Int, positionMs: Long, @@ -563,11 +605,19 @@ class PluginSimpleBasePlayer( ): ListenableFuture<*> { // NOTE: Maps seek to next/previous track, to seek forward/backward in current track. if (seekCommand == COMMAND_SEEK_TO_NEXT) { + emitExternalPlaybackCommand(ExternalPlaybackCommandAction.SeekForward) return super.handleSeek(mediaItemIndex, positionMs, COMMAND_SEEK_FORWARD) } else if (seekCommand == COMMAND_SEEK_TO_PREVIOUS) { + emitExternalPlaybackCommand(ExternalPlaybackCommandAction.SeekBackward) return super.handleSeek(mediaItemIndex, positionMs, COMMAND_SEEK_BACK) } + when (seekCommand) { + COMMAND_SEEK_FORWARD -> emitExternalPlaybackCommand(ExternalPlaybackCommandAction.SeekForward) + COMMAND_SEEK_BACK -> emitExternalPlaybackCommand(ExternalPlaybackCommandAction.SeekBackward) + else -> emitExternalPlaybackCommand(ExternalPlaybackCommandAction.SeekTo, positionMs) + } + if (usesWholeBookTimebase() && seekCommand != COMMAND_SEEK_FORWARD && seekCommand != COMMAND_SEEK_BACK diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt index 39565660..da8e9ebb 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt @@ -12,6 +12,8 @@ import androidx.savedstate.SavedStateRegistryOwner import dk.nota.flutterreadium.events.NarrationSyncEventChannel import dk.nota.flutterreadium.events.ReadiumError import dk.nota.flutterreadium.events.ReadiumErrorEventChannel +import dk.nota.flutterreadium.events.ReadiumExternalPlaybackCommand +import dk.nota.flutterreadium.events.ReadiumExternalPlaybackCommandEventChannel import dk.nota.flutterreadium.events.ReadiumReaderStatus import dk.nota.flutterreadium.events.ReadiumReaderStatusEventChannel import dk.nota.flutterreadium.events.TextLocatorEventChannel @@ -130,6 +132,8 @@ object ReadiumReader : private var timedBasedStateEventChannel: TimedBasedStateEventChannel? = null + private var externalPlaybackCommandEventChannel: ReadiumExternalPlaybackCommandEventChannel? = null + private var textLocatorEventChannel: TextLocatorEventChannel? = null private var readiumReaderStatusEventChannel: ReadiumReaderStatusEventChannel? = null @@ -286,6 +290,9 @@ object ReadiumReader : timedBasedStateEventChannel?.dispose() timedBasedStateEventChannel = TimedBasedStateEventChannel(messenger) + externalPlaybackCommandEventChannel?.dispose() + externalPlaybackCommandEventChannel = ReadiumExternalPlaybackCommandEventChannel(messenger) + textLocatorEventChannel?.dispose() textLocatorEventChannel = TextLocatorEventChannel(messenger) @@ -486,6 +493,9 @@ object ReadiumReader : timedBasedStateEventChannel?.dispose() timedBasedStateEventChannel = null + externalPlaybackCommandEventChannel?.dispose() + externalPlaybackCommandEventChannel = null + textLocatorEventChannel?.dispose() textLocatorEventChannel = null @@ -1808,6 +1818,13 @@ object ReadiumReader : errorChannel?.sendEvent(error) } + /** + * Emit an external playback command received from system media controls. + */ + fun emitExternalPlaybackCommand(command: ReadiumExternalPlaybackCommand) { + externalPlaybackCommandEventChannel?.sendEvent(command) + } + /** * Emit text locator to the flutter layer */ diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/events/ReadiumExternalPlaybackCommandEventChannel.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/events/ReadiumExternalPlaybackCommandEventChannel.kt new file mode 100644 index 00000000..4af6660b --- /dev/null +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/events/ReadiumExternalPlaybackCommandEventChannel.kt @@ -0,0 +1,47 @@ +package dk.nota.flutterreadium.events + +import dk.nota.flutterreadium.PluginLog +import io.flutter.plugin.common.BinaryMessenger +import kotlinx.coroutines.launch + +/** + * Event channel for playback commands received from system media controls. + */ +class ReadiumExternalPlaybackCommandEventChannel( + messenger: BinaryMessenger, +) : EventChannelWrapper(messenger, "dk.nota.flutter_readium/external-playback-command") { + override fun sendEvent(data: ReadiumExternalPlaybackCommand) { + launch { + PluginLog.d("ReadiumExternalPlaybackCommand", "::sendEvent $data") + eventSink?.success(data.toMap()) + } + } +} + +data class ReadiumExternalPlaybackCommand( + val action: ExternalPlaybackCommandAction, + /** + * Requested playback position, in milliseconds, relative to the configured + * control-panel timebase, for seek-to commands. + */ + val position: Long? = null, +) { + fun toMap(): Map = + buildMap { + put("action", action.wireValue) + position?.let { put("position", it) } + } +} + +enum class ExternalPlaybackCommandAction( + val wireValue: String, +) { + Play("play"), + Pause("pause"), + SeekForward("seekForward"), + SeekBackward("seekBackward"), + SeekTo("seekTo"), + Next("next"), + Previous("previous"), + Unknown("unknown"), +} diff --git a/flutter_readium/example/lib/state/player_controls_bloc.dart b/flutter_readium/example/lib/state/player_controls_bloc.dart index a7be38fb..dbe64868 100644 --- a/flutter_readium/example/lib/state/player_controls_bloc.dart +++ b/flutter_readium/example/lib/state/player_controls_bloc.dart @@ -472,6 +472,8 @@ class PlayerControlsBloc extends Bloc Stream get timebasedStateStream => instance.onTimebasedPlayerStateChanged; + Stream get externalPlaybackCommandStream => instance.onExternalPlaybackCommand; + /// Emits the current [Locator] for the active publication, regardless of media type. /// Backed by a [BehaviorSubject] so a single underlying subscription is reused and /// late subscribers receive the most recent value on subscribe. diff --git a/flutter_readium/example/lib/widgets/external_playback_command_status.widget.dart b/flutter_readium/example/lib/widgets/external_playback_command_status.widget.dart new file mode 100644 index 00000000..65507766 --- /dev/null +++ b/flutter_readium/example/lib/widgets/external_playback_command_status.widget.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_readium/flutter_readium.dart'; + +class ExternalPlaybackCommandStatus extends StatefulWidget { + const ExternalPlaybackCommandStatus({ + required this.commands, + super.key, + }); + + final Stream commands; + + @override + State createState() => _ExternalPlaybackCommandStatusState(); +} + +class _ExternalPlaybackCommandStatusState extends State { + late StreamSubscription _subscription; + Timer? _clearTimer; + ReadiumExternalPlaybackCommand? _command; + + @override + void initState() { + super.initState(); + _subscribe(); + } + + @override + void didUpdateWidget(covariant ExternalPlaybackCommandStatus oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.commands != widget.commands) { + unawaited(_subscription.cancel()); + _subscribe(); + } + } + + @override + void dispose() { + _clearTimer?.cancel(); + unawaited(_subscription.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('External playback command: '), + _command == null + ? Text('-') + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: _actionColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text(_label), + ), + ], + ); + } + + Color? get _actionColor { + return switch (_command?.action) { + ExternalPlaybackCommandAction.pause => Colors.yellow[600], + ExternalPlaybackCommandAction.play => Colors.lightGreen, + ExternalPlaybackCommandAction.togglePlayPause => Colors.orange, + + ExternalPlaybackCommandAction.previous => Colors.blue[300], + ExternalPlaybackCommandAction.next => Colors.blue[300], + ExternalPlaybackCommandAction.seekBackward => Colors.blue[300], + ExternalPlaybackCommandAction.seekForward => Colors.blue[300], + ExternalPlaybackCommandAction.seekTo => Colors.blue[300], + + ExternalPlaybackCommandAction.unknown => Colors.red[300], + _ => null, + }; + } + + String get _label { + final command = _command; + if (command == null) { + return ''; + } + + final position = command.position; + final positionLabel = position == null ? '' : ' (position: ${position.inMilliseconds} ms)'; + return '${command.action.name}$positionLabel'; + } + + void _subscribe() { + _subscription = widget.commands.listen(_showCommand); + } + + void _showCommand(ReadiumExternalPlaybackCommand command) { + if (!mounted) { + return; + } + _clearTimer?.cancel(); + setState(() => _command = command); + _clearTimer = Timer(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _command = null); + } + }); + } +} diff --git a/flutter_readium/example/lib/widgets/timebased.state.widget.dart b/flutter_readium/example/lib/widgets/timebased.state.widget.dart index 041a89e1..71d8cf5b 100644 --- a/flutter_readium/example/lib/widgets/timebased.state.widget.dart +++ b/flutter_readium/example/lib/widgets/timebased.state.widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../state/index.dart'; +import 'external_playback_command_status.widget.dart'; class TimebasedStateWidget extends StatefulWidget { const TimebasedStateWidget({super.key}); @@ -24,6 +25,9 @@ class _TimebasedStateWidgetState extends State { spacing: 6, children: [ Text('State: ${snapshot.data?.state.name.toUpperCase()}'), + ExternalPlaybackCommandStatus( + commands: context.read().externalPlaybackCommandStream, + ), Text( 'Offset: ${snapshot.data?.currentOffset?.inSeconds} of ${snapshot.data?.currentDuration?.inSeconds} seconds', ), diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift index 358171de..ef74bb25 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -40,6 +40,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin /// Timebased player events & state internal var timebasedPlayerStateStreamHandler: EventStreamHandler? internal var lastTimebasedPlayerState: ReadiumTimebasedState? = nil + internal var externalPlaybackCommandStreamHandler: EventStreamHandler? /// Timebased Navigator. Can be TTS, Audio or MediaOverlay implementations. internal var timebasedNavigator: FlutterTimebasedNavigator? = nil @@ -67,6 +68,7 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin let plugin = FlutterReadiumPlugin() registrar.addMethodCallDelegate(plugin, channel: channel) plugin.timebasedPlayerStateStreamHandler = EventStreamHandler(withName: "timebased-state", messenger: registrar.messenger()) + plugin.externalPlaybackCommandStreamHandler = EventStreamHandler(withName: "external-playback-command", messenger: registrar.messenger()) // text-locator and reader-status opt in to buffering: the EPUB platform view // can fire its first event before the Dart onListen handshake completes, so // the buffer ensures that event is never silently dropped. @@ -124,6 +126,8 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin await closePublication(ifGeneration: nil) timebasedPlayerStateStreamHandler?.dispose() timebasedPlayerStateStreamHandler = nil + externalPlaybackCommandStreamHandler?.dispose() + externalPlaybackCommandStreamHandler = nil textLocatorStreamHandler?.dispose() textLocatorStreamHandler = nil readerStatusStreamHandler?.dispose() @@ -758,6 +762,11 @@ extension FlutterReadiumPlugin { NowPlayingInfo.shared.clear() } + @MainActor + func emitExternalPlaybackCommand(_ command: ReadiumExternalPlaybackCommand) { + externalPlaybackCommandStreamHandler?.sendEvent(command.toMap()) + } + private func loadPublication ( fromUrlStr: String, ) async -> Result { diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumExternalPlaybackCommand.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumExternalPlaybackCommand.swift new file mode 100644 index 00000000..c444fa85 --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/model/ReadiumExternalPlaybackCommand.swift @@ -0,0 +1,38 @@ +import Foundation + +enum ExternalPlaybackCommandAction: String { + case play + case pause + case togglePlayPause + case seekForward + case seekBackward + case seekTo + case next + case previous + case unknown +} + +struct ReadiumExternalPlaybackCommand { + let action: ExternalPlaybackCommandAction + let position: TimeInterval? + + init( + action: ExternalPlaybackCommandAction, + position: TimeInterval? = nil + ) { + self.action = action + self.position = position + } + + func toMap() -> [String: Any] { + var map: [String: Any] = [ + "action": action.rawValue + ] + + if let position = position { + map["position"] = Int(position * 1000) + } + + return map + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift index 80ae165b..50afdf4d 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/navigator/NowPlayingInfoUpdater.swift @@ -191,20 +191,33 @@ public class NowPlayingInfoUpdater { } } + @MainActor + func emit(_ action: ExternalPlaybackCommandAction, position: TimeInterval? = nil) { + FlutterReadiumPlugin.instance?.emitExternalPlaybackCommand( + ReadiumExternalPlaybackCommand( + action: action, + position: position + ) + ) + } + on(rcc.playCommand) { navigator, _ in Task { @MainActor in + emit(.play) await navigator.resume() } } on(rcc.pauseCommand) { navigator, _ in Task { @MainActor in + emit(.pause) await navigator.pause() } } on(rcc.togglePlayPauseCommand) { navigator, _ in Task { @MainActor in + emit(.togglePlayPause) await navigator.togglePlayPause() } } @@ -212,6 +225,7 @@ public class NowPlayingInfoUpdater { if (skipTrackEnabled) { on(rcc.previousTrackCommand) { navigator, _ in Task { @MainActor in + emit(.previous) // TODO: Should these actually skip a full track? await navigator.seekBackward() } @@ -219,6 +233,7 @@ public class NowPlayingInfoUpdater { on(rcc.nextTrackCommand) { navigator, _ in Task { @MainActor in + emit(.next) // TODO: Should these actually skip a full track? await navigator.seekForward() } @@ -230,13 +245,15 @@ public class NowPlayingInfoUpdater { if (!preferredIntervals.isEmpty) { on(rcc.skipBackwardCommand) { navigator, _ in - Task { + Task { @MainActor in + emit(.seekBackward) await navigator.seekBackward() } } on(rcc.skipForwardCommand) { navigator, _ in - Task { + Task { @MainActor in + emit(.seekForward) await navigator.seekForward() } } @@ -247,12 +264,14 @@ public class NowPlayingInfoUpdater { guard let event = event as? MPChangePlaybackPositionCommandEvent else { return } - Task { + let position = event.positionTime + Task { @MainActor in + emit(.seekTo, position: position) if self.timebase == .wholeBook, let audioNavigator = navigator as? FlutterAudioNavigator { - await audioNavigator.seek(toPublicationOffset: event.positionTime) + await audioNavigator.seek(toPublicationOffset: position) } else { - await navigator.seek(toOffset: event.positionTime) + await navigator.seek(toOffset: position) } } } diff --git a/flutter_readium/lib/flutter_readium.dart b/flutter_readium/lib/flutter_readium.dart index c45ca783..fa6af692 100644 --- a/flutter_readium/lib/flutter_readium.dart +++ b/flutter_readium/lib/flutter_readium.dart @@ -95,6 +95,10 @@ class FlutterReadium { /// Stream emitting the current time-based playback state (including audio Locator) during playback. Stream get onTimebasedPlayerStateChanged => _platform.onTimebasedPlayerStateChanged; + /// Stream emitting playback commands received from headphones, lock screen, + /// Control Center, or Android media-session controls. + Stream get onExternalPlaybackCommand => _platform.onExternalPlaybackCommand; + /// Stream emitting any errors that occur within the reader, such as failed navigation or playback errors. Stream get onErrorEvent => _platform.onErrorEvent; diff --git a/flutter_readium/lib/src/flutter_readium_web.dart b/flutter_readium/lib/src/flutter_readium_web.dart index 5f9179a3..530bfe1f 100644 --- a/flutter_readium/lib/src/flutter_readium_web.dart +++ b/flutter_readium/lib/src/flutter_readium_web.dart @@ -51,6 +51,8 @@ class FlutterReadiumWebPlugin extends FlutterReadiumPlatform { static final StreamController _locatorTextController = StreamController.broadcast(); static final StreamController _timebasedStateController = StreamController.broadcast(); + static final StreamController _externalPlaybackCommandController = + StreamController.broadcast(); static final StreamController _readerStatusController = StreamController.broadcast(); static final StreamController _errorEventController = StreamController.broadcast(); @@ -85,6 +87,9 @@ class FlutterReadiumWebPlugin extends FlutterReadiumPlatform { @override Stream get onTimebasedPlayerStateChanged => _timebasedStateController.stream; + @override + Stream get onExternalPlaybackCommand => _externalPlaybackCommandController.stream; + @override Stream get onReaderStatusChanged => _readerStatusController.stream; diff --git a/flutter_readium/test/flutter_readium_test.dart b/flutter_readium/test/flutter_readium_test.dart index c7a5d620..f7f91438 100644 --- a/flutter_readium/test/flutter_readium_test.dart +++ b/flutter_readium/test/flutter_readium_test.dart @@ -20,6 +20,7 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut final _textLocatorController = StreamController.broadcast(); final _statusController = StreamController.broadcast(); final _timebasedController = StreamController.broadcast(); + final _externalPlaybackCommandController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); final _narrationSyncController = StreamController.broadcast(); @@ -43,6 +44,9 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut @override Stream get onTimebasedPlayerStateChanged => _timebasedController.stream; + @override + Stream get onExternalPlaybackCommand => _externalPlaybackCommandController.stream; + @override Stream get onErrorEvent => _errorController.stream; @@ -169,6 +173,8 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut void emitLocator(Locator l) => _textLocatorController.add(l); void emitStatus(ReadiumReaderStatus s) => _statusController.add(s); + void emitExternalPlaybackCommand(ReadiumExternalPlaybackCommand command) => + _externalPlaybackCommandController.add(command); void emitError(ReadiumError e) => _errorController.add(e); } @@ -331,6 +337,19 @@ void main() { }); }); + group('onExternalPlaybackCommand stream', () { + test('emits commands from the platform', () async { + const command = ReadiumExternalPlaybackCommand( + action: ExternalPlaybackCommandAction.seekForward, + ); + final future = reader.onExternalPlaybackCommand.first; + + platform.emitExternalPlaybackCommand(command); + + expect(await future, same(command)); + }); + }); + group('onErrorEvent stream', () { test('emits errors from the platform', () async { final error = ReadiumError('something went wrong', code: 'ERR_42'); diff --git a/flutter_readium_platform_interface/CHANGELOG.md b/flutter_readium_platform_interface/CHANGELOG.md index 9d97ed59..aac16aa6 100644 --- a/flutter_readium_platform_interface/CHANGELOG.md +++ b/flutter_readium_platform_interface/CHANGELOG.md @@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- `ReadiumExternalPlaybackCommand` and `onExternalPlaybackCommand` in the shared + platform interface for distinguishing system media-control commands from + ordinary playback state changes. + ## [0.4.0] - 2026-08-17 ### Added diff --git a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart index 9e04f0ba..dc8c771f 100644 --- a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart +++ b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart @@ -232,6 +232,13 @@ abstract class FlutterReadiumPlatform extends PlatformInterface { ); } + /// Stream emitting playback commands received from system media controls. + Stream get onExternalPlaybackCommand { + throw UnimplementedError( + 'onExternalPlaybackCommand stream has not been implemented.', + ); + } + /// State stream for error events occurring in the reader or playback. Stream get onErrorEvent { throw UnimplementedError('onErrorEvent stream has not been implemented.'); diff --git a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart index 20552269..0a9174bb 100644 --- a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart +++ b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart @@ -22,6 +22,9 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { @visibleForTesting EventChannel timebasedStateChannel = const EventChannel('dk.nota.flutter_readium/timebased-state'); + @visibleForTesting + EventChannel externalPlaybackCommandChannel = const EventChannel('dk.nota.flutter_readium/external-playback-command'); + @visibleForTesting EventChannel errorEventChannel = const EventChannel('dk.nota.flutter_readium/error'); @@ -34,6 +37,7 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { Stream? _onTextLocatorChanged; Stream? _onTimebasedPlayerStateChanged; + Stream? _onExternalPlaybackCommand; Stream? _onReaderStatusChanged; Stream? _onErrorEvent; Stream? _onNarrationSyncChanged; @@ -73,6 +77,15 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { return _onTimebasedPlayerStateChanged!; } + @override + Stream get onExternalPlaybackCommand { + _onExternalPlaybackCommand ??= externalPlaybackCommandChannel.receiveBroadcastStream().map((dynamic event) { + final map = Map.from(event as Map); + return ReadiumExternalPlaybackCommand.fromJson(map); + }).asBroadcastStream(); + return _onExternalPlaybackCommand!; + } + /// Fires whenever the reader status changes. /// /// Like [onTextLocatorChanged], the underlying event channel opts in to diff --git a/flutter_readium_platform_interface/lib/src/external_playback_command.dart b/flutter_readium_platform_interface/lib/src/external_playback_command.dart new file mode 100644 index 00000000..a0cfbdf3 --- /dev/null +++ b/flutter_readium_platform_interface/lib/src/external_playback_command.dart @@ -0,0 +1,60 @@ +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'utils/jsonable.dart'; + +/// A playback command received from system media controls, such as headphones, +/// the iOS Control Center, or the Android media session / notification. +@immutable +class ReadiumExternalPlaybackCommand implements JSONable { + const ReadiumExternalPlaybackCommand({ + required this.action, + this.position, + }); + + factory ReadiumExternalPlaybackCommand.fromJson(final Map map) { + final jsonObject = Map.of(map); + final position = jsonObject.optNullableInt('position', remove: true); + + return ReadiumExternalPlaybackCommand( + action: ExternalPlaybackCommandAction.fromString( + jsonObject.optString('action', remove: true), + ), + position: position != null ? Duration(milliseconds: position) : null, + ); + } + + /// The requested playback action. + final ExternalPlaybackCommandAction action; + + /// Requested playback position for seek-to commands, relative to the + /// configured control-panel timebase. + final Duration? position; + + @override + Map toJson() => {} + ..put('action', action.name) + ..putOpt('position', position?.inMilliseconds); + + @override + String toString() => 'ReadiumExternalPlaybackCommand(action=$action, position=$position)'; +} + +/// Playback commands emitted by [FlutterReadiumPlatform.onExternalPlaybackCommand]. +enum ExternalPlaybackCommandAction { + play, + pause, + togglePlayPause, + seekForward, + seekBackward, + seekTo, + next, + previous, + unknown; + + static ExternalPlaybackCommandAction fromString(final String action) => + ExternalPlaybackCommandAction.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == action.toLowerCase(), + ) ?? + ExternalPlaybackCommandAction.unknown; +} diff --git a/flutter_readium_platform_interface/lib/src/index.dart b/flutter_readium_platform_interface/lib/src/index.dart index efc02346..3f652bb0 100644 --- a/flutter_readium_platform_interface/lib/src/index.dart +++ b/flutter_readium_platform_interface/lib/src/index.dart @@ -1,6 +1,7 @@ export 'enums.dart'; export 'exceptions/index.dart'; export 'extensions/index.dart'; +export 'external_playback_command.dart'; export 'reader/index.dart'; export 'shared/index.dart'; export 'timebased_state.dart'; diff --git a/flutter_readium_platform_interface/test/flutter_readium_platform_interface_test.dart b/flutter_readium_platform_interface/test/flutter_readium_platform_interface_test.dart index c40a8636..d57a43f4 100644 --- a/flutter_readium_platform_interface/test/flutter_readium_platform_interface_test.dart +++ b/flutter_readium_platform_interface/test/flutter_readium_platform_interface_test.dart @@ -17,6 +17,10 @@ void main() { locations: Locations(cssSelector: '#loc1'), text: LocatorText(before: 'a', highlight: 'b', after: 'c'), ); + const testExternalPlaybackCommand = ReadiumExternalPlaybackCommand( + action: ExternalPlaybackCommandAction.seekTo, + position: Duration(seconds: 42), + ); setUp(() async { methodChannelReadium = MethodChannelFlutterReadium(); @@ -61,6 +65,27 @@ void main() { return null; }, ); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(methodChannelReadium.externalPlaybackCommandChannel.name), + (methodCall) async { + switch (methodCall.method) { + case 'listen': + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + methodChannelReadium.externalPlaybackCommandChannel.name, + methodChannelReadium.externalPlaybackCommandChannel.codec.encodeSuccessEnvelope( + testExternalPlaybackCommand.toJson(), + ), + (_) {}, + ); + break; + case 'cancel': + default: + return null; + } + return null; + }, + ); }); test( @@ -88,6 +113,23 @@ void main() { ); }); + test( + 'onExternalPlaybackCommand emits the command sent from the platform', + () async { + final result = await methodChannelReadium.onExternalPlaybackCommand.first; + expect(result.action, testExternalPlaybackCommand.action); + expect(result.position, testExternalPlaybackCommand.position); + }, + ); + + test('onExternalPlaybackCommand keeps its subscription across publication closes', () async { + final stream = methodChannelReadium.onExternalPlaybackCommand; + + await methodChannelReadium.closePublication(); + + expect(methodChannelReadium.onExternalPlaybackCommand, same(stream)); + }); + test('setNarrationSyncEnabled invokes the channel with the bool argument', () async { await methodChannelReadium.setNarrationSyncEnabled(true); await methodChannelReadium.setNarrationSyncEnabled(false); diff --git a/flutter_readium_platform_interface/test/models_test.dart b/flutter_readium_platform_interface/test/models_test.dart index 470835de..178dc747 100644 --- a/flutter_readium_platform_interface/test/models_test.dart +++ b/flutter_readium_platform_interface/test/models_test.dart @@ -300,6 +300,40 @@ void main() { }); }); + // --------------------------------------------------------------------------- + // ExternalPlaybackCommandAction enum + // --------------------------------------------------------------------------- + group('ReadiumExternalPlaybackCommand', () { + test('parses every action case-insensitively', () { + for (final action in ExternalPlaybackCommandAction.values) { + expect( + ExternalPlaybackCommandAction.fromString(action.name.toUpperCase()), + action, + ); + } + }); + + test('round-trips through toJson / fromJson', () { + const command = ReadiumExternalPlaybackCommand( + action: ExternalPlaybackCommandAction.seekTo, + position: Duration(seconds: 42), + ); + + final restored = ReadiumExternalPlaybackCommand.fromJson(command.toJson()); + + expect(restored.action, ExternalPlaybackCommandAction.seekTo); + expect(restored.position, const Duration(seconds: 42)); + }); + + test('unknown action falls back to unknown', () { + final command = ReadiumExternalPlaybackCommand.fromJson({ + 'action': 'definitelyNotACommand', + }); + + expect(command.action, ExternalPlaybackCommandAction.unknown); + }); + }); + // --------------------------------------------------------------------------- // ReadiumReaderStatus enum // ---------------------------------------------------------------------------