Merge branch 'main' into develop/mpv

This commit is contained in:
Damontecres 2025-11-07 16:11:00 -05:00
commit 7acefe4ba3
No known key found for this signature in database
14 changed files with 370 additions and 151 deletions

View file

@ -33,6 +33,7 @@ fun SliderBar(
min: Long,
max: Long,
onChange: (Long) -> Unit,
enableWrapAround: Boolean,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interval: Int = 1,
@ -52,7 +53,7 @@ fun SliderBar(
onChange(currentValue)
},
onLeft = {
if (currentValue <= min) {
if (enableWrapAround && currentValue <= min) {
currentValue = max
} else {
currentValue = (currentValue - interval).coerceAtLeast(min)
@ -60,7 +61,7 @@ fun SliderBar(
onChange(currentValue)
},
onRight = {
if (currentValue >= max) {
if (enableWrapAround && currentValue >= max) {
currentValue = min
} else {
currentValue = (currentValue + interval).coerceAtMost(max)

View file

@ -357,15 +357,7 @@ fun PersonHeader(
Text(
text = name,
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displaySmall.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp),

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -89,12 +90,16 @@ fun MovieDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: MovieViewModel = hiltViewModel(),
viewModel: MovieViewModel =
hiltViewModel<MovieViewModel, MovieViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.init(destination.itemId)
LifecycleResumeEffect(Unit) {
viewModel.init()
onPauseOrDispose { }
}
val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf())

View file

@ -16,9 +16,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@ -64,15 +61,7 @@ fun MovieDetailsHeader(
Text(
text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displayMedium.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
style = MaterialTheme.typography.displayMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
@ -16,7 +17,6 @@ import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
@ -26,10 +26,15 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -39,26 +44,53 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@HiltViewModel(assistedFactory = MovieViewModel.Factory::class)
class MovieViewModel
@Inject
@AssistedInject
constructor(
api: ApiClient,
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
) : LoadingItemViewModel(api) {
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId2: UUID): MovieViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val item = MutableLiveData<BaseItem?>(null)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
fun init(itemId: UUID): Job? =
init {
init()
}
private fun fetchAndSetItem(): Deferred<BaseItem> =
viewModelScope.async(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val item =
api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api)
}
this@MovieViewModel.item.setValueOnMain(item)
item
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
@ -66,84 +98,83 @@ class MovieViewModel
"Error fetching movie",
),
) {
fetchAndSetItem(itemId)
item.value?.let { item ->
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
val item = fetchAndSetItem().await()
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item
chosenStreams.value = result
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
} else {
listOf()
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
} else {
listOf()
}
}
withContext(Dispatchers.Main) {
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api)
}
if (!similar.isInitialized) {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.Companion.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
}
withContext(Dispatchers.Main) {
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api)
}
if (!similar.isInitialized) {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.Companion.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
}
}
fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(itemUuid)
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemUuid)
api.playStateApi.markUnplayedItem(itemId)
}
fetchAndSetItem(itemUuid)
fetchAndSetItem()
}
fun setFavorite(favorite: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemUuid)
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemUuid)
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
fetchAndSetItem(itemUuid)
fetchAndSetItem()
}
fun savePlayVersion(

View file

@ -55,8 +55,8 @@ fun FocusedEpisodeHeader(
?.let {
add(it)
}
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
dto.officialRating?.let(::add)
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
}
DotSeparatedRow(
texts = details,

View file

@ -263,7 +263,7 @@ fun HomePageContent(
}
Column(modifier = Modifier.fillMaxSize()) {
MainPageHeader(
HomePageHeader(
item = focusedItem,
modifier =
Modifier
@ -357,7 +357,7 @@ fun HomePageContent(
}
@Composable
fun MainPageHeader(
fun HomePageHeader(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
@ -396,6 +396,7 @@ fun MainPageHeader(
dto.timeRemaining?.roundMinutes?.let {
add("$it left")
}
dto.officialRating?.let(::add)
}
title?.let {
Text(

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -18,12 +19,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -34,7 +38,11 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.ColorImage
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec
@ -67,7 +75,7 @@ fun NextUpEpisode(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Text(
text = stringResource(R.string.next_up) + "...",
@ -88,8 +96,10 @@ fun NextUpEpisode(
interactionSource = interactionSource,
modifier =
Modifier
// .fillMaxWidth(.4f)
// .fillMaxHeight()
.fillMaxHeight()
.aspectRatio(aspectRatio)
// .fillMaxSize()
// .weight(1f)
.focusRequester(focusRequester),
)
Column(
@ -133,67 +143,79 @@ fun NextUpCard(
onClick = onClick,
interactionSource = interactionSource,
) {
Box(modifier = Modifier) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier,
modifier = Modifier.fillMaxSize(),
)
if (timeLeft != null && timeLeft > Duration.ZERO) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.background(
AppColors.TransparentBlack50,
shape = CircleShape,
),
) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.background(
AppColors.TransparentBlack50,
shape = CircleShape,
),
) {
if (timeLeft != null && timeLeft > Duration.ZERO) {
Text(
text = timeLeft.toString(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(12.dp),
)
} else {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.size(60.dp)
.align(Alignment.Center),
)
}
} else {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.size(60.dp)
.align(Alignment.Center),
)
}
}
}
}
@OptIn(ExperimentalCoilApi::class)
@PreviewTvSpec
@Composable
private fun NextUpEpisodePreview() {
WholphinTheme(true) {
NextUpEpisode(
title = "Episode Title",
description = "This is the description of the episode. It might be long.",
imageUrl = "",
onClick = {},
aspectRatio = 4f / 3,
timeLeft = 30.seconds,
modifier =
Modifier
.padding(16.dp)
.height(200.dp)
.width(400.dp)
val previewHandler =
AsyncImagePreviewHandler {
ColorImage(Color.Blue.toArgb())
}
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) {
NextUpEpisode(
title = "Episode Title",
description =
"This is the description of the episode. It might be long. " +
"It might be very, very long and overflow the available space. " +
"But it just keeps going and going.",
imageUrl = "",
onClick = {},
aspectRatio = 4f / 3,
timeLeft = 30.seconds,
modifier =
Modifier
.padding(16.dp)
.height(200.dp)
.width(400.dp)
// .fillMaxWidth(.4f)
// .align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
}
}
}

View file

@ -352,7 +352,7 @@ fun LeftPlaybackButtons(
}
}
private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "2.0")
private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
@Composable
fun RightPlaybackButtons(
@ -456,6 +456,7 @@ fun RightPlaybackButtons(
showOptionsDialog = false
},
onSelectChoice = { index, _ ->
onControllerInteractionForDialog.invoke()
when (index) {
0 -> showAudioDialog = true
1 -> showSpeedDialog = true

View file

@ -78,6 +78,7 @@ fun SliderPreference(
interval = preference.interval,
onChange = onChange,
color = MaterialTheme.colorScheme.border,
enableWrapAround = false,
interactionSource = interactionSource,
modifier = Modifier.weight(1f),
)

View file

@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -28,6 +30,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@ -35,6 +39,7 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
@ -46,19 +51,25 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.DownloadCallback
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Release
import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.Version
import com.github.damontecres.wholphin.util.formatBytes
import com.mikepenz.markdown.m3.Markdown
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -69,10 +80,15 @@ class UpdateViewModel
constructor(
val updater: UpdateChecker,
val navigationManager: NavigationManager,
) : ViewModel() {
) : ViewModel(),
DownloadCallback {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null)
val downloading = MutableLiveData<Boolean>(false)
val contentLength = MutableLiveData<Long>(-1)
val bytesDownloaded = MutableLiveData<Long>(-1)
val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) {
@ -80,17 +96,55 @@ class UpdateViewModel
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) {
contentLength.value = -1
bytesDownloaded.value = -1
this@UpdateViewModel.release.value = release
loading.value = LoadingState.Success
}
}
}
private var downloadJob: Job? = null
fun installRelease(release: Release) {
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) {
updater.installRelease(release)
downloadJob =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Failed to install update",
),
) {
downloading.setValueOnMain(true)
updater.installRelease(release, this@UpdateViewModel)
downloading.setValueOnMain(false)
}
}
fun cancelDownload() {
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error",
),
) {
downloadJob?.cancel()
withContext(Dispatchers.Main) {
downloading.value = false
contentLength.value = -1
bytesDownloaded.value = -1
}
}
}
override fun contentLength(contentLength: Long) {
this@UpdateViewModel.contentLength.value = contentLength
}
override fun bytesDownloaded(bytes: Long) {
this@UpdateViewModel.bytesDownloaded.value = bytes
}
}
@Composable
@ -101,6 +155,11 @@ fun InstallUpdatePage(
) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null)
val isDownloading by viewModel.downloading.observeAsState(false)
val contentLength by viewModel.contentLength.observeAsState(-1L)
val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1)
LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl)
}
@ -121,7 +180,7 @@ fun InstallUpdatePage(
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success ->
LoadingState.Success -> {
release?.let {
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
@ -136,9 +195,26 @@ fun InstallUpdatePage(
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier,
modifier =
modifier
.ifElse(
isDownloading,
Modifier
.blur(8.dp)
.alpha(.33f),
),
)
}
if (isDownloading) {
DownloadDialog(
contentLength = contentLength,
bytesDownloaded = bytesDownloaded,
onDismissRequest = {
viewModel.cancelDownload()
},
)
}
}
}
}
@ -250,6 +326,65 @@ fun InstallUpdatePageContent(
}
}
@Composable
fun DownloadDialog(
contentLength: Long,
bytesDownloaded: Long,
onDismissRequest: () -> Unit,
) {
val progress =
if (contentLength > 0) {
bytesDownloaded.toFloat() / contentLength
} else {
null
}
BasicDialog(
onDismissRequest = onDismissRequest,
elevation = 6.dp,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
Text(
text = stringResource(R.string.downloading),
fontSize = 24.sp,
color = MaterialTheme.colorScheme.onSurface,
)
if (progress != null) {
CircularProgressIndicator(
progress = { progress },
Modifier
.size(48.dp)
.padding(8.dp),
)
} else {
CircularProgressIndicator(
Modifier
.size(48.dp)
.padding(8.dp),
)
}
}
if (progress != null) {
val bytes = formatBytes(bytesDownloaded)
val size = formatBytes(contentLength)
Text(
text = "$bytes / $size",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun InstallUpdatePageContentPreview() {

View file

@ -30,8 +30,11 @@ import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.headersContentLength
import timber.log.Timber
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
@ -170,7 +173,10 @@ class UpdateChecker
}
}
suspend fun installRelease(release: Release) {
suspend fun installRelease(
release: Release,
callback: DownloadCallback,
) {
withContext(Dispatchers.IO) {
cleanup()
val request =
@ -182,6 +188,9 @@ class UpdateChecker
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
withContext(Dispatchers.Main) {
callback.contentLength(it.headersContentLength())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
@ -201,7 +210,7 @@ class UpdateChecker
if (uri != null) {
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!)
copyTo(input, output!!, callback = callback)
}
}
@ -212,7 +221,7 @@ class UpdateChecker
} else {
Timber.e("Resolver URI is null, trying fallback")
// showToast(context, "Unable to download the apk")
val targetFile = fallbackDownload(it)
val targetFile = fallbackDownload(it, callback)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data =
@ -224,7 +233,7 @@ class UpdateChecker
context.startActivity(intent)
}
} else {
val targetFile = fallbackDownload(it)
val targetFile = fallbackDownload(it, callback)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
@ -250,13 +259,18 @@ class UpdateChecker
}
}
private fun fallbackDownload(response: Response): File {
private suspend fun fallbackDownload(
response: Response,
callback: DownloadCallback,
): File {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
response.body!!.byteStream().copyTo(output)
response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback)
}
}
return targetFile
}
@ -324,3 +338,29 @@ data class Release(
val body: String?,
val notes: List<String>,
)
interface DownloadCallback {
fun contentLength(contentLength: Long)
fun bytesDownloaded(bytes: Long)
}
suspend fun copyTo(
input: InputStream,
out: OutputStream,
bufferSize: Int = 16 * 1024,
callback: DownloadCallback,
): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
withContext(Dispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
}
bytes = input.read(buffer)
}
return bytesCopied
}