Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use ViewModel in DreamService #3692

Merged
merged 2 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner

abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner {
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, ViewModelStoreOwner {
@Suppress("LeakingThis")
private val lifecycleRegistry = LifecycleRegistry(this)

Expand All @@ -23,6 +26,7 @@ abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner {
}

override val lifecycle: Lifecycle get() = lifecycleRegistry
override val viewModelStore = ViewModelStore()
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry

@CallSuper
Expand Down Expand Up @@ -52,6 +56,7 @@ abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner {

// Inject dependencies normally added by appcompat activities
view.setViewTreeLifecycleOwner(this)
view.setViewTreeViewModelStoreOwner(this)
view.setViewTreeSavedStateRegistryOwner(this)

// Set content composable
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiClient>()
val viewModel = koinViewModel<DreamViewModel>()
val userPreferences = koinInject<UserPreferences>()
val mediaManager = koinInject<MediaManager>()
val imageLoader = koinInject<ImageLoader>()
val context = LocalContext.current

var libraryShowcase by remember { mutableStateOf<DreamContent.LibraryShowcase?>(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
}
}
Loading