feat(audio): implement audiobook player with ExoPlayer and MediaSession

Add a full audiobook player for Audiobookshelf books:
- PlayerScreen/PlayerModel/PlayerContent with playback controls, progress, speed, skip
- AudioPlaybackService as MediaSessionService for background playback + notification
- ABS item lookup and per-file audio URLs using ServerSettings URL/token
- Hilt bindings, notification icon/strings, and required manifest permissions/service

Also removes the stale CacheRepositoryImpl import from RepositoryModule so the
worktree compiles after the cache files were deleted.
This commit is contained in:
Atte149 2026-06-16 12:04:53 +03:00
parent ed3afc1f16
commit acff930d7b
18 changed files with 888 additions and 3 deletions

View file

@ -22,6 +22,12 @@
tools:node="remove"
tools:ignore="ScopedStorage" />
<!-- Audio playback -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".Application"
android:allowBackup="false"
@ -56,6 +62,16 @@
android:theme="@style/BookStory"
android:windowSoftInputMode="adjustResize" />
<!-- Audiobook playback service -->
<service
android:name=".data.playback.AudioPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<!-- Save app locales -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

View file

@ -28,14 +28,15 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParser
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
import org.dueattendant149.bookshelf.data.parser.text.TextParser
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
import org.dueattendant149.bookshelf.data.repository.AudiobookshelfRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.repository.BookRepository
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
@ -96,6 +97,12 @@ abstract class RepositoryModule {
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
): RemoteLibraryRepository
@Binds
@Singleton
abstract fun bindAudiobookshelfRepository(
audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl
): AudiobookshelfRepository
@Binds
@Singleton
abstract fun bindBookMapper(

View file

@ -0,0 +1,118 @@
/*
* 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.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import dagger.hilt.android.AndroidEntryPoint
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.presentation.main.MainActivity
import javax.inject.Inject
@AndroidEntryPoint
@OptIn(UnstableApi::class)
class AudioPlaybackService : MediaSessionService() {
@Inject
lateinit var exoPlayer: ExoPlayer
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= 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
}
}

View file

@ -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 = ""
}

View file

@ -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()
}
}

View file

@ -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<String>
@GET("api/items/{itemId}")
suspend fun getItem(
@Path("itemId") itemId: String,
): Response<AbsItemResponse>
}

View file

@ -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<AbsAudioFile> = 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,
)

View file

@ -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<List<AudioTrack>> {
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)
}
}
}

View file

@ -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,
)

View file

@ -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<List<AudioTrack>>
}

View file

@ -11,7 +11,9 @@ import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@Parcelize
object RemoteLibraryScreen : Screen, Parcelable {
@ -19,6 +21,13 @@ object RemoteLibraryScreen : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>()
RemoteLibraryContent(model = model)
val navigator = LocalNavigator.current
RemoteLibraryContent(
model = model,
onPlayAudio = { book ->
navigator.push(PlayerScreen(book))
}
)
}
}

View file

@ -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<org.dueattendant149.bookshelf.domain.model.player.AudioTrack>) {
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()
}
}

View file

@ -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<PlayerModel>()
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,
)
}
}

View file

@ -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<AudioTrack> = 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,
)

View file

@ -19,12 +19,15 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue
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
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.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -39,13 +42,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
@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,
@ -144,6 +151,18 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
text = book.author.getAsString()
?: stringResource(R.string.unknown_author)
)
},
trailingContent = {
if (book.hasAudio) {
IconButton(
onClick = { onPlayAudio(book) },
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Play audiobook"
)
}
}
}
)
}

View file

@ -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<PlayerState>,
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)
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -9,6 +9,10 @@
<string name="app_name" translatable="false">Book\'s Story</string>
<string name="default_string">Default</string>
<!-- Audio playback -->
<string name="audio_playback_channel">Audio playback</string>
<string name="audio_playback_channel_description">Shows the currently playing audiobook</string>
<!-- Screens -->
<string name="library_screen">Library</string>
<string name="history_screen">History</string>