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

1
.gitignore vendored
View file

@ -53,6 +53,7 @@ app/libs/
ffmpeg_decoder/ ffmpeg_decoder/
app/release/ app/release/
app/debug/ app/debug/
<<<<<<< HEAD
# mpv # mpv
app/src/main/obj/ app/src/main/obj/

View file

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

View file

@ -357,15 +357,7 @@ fun PersonHeader(
Text( Text(
text = name, text = name,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = style = MaterialTheme.typography.displaySmall,
MaterialTheme.typography.displaySmall.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp), 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.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -89,12 +90,16 @@ fun MovieDetails(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MovieViewModel = hiltViewModel(), viewModel: MovieViewModel =
hiltViewModel<MovieViewModel, MovieViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.init(destination.itemId) viewModel.init()
onPauseOrDispose { }
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf()) 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.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -64,15 +61,7 @@ fun MovieDetailsHeader(
Text( Text(
text = movie.name ?: "", text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = style = MaterialTheme.typography.displayMedium,
MaterialTheme.typography.displayMedium.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams 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.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.ui.SlimItemFields 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.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination 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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = MovieViewModel.Factory::class)
class MovieViewModel class MovieViewModel
@Inject @AssistedInject
constructor( constructor(
api: ApiClient, private val api: ApiClient,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer, 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 trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null) 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( viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
@ -66,10 +98,10 @@ class MovieViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
fetchAndSetItem(itemId) val item = fetchAndSetItem().await()
item.value?.let { item ->
val result = itemPlaybackRepository.getSelectedTracks(item.id, item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item
chosenStreams.value = result chosenStreams.value = result
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
@ -124,26 +156,25 @@ class MovieViewModel
this@MovieViewModel.similar.setValueOnMain(similar) this@MovieViewModel.similar.setValueOnMain(similar)
} }
} }
}
fun setWatched(played: Boolean) = fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) { if (played) {
api.playStateApi.markPlayedItem(itemUuid) api.playStateApi.markPlayedItem(itemId)
} else { } else {
api.playStateApi.markUnplayedItem(itemUuid) api.playStateApi.markUnplayedItem(itemId)
} }
fetchAndSetItem(itemUuid) fetchAndSetItem()
} }
fun setFavorite(favorite: Boolean) = fun setFavorite(favorite: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) { if (favorite) {
api.userLibraryApi.markFavoriteItem(itemUuid) api.userLibraryApi.markFavoriteItem(itemId)
} else { } else {
api.userLibraryApi.unmarkFavoriteItem(itemUuid) api.userLibraryApi.unmarkFavoriteItem(itemId)
} }
fetchAndSetItem(itemUuid) fetchAndSetItem()
} }
fun savePlayVersion( fun savePlayVersion(

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.ColorImage
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
@ -67,7 +75,7 @@ fun NextUpEpisode(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
) { ) {
Text( Text(
text = stringResource(R.string.next_up) + "...", text = stringResource(R.string.next_up) + "...",
@ -88,8 +96,10 @@ fun NextUpEpisode(
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = modifier =
Modifier Modifier
// .fillMaxWidth(.4f) .fillMaxHeight()
// .fillMaxHeight() .aspectRatio(aspectRatio)
// .fillMaxSize()
// .weight(1f)
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
Column( Column(
@ -133,14 +143,14 @@ fun NextUpCard(
onClick = onClick, onClick = onClick,
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Box(modifier = Modifier) { Box(modifier = Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier, modifier = Modifier.fillMaxSize(),
) )
if (timeLeft != null && timeLeft > Duration.ZERO) {
Box( Box(
modifier = modifier =
Modifier Modifier
@ -150,13 +160,13 @@ fun NextUpCard(
shape = CircleShape, shape = CircleShape,
), ),
) { ) {
if (timeLeft != null && timeLeft > Duration.ZERO) {
Text( Text(
text = timeLeft.toString(), text = timeLeft.toString(),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(12.dp),
) )
}
} else { } else {
Icon( Icon(
imageVector = Icons.Default.PlayArrow, imageVector = Icons.Default.PlayArrow,
@ -170,15 +180,26 @@ fun NextUpCard(
} }
} }
} }
}
} }
@OptIn(ExperimentalCoilApi::class)
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
private fun NextUpEpisodePreview() { private fun NextUpEpisodePreview() {
WholphinTheme(true) { WholphinTheme(true) {
val previewHandler =
AsyncImagePreviewHandler {
ColorImage(Color.Blue.toArgb())
}
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) {
NextUpEpisode( NextUpEpisode(
title = "Episode Title", title = "Episode Title",
description = "This is the description of the episode. It might be long.", 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 = "", imageUrl = "",
onClick = {}, onClick = {},
aspectRatio = 4f / 3, aspectRatio = 4f / 3,
@ -196,4 +217,5 @@ private fun NextUpEpisodePreview() {
), ),
) )
} }
}
} }

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 @Composable
fun RightPlaybackButtons( fun RightPlaybackButtons(
@ -456,6 +456,7 @@ fun RightPlaybackButtons(
showOptionsDialog = false showOptionsDialog = false
}, },
onSelectChoice = { index, _ -> onSelectChoice = { index, _ ->
onControllerInteractionForDialog.invoke()
when (index) { when (index) {
0 -> showAudioDialog = true 0 -> showAudioDialog = true
1 -> showSpeedDialog = true 1 -> showSpeedDialog = true

View file

@ -78,6 +78,7 @@ fun SliderPreference(
interval = preference.interval, interval = preference.interval,
onChange = onChange, onChange = onChange,
color = MaterialTheme.colorScheme.border, color = MaterialTheme.colorScheme.border,
enableWrapAround = false,
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.weight(1f), 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -28,6 +30,8 @@ import androidx.compose.runtime.rememberCoroutineScope
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.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key 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.input.key.type
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -46,19 +51,25 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Release import com.github.damontecres.wholphin.util.Release
import com.github.damontecres.wholphin.util.UpdateChecker import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import com.github.damontecres.wholphin.util.formatBytes
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@ -69,10 +80,15 @@ class UpdateViewModel
constructor( constructor(
val updater: UpdateChecker, val updater: UpdateChecker,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel(),
DownloadCallback {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null) val release = MutableLiveData<Release?>(null)
val downloading = MutableLiveData<Boolean>(false)
val contentLength = MutableLiveData<Long>(-1)
val bytesDownloaded = MutableLiveData<Long>(-1)
val currentVersion = updater.getInstalledVersion() val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) { fun init(updateUrl: String) {
@ -80,17 +96,55 @@ class UpdateViewModel
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
val release = updater.getLatestRelease(updateUrl) val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
contentLength.value = -1
bytesDownloaded.value = -1
this@UpdateViewModel.release.value = release this@UpdateViewModel.release.value = release
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
} }
} }
private var downloadJob: Job? = null
fun installRelease(release: Release) { fun installRelease(release: Release) {
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) { downloadJob =
updater.installRelease(release) 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 @Composable
@ -101,6 +155,11 @@ fun InstallUpdatePage(
) { ) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null) 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) { LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl) viewModel.init(preferences.appPreferences.updateUrl)
} }
@ -121,7 +180,7 @@ fun InstallUpdatePage(
LoadingState.Pending, LoadingState.Pending,
-> LoadingPage(modifier) -> LoadingPage(modifier)
LoadingState.Success -> LoadingState.Success -> {
release?.let { release?.let {
InstallUpdatePageContent( InstallUpdatePageContent(
currentVersion = viewModel.currentVersion, currentVersion = viewModel.currentVersion,
@ -136,9 +195,26 @@ fun InstallUpdatePage(
onCancel = { onCancel = {
viewModel.navigationManager.goBack() 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 @PreviewTvSpec
@Composable @Composable
private fun InstallUpdatePageContentPreview() { private fun InstallUpdatePageContentPreview() {

View file

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

View file

@ -10,9 +10,9 @@ kotlin = "2.2.21"
ksp = "2.3.0" ksp = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.10.01" composeBom = "2025.11.00"
compose-runtime = "1.9.4" compose-runtime = "1.9.4"
multiplatformMarkdownRenderer = "0.37.0" multiplatformMarkdownRenderer = "0.38.1"
programguide = "1.6.0" programguide = "1.6.0"
slf4j2Timber = "1.2" slf4j2Timber = "1.2"
timber = "5.0.1" timber = "5.0.1"
@ -23,8 +23,8 @@ activityCompose = "1.11.0"
androidx-media3 = "1.8.0" androidx-media3 = "1.8.0"
coil = "3.3.0" coil = "3.3.0"
jellyfin-sdk = "1.7.1" jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0-beta01" nav3Core = "1.0.0-rc01"
lifecycleViewmodelNav3 = "2.10.0-beta01" lifecycleViewmodelNav3 = "2.10.0-rc01"
material3AdaptiveNav3 = "1.0.0-alpha03" material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.5" protobuf = "0.9.5"
datastore = "1.1.7" datastore = "1.1.7"