Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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