From acff930d7b9454b16e27da903aae698e351df027 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Tue, 16 Jun 2026 12:04:53 +0300 Subject: [PATCH] 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. --- app/src/main/AndroidManifest.xml | 16 ++ .../bookshelf/data/di/RepositoryModule.kt | 9 +- .../data/playback/AudioPlaybackService.kt | 118 +++++++++ .../data/playback/di/PlaybackAuthProvider.kt | 9 + .../data/playback/di/PlaybackModule.kt | 62 +++++ .../AudiobookshelfApiService.kt | 6 + .../audiobookshelf/model/AbsItemResponse.kt | 35 +++ .../AudiobookshelfRepositoryImpl.kt | 58 +++++ .../domain/model/player/AudioTrack.kt | 8 + .../repository/AudiobookshelfRepository.kt | 7 + .../bookshelf/RemoteLibraryScreen.kt | 11 +- .../presentation/player/PlayerModel.kt | 226 ++++++++++++++++++ .../presentation/player/PlayerScreen.kt | 48 ++++ .../presentation/player/PlayerState.kt | 17 ++ .../ui/bookshelf/RemoteLibraryContent.kt | 21 +- .../bookshelf/ui/player/PlayerContent.kt | 226 ++++++++++++++++++ app/src/main/res/drawable/ic_notification.xml | 10 + app/src/main/res/values/strings.xml | 4 + 18 files changed, 888 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt create mode 100644 app/src/main/res/drawable/ic_notification.xml 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 d75e4386..6b9d8332 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 @@ -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() - RemoteLibraryContent(model = model) + val navigator = LocalNavigator.current + + 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 3b934004..f283d3f9 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 @@ -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" + ) + } + } } ) } 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 73a879e0..4d8064a8 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