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