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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.SkipNext
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
import androidx.compose.material.icons.filled.SkipPrevious
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -25,13 +29,16 @@ import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -39,11 +46,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import android.content.Context
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import org.dueattendant149.bookreader.data.settings.ServerSettings
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -55,20 +62,49 @@ fun AudioPlayerScreen(
|
||||||
BackHandler { onBackClick() }
|
BackHandler { onBackClick() }
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
|
val viewModel: AudioPlayerViewModel = hiltViewModel()
|
||||||
var isPlaying by remember { mutableStateOf(false) }
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var position by remember { mutableStateOf(0L) }
|
|
||||||
var duration by remember { mutableStateOf(0L) }
|
|
||||||
var currentTrack by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
LaunchedEffect(itemId) {
|
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) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose { exoPlayer?.release() }
|
||||||
exoPlayer?.release()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -76,7 +112,10 @@ fun AudioPlayerScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(title, maxLines = 1) },
|
title = { Text(title, maxLines = 1) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBackClick) {
|
IconButton(onClick = {
|
||||||
|
exoPlayer?.release()
|
||||||
|
onBackClick()
|
||||||
|
}) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,14 +130,61 @@ fun AudioPlayerScreen(
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
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))
|
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) {
|
if (duration > 0) {
|
||||||
Slider(
|
Slider(
|
||||||
value = position.toFloat(),
|
value = position.toFloat(),
|
||||||
|
|
@ -117,37 +203,38 @@ fun AudioPlayerScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Player controls
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { exoPlayer?.seekToPreviousMediaItem() }) {
|
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 = {
|
IconButton(onClick = {
|
||||||
exoPlayer?.let { if (it.isPlaying) it.pause() else it.play() }
|
exoPlayer?.let { if (it.isPlaying) it.pause() else it.play() }
|
||||||
isPlaying = exoPlayer?.isPlaying == true
|
isPlaying = exoPlayer?.isPlaying == true
|
||||||
}, modifier = Modifier.padding(8.dp)) {
|
}, modifier = Modifier.size(64.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
contentDescription = "Play/Pause",
|
contentDescription = "Play/Pause",
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { exoPlayer?.seekToNextMediaItem() }) {
|
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 {
|
private fun formatTime(ms: Long): String {
|
||||||
val s = ms / 1000
|
val s = ms / 1000
|
||||||
val m = s / 60
|
val h = s / 3600
|
||||||
|
val m = (s % 3600) / 60
|
||||||
val sec = s % 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.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -76,7 +77,16 @@ class BooksViewModel
|
||||||
repository.downloadEbook(item.id)
|
repository.downloadEbook(item.id)
|
||||||
.onSuccess { body ->
|
.onSuccess { body ->
|
||||||
val file = saveToCache(item, 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)
|
_uiState.value = _uiState.value.copy(downloadingItemId = null)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths>
|
<paths>
|
||||||
<!-- Corresponds to context.cacheDir/shared_files/ -->
|
|
||||||
<cache-path name="shared_files" path="shared_files/" />
|
<cache-path name="shared_files" path="shared_files/" />
|
||||||
<cache-path name="cache_root" path="." />
|
<cache-path name="cache_root" path="." />
|
||||||
|
<files-path name="books" path="." />
|
||||||
</paths>
|
</paths>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue