diff --git a/.gitignore b/.gitignore index 87328089..a5248a5b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ app/libs/ ffmpeg_decoder/ app/release/ app/debug/ +<<<<<<< HEAD # mpv app/src/main/obj/ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index 4e49da20..2b57a73c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index bd04b4c6..3e8a8d12 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -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), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index b88607e4..fe784adb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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( + 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()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 1bf0869a..45382fc6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -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, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index a384e016..8f7c2cf8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -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.Pending) + val item = MutableLiveData(null) val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) val chapters = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(null) - fun init(itemId: UUID): Job? = + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index d4cacc99..78775e03 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 64c00953..7e424cb6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt index fc39255e..5596a864 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/NextUpEpisode.kt @@ -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), + ), + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 3bbf54ba..2a1afe61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index a7e13e69..4aefde8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -78,6 +78,7 @@ fun SliderPreference( interval = preference.interval, onChange = onChange, color = MaterialTheme.colorScheme.border, + enableWrapAround = false, interactionSource = interactionSource, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index edb461f2..4d02def5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -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.Pending) val release = MutableLiveData(null) + val downloading = MutableLiveData(false) + val contentLength = MutableLiveData(-1) + val bytesDownloaded = MutableLiveData(-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() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt index d177e39d..144e566d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt @@ -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, ) + +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 +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c00502d..d6143504 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,9 +10,9 @@ kotlin = "2.2.21" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2025.10.01" +composeBom = "2025.11.00" compose-runtime = "1.9.4" -multiplatformMarkdownRenderer = "0.37.0" +multiplatformMarkdownRenderer = "0.38.1" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -23,8 +23,8 @@ activityCompose = "1.11.0" androidx-media3 = "1.8.0" coil = "3.3.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.0.0-beta01" -lifecycleViewmodelNav3 = "2.10.0-beta01" +nav3Core = "1.0.0-rc01" +lifecycleViewmodelNav3 = "2.10.0-rc01" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.5" datastore = "1.1.7"