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.
This commit is contained in:
parent
9e0ac90613
commit
a8f00ff3ba
4 changed files with 171 additions and 26 deletions
|
|
@ -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<ExoPlayer?>(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<ExoPlayer?>(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)
|
||||
}
|
||||
|
|
@ -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<AudioTrackResponse> = 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<AudioPlayerUiState> = _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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Application>()
|
||||
val contentUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
_openBook.tryEmit(contentUri)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(downloadingItemId = null)
|
||||
}
|
||||
.onFailure {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Corresponds to context.cacheDir/shared_files/ -->
|
||||
<cache-path name="shared_files" path="shared_files/" />
|
||||
<cache-path name="cache_root" path="." />
|
||||
<files-path name="books" path="." />
|
||||
</paths>
|
||||
Loading…
Add table
Add a link
Reference in a new issue