From f3a89d2051e799417b2b5cb6a7936d7b16e86c80 Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Fri, 21 Jun 2024 22:49:39 +0200 Subject: [PATCH] Move dream service logic to view model --- .../org/jellyfin/androidtv/di/AppModule.kt | 2 + .../integration/dream/DreamViewModel.kt | 168 ++++++++++++++++++ .../integration/dream/composable/DreamHost.kt | 127 +------------ 3 files changed, 176 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt diff --git a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt index 7c0b302e19..6bbebfe467 100644 --- a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt +++ b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt @@ -21,6 +21,7 @@ import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl import org.jellyfin.androidtv.data.repository.UserViewsRepository import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl import org.jellyfin.androidtv.data.service.BackgroundService +import org.jellyfin.androidtv.integration.dream.DreamViewModel import org.jellyfin.androidtv.ui.ScreensaverViewModel import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher import org.jellyfin.androidtv.ui.navigation.Destinations @@ -127,6 +128,7 @@ val appModule = module { viewModel { PictureViewerViewModel(get()) } viewModel { ScreensaverViewModel(get()) } viewModel { SearchViewModel(get()) } + viewModel { DreamViewModel(get(), get(), get(), get(), get()) } single { BackgroundService(get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt new file mode 100644 index 0000000000..b70590b4e0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt @@ -0,0 +1,168 @@ +package org.jellyfin.androidtv.integration.dream + +import android.annotation.SuppressLint +import android.content.Context +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.jellyfin.androidtv.integration.dream.model.DreamContent +import org.jellyfin.androidtv.preference.UserPreferences +import org.jellyfin.androidtv.ui.playback.AudioEventListener +import org.jellyfin.androidtv.ui.playback.MediaManager +import org.jellyfin.androidtv.ui.playback.PlaybackController +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageFormat +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@SuppressLint("StaticFieldLeak") +class DreamViewModel( + private val api: ApiClient, + private val imageLoader: ImageLoader, + private val context: Context, + private val mediaManager: MediaManager, + private val userPreferences: UserPreferences, +) : ViewModel() { + private val _mediaContent = callbackFlow { + trySend(mediaManager.currentAudioItem) + + val listener = object : AudioEventListener { + override fun onPlaybackStateChange( + newState: PlaybackController.PlaybackState, + currentItem: BaseItemDto? + ) { + trySend(currentItem) + } + + override fun onQueueStatusChanged(hasQueue: Boolean) { + trySend(mediaManager.currentAudioItem) + } + } + + mediaManager.addAudioEventListener(listener) + awaitClose { mediaManager.removeAudioEventListener(listener) } + } + .distinctUntilChanged() + .map { it?.let(DreamContent::NowPlaying) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + DreamContent.NowPlaying(mediaManager.currentAudioItem) + ) + + private val _libraryContent = flow { + // Load first library item after 2 seconds + // to force the logo at the start of the screensaver + emit(null) + delay(2.seconds) + + while (true) { + val next = getRandomLibraryShowcase() + if (next != null) { + emit(next) + delay(30.seconds) + } else { + delay(3.seconds) + } + } + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + val content = combine(_mediaContent, _libraryContent) { mediaContent, libraryContent -> + mediaContent ?: libraryContent ?: DreamContent.Logo + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo, + ) + + private suspend fun getRandomLibraryShowcase(): DreamContent.LibraryShowcase? { + val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired] + val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax] + + try { + val response by api.itemsApi.getItems( + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + sortBy = listOf(ItemSortBy.RANDOM), + limit = 5, + imageTypes = listOf(ImageType.BACKDROP), + maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(), + hasParentalRating = if (requireParentalRating) true else null, + ) + + val item = response.items?.firstOrNull { item -> + !item.backdropImageTags.isNullOrEmpty() + } ?: return null + + Timber.i("Loading random library showcase item ${item.id}") + + val backdropTag = item.backdropImageTags!!.randomOrNull() + ?: item.imageTags?.get(ImageType.BACKDROP) + + val logoTag = item.imageTags?.get(ImageType.LOGO) + + val backdropUrl = api.imageApi.getItemImageUrl( + itemId = item.id, + imageType = ImageType.BACKDROP, + tag = backdropTag, + format = ImageFormat.WEBP, + ) + + val logoUrl = api.imageApi.getItemImageUrl( + itemId = item.id, + imageType = ImageType.LOGO, + tag = logoTag, + format = ImageFormat.WEBP, + ) + + val (logo, backdrop) = withContext(Dispatchers.IO) { + val logoDeferred = async { + imageLoader.execute( + request = ImageRequest.Builder(context).data(logoUrl).build() + ).drawable?.toBitmap() + } + + val backdropDeferred = async { + imageLoader.execute( + request = ImageRequest.Builder(context).data(backdropUrl).build() + ).drawable?.toBitmap() + } + + awaitAll(logoDeferred, backdropDeferred) + } + + if (backdrop == null) { + return null + } + + return DreamContent.LibraryShowcase(item, backdrop, logo) + } catch (err: ApiClientException) { + Timber.e(err) + return null + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt index 50d6ecabdc..33d266b8ee 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt @@ -1,140 +1,25 @@ package org.jellyfin.androidtv.integration.dream.composable -import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.core.graphics.drawable.toBitmap -import coil.ImageLoader -import coil.request.ImageRequest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import org.jellyfin.androidtv.integration.dream.model.DreamContent +import org.jellyfin.androidtv.integration.dream.DreamViewModel import org.jellyfin.androidtv.preference.UserPreferences import org.jellyfin.androidtv.preference.constant.ClockBehavior -import org.jellyfin.androidtv.ui.composable.rememberMediaItem -import org.jellyfin.androidtv.ui.playback.MediaManager -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.ApiClientException -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageFormat -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaType +import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject -import timber.log.Timber -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll @Composable fun DreamHost() { - val api = koinInject() + val viewModel = koinViewModel() val userPreferences = koinInject() - val mediaManager = koinInject() - val imageLoader = koinInject() - val context = LocalContext.current - - var libraryShowcase by remember { mutableStateOf(null) } - val mediaItem by rememberMediaItem(mediaManager) - - LaunchedEffect(true) { - delay(2.seconds) - - while (true) { - val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired] - val maxOfficialRating = userPreferences[UserPreferences.screensaverAgeRatingMax] - libraryShowcase = getRandomLibraryShowcase(context, api, maxOfficialRating, requireParentalRating, imageLoader) - delay(30.seconds) - } - } + val content by viewModel.content.collectAsState() DreamView( - content = when { - mediaItem?.mediaType == MediaType.AUDIO -> DreamContent.NowPlaying(mediaItem) - libraryShowcase != null -> libraryShowcase!! - else -> DreamContent.Logo - }, + content = content, showClock = when (userPreferences[UserPreferences.clockBehavior]) { ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true else -> false } ) } - -private suspend fun getRandomLibraryShowcase( - context: Context, - api: ApiClient, - maxParentalRating: Int, - requireParentalRating: Boolean, - imageLoader: ImageLoader, -): DreamContent.LibraryShowcase? { - try { - val response by api.itemsApi.getItems( - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - recursive = true, - sortBy = listOf(ItemSortBy.RANDOM), - limit = 5, - imageTypes = listOf(ImageType.BACKDROP), - maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(), - hasParentalRating = if (requireParentalRating) true else null, - ) - - val item = response.items?.firstOrNull { item -> - !item.backdropImageTags.isNullOrEmpty() - } ?: return null - - Timber.i("Loading random library showcase item ${item.id}") - - val backdropTag = item.backdropImageTags!!.randomOrNull() - ?: item.imageTags?.get(ImageType.BACKDROP) - - val logoTag = item.imageTags?.get(ImageType.LOGO) - - val backdropUrl = api.imageApi.getItemImageUrl( - itemId = item.id, - imageType = ImageType.BACKDROP, - tag = backdropTag, - format = ImageFormat.WEBP, - ) - - val logoUrl = api.imageApi.getItemImageUrl( - itemId = item.id, - imageType = ImageType.LOGO, - tag = logoTag, - format = ImageFormat.WEBP, - ) - - val (logo, backdrop) = withContext(Dispatchers.IO) { - val logoDeferred = async { - imageLoader.execute( - request = ImageRequest.Builder(context).data(logoUrl).build() - ).drawable?.toBitmap() - } - - val backdropDeferred = async { - imageLoader.execute( - request = ImageRequest.Builder(context).data(backdropUrl).build() - ).drawable?.toBitmap() - } - - awaitAll(logoDeferred, backdropDeferred) - } - - if (backdrop == null) { - return null - } - - return DreamContent.LibraryShowcase(item, backdrop, logo) - } catch (err: ApiClientException) { - Timber.e(err) - return null - } -}