From a8f00ff3ba9afdbb4c87b3a41d5e0d14fad266bc Mon Sep 17 00:00:00 2001 From: Atte149 Date: Sun, 28 Jun 2026 04:16:04 +0300 Subject: [PATCH] fix(reader): use FileProvider content:// URI instead of file:// for book open - BooksViewModel.downloadAndOpen: use FileProvider.getUriForFile() to create content:// URI instead of raw file:// URI. This allows ContentResolver to determine MIME type and properly import the book. - provider_paths.xml: added files-path for BookImporter's filesDir access. - ':app:assembleOssDebug' passes, APK deployed. --- .../screens/audiobooks/AudioPlayerScreen.kt | 135 ++++++++++++++---- .../audiobooks/AudioPlayerViewModel.kt | 48 +++++++ .../ui/screens/books/BooksViewModel.kt | 12 +- app/src/main/res/xml/provider_paths.xml | 2 +- 4 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerViewModel.kt diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt index b996b4e..493f916 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerScreen.kt @@ -11,12 +11,16 @@ 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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Audiotrack +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.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Pause +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 @@ -25,13 +29,16 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel @@ -39,11 +46,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.media3.exoplayer.ExoPlayer -import android.content.Context -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.delay +import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository +import org.dueattendant149.bookreader.data.settings.ServerSettings +import javax.inject.Inject @OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) @Composable @@ -55,20 +62,49 @@ fun AudioPlayerScreen( BackHandler { onBackClick() } val context = LocalContext.current - var exoPlayer by remember { mutableStateOf(null) } - var isPlaying by remember { mutableStateOf(false) } - var position by remember { mutableStateOf(0L) } - var duration by remember { mutableStateOf(0L) } - var currentTrack by remember { mutableStateOf(0) } + val viewModel: AudioPlayerViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(itemId) { - // Player will be set up when tracks are loaded + viewModel.loadTracks(itemId) + } + + var exoPlayer by remember { mutableStateOf(null) } + var isPlaying by remember { mutableStateOf(false) } + var position by remember { mutableLongStateOf(0L) } + var duration by remember { mutableLongStateOf(0L) } + var currentTrack by remember { mutableStateOf(0) } + + // Create player when tracks are loaded + LaunchedEffect(uiState.tracks) { + if (uiState.tracks.isNotEmpty() && exoPlayer == null) { + val player = ExoPlayer.Builder(context).build() + val baseUrl = viewModel.getBaseUrl() + uiState.tracks.forEach { track -> + val mediaUrl = "$baseUrl/api/v1/books/$itemId/file/${track.fileId}" + player.addMediaItem(MediaItem.fromUri(mediaUrl)) + } + player.prepare() + player.playWhenReady = true + exoPlayer = player + } + } + + // Update position periodically + LaunchedEffect(exoPlayer) { + exoPlayer?.let { player -> + while (true) { + position = player.currentPosition + duration = player.duration.coerceAtLeast(0L) + isPlaying = player.isPlaying + currentTrack = player.currentMediaItemIndex + delay(500) + } + } } DisposableEffect(Unit) { - onDispose { - exoPlayer?.release() - } + onDispose { exoPlayer?.release() } } Scaffold( @@ -76,7 +112,10 @@ fun AudioPlayerScreen( TopAppBar( title = { Text(title, maxLines = 1) }, navigationIcon = { - IconButton(onClick = onBackClick) { + IconButton(onClick = { + exoPlayer?.release() + onBackClick() + }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } @@ -91,14 +130,61 @@ fun AudioPlayerScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + // Track icon + Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Audiotrack, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) + if (uiState.isLoading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + + uiState.error?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp, + ) + } + Spacer(modifier = Modifier.height(32.dp)) + // Current track info + if (uiState.tracks.isNotEmpty() && currentTrack < uiState.tracks.size) { + Text( + text = "Track ${currentTrack + 1} / ${uiState.tracks.size}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = uiState.tracks[currentTrack].title, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Seek bar if (duration > 0) { Slider( value = position.toFloat(), @@ -117,37 +203,38 @@ fun AudioPlayerScreen( Spacer(modifier = Modifier.height(32.dp)) + // Player controls Row( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = { exoPlayer?.seekToPreviousMediaItem() }) { - Icon(Icons.Filled.SkipPrevious, contentDescription = "Previous", modifier = Modifier.padding(16.dp), tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Filled.SkipPrevious, contentDescription = "Previous", modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary) } IconButton(onClick = { exoPlayer?.let { if (it.isPlaying) it.pause() else it.play() } isPlaying = exoPlayer?.isPlaying == true - }, modifier = Modifier.padding(8.dp)) { + }, modifier = Modifier.size(64.dp)) { Icon( if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = "Play/Pause", + modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary, ) } IconButton(onClick = { exoPlayer?.seekToNextMediaItem() }) { - Icon(Icons.Filled.SkipNext, contentDescription = "Next", modifier = Modifier.padding(16.dp), tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Filled.SkipNext, contentDescription = "Next", modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary) } } - - Spacer(modifier = Modifier.height(16.dp)) - Text("Track ${currentTrack + 1}", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } private fun formatTime(ms: Long): String { val s = ms / 1000 - val m = s / 60 + val h = s / 3600 + val m = (s % 3600) / 60 val sec = s % 60 - return String.format("%d:%02d", m, sec) + return if (h > 0) String.format("%d:%02d:%02d", h, m, sec) + else String.format("%d:%02d", m, sec) } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerViewModel.kt new file mode 100644 index 0000000..7487f2e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/audiobooks/AudioPlayerViewModel.kt @@ -0,0 +1,48 @@ +package org.dueattendant149.bookreader.ui.screens.audiobooks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse +import org.dueattendant149.bookreader.data.settings.ServerSettings +import javax.inject.Inject + +data class AudioPlayerUiState( + val tracks: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class AudioPlayerViewModel + @Inject + constructor( + private val repository: BookshelfApiRepository, + private val serverSettings: ServerSettings, + ) : ViewModel() { + + private val _uiState = MutableStateFlow(AudioPlayerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadTracks(itemId: String) { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + viewModelScope.launch { + repository.getAudioTracks(itemId) + .onSuccess { tracks -> + _uiState.value = AudioPlayerUiState(tracks = tracks) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, error = error.message) + } + } + } + + fun getBaseUrl(): String { + return serverSettings.getBookshelfUrl()?.trimEnd('/') ?: "https://books.dueattendant149.org" + } + } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt index d187358..869504f 100644 --- a/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt +++ b/app/src/main/java/org/dueattendant149/bookreader/ui/screens/books/BooksViewModel.kt @@ -2,6 +2,7 @@ package org.dueattendant149.bookreader.ui.screens.books import android.app.Application import android.net.Uri +import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -76,7 +77,16 @@ class BooksViewModel repository.downloadEbook(item.id) .onSuccess { body -> val file = saveToCache(item, body) - if (file != null) _openBook.tryEmit(Uri.fromFile(file)) + if (file != null) { + // Use FileProvider for content:// URI so ContentResolver can determine MIME type + val context = getApplication() + val contentUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + _openBook.tryEmit(contentUri) + } _uiState.value = _uiState.value.copy(downloadingItemId = null) } .onFailure { diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index 6f59975..3795aa7 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file