Merge feature/audio: audiobook player with ExoPlayer
This commit is contained in:
commit
abe1a3db14
18 changed files with 896 additions and 10 deletions
|
|
@ -22,6 +22,12 @@
|
||||||
tools:node="remove"
|
tools:node="remove"
|
||||||
tools:ignore="ScopedStorage" />
|
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
|
<application
|
||||||
android:name=".Application"
|
android:name=".Application"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
@ -56,6 +62,16 @@
|
||||||
android:theme="@style/BookStory"
|
android:theme="@style/BookStory"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
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 -->
|
<!-- Save app locales -->
|
||||||
<service
|
<service
|
||||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParser
|
||||||
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
|
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
|
||||||
import org.dueattendant149.bookshelf.data.parser.text.TextParser
|
import org.dueattendant149.bookshelf.data.parser.text.TextParser
|
||||||
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
|
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.BookRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
|
||||||
|
|
@ -36,6 +37,7 @@ import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
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.BookRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
||||||
|
|
@ -103,6 +105,12 @@ abstract class RepositoryModule {
|
||||||
cacheRepositoryImpl: CacheRepositoryImpl
|
cacheRepositoryImpl: CacheRepositoryImpl
|
||||||
): CacheRepository
|
): CacheRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAudiobookshelfRepository(
|
||||||
|
audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl
|
||||||
|
): AudiobookshelfRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindBookMapper(
|
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
|
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
@ -34,4 +35,9 @@ interface AudiobookshelfApiService {
|
||||||
suspend fun getProgress(
|
suspend fun getProgress(
|
||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): Response<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>>
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
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.presentation.reader.ReaderScreen
|
||||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
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))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.CloudQueue
|
import androidx.compose.material.icons.filled.CloudQueue
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Download
|
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.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
|
|
@ -58,7 +59,10 @@ import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlace
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
fun RemoteLibraryContent(
|
||||||
|
model: RemoteLibraryModel,
|
||||||
|
onPlayAudio: (Book) -> Unit,
|
||||||
|
) {
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
val refreshState = rememberPullRefreshState(
|
val refreshState = rememberPullRefreshState(
|
||||||
refreshing = state.isLoading,
|
refreshing = state.isLoading,
|
||||||
|
|
@ -177,7 +181,8 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
isCaching = isCaching,
|
isCaching = isCaching,
|
||||||
onDownload = { model.cacheBook(book) },
|
onDownload = { model.cacheBook(book) },
|
||||||
onDelete = { model.deleteCache(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,
|
onDownload: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onRead: (() -> Unit)?,
|
onRead: (() -> Unit)?,
|
||||||
|
onPlayAudio: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
||||||
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
||||||
|
|
@ -267,6 +273,14 @@ private fun BookListItem(
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Row {
|
Row {
|
||||||
|
if (book.hasAudio) {
|
||||||
|
IconButton(onClick = onPlayAudio ?: {}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = stringResource(R.string.bookshelf_play_audio_action)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showDownload) {
|
if (showDownload) {
|
||||||
IconButton(onClick = onDownload) {
|
IconButton(onClick = onDownload) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -296,19 +310,26 @@ private fun BookListItem(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onRead?.let { read ->
|
if (onRead != null || onPlayAudio != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(bottom = 12.dp),
|
.padding(bottom = 12.dp),
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
) {
|
) {
|
||||||
Button(
|
onRead?.let { read ->
|
||||||
onClick = read,
|
Button(
|
||||||
enabled = !isCaching
|
onClick = read,
|
||||||
) {
|
enabled = !isCaching
|
||||||
Text(stringResource(R.string.read))
|
) {
|
||||||
|
Text(stringResource(R.string.read))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPlayAudio?.let { play ->
|
||||||
|
Button(onClick = play) {
|
||||||
|
Text(stringResource(R.string.bookshelf_play_audio_action))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="app_name" translatable="false">Book\'s Story</string>
|
||||||
<string name="default_string">Default</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 -->
|
<!-- Screens -->
|
||||||
<string name="library_screen">Library</string>
|
<string name="library_screen">Library</string>
|
||||||
<string name="history_screen">History</string>
|
<string name="history_screen">History</string>
|
||||||
|
|
@ -533,6 +537,7 @@
|
||||||
<string name="bookshelf_cache_failed">Failed</string>
|
<string name="bookshelf_cache_failed">Failed</string>
|
||||||
<string name="bookshelf_download_action">Download</string>
|
<string name="bookshelf_download_action">Download</string>
|
||||||
<string name="bookshelf_delete_cache_action">Delete</string>
|
<string name="bookshelf_delete_cache_action">Delete</string>
|
||||||
|
<string name="bookshelf_play_audio_action">Play</string>
|
||||||
<string name="arrow_content_desc">Arrow</string>
|
<string name="arrow_content_desc">Arrow</string>
|
||||||
<string name="reset_start_content_desc">Reset start guide</string>
|
<string name="reset_start_content_desc">Reset start guide</string>
|
||||||
<string name="open_in_web_content_desc">Open in web</string>
|
<string name="open_in_web_content_desc">Open in web</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue