Skip to content
Open
1 change: 1 addition & 0 deletions docs/api-reference/flutter-readium.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
22 changes: 22 additions & 0 deletions docs/api-reference/streams-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/getting-started/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions docs/guides/audiobook-playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -144,11 +146,21 @@ class PluginMediaService :
): ListenableFuture<SessionResult> {
// 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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Long>? = null,
private val onExternalPlaybackCommand: ((ReadiumExternalPlaybackCommand) -> Unit)? = null,
) : ForwardingSimpleBasePlayer(player) {
private data class PublicationSeekTarget(
val mediaItemIndex: Int,
Expand Down Expand Up @@ -556,18 +575,49 @@ 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,
seekCommand: Int,
): 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -286,6 +290,9 @@ object ReadiumReader :
timedBasedStateEventChannel?.dispose()
timedBasedStateEventChannel = TimedBasedStateEventChannel(messenger)

externalPlaybackCommandEventChannel?.dispose()
externalPlaybackCommandEventChannel = ReadiumExternalPlaybackCommandEventChannel(messenger)

textLocatorEventChannel?.dispose()
textLocatorEventChannel = TextLocatorEventChannel(messenger)

Expand Down Expand Up @@ -486,6 +493,9 @@ object ReadiumReader :
timedBasedStateEventChannel?.dispose()
timedBasedStateEventChannel = null

externalPlaybackCommandEventChannel?.dispose()
externalPlaybackCommandEventChannel = null

textLocatorEventChannel?.dispose()
textLocatorEventChannel = null

Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReadiumExternalPlaybackCommand>(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<String, Any> =
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"),
}
2 changes: 2 additions & 0 deletions flutter_readium/example/lib/state/player_controls_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,8 @@ class PlayerControlsBloc extends Bloc<PlayerControlsEvent, PlayerControlsState>

Stream<ReadiumTimebasedState> get timebasedStateStream => instance.onTimebasedPlayerStateChanged;

Stream<ReadiumExternalPlaybackCommand> 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.
Expand Down
Loading
Loading