diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 00225368..1b47da2d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,12 @@
tools:node="remove"
tools:ignore="ScopedStorage" />
+
+
+
+
+
+
+
+
+
+
+
+
+
= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
+ mediaSession = MediaSession.Builder(this, exoPlayer)
+ .setSessionActivity(sessionActivity)
+ .build()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent?.getParcelableExtra(EXTRA_BOOK)
+ }
+
+ val notification = buildNotification(book)
+ startForeground(NOTIFICATION_ID, notification)
+
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+ return mediaSession
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ val player = mediaSession?.player ?: exoPlayer
+ if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
+ stopSelf()
+ }
+ }
+
+ override fun onDestroy() {
+ mediaSession?.run {
+ player.release()
+ release()
+ }
+ mediaSession = null
+ super.onDestroy()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.audio_playback_channel),
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.audio_playback_channel_description)
+ }
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun buildNotification(book: Book?): Notification {
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(book?.title ?: getString(R.string.app_name))
+ .setContentText(book?.author?.getAsString() ?: "")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(true)
+ .setSilent(true)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .build()
+ }
+
+ companion object {
+ const val EXTRA_BOOK = "extra_book"
+ private const val CHANNEL_ID = "audio_playback"
+ private const val NOTIFICATION_ID = 1
+ }
+}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt
new file mode 100644
index 00000000..3126304a
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt
@@ -0,0 +1,9 @@
+package org.dueattendant149.bookshelf.data.playback.di
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PlaybackAuthProvider @Inject constructor() {
+ var token: String = ""
+}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt
new file mode 100644
index 00000000..28bc44d0
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt
@@ -0,0 +1,62 @@
+package org.dueattendant149.bookshelf.data.playback.di
+
+import android.content.Context
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object PlaybackModule {
+
+ @Provides
+ @Singleton
+ fun providePlaybackAuthProvider(): PlaybackAuthProvider = PlaybackAuthProvider()
+
+ @OptIn(UnstableApi::class)
+ @Provides
+ @Singleton
+ fun provideExoPlayer(
+ @ApplicationContext context: Context,
+ authProvider: PlaybackAuthProvider,
+ okHttpClient: OkHttpClient,
+ ): ExoPlayer {
+ val client = okHttpClient.newBuilder()
+ .addInterceptor { chain ->
+ val request = chain.request()
+ val token = authProvider.token
+ if (token.isNotBlank()) {
+ chain.proceed(
+ request.newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ )
+ } else {
+ chain.proceed(request)
+ }
+ }
+ .build()
+
+ val dataSourceFactory = OkHttpDataSource.Factory(client)
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(C.USAGE_MEDIA)
+ .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
+ .build()
+
+ return ExoPlayer.Builder(context)
+ .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
+ .setAudioAttributes(audioAttributes, true)
+ .setHandleAudioBecomingNoisy(true)
+ .build()
+ }
+}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt
index 1dbe391f..6dcff1cd 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt
@@ -1,6 +1,7 @@
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
import okhttp3.ResponseBody
+import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
@@ -34,4 +35,9 @@ interface AudiobookshelfApiService {
suspend fun getProgress(
@Path("itemId") itemId: String,
): Response
+
+ @GET("api/items/{itemId}")
+ suspend fun getItem(
+ @Path("itemId") itemId: String,
+ ): Response
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt
new file mode 100644
index 00000000..99e92b6d
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt
@@ -0,0 +1,35 @@
+package org.dueattendant149.bookshelf.data.remote.audiobookshelf.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class AbsItemResponse(
+ val id: String = "",
+ val media: AbsMedia? = null,
+)
+
+@Serializable
+data class AbsMedia(
+ val metadata: AbsMediaMetadata? = null,
+ @SerialName("audioFiles")
+ val audioFiles: List = emptyList(),
+)
+
+@Serializable
+data class AbsMediaMetadata(
+ val title: String? = null,
+)
+
+@Serializable
+data class AbsAudioFile(
+ val ino: String = "",
+ val metadata: AbsAudioFileMetadata? = null,
+ val duration: Double = 0.0,
+)
+
+@Serializable
+data class AbsAudioFileMetadata(
+ val filename: String? = null,
+ val ext: String? = null,
+)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt
new file mode 100644
index 00000000..740c26b7
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt
@@ -0,0 +1,58 @@
+/*
+ * Book's Story — free and open-source Material You eBook reader.
+ * Copyright (C) 2024-2026 Acclorite
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package org.dueattendant149.bookshelf.data.repository
+
+import android.util.Log
+import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
+import org.dueattendant149.bookshelf.data.settings.ServerSettings
+import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
+import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AudiobookshelfRepositoryImpl
+ @Inject
+ constructor(
+ private val clientFactory: AudiobookshelfApiClientFactory,
+ private val serverSettings: ServerSettings,
+ ) : AudiobookshelfRepository {
+ override suspend fun getAudioTracks(itemId: String): Result> {
+ return runCatching {
+ val url = serverSettings.getAbsUrl()
+ val token = serverSettings.getAbsToken()
+ if (url.isNullOrBlank() || token.isNullOrBlank()) {
+ return Result.failure(IllegalStateException("Audiobookshelf not configured"))
+ }
+
+ val client = clientFactory.provideClient(url, token)
+ ?: return Result.failure(IllegalStateException("Failed to create ABS client"))
+
+ val response = client.getItem(itemId)
+ if (!response.isSuccessful) {
+ return Result.failure(RuntimeException("Failed to fetch item: ${response.code()}"))
+ }
+
+ val item = response.body()
+ ?: return Result.failure(RuntimeException("Empty item response"))
+
+ item.media?.audioFiles.orEmpty()
+ .mapIndexed { index, file ->
+ AudioTrack(
+ fileId = file.ino,
+ title = file.metadata?.filename?.takeIf { it.isNotBlank() }
+ ?: "Track ${index + 1}",
+ durationMs = (file.duration * 1000).toLong(),
+ order = index,
+ )
+ }
+ .sortedBy { it.order }
+ }.onFailure {
+ Log.e("AudiobookshelfRepo", "getAudioTracks failed", it)
+ }
+ }
+ }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt
new file mode 100644
index 00000000..1f7b57e6
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt
@@ -0,0 +1,8 @@
+package org.dueattendant149.bookshelf.domain.model.player
+
+data class AudioTrack(
+ val fileId: String,
+ val title: String,
+ val durationMs: Long,
+ val order: Int,
+)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt
new file mode 100644
index 00000000..8e48d6e7
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt
@@ -0,0 +1,7 @@
+package org.dueattendant149.bookshelf.domain.repository
+
+import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
+
+interface AudiobookshelfRepository {
+ suspend fun getAudioTracks(itemId: String): Result>
+}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt
index c5aedec1..e3e6a22d 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen
+import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@@ -37,6 +38,11 @@ object RemoteLibraryScreen : Screen, Parcelable {
}
}
- RemoteLibraryContent(model = model)
+ RemoteLibraryContent(
+ model = model,
+ onPlayAudio = { book ->
+ navigator.push(PlayerScreen(book))
+ }
+ )
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt
new file mode 100644
index 00000000..8a280a79
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt
@@ -0,0 +1,226 @@
+/*
+ * Book's Story — free and open-source Material You eBook reader.
+ * Copyright (C) 2024-2026 Acclorite
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package org.dueattendant149.bookshelf.presentation.player
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
+import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
+import org.dueattendant149.bookshelf.data.settings.ServerSettings
+import org.dueattendant149.bookshelf.domain.model.library.Book
+import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
+import org.dueattendant149.bookshelf.domain.util.fixUriScheme
+import javax.inject.Inject
+
+@HiltViewModel
+class PlayerModel
+ @OptIn(UnstableApi::class)
+ @Inject
+ constructor(
+ @ApplicationContext private val context: Context,
+ private val exoPlayer: ExoPlayer,
+ private val serverSettings: ServerSettings,
+ private val audiobookshelfRepository: AudiobookshelfRepository,
+ private val authProvider: PlaybackAuthProvider,
+ ) : ViewModel() {
+ private val _state = MutableStateFlow(PlayerState())
+ val state = _state.asStateFlow()
+
+ private val listener =
+ object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ _state.update { it.copy(isPlaying = isPlaying) }
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ _state.update {
+ it.copy(
+ isLoading = playbackState == Player.STATE_BUFFERING,
+ durationMs = exoPlayer.duration.coerceAtLeast(0L),
+ )
+ }
+ }
+
+ override fun onPositionDiscontinuity(
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int,
+ ) {
+ _state.update {
+ it.copy(
+ currentTrackIndex = exoPlayer.currentMediaItemIndex,
+ currentPositionMs = newPosition.positionMs.coerceAtLeast(0L),
+ durationMs = exoPlayer.duration.coerceAtLeast(0L),
+ )
+ }
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ _state.update {
+ it.copy(
+ currentTrackIndex = exoPlayer.currentMediaItemIndex,
+ durationMs = exoPlayer.duration.coerceAtLeast(0L),
+ currentPositionMs = 0L,
+ )
+ }
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ _state.update { it.copy(error = error.localizedMessage, isLoading = false) }
+ }
+ }
+
+ init {
+ exoPlayer.addListener(listener)
+ startProgressUpdates()
+ }
+
+ fun init(book: Book) {
+ if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank()) return
+ _state.update { it.copy(book = book, isLoading = true, error = null) }
+
+ viewModelScope.launch {
+ val url = serverSettings.getAbsUrl()?.fixUriScheme()
+ val token = serverSettings.getAbsToken()
+ if (url.isNullOrBlank() || token.isNullOrBlank()) {
+ _state.update { it.copy(isLoading = false, error = "Audiobookshelf not configured") }
+ return@launch
+ }
+
+ authProvider.token = token
+
+ val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
+ tracksResult.fold(
+ onSuccess = { tracks ->
+ if (tracks.isEmpty()) {
+ _state.update { it.copy(isLoading = false, error = "No audio files found") }
+ return@fold
+ }
+ _state.update { it.copy(tracks = tracks) }
+ loadTracks(url, tracks)
+ startService(book)
+ },
+ onFailure = { error ->
+ _state.update { it.copy(isLoading = false, error = error.message) }
+ }
+ )
+ }
+ }
+
+ private fun loadTracks(baseUrl: String, tracks: List) {
+ val mediaItems = tracks.map { track ->
+ val uri = "${baseUrl}api/items/${_state.value.book.remoteId}/file/${track.fileId}"
+ MediaItem.Builder()
+ .setMediaId(track.fileId)
+ .setUri(uri)
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(track.title)
+ .setAlbumTitle(_state.value.book.title)
+ .setArtist(_state.value.book.author.getAsString())
+ .build()
+ )
+ .build()
+ }
+
+ exoPlayer.setMediaItems(mediaItems)
+ exoPlayer.prepare()
+ exoPlayer.play()
+ }
+
+ private fun startProgressUpdates() {
+ viewModelScope.launch {
+ while (isActive) {
+ if (exoPlayer.isPlaying || exoPlayer.isLoading) {
+ _state.update {
+ it.copy(
+ currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L),
+ bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L),
+ durationMs = exoPlayer.duration.coerceAtLeast(0L),
+ )
+ }
+ }
+ delay(500)
+ }
+ }
+ }
+
+ private fun startService(book: Book) {
+ val intent = Intent(context, AudioPlaybackService::class.java).apply {
+ putExtra(AudioPlaybackService.EXTRA_BOOK, book)
+ }
+ context.startForegroundService(intent)
+ }
+
+ fun playPause() {
+ if (exoPlayer.isPlaying) {
+ exoPlayer.pause()
+ } else {
+ exoPlayer.play()
+ }
+ }
+
+ fun seekTo(positionMs: Long) {
+ exoPlayer.seekTo(positionMs.coerceIn(0L, exoPlayer.duration.coerceAtLeast(0L)))
+ }
+
+ fun seekForward(ms: Long = 30_000L) {
+ seekTo(exoPlayer.currentPosition + ms)
+ }
+
+ fun seekBackward(ms: Long = 10_000L) {
+ seekTo(exoPlayer.currentPosition - ms)
+ }
+
+ fun skipToNext() {
+ if (exoPlayer.hasNextMediaItem()) {
+ exoPlayer.seekToNextMediaItem()
+ }
+ }
+
+ fun skipToPrevious() {
+ if (exoPlayer.hasPreviousMediaItem()) {
+ exoPlayer.seekToPreviousMediaItem()
+ }
+ }
+
+ fun setSpeed(speed: Float) {
+ val clamped = speed.coerceIn(0.5f, 2.0f)
+ exoPlayer.setPlaybackSpeed(clamped)
+ _state.update { it.copy(playbackSpeed = clamped) }
+ }
+
+ fun selectTrack(index: Int) {
+ if (index in 0 until exoPlayer.mediaItemCount) {
+ exoPlayer.seekToDefaultPosition(index)
+ }
+ }
+
+ override fun onCleared() {
+ exoPlayer.removeListener(listener)
+ exoPlayer.stop()
+ exoPlayer.clearMediaItems()
+ context.stopService(Intent(context, AudioPlaybackService::class.java))
+ super.onCleared()
+ }
+ }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt
new file mode 100644
index 00000000..ea1c6d9b
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt
@@ -0,0 +1,48 @@
+/*
+ * Book's Story — free and open-source Material You eBook reader.
+ * Copyright (C) 2024-2026 Acclorite
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package org.dueattendant149.bookshelf.presentation.player
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.hilt.navigation.compose.hiltViewModel
+import kotlinx.parcelize.Parcelize
+import org.dueattendant149.bookshelf.domain.model.library.Book
+import org.dueattendant149.bookshelf.presentation.navigator.Screen
+import org.dueattendant149.bookshelf.ui.player.PlayerContent
+
+@Parcelize
+data class PlayerScreen(val book: Book) : Screen, Parcelable {
+
+ @Composable
+ override fun Content() {
+ val model = hiltViewModel()
+
+ LaunchedEffect(book) {
+ model.init(book)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ // ExoPlayer lifecycle is tied to the ViewModel; no extra cleanup needed here.
+ }
+ }
+
+ PlayerContent(
+ state = model.state,
+ onPlayPause = model::playPause,
+ onSeek = model::seekTo,
+ onSeekForward = model::seekForward,
+ onSeekBackward = model::seekBackward,
+ onSkipNext = model::skipToNext,
+ onSkipPrevious = model::skipToPrevious,
+ onSetSpeed = model::setSpeed,
+ onSelectTrack = model::selectTrack,
+ )
+ }
+}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt
new file mode 100644
index 00000000..7cfc472d
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt
@@ -0,0 +1,17 @@
+package org.dueattendant149.bookshelf.presentation.player
+
+import org.dueattendant149.bookshelf.domain.model.library.Book
+import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
+
+data class PlayerState(
+ val book: Book = Book.default,
+ val tracks: List = emptyList(),
+ val currentTrackIndex: Int = 0,
+ val isPlaying: Boolean = false,
+ val isLoading: Boolean = false,
+ val currentPositionMs: Long = 0L,
+ val durationMs: Long = 0L,
+ val bufferedPositionMs: Long = 0L,
+ val playbackSpeed: Float = 1f,
+ val error: String? = null,
+)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt
index 69764fac..cf35411f 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@@ -58,7 +59,10 @@ import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlace
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
-fun RemoteLibraryContent(model: RemoteLibraryModel) {
+fun RemoteLibraryContent(
+ model: RemoteLibraryModel,
+ onPlayAudio: (Book) -> Unit,
+) {
val state by model.state.collectAsStateWithLifecycle()
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
@@ -177,7 +181,8 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
isCaching = isCaching,
onDownload = { model.cacheBook(book) },
onDelete = { model.deleteCache(book) },
- onRead = if (book.hasEbook) ({ model.openBook(book) }) else null
+ onRead = if (book.hasEbook) ({ model.openBook(book) }) else null,
+ onPlayAudio = if (book.hasAudio) ({ onPlayAudio(book) }) else null
)
}
@@ -234,6 +239,7 @@ private fun BookListItem(
onDownload: () -> Unit,
onDelete: () -> Unit,
onRead: (() -> Unit)?,
+ onPlayAudio: (() -> Unit)?,
) {
val cacheState = cacheStatus?.state ?: CacheState.NONE
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
@@ -267,6 +273,14 @@ private fun BookListItem(
},
trailingContent = {
Row {
+ if (book.hasAudio) {
+ IconButton(onClick = onPlayAudio ?: {}) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = stringResource(R.string.bookshelf_play_audio_action)
+ )
+ }
+ }
if (showDownload) {
IconButton(onClick = onDownload) {
Icon(
@@ -296,19 +310,26 @@ private fun BookListItem(
)
}
- onRead?.let { read ->
+ if (onRead != null || onPlayAudio != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
- horizontalArrangement = Arrangement.End
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
- Button(
- onClick = read,
- enabled = !isCaching
- ) {
- Text(stringResource(R.string.read))
+ onRead?.let { read ->
+ Button(
+ onClick = read,
+ enabled = !isCaching
+ ) {
+ Text(stringResource(R.string.read))
+ }
+ }
+ onPlayAudio?.let { play ->
+ Button(onClick = play) {
+ Text(stringResource(R.string.bookshelf_play_audio_action))
+ }
}
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt
new file mode 100644
index 00000000..4ed1a8c1
--- /dev/null
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt
@@ -0,0 +1,226 @@
+/*
+ * Book's Story — free and open-source Material You eBook reader.
+ * Copyright (C) 2024-2026 Acclorite
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package org.dueattendant149.bookshelf.ui.player
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.flow.StateFlow
+import org.dueattendant149.bookshelf.presentation.player.PlayerState
+import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PlayerContent(
+ state: StateFlow,
+ onPlayPause: () -> Unit,
+ onSeek: (Long) -> Unit,
+ onSeekForward: () -> Unit,
+ onSeekBackward: () -> Unit,
+ onSkipNext: () -> Unit,
+ onSkipPrevious: () -> Unit,
+ onSetSpeed: (Float) -> Unit,
+ onSelectTrack: (Int) -> Unit,
+) {
+ val playerState by state.collectAsStateWithLifecycle()
+ val navigator = LocalNavigator.current
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(playerState.book.title) },
+ navigationIcon = {
+ IconButton(onClick = { navigator.pop() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = playerState.book.author.getAsString() ?: "",
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ val currentTrack = playerState.tracks.getOrNull(playerState.currentTrackIndex)
+ Text(
+ text = currentTrack?.title ?: "No track",
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+
+ if (playerState.isLoading) {
+ CircularProgressIndicator()
+ }
+
+ playerState.error?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val duration = playerState.durationMs.coerceAtLeast(1L)
+ var sliderPosition by remember(playerState.currentPositionMs) {
+ mutableFloatStateOf(playerState.currentPositionMs / duration.toFloat())
+ }
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ onSeek((sliderPosition * duration).roundToInt().toLong())
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(formatMs(playerState.currentPositionMs))
+ Text(formatMs(playerState.durationMs))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onSeekBackward) {
+ Text("-10s")
+ }
+ IconButton(onClick = onSkipPrevious) {
+ Icon(Icons.Default.SkipPrevious, contentDescription = "Previous")
+ }
+ IconButton(
+ onClick = onPlayPause,
+ modifier = Modifier.size(64.dp)
+ ) {
+ Icon(
+ imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (playerState.isPlaying) "Pause" else "Play",
+ modifier = Modifier.size(48.dp)
+ )
+ }
+ IconButton(onClick = onSkipNext) {
+ Icon(Icons.Default.SkipNext, contentDescription = "Next")
+ }
+ IconButton(onClick = onSeekForward) {
+ Text("+30s")
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ listOf(0.5f, 1f, 1.25f, 1.5f, 2f).forEach { speed ->
+ TextButton(
+ onClick = { onSetSpeed(speed) },
+ ) {
+ Text(
+ text = "${speed}x",
+ color = if (playerState.playbackSpeed == speed) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ }
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ itemsIndexed(playerState.tracks) { index, track ->
+ val selected = index == playerState.currentTrackIndex
+ Card(
+ onClick = { onSelectTrack(index) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = "${index + 1}. ${track.title}",
+ color = if (selected) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun formatMs(ms: Long): String {
+ val totalSeconds = ms / 1000
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+ return if (hours > 0) {
+ String.format("%d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ String.format("%02d:%02d", minutes, seconds)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 00000000..fdf1b759
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 52c2b28e..6a8865bd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -9,6 +9,10 @@
Book\'s Story
Default
+
+ Audio playback
+ Shows the currently playing audiobook
+
Library
History
@@ -533,6 +537,7 @@
Failed
Download
Delete
+ Play
Arrow
Reset start guide
Open in web