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:
parent
ed3afc1f16
commit
acff930d7b
18 changed files with 888 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = ""
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue