Remove dependency on livedata libraries (#1453)

## Description
Refactors all usages of `LiveData` into flows in keeping with best
practices. This also includes refactoring several `ViewModel`s to
combine multiple `LiveData` into a new single `StateFlow`.

There are no user facing changes

### Related issues
None

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-25 08:27:19 -04:00 committed by GitHub
parent 982d9e6fe8
commit 0b450fc0ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 2170 additions and 2283 deletions

View file

@ -273,11 +273,9 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)

View file

@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
@ -168,7 +169,7 @@ class MainActivity : AppCompatActivity() {
navigationManager.backStack = NavBackStack(startDestination)
}
viewModel.serverRepository.currentUser.observe(this) { user ->
viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
if (user?.hasPin == true) {
window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
@ -435,7 +436,7 @@ class MainActivityViewModel
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected =
serverRepository.currentUser.value?.let {
serverRepository.current.value?.user?.let {
it.hasPin || it.requireLogin
} == true
if (prefs.signInAutomatically && !profileProtected) {

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -88,7 +87,7 @@ class WholphinDreamService :
setViewTreeLifecycleOwner(this@WholphinDreamService)
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
setContent {
val user by serverRepository.currentUser.observeAsState()
val user by serverRepository.currentUserFlow.collectAsState(null)
if (user != null) {
var prefs by remember { mutableStateOf<AppPreferences?>(null) }
LaunchedEffect(Unit) {

View file

@ -33,7 +33,7 @@ class ItemPlaybackRepository
item: BaseItem,
prefs: UserPreferences,
): ChosenStreams? =
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
@ -91,7 +91,7 @@ class ItemPlaybackRepository
sourceId: UUID,
): ItemPlayback? =
withContext(Dispatchers.IO) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
val itemPlayback =
ItemPlayback(
userId = user.rowId,
@ -168,7 +168,7 @@ class ItemPlaybackRepository
val toSave =
if (itemPlayback.userId < 0) {
val userRowId =
serverRepository.currentUser.value
serverRepository.currentUser
?.rowId
?.takeIf { it >= 0 }
?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user")
@ -184,7 +184,7 @@ class ItemPlaybackRepository
itemId: UUID,
trackIndex: Int,
): ItemTrackModification? =
serverRepository.currentUser.value?.rowId?.let { userId ->
serverRepository.currentUser?.rowId?.let { userId ->
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
}
@ -193,7 +193,7 @@ class ItemPlaybackRepository
trackIndex: Int,
delay: Duration,
) {
serverRepository.currentUser.value?.rowId?.let { userId ->
serverRepository.currentUser?.rowId?.let { userId ->
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
itemPlaybackDao.saveItem(
ItemTrackModification(

View file

@ -4,18 +4,18 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
@ -46,14 +46,17 @@ class ServerRepository
val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
private var _current = MutableStateFlow<CurrentUser?>(null)
val current: StateFlow<CurrentUser?> = _current
private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null)
val currentUserDto: LiveData<UserDto?> = _currentUserDto
private var _currentUserDto = MutableStateFlow<UserDto?>(null)
val currentUserDto: UserDto? get() = _currentUserDto.value
val currentUserDtoFlow: StateFlow<UserDto?> get() = _currentUserDto
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
val currentServer: JellyfinServer? get() = _current.value?.server
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: JellyfinUser? get() = _current.value?.user
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
@ -65,7 +68,7 @@ class ServerRepository
serverDao.addOrUpdateServer(server)
}
apiClient.update(baseUrl = server.url, accessToken = null)
_current.setValueOnMain(null)
_current.value = null
}
/**
@ -127,7 +130,7 @@ class ServerRepository
userId: UUID?,
): CurrentUser? {
if (serverId == null || userId == null) {
_current.setValueOnMain(null)
_current.value = null
return null
}
val serverAndUsers =
@ -218,7 +221,7 @@ class ServerRepository
}
suspend fun removeUser(user: JellyfinUser) {
if (currentUser.value?.id == user.id) {
if (current.value?.user?.id == user.id) {
withContext(Dispatchers.Main) {
_current.value = null
}
@ -237,7 +240,7 @@ class ServerRepository
}
suspend fun removeServer(server: JellyfinServer) {
if (currentServer.value?.id == server.id) {
if (current.value?.server?.id == server.id) {
withContext(Dispatchers.Main) {
_current.value = null
}
@ -274,18 +277,19 @@ class ServerRepository
) {
val newUser = user.copy(pin = pin, requireLogin = requireLogin)
val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
val cur = current.value
if (cur?.user?.id == updatedUser.id && cur.server?.id == user.serverId) {
// Updating current user, so push out the change
current.value?.let {
val newCurrent = it.copy(user = updatedUser)
_current.setValueOnMain(newCurrent)
_current.value = newCurrent
}
}
}
suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(ioDispatcher) {
val userId = currentUser.value?.id
val userId = current.value?.user?.id
if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization")
throw IllegalStateException("Must be logged in to authorize Quick Connect")

View file

@ -2,10 +2,12 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext
@ -152,7 +154,7 @@ class DatePlayedInvalidationService
private val activity = (context as AppCompatActivity)
init {
serverRepository.current.observe(activity) {
serverRepository.current.collectLatestIn(activity.lifecycleScope) {
datePlayedService.invalidateAll()
}
}

View file

@ -265,8 +265,8 @@ class HomeSettingsService
*/
suspend fun createDefault(userId: UUID): HomePageResolvedSettings {
Timber.v("Creating default settings")
val user = serverRepository.currentUser.value?.takeIf { it.id == userId }
val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId }
val user = serverRepository.currentUser?.takeIf { it.id == userId }
val userDto = serverRepository.currentUserDto?.takeIf { it.id == userId }
val libraries =
if (user != null) {
navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false)
@ -679,7 +679,7 @@ class HomeSettingsService
val genreImages =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
scope = scope,
imageUrlService = imageUrlService,
genres = genreIds,

View file

@ -14,6 +14,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -94,7 +95,7 @@ class LatestNextUpSchedulerService
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
init {
serverRepository.current.observe(activity) { user ->
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME)

View file

@ -63,7 +63,7 @@ class MediaManagementService
return enabled &&
item.canDelete &&
if (item.type == BaseItemKind.RECORDING) {
serverRepository.currentUserDto.value
serverRepository.currentUserDto
?.policy
?.enableLiveTvManagement == true
} else {

View file

@ -62,7 +62,7 @@ class MediaReportService
.content.mediaSources
val sourcesJson = json.encodeToString(sources)
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
val serverVersion = serverRepository.currentServer.value?.serverVersion
val serverVersion = serverRepository.currentServer?.serverVersion
val deviceProfile =
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
val deviceProfileJson = json.encodeToString(deviceProfile)

View file

@ -159,7 +159,7 @@ class MusicService
val items =
api.instantMixApi
.getInstantMixFromItem(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
itemId = itemId,
limit = 200,
fields = DefaultItemFields,

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.asFlow
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -58,9 +57,8 @@ class NavDrawerService
init {
// Handle updating the nav drawer when the user changes
serverRepository.currentUser
.asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
serverRepository.currentUserFlow
.combine(serverRepository.currentUserDtoFlow) { user, userDto ->
Pair(user, userDto)
}.onEach { (user, userDto) ->
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)

View file

@ -93,7 +93,7 @@ class PlaylistCreator
val request =
filter.applyTo(
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
parentId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
@ -324,7 +324,7 @@ class PlaylistCreator
name: String,
initialItems: List<UUID>,
): UUID? =
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
api.playlistsApi
.createPlaylist(
CreatePlaylistDto(

View file

@ -94,7 +94,7 @@ class SeerrServerRepository
server = seerrServerDao.getServer(url)
}
server?.server?.let { server ->
serverRepository.currentUser.value?.let { jellyfinUser ->
serverRepository.currentUser?.let { jellyfinUser ->
// TODO test api key
val user =
SeerrUser(
@ -126,7 +126,7 @@ class SeerrServerRepository
server = seerrServerDao.getServer(url)
}
server?.server?.let { server ->
serverRepository.currentUser.value?.let { jellyfinUser ->
serverRepository.currentUser?.let { jellyfinUser ->
// TODO Need to update server early so that cookies are saved
seerrApi.update(server.url, null)
val userConfig = seerrLogin(seerrApi.api, authMethod, username, password)

View file

@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext
@ -42,7 +43,7 @@ class ServerEventListener
init {
activity.lifecycle.addObserver(this)
serverRepository.current.observe(activity) {
serverRepository.current.collectLatestIn(activity.lifecycleScope) {
Timber.d("New user/server: %s", it)
listenJob?.cancel()
if (it != null) {

View file

@ -30,7 +30,7 @@ class StreamChoiceService
private val serverRepository: ServerRepository,
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
) {
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
suspend fun updateAudio(
dto: BaseItemDto,
@ -58,7 +58,7 @@ class StreamChoiceService
) {
val seriesId = dto.seriesId
if (seriesId != null) {
val userId = serverRepository.currentUser.value!!.rowId
val userId = serverRepository.currentUser!!.rowId
val currentPlc =
playbackLanguageChoiceDao.get(userId, seriesId)
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
@ -70,7 +70,7 @@ class StreamChoiceService
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
dto.seriesId?.let {
playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it)
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
}
/**
@ -107,7 +107,13 @@ class StreamChoiceService
plc: PlaybackLanguageChoice?,
prefs: UserPreferences,
): MediaStream? {
val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) }
val plc =
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser!!.rowId,
it,
)
}
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
chooseAudioStream(candidates, itemPlayback, plc, prefs)
@ -158,7 +164,7 @@ class StreamChoiceService
val plc =
plc ?: seriesId?.let {
playbackLanguageChoiceDao.get(
serverRepository.currentUser.value!!.rowId,
serverRepository.currentUser!!.rowId,
it,
)
}
@ -194,7 +200,7 @@ class StreamChoiceService
}
val itemPlayback =
ItemPlayback(
userId = serverRepository.currentUser.value!!.rowId,
userId = serverRepository.currentUser!!.rowId,
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
subtitleIndex = TrackIndex.ONLY_FORCED,
)

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.services
import androidx.lifecycle.asFlow
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
@ -52,8 +51,7 @@ class SuggestionService
parentId: UUID,
itemKind: BaseItemKind,
): Flow<SuggestionsResource> {
return serverRepository.currentUser
.asFlow()
return serverRepository.currentUserFlow
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedSuggestions = cache.get(userId, parentId, itemKind)

View file

@ -8,15 +8,14 @@ import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -35,6 +34,7 @@ class SuggestionsSchedulerService
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val workManager: WorkManager,
@param:IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
private val activity =
(context as? AppCompatActivity)
@ -42,18 +42,18 @@ class SuggestionsSchedulerService
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
init {
serverRepository.current.observe(activity) { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
serverRepository.current.collect { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)
}
}
}
}

View file

@ -22,7 +22,7 @@ class UserPreferencesService
val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
serverRepository.currentUserDto!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
UserPreferences(
appPrefs,

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.SeerrServerDao
@ -39,7 +38,7 @@ class UserSwitchListener
init {
context as AppCompatActivity
context.lifecycleScope.launchDefault {
serverRepository.currentUser.asFlow().collect { user ->
serverRepository.currentUserFlow.collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }

View file

@ -116,7 +116,7 @@ object AppModule {
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token ->
serverRepository.current.value?.user?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
@ -170,7 +170,10 @@ object AppModule {
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
fun key(itemId: String): String =
serverRepository.current.value.let {
"${it?.server?.id}_${it?.user?.id}_$itemId"
}
override fun getRememberedTab(
preferences: UserPreferences,

View file

@ -13,6 +13,7 @@ import androidx.work.WorkManager
import androidx.work.await
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext
@ -43,7 +44,7 @@ class TvProviderSchedulerService
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
init {
serverRepository.current.observe(activity) { user ->
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
if (supportsTvProvider) {
if (user != null) {

View file

@ -27,7 +27,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.media3.common.Player
import coil3.request.ErrorResult
import com.github.damontecres.wholphin.data.model.BaseItem
@ -397,11 +396,6 @@ fun CoroutineScope.launchDefault(
*/
fun UUID.toServerString() = this.toString().replace("-", "")
suspend fun <T> MutableLiveData<T>.setValueOnMain(value: T) =
withContext(Dispatchers.Main) {
this@setValueOnMain.value = value
}
fun equalsNotNull(
a: Any?,
b: Any?,

View file

@ -66,7 +66,7 @@ fun CollectionFolderGrid(
preferences: UserPreferences,
item: BaseItem?,
title: String,
loadingState: DataLoadingState<List<BaseItem?>>,
items: DataLoadingState<List<BaseItem?>>,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
@ -91,7 +91,7 @@ fun CollectionFolderGrid(
) {
val context = LocalContext.current
val pager = (loadingState as? DataLoadingState.Success)?.data
val pager = (items as? DataLoadingState.Success)?.data
var showHeader by rememberSaveable { mutableStateOf(true) }
val headerRowFocusRequester = remember { FocusRequester() }
@ -119,7 +119,7 @@ fun CollectionFolderGrid(
modifier = Modifier.fillMaxSize(),
) {
CollectionFolderHeader(
showHeader = showHeader || loadingState !is DataLoadingState.Success,
showHeader = showHeader || items !is DataLoadingState.Success,
showTitle = showTitle,
playEnabled = playEnabled && pager?.isNotEmpty() == true,
title = title,
@ -147,7 +147,7 @@ fun CollectionFolderGrid(
.padding(HeaderUtils.padding),
)
}
when (val state = loadingState) {
when (val state = items) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {

View file

@ -82,7 +82,7 @@ fun CollectionFolderList(
preferences: UserPreferences,
item: BaseItem?,
title: String,
loadingState: DataLoadingState<List<BaseItem?>>,
items: DataLoadingState<List<BaseItem?>>,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
@ -108,7 +108,7 @@ fun CollectionFolderList(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val pager = (loadingState as? DataLoadingState.Success)?.data
val pager = (items as? DataLoadingState.Success)?.data
var showHeader by rememberSaveable { mutableStateOf(true) }
val headerRowFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
@ -147,7 +147,7 @@ fun CollectionFolderList(
Column(modifier = modifier) {
CollectionFolderHeader(
showHeader = showHeader || loadingState !is DataLoadingState.Success,
showHeader = showHeader || items !is DataLoadingState.Success,
showTitle = showTitle,
playEnabled = playEnabled && pager?.isNotEmpty() == true,
title = title,
@ -162,7 +162,7 @@ fun CollectionFolderList(
onFilterChange = onFilterChange,
modifier = Modifier.focusRequester(headerRowFocusRequester),
)
when (val state = loadingState) {
when (val state = items) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@ -19,12 +18,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
@ -56,15 +54,12 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.util.FilterUtils
@ -74,6 +69,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -81,12 +77,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
@ -95,6 +94,7 @@ import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
@ -104,7 +104,7 @@ class CollectionFolderViewModel
@AssistedInject
constructor(
private val savedStateHandle: SavedStateHandle,
api: ApiClient,
private val api: ApiClient,
@param:ApplicationContext private val context: Context,
val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
@ -117,13 +117,13 @@ class CollectionFolderViewModel
private val musicService: MusicService,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted val itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") private val recursive: Boolean,
@Assisted private val collectionFilter: CollectionFolderFilter,
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
@Assisted defaultViewOptions: ViewOptions,
) : ItemViewModel(api) {
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(
@ -136,11 +136,8 @@ class CollectionFolderViewModel
): CollectionFolderViewModel
}
val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading)
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
val sortAndDirection = MutableLiveData<SortAndDirection>()
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
val viewOptions = MutableStateFlow<ViewOptions>(defaultViewOptions)
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
val state: StateFlow<CollectionFolderState> = _state
var position: Int
get() = savedStateHandle.get<Int>("position") ?: 0
@ -150,19 +147,22 @@ class CollectionFolderViewModel
init {
viewModelScope.launchIO {
super.itemId = itemId
try {
val item =
itemId.toUUIDOrNull()?.let {
fetchItem(it)
}
api.userLibraryApi
.getItem(itemId.toUUID())
.content
.let(::BaseItem)
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
this@CollectionFolderViewModel.viewOptions.value =
libraryDisplayInfo?.viewOptions ?: defaultViewOptions
_state.update {
it.copy(
viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
)
}
val sortAndDirection =
if (collectionFilter.useSavedLibraryDisplayInfo) {
@ -178,12 +178,13 @@ class CollectionFolderViewModel
collectionFilter.filter
}
_state.update { it.copy(item = DataLoadingState.Success(item)) }
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
.join()
// onResumePage()
} catch (ex: Exception) {
Timber.e(ex, "Error during init")
loading.setValueOnMain(DataLoadingState.Error(ex))
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
}
}
mediaManagementService.deletedItemFlow
@ -200,7 +201,7 @@ class CollectionFolderViewModel
) {
try {
val pager =
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
position.let {
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
val item = pager?.get(it)
@ -218,12 +219,12 @@ class CollectionFolderViewModel
}
private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter = this.filter.value!!,
newSort: SortAndDirection = this.sortAndDirection.value!!,
viewOptions: ViewOptions? = this.viewOptions.value,
newFilter: GetItemsFilter = state.value.filter,
newSort: SortAndDirection = state.value.sortAndDirection,
viewOptions: ViewOptions? = state.value.viewOptions,
) {
if (collectionFilter.useSavedLibraryDisplayInfo) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
viewModelScope.launchIO {
val libraryDisplayInfo =
LibraryDisplayInfo(
@ -241,7 +242,7 @@ class CollectionFolderViewModel
}
fun saveViewOptions(viewOptions: ViewOptions) {
this.viewOptions.value = viewOptions
_state.update { it.copy(viewOptions = viewOptions) }
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions)
if (!viewOptions.showBackdrop) {
@ -255,8 +256,14 @@ class CollectionFolderViewModel
recursive: Boolean,
) {
Timber.v("onFilterChange: filter=%s", newFilter)
saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!)
loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary)
saveLibraryDisplayInfo(newFilter)
loadResults(
false,
state.value.sortAndDirection,
recursive,
newFilter,
useSeriesForPrimary,
)
}
fun onSortChange(
@ -281,21 +288,23 @@ class CollectionFolderViewModel
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
) = viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
if (resetState) {
loading.value = DataLoadingState.Loading
}
backgroundLoading.value = LoadingState.Loading
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
_state.update {
it.copy(
items = DataLoadingState.Loading,
backgroundLoading = LoadingState.Loading,
sortAndDirection = sortAndDirection,
filter = filter,
)
}
try {
val newPager =
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
loading.value = DataLoadingState.Success(newPager)
backgroundLoading.value = LoadingState.Success
_state.update {
it.copy(
items = DataLoadingState.Success(newPager),
backgroundLoading = LoadingState.Success,
)
}
} catch (ex: Exception) {
Timber.e(
@ -304,9 +313,7 @@ class CollectionFolderViewModel
sortAndDirection,
filter,
)
withContext(Dispatchers.Main) {
loading.value = DataLoadingState.Error(ex)
}
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
}
}
@ -359,7 +366,7 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
): GetItemsRequest {
val item = item.value
val item = state.value.item.successValue
val includeItemTypes =
item
?.data
@ -413,18 +420,15 @@ class CollectionFolderViewModel
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
serverRepository.currentUser?.id,
itemId.toUUID(),
filterOption,
)
suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) {
val sort = sortAndDirection.value
val filter = filter.value
if (sort == null || filter == null) {
return@withContext null
}
val sort = state.value.sortAndDirection
val filter = state.value.filter
val request =
createGetItemsRequest(
sortAndDirection = sort,
@ -447,7 +451,7 @@ class CollectionFolderViewModel
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
(loading.value as? DataLoadingState.Success)?.let {
(state.value.items as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
}
@ -458,7 +462,7 @@ class CollectionFolderViewModel
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
(loading.value as? DataLoadingState.Success)?.let {
(state.value.items as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
}
@ -480,9 +484,9 @@ class CollectionFolderViewModel
fun onResumePage() {
viewModelScope.launchIO {
item.value?.let {
Timber.v("onResumePage: %s", loading.value!!::class)
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
state.value.item.successValue?.let {
Timber.v("onResumePage: %s", state.value.items::class)
if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) {
themeSongPlayer.playThemeFor(it.id)
}
}
@ -511,6 +515,15 @@ class CollectionFolderViewModel
) = addToQueue(api, musicService, item, index)
}
data class CollectionFolderState(
val item: DataLoadingState<BaseItem> = DataLoadingState.Loading,
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Loading,
val backgroundLoading: LoadingState = LoadingState.Loading,
val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT,
val filter: GetItemsFilter = GetItemsFilter(),
val viewOptions: ViewOptions,
)
/**
* Shows a collection folder as a grid
*
@ -623,18 +636,12 @@ fun CollectionFolderView(
)
},
) {
val context = LocalContext.current
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
val filter by viewModel.filter.observeAsState(initialFilter.filter)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val viewOptions by viewModel.viewOptions.collectAsState()
val state by viewModel.state.collectAsState()
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
var showViewOptions by rememberSaveable { mutableStateOf(false) }
val contextActions =
@ -689,12 +696,12 @@ fun CollectionFolderView(
actions.onClickPlayAll ?: { shuffle ->
itemId.toUUIDOrNull()?.let {
val destination =
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
if (state.item.successValue?.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = it,
index = 0,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
filter = CollectionFolderFilter(filter = state.filter),
sortAndDirection = state.sortAndDirection,
recursive = true,
startSlideshow = true,
)
@ -704,8 +711,8 @@ fun CollectionFolderView(
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
sortAndDirection = state.sortAndDirection,
filter = state.filter,
)
}
viewModel.navigateTo(destination)
@ -719,8 +726,8 @@ fun CollectionFolderView(
Destination.Slideshow(
parentId = item.id,
index = index,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
filter = CollectionFolderFilter(filter = state.filter),
sortAndDirection = state.sortAndDirection,
recursive = true,
startSlideshow = true,
)
@ -732,7 +739,7 @@ fun CollectionFolderView(
)
}
when (val state = loading) {
when (val st = state.item) {
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
@ -742,6 +749,7 @@ fun CollectionFolderView(
is DataLoadingState.Error,
is DataLoadingState.Success<*>,
-> {
val item = st.successValue
val title =
initialFilter.nameOverride
?: item?.name
@ -755,23 +763,23 @@ fun CollectionFolderView(
viewModel.release()
}
}
if (viewOptions.type == ViewOptionsType.GRID) {
if (state.viewOptions.type == ViewOptionsType.GRID) {
CollectionFolderGrid(
preferences = preferences,
initialPosition = viewModel.position,
item = item,
title = title,
loadingState = state as DataLoadingState<List<BaseItem?>>,
sortAndDirection = sortAndDirection!!,
items = state.items,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
onSortChange = {
viewModel.onSortChange(it, recursive, filter)
viewModel.onSortChange(it, recursive, state.filter)
},
filterOptions = filterOptions,
currentFilter = filter,
currentFilter = state.filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
},
@ -785,7 +793,7 @@ fun CollectionFolderView(
positionCallback?.invoke(columns, position)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = viewOptions,
viewOptions = state.viewOptions,
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
@ -798,17 +806,17 @@ fun CollectionFolderView(
initialPosition = viewModel.position,
item = item,
title = title,
loadingState = state as DataLoadingState<List<BaseItem?>>,
sortAndDirection = sortAndDirection!!,
items = state.items,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
focusRequesterOnEmpty = focusRequesterOnEmpty,
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
onSortChange = {
viewModel.onSortChange(it, recursive, filter)
viewModel.onSortChange(it, recursive, state.filter)
},
filterOptions = filterOptions,
currentFilter = filter,
currentFilter = state.filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
},
@ -822,7 +830,7 @@ fun CollectionFolderView(
positionCallback?.invoke(columns, position)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
viewOptions = viewOptions,
viewOptions = state.viewOptions,
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
@ -832,7 +840,7 @@ fun CollectionFolderView(
}
AnimatedVisibility(
backgroundLoading == LoadingState.Loading,
state.backgroundLoading == LoadingState.Loading,
modifier =
Modifier
.align(Alignment.Center)
@ -854,7 +862,7 @@ fun CollectionFolderView(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
@ -887,11 +895,11 @@ fun CollectionFolderView(
}
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(
viewOptions = viewOptions,
viewOptions = state.viewOptions,
defaultViewOptions = defaultViewOptions,
onDismissRequest = {
showViewOptions = false
viewModel.saveViewOptions(viewOptions)
viewModel.saveViewOptions(state.viewOptions)
},
onViewOptionsChange = viewModel::saveViewOptions,
)

View file

@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
@ -29,12 +28,11 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.mayakapps.kache.InMemoryKache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -44,7 +42,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
@ -80,54 +80,63 @@ class GenreViewModel
): GenreViewModel
}
val item = MutableLiveData<BaseItem?>(null)
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<Genre>>(listOf())
private val _state = MutableStateFlow(GenreGridState())
val state: StateFlow<GenreGridState> = _state
fun init(cardWidthPx: Int) {
loading.value = LoadingState.Loading
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
val item =
api.userLibraryApi.getItem(itemId = itemId).content.let {
BaseItem(it, false)
}
this@GenreViewModel.item.setValueOnMain(item)
val request =
GetGenresRequest(
userId = serverRepository.currentUser.value?.id,
parentId = itemId,
fields = SlimItemFields,
includeItemTypes = includeItemTypes,
)
val genres =
GetGenresRequestHandler
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null)
_state.update { it.copy(item = DataLoadingState.Loading) }
viewModelScope.launchIO {
try {
val item =
api.userLibraryApi.getItem(itemId = itemId).content.let {
BaseItem(it, false)
}
withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
val genreToUrl =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser.value?.id,
scope = viewModelScope,
imageUrlService = imageUrlService,
genres = genres.map { it.id },
parentId = itemId,
includeItemTypes = includeItemTypes,
cardWidthPx = cardWidthPx,
)
val genresWithImages =
genres.map {
val request =
GetGenresRequest(
userId = serverRepository.currentUser?.id,
parentId = itemId,
fields = SlimItemFields,
includeItemTypes = includeItemTypes,
)
val genres =
GetGenresRequestHandler
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null)
}
_state.update {
it.copy(
imageUrl = genreToUrl[it.id],
item = DataLoadingState.Success(item),
genres = genres,
)
}
this@GenreViewModel.genres.setValueOnMain(genresWithImages)
val genreToUrl =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser?.id,
scope = viewModelScope,
imageUrlService = imageUrlService,
genres = genres.map { it.id },
parentId = itemId,
includeItemTypes = includeItemTypes,
cardWidthPx = cardWidthPx,
)
val genresWithImages =
genres.map {
it.copy(
imageUrl = genreToUrl[it.id],
)
}
_state.update {
it.copy(
genres = genresWithImages,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching genres")
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
}
}
}
@ -146,6 +155,11 @@ class GenreViewModel
}
}
data class GenreGridState(
val item: DataLoadingState<BaseItem> = DataLoadingState.Pending,
val genres: List<Genre> = emptyList(),
)
data class GenreCacheKey(
val userId: UUID?,
val parentId: UUID,
@ -266,34 +280,33 @@ fun GenreCardGrid(
OneTimeLaunchedEffect {
viewModel.init(cardWidthPx)
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val genres by viewModel.genres.observeAsState(listOf())
val state by viewModel.state.collectAsState()
val gridFocusRequester = remember { FocusRequester() }
when (val st = loading) {
LoadingState.Pending,
LoadingState.Loading,
when (val st = state.item) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
LoadingPage(modifier.focusable())
}
is LoadingState.Error -> {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier.focusable())
}
LoadingState.Success -> {
is DataLoadingState.Success<BaseItem> -> {
Box(modifier = modifier) {
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
val item by viewModel.item.observeAsState(null)
val item = st.data
CardGrid(
pager = genres,
pager = state.genres,
onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo(
createGenreDestination(
genreId = genre.id,
genreName = genre.name,
parentId = itemId,
parentName = item?.title,
parentName = item.title,
includeItemTypes = includeItemTypes,
),
)

View file

@ -5,15 +5,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
@ -26,16 +25,18 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.RequestHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
class ItemGridViewModel
@ -45,8 +46,8 @@ class ItemGridViewModel
private val navigationManager: NavigationManager,
@Assisted private val destination: Destination.ItemGrid<*>,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val items = MutableLiveData<List<BaseItem?>>(listOf())
private val _state = MutableStateFlow(ItemGridState())
val state: StateFlow<ItemGridState> = _state
@AssistedFactory
interface Factory {
@ -54,22 +55,28 @@ class ItemGridViewModel
}
init {
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) {
val request = destination.request as Any
val pager =
ApiRequestPager(
api,
request,
destination.requestHandler as RequestHandler<Any>,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (pager.isNotEmpty()) {
pager.getBlocking(0)
}
withContext(Dispatchers.Main) {
this@ItemGridViewModel.items.value = pager
this@ItemGridViewModel.loading.value = LoadingState.Success
viewModelScope.launchIO {
try {
val request = destination.request as Any
val pager =
ApiRequestPager(
api,
request,
destination.requestHandler as RequestHandler<Any>,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (pager.isNotEmpty()) {
pager.getBlocking(0)
}
_state.update {
it.copy(items = DataLoadingState.Success(pager))
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching items")
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
}
}
}
@ -79,6 +86,10 @@ class ItemGridViewModel
}
}
data class ItemGridState(
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Pending,
)
/**
* Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem]
*/
@ -91,20 +102,19 @@ fun ItemGrid(
creationCallback = { it.create(destination) },
),
) {
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val items by viewModel.items.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
val state by viewModel.state.collectAsState()
when (val st = state.items) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
is DataLoadingState.Success<List<BaseItem?>> -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
@ -118,7 +128,7 @@ fun ItemGrid(
modifier = Modifier.fillMaxWidth(),
)
CardGrid(
pager = items,
pager = st.data,
onClickItem = { index: Int, item: BaseItem ->
// TODO handle more types
viewModel.navigateTo(Destination.Playback(item.id, 0))

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -36,7 +35,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -351,7 +349,7 @@ fun RecommendedContent(
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
OneTimeLaunchedEffect {
viewModel.init()
@ -451,7 +449,7 @@ fun RecommendedContent(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
@ -29,23 +28,25 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.StudioCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = StudioViewModel.Factory::class)
@ -67,41 +68,46 @@ class StudioViewModel
): StudioViewModel
}
val item = MutableLiveData<BaseItem?>(null)
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val studios = MutableLiveData<List<Studio>>(listOf())
private val _state = MutableStateFlow(StudioGridState())
val state: StateFlow<StudioGridState> = _state
fun init(cardWidthPx: Int) {
loading.value = LoadingState.Loading
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
val item =
api.userLibraryApi.getItem(itemId = itemId).content.let {
BaseItem(it, false)
}
this@StudioViewModel.item.setValueOnMain(item)
val request =
GetStudiosRequest(
userId = serverRepository.currentUser.value?.id,
parentId = itemId,
fields = SlimItemFields,
includeItemTypes = includeItemTypes,
)
val studios =
GetStudiosRequestHandler
.execute(api, request)
.content.items
.map {
val imageUrl =
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
)
Studio(it.id, it.name ?: "", imageUrl)
_state.update { it.copy(item = DataLoadingState.Loading) }
viewModelScope.launchIO {
try {
val item =
api.userLibraryApi.getItem(itemId = itemId).content.let {
BaseItem(it, false)
}
withContext(Dispatchers.Main) {
this@StudioViewModel.studios.value = studios
loading.value = LoadingState.Success
val request =
GetStudiosRequest(
userId = serverRepository.currentUser?.id,
parentId = itemId,
fields = SlimItemFields,
includeItemTypes = includeItemTypes,
)
val studios =
GetStudiosRequestHandler
.execute(api, request)
.content.items
.map {
val imageUrl =
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
)
Studio(it.id, it.name ?: "", imageUrl)
}
_state.update {
it.copy(
item = DataLoadingState.Success(item),
studios = studios,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching studios")
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
}
}
}
@ -110,7 +116,7 @@ class StudioViewModel
withContext(Dispatchers.IO) {
val request =
GetStudiosRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
parentId = itemId,
nameLessThan = letter.toString(),
limit = 0,
@ -133,6 +139,11 @@ data class Studio(
override val sortName: String get() = name
}
data class StudioGridState(
val item: DataLoadingState<BaseItem> = DataLoadingState.Pending,
val studios: List<Studio> = emptyList(),
)
@Composable
fun StudioCardGrid(
itemId: UUID,
@ -162,34 +173,32 @@ fun StudioCardGrid(
OneTimeLaunchedEffect {
viewModel.init(cardWidthPx)
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val studios by viewModel.studios.observeAsState(listOf())
val state by viewModel.state.collectAsState()
val gridFocusRequester = remember { FocusRequester() }
when (val st = loading) {
LoadingState.Pending,
LoadingState.Loading,
when (val st = state.item) {
DataLoadingState.Pending,
DataLoadingState.Loading,
-> {
LoadingPage(modifier.focusable())
}
is LoadingState.Error -> {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier.focusable())
}
LoadingState.Success -> {
is DataLoadingState.Success<BaseItem> -> {
Box(modifier = modifier) {
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
val item by viewModel.item.observeAsState(null)
CardGrid(
pager = studios,
pager = state.studios,
onClickItem = { _, studio ->
viewModel.navigationManager.navigateTo(
createStudioDestination(
studioId = studio.id,
name = studio.name,
parentId = itemId,
parentName = item?.title,
parentName = st.data.title,
includeItemTypes = includeItemTypes,
),
)

View file

@ -2,18 +2,17 @@ package com.github.damontecres.wholphin.ui.data
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import java.util.UUID
@ -30,16 +29,17 @@ class AddPlaylistViewModel
@param:ApplicationContext private val context: Context,
private val playlistCreator: PlaylistCreator,
) : ViewModel() {
val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending)
val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending)
fun loadPlaylists(mediaType: MediaType?) {
viewModelScope.launchIO {
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading)
this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading
try {
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists))
this@AddPlaylistViewModel.playlistState.value =
PlaylistLoadingState.Success(playlists)
} catch (ex: Exception) {
playlistState.setValueOnMain(PlaylistLoadingState.Error(ex))
playlistState.value = PlaylistLoadingState.Error(ex)
}
}
}

View file

@ -8,8 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -21,7 +21,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
@ -41,10 +40,10 @@ import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import java.util.UUID
@ -61,16 +60,16 @@ class LiveTvCollectionViewModel
val backdropService: BackdropService,
) : ViewModel(),
RememberTabManager by rememberTabManager {
val recordingFolders = MutableLiveData<List<TabId>>()
val recordingFolders = MutableStateFlow<List<TabId>>(emptyList())
init {
viewModelScope.launchIO {
val folders =
api.liveTvApi
.getRecordingFolders(userId = serverRepository.currentUser.value?.id)
.getRecordingFolders(userId = serverRepository.currentUser?.id)
.content.items
.map { TabId(it.name ?: "Recordings", it.id) }
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
recordingFolders.value = folders
}
}
}
@ -89,7 +88,7 @@ fun CollectionFolderLiveTv(
) {
val rememberedTabIndex =
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val folders by viewModel.recordingFolders.observeAsState(listOf())
val folders by viewModel.recordingFolders.collectAsState()
val tvGuideStr = stringResource(R.string.tv_guide)
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)

View file

@ -99,7 +99,7 @@ class CollectionFolderMusicViewModel
viewModelScope.launchDefault {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
parentId = itemId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
recursive = true,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -10,13 +11,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toServerString
@ -50,6 +49,7 @@ fun CollectionFolderPhotoAlbum(
},
) {
var showHeader by remember { mutableStateOf(true) }
val state by viewModel.state.collectAsState()
CollectionFolderView(
preferences = preferences,
actions =
@ -63,11 +63,9 @@ fun CollectionFolderPhotoAlbum(
index = index,
filter =
CollectionFolderFilter(
filter = viewModel.filter.value ?: GetItemsFilter(),
filter = state.filter,
),
sortAndDirection =
viewModel.sortAndDirection.value
?: SortAndDirection.DEFAULT,
sortAndDirection = state.sortAndDirection,
recursive = recursive,
startSlideshow = false,
)

View file

@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -28,7 +28,6 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
@ -43,9 +42,8 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi
@ -66,8 +64,8 @@ class DebugViewModel
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
val supportedModes by lazy {
val displayManager =
@ -106,16 +104,14 @@ class DebugViewModel
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let {
serverRepository.currentUser?.rowId?.let {
val results = itemPlaybackDao.getItems(it)
withContext(Dispatchers.Main) {
itemPlaybacks.value = results
}
itemPlaybacks.value = results
}
}
viewModelScope.launchIO {
val logcat = getLogCatLines()
withContext(Dispatchers.Main) {
this@DebugViewModel.logcat.value = logcat
}
this@DebugViewModel.logcat.value = logcat
}
}
@ -210,8 +206,8 @@ fun DebugPage(
}
}
val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf())
val logcat by viewModel.logcat.observeAsState(listOf())
val itemPlaybacks by viewModel.itemPlaybacks.collectAsState()
val logcat by viewModel.logcat.collectAsState()
LazyColumn(
state = columnState,
@ -341,17 +337,17 @@ fun DebugPage(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Current server: ${viewModel.serverRepository.currentServer.value}",
text = "Current server: ${viewModel.serverRepository.currentServer}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Current user: ${viewModel.serverRepository.currentUser.value}",
text = "Current user: ${viewModel.serverRepository.currentUser}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}",
text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -134,7 +133,7 @@ class HomeRowGridViewModel
try {
val preferences = userPreferencesService.getCurrent()
val prefs = preferences.appPreferences.homePagePreferences
serverRepository.currentUserDto.value?.let { userDto ->
serverRepository.currentUserDto?.let { userDto ->
val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val result =
@ -244,7 +243,7 @@ fun HomeRowGrid(
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
val contextActions =
remember {
@ -381,7 +380,7 @@ fun HomeRowGrid(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -1,91 +0,0 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import java.util.UUID
/**
* Basic [ViewModel] for a single fetchable item from the API
*/
abstract class ItemViewModel(
val api: ApiClient,
) : ViewModel() {
val item = MutableLiveData<BaseItem?>(null)
lateinit var itemId: String
var itemUuid: UUID? = null
suspend fun fetchItem(itemId: UUID): BaseItem =
withContext(Dispatchers.IO) {
this@ItemViewModel.itemId = itemId.toServerString()
this@ItemViewModel.itemUuid = itemId
val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem.let {
withContext(Dispatchers.Main) {
item.value = fetchedItem
}
fetchedItem
}
}
fun imageUrl(
itemId: UUID,
type: ImageType,
): String? = api.imageApi.getItemImageUrl(itemId, type)
}
/**
* Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred
*/
abstract class LoadingItemViewModel(
api: ApiClient,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
open fun init(
itemId: UUID,
potential: BaseItem?,
): Deferred<BaseItem?> =
viewModelScope.async(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
) + Dispatchers.IO,
) {
loading.setValueOnMain(LoadingState.Loading)
try {
fetchAndSetItem(itemId)
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
withContext(Dispatchers.Main) {
item.value = null
loading.value = LoadingState.Error("Error loading item $itemId", e)
}
null
}
}
open suspend fun fetchAndSetItem(itemId: UUID): BaseItem =
withContext(Dispatchers.IO) {
val item = fetchItem(itemId)
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
return@withContext item
}
}

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -33,7 +32,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -48,7 +47,6 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -68,7 +66,6 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ResStringProvider
@ -76,13 +73,16 @@ import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RowLoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
@ -91,50 +91,76 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.time.LocalDate
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@HiltViewModel(assistedFactory = PersonViewModel.Factory::class)
class PersonViewModel
@Inject
@AssistedInject
constructor(
api: ApiClient,
private val api: ApiClient,
val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
private val seerrService: SeerrService,
) : LoadingItemViewModel(api) {
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): PersonViewModel
}
fun init(itemId: UUID) {
viewModelScope.launchIO(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
),
) {
super.init(itemId, null).await()?.let { person ->
private val _state = MutableStateFlow(PersonState())
val state: StateFlow<PersonState> = _state
private suspend fun updatePerson(): BaseItem {
val person =
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it) }
_state.update {
it.copy(
person = DataLoadingState.Success(person),
)
}
return person
}
init {
viewModelScope.launchIO {
try {
val person = updatePerson()
val dto = person.data
if ((dto.movieCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.MOVIE, movies)
fetchRow(person.id, BaseItemKind.MOVIE) { state, row ->
state.copy(movies = row)
}
} else {
movies.setValueOnMain(RowLoadingState.Success(listOf()))
_state.update { it.copy(movies = RowLoadingState.Success(emptyList())) }
}
if ((dto.seriesCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.SERIES, series)
fetchRow(person.id, BaseItemKind.SERIES) { state, row ->
state.copy(series = row)
}
} else {
series.setValueOnMain(RowLoadingState.Success(listOf()))
_state.update { it.copy(series = RowLoadingState.Success(emptyList())) }
}
if ((dto.episodeCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.EPISODE, episodes)
fetchRow(person.id, BaseItemKind.EPISODE) { state, row ->
state.copy(episodes = row)
}
} else {
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
_state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) }
}
viewModelScope.launchIO {
val results = seerrService.similar(person).orEmpty()
discovered.update { results }
_state.update {
it.copy(
discovered = results,
)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching person %s", itemId)
_state.update { it.copy(person = DataLoadingState.Error(ex)) }
}
}
}
@ -142,10 +168,12 @@ class PersonViewModel
private fun fetchRow(
itemId: UUID,
type: BaseItemKind,
target: MutableLiveData<RowLoadingState>,
update: (PersonState, RowLoadingState) -> PersonState,
) {
viewModelScope.launchIO {
target.setValueOnMain(RowLoadingState.Loading)
_state.update {
update.invoke(it, RowLoadingState.Loading)
}
try {
val request =
GetItemsRequest(
@ -165,96 +193,105 @@ class PersonViewModel
pageSize = 15,
useSeriesForPrimary = false,
).init()
target.setValueOnMain(RowLoadingState.Success(pager))
_state.update {
update.invoke(it, RowLoadingState.Success(pager))
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching $type for $itemId")
target.setValueOnMain(RowLoadingState.Error(ex))
_state.update {
update.invoke(it, RowLoadingState.Error(ex))
}
}
}
}
fun setFavorite(favorite: Boolean) {
viewModelScope.launchIO {
itemUuid?.let {
favoriteWatchManager.setFavorite(it, favorite)
fetchAndSetItem(it)
}
favoriteWatchManager.setFavorite(itemId, favorite)
updatePerson()
}
}
}
data class PersonState(
val person: DataLoadingState<BaseItem> = DataLoadingState.Pending,
val movies: RowLoadingState = RowLoadingState.Pending,
val series: RowLoadingState = RowLoadingState.Pending,
val episodes: RowLoadingState = RowLoadingState.Pending,
val discovered: List<DiscoverItem> = emptyList(),
)
@Composable
fun PersonPage(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: PersonViewModel = hiltViewModel(),
viewModel: PersonViewModel =
hiltViewModel<PersonViewModel, PersonViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
) {
OneTimeLaunchedEffect {
viewModel.init(destination.itemId)
}
val person by viewModel.item.observeAsState()
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
val discovered by viewModel.discovered.collectAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
val state by viewModel.state.collectAsState()
when (val st = state.person) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
person?.let { person ->
var showOverviewDialog by remember { mutableStateOf(false) }
val name = person.name ?: person.id.toString()
val imageUrlService = LocalImageUrlService.current
val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) }
PersonPageContent(
preferences = preferences,
name = name,
overview = person.data.overview,
imageUrl = imageUrl,
birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(),
favorite = person.favorite,
movies = movies,
series = series,
episodes = episodes,
onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
overviewOnClick = { showOverviewDialog = true },
favoriteOnClick = {
viewModel.setFavorite(!person.favorite)
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigationManager.navigateTo(item.destination)
},
modifier = modifier,
)
AnimatedVisibility(showOverviewDialog) {
ItemDetailsDialog(
info =
ItemDetailsDialogInfo(
title = name,
overview = person.data.overview,
genres = listOf(),
files = listOf(),
),
showFilePath = false,
onDismissRequest = { showOverviewDialog = false },
is DataLoadingState.Success<BaseItem> -> {
val person = st.data
var showOverviewDialog by remember { mutableStateOf(false) }
val name = remember(person) { person.name ?: person.id.toString() }
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember {
imageUrlService.getItemImageUrl(
itemId = person.id,
imageType = ImageType.PRIMARY,
)
}
PersonPageContent(
preferences = preferences,
name = name,
overview = person.data.overview,
imageUrl = imageUrl,
birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(),
favorite = person.favorite,
movies = state.movies,
series = state.series,
episodes = state.episodes,
onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
overviewOnClick = { showOverviewDialog = true },
favoriteOnClick = {
viewModel.setFavorite(!person.favorite)
},
discovered = state.discovered,
onClickDiscover = { index, item ->
viewModel.navigationManager.navigateTo(item.destination)
},
modifier = modifier,
)
AnimatedVisibility(showOverviewDialog) {
ItemDetailsDialog(
info =
ItemDetailsDialogInfo(
title = name,
overview = person.data.overview,
genres = listOf(),
files = listOf(),
),
showFilePath = false,
onDismissRequest = { showOverviewDialog = false },
)
}
}
}

View file

@ -28,7 +28,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -168,7 +167,7 @@ class PlaylistViewModel
.let { BaseItem(it, false) }
state.update { it.copy(playlist = playlist) }
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
@ -198,7 +197,7 @@ class PlaylistViewModel
)
}
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
viewModelScope.launchIO {
val libraryDisplayInfo =
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
@ -304,7 +303,7 @@ class PlaylistViewModel
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
serverRepository.currentUser?.id,
itemId,
filterOption,
)
@ -374,7 +373,7 @@ fun PlaylistDetails(
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val addPlaylistState by addToPlaylistViewModel.playlistState.collectAsState()
fun play(
index: Int,

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@ -52,7 +51,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -79,7 +77,7 @@ fun CollectionDetails(
// Dialogs
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
var showViewOptionsDialog by remember { mutableStateOf(false) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -247,7 +245,7 @@ fun CollectionDetails(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail.collection
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
@ -99,16 +98,14 @@ class CollectionViewModel
}
private val viewOptionsFlow =
serverRepository.currentUser
.asFlow()
serverRepository.currentUserFlow
.filterNotNull()
.flatMapLatest {
keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
private val libraryDisplayInfoFlow =
serverRepository.currentUser
.asFlow()
serverRepository.currentUserFlow
.filterNotNull()
.flatMapLatest {
libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
@ -282,7 +279,7 @@ class CollectionViewModel
}
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
parentId = itemId,
includeItemTypes = includeItemTypes,
excludeItemTypes = excludeItemTypes,
@ -298,7 +295,7 @@ class CollectionViewModel
fun changeSort(sortAndDirection: SortAndDirection) {
viewModelScope.launchIO {
val user = serverRepository.currentUser.value
val user = serverRepository.currentUser
val state = _state.value
if (user != null) {
libraryDisplayInfoDao.saveItem(
@ -317,7 +314,7 @@ class CollectionViewModel
fun changeFilter(filter: GetItemsFilter) {
viewModelScope.launchIO {
val user = serverRepository.currentUser.value
val user = serverRepository.currentUser
val state = _state.value
if (user != null) {
libraryDisplayInfoDao.saveItem(
@ -344,7 +341,7 @@ class CollectionViewModel
backdropService.clearBackdrop()
}
}
serverRepository.currentUser.value?.id?.let { userId ->
serverRepository.currentUser?.id?.let { userId ->
keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
}
}
@ -353,7 +350,7 @@ class CollectionViewModel
suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
serverRepository.currentUser?.id,
itemId,
filterOption,
)

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -64,8 +63,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -86,16 +85,9 @@ fun DiscoverMovieDetails(
viewModel.init()
onPauseOrDispose { }
}
val item by viewModel.movie.observeAsState()
val rating by viewModel.rating.observeAsState(null)
val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val state by viewModel.state.collectAsState()
val userConfig by viewModel.userConfig.collectAsState(null)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
val canCancel by viewModel.canCancelRequest.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -103,98 +95,97 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
when (val st = state.movie) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
item?.let { movie ->
DiscoverMovieDetailsContent(
preferences = preferences,
movie = movie,
userConfig = userConfig,
rating = rating,
canCancel = canCancel,
people = people,
trailers = trailers,
similar = similar,
recommended = recommended,
requestOnClick = {
movie.id?.let { id ->
if (request4kEnabled) {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
is DataLoadingState.Success<MovieDetails> -> {
val movie = st.data
DiscoverMovieDetailsContent(
preferences = preferences,
movie = movie,
userConfig = userConfig,
rating = state.rating,
canCancel = state.canCancelRequest,
people = state.people,
trailers = state.trailers,
similar = state.similar,
recommended = state.recommended,
requestOnClick = {
movie.id?.let { id ->
if (request4kEnabled) {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
)
} else {
viewModel.request(id, false)
}
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
),
)
} else {
viewModel.request(id, false)
}
},
cancelOnClick = {
movie.id?.let { viewModel.cancelRequest(it) }
},
onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
onClickPerson = { item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = movie.title ?: resources.getString(R.string.unknown),
overview = movie.overview,
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(),
)
},
goToOnClick = {
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
},
moreOnClick = {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items = listOf(),
)
},
onLongClickPerson = { index, person -> },
onLongClickSimilar = { index, similar ->
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
modifier = modifier,
)
}
}
},
cancelOnClick = {
movie.id?.let { viewModel.cancelRequest(it) }
},
onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
onClickPerson = { item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = movie.title ?: resources.getString(R.string.unknown),
overview = movie.overview,
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(),
)
},
goToOnClick = {
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
},
moreOnClick = {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items = listOf(),
)
},
onLongClickPerson = { index, person -> },
onLongClickSimilar = { index, similar ->
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
modifier = modifier,
)
}
}
overviewDialog?.let { info ->

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
@ -23,9 +22,8 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -36,13 +34,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
class DiscoverMovieViewModel
@ -62,15 +60,8 @@ class DiscoverMovieViewModel
fun create(item: DiscoverItem): DiscoverMovieViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val movie = MutableLiveData<MovieDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false)
private val _state = MutableStateFlow(DiscoverMovieState())
val state: StateFlow<DiscoverMovieState> = _state
val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled =
@ -80,85 +71,92 @@ class DiscoverMovieViewModel
init()
}
private fun fetchAndSetItem(): Deferred<MovieDetails> =
viewModelScope.async(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
this@DiscoverMovieViewModel.movie.setValueOnMain(movie)
movie
private fun fetchAndSetItem(): Deferred<MovieDetails?> =
viewModelScope.async(Dispatchers.IO) {
try {
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
movie
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error updating movie details")
null
}
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
viewModelScope.launchIO {
Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await()
val discoveredItem = seerrService.createDiscoverItem(movie)
backdropService.submit(discoveredItem)
try {
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
val discoveredItem = seerrService.createDiscoverItem(movie)
backdropService.submit(discoveredItem)
updateCanCancel()
updateCanCancel()
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
rating.setValueOnMain(DiscoverRating(result))
}
if (!similar.isInitialized) {
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
similar.setValueOnMain(result)
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
_state.update { it.copy(rating = DiscoverRating(result)) }
}
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
recommended.setValueOnMain(result)
if (state.value.similar.isEmpty()) {
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
_state.update { it.copy(similar = result) }
}
viewModelScope.launchIO {
val result =
seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
_state.update { it.copy(recommended = result) }
}
}
}
val people =
movie.credits
?.cast
?.map { seerrService.createDiscoverItem(it) }
.orEmpty() +
val people =
movie.credits
?.crew
?.cast
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
this@DiscoverMovieViewModel.people.setValueOnMain(people)
val trailers =
movie.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty()
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
.orEmpty() +
movie.credits
?.crew
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
_state.update { it.copy(people = people) }
val trailers =
movie.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty()
_state.update { it.copy(trailers = trailers) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error updating movie details")
_state.update { it.copy(movie = DataLoadingState.Error(ex)) }
}
}
private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests)
canCancelRequest.update { canCancel }
val canCancel =
canUserCancelRequest(
user,
state.value.movie.successValue
?.mediaInfo
?.requests,
)
_state.update { it.copy(canCancelRequest = canCancel) }
}
fun navigateTo(destination: Destination) {
@ -185,7 +183,7 @@ class DiscoverMovieViewModel
fun cancelRequest(id: Int) {
viewModelScope.launchIO {
movie.value?.mediaInfo?.requests?.firstOrNull()?.let {
state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
// TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
fetchAndSetItem().await()
@ -204,3 +202,14 @@ fun canUserCancelRequest(
user.hasPermission(SeerrPermission.REQUEST) &&
requests?.any { it.requestedBy?.id == user?.id } == true
)
data class DiscoverMovieState(
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val seasons: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),
val people: List<DiscoverItem> = emptyList(),
val similar: List<DiscoverItem> = emptyList(),
val recommended: List<DiscoverItem> = emptyList(),
val canCancelRequest: Boolean = false,
)

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -66,8 +65,9 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.successValue
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -85,17 +85,8 @@ fun DiscoverSeriesDetails(
) {
val context = LocalContext.current
val resources = LocalResources.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.tvSeries.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf())
val userConfig by viewModel.userConfig.collectAsState(null)
val state by viewModel.state.collectAsState()
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
val canCancel by viewModel.canCancelRequest.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -105,74 +96,73 @@ fun DiscoverSeriesDetails(
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
when (val st = state.tvSeries) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
item?.let { item ->
val rating by viewModel.rating.observeAsState(null)
DiscoverSeriesDetailsContent(
preferences = preferences,
series = item,
userConfig = userConfig,
rating = rating,
canCancel = canCancel,
seasons = seasons,
people = people,
similar = similar,
recommended = recommended,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
onClickPerson = {
viewModel.navigateTo(Destination.DiscoveredItem(it))
},
goToOnClick = {
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = item.name ?: resources.getString(R.string.unknown),
overview = item.overview,
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(),
)
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
trailers = trailers,
requestOnClick = {
item.id?.let { id ->
showRequestSeasonDialog = true
}
},
cancelOnClick = {
item.id?.let { viewModel.cancelRequest(it) }
},
moreOnClick = {
},
onLongClickPerson = { _, _ -> },
onLongClickSimilar = { _, _ -> },
)
}
is DataLoadingState.Success<TvDetails> -> {
val item = st.data
val userConfig by viewModel.userConfig.collectAsState(null)
DiscoverSeriesDetailsContent(
preferences = preferences,
series = item,
userConfig = userConfig,
rating = state.rating,
canCancel = state.canCancelRequest,
seasons = state.seasons,
people = state.people,
similar = state.similar,
recommended = state.recommended,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item))
},
onClickPerson = {
viewModel.navigateTo(Destination.DiscoveredItem(it))
},
goToOnClick = {
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
viewModel.navigateTo(
Destination.MediaItem(
itemId = it,
type = BaseItemKind.MOVIE,
),
)
}
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = item.name ?: resources.getString(R.string.unknown),
overview = item.overview,
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
files = listOf(),
)
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
trailers = state.trailers,
requestOnClick = {
item.id?.let { id ->
showRequestSeasonDialog = true
}
},
cancelOnClick = {
item.id?.let { viewModel.cancelRequest(it) }
},
moreOnClick = {
},
onLongClickPerson = { _, _ -> },
onLongClickSimilar = { _, _ -> },
)
}
}
overviewDialog?.let { info ->
@ -203,11 +193,13 @@ fun DiscoverSeriesDetails(
}
if (showRequestSeasonDialog) {
RequestSeasonsDialog(
title = item?.name ?: "",
seasons = seasons,
title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons,
request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k ->
item?.id?.let { viewModel.request(it, seasons, is4k) }
state.tvSeries.successValue
?.id
?.let { viewModel.request(it, seasons, is4k) }
showRequestSeasonDialog = false
},
onDismissRequest = { showRequestSeasonDialog = false },

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
@ -21,25 +20,24 @@ import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.successValue
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.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@ -61,16 +59,8 @@ class DiscoverSeriesViewModel
fun create(item: DiscoverItem): DiscoverSeriesViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val tvSeries = MutableLiveData<TvDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null)
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false)
private val _state = MutableStateFlow(DiscoverSeriesState())
val state: StateFlow<DiscoverSeriesState> = _state
val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false }
@ -79,128 +69,132 @@ class DiscoverSeriesViewModel
init()
}
private fun fetchAndSetItem(): Deferred<TvDetails> =
viewModelScope.async(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv)
tv
private fun fetchAndSetItem(): Deferred<TvDetails?> =
viewModelScope.async(Dispatchers.IO) {
try {
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
tv
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error updating tv details")
null
}
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
viewModelScope.launchIO {
Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await()
val discoveredItem = seerrService.createDiscoverItem(tv)
backdropService.submit(discoveredItem)
try {
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
val discoveredItem = seerrService.createDiscoverItem(tv)
backdropService.submit(discoveredItem)
updateSeasonStatus()
updateCanCancel()
updateSeasonStatus(tv)
updateCanCancel()
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
rating.setValueOnMain(DiscoverRating(result))
}
if (!similar.isInitialized) {
viewModelScope.launchIO {
val result =
seerrService.api.tvApi
.tvTvIdSimilarGet(tvId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
similar.setValueOnMain(result)
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
_state.update { it.copy(rating = DiscoverRating(result)) }
}
viewModelScope.launchIO {
val result =
seerrService.api.tvApi
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
recommended.setValueOnMain(result)
if (state.value.similar.isEmpty()) {
viewModelScope.launchIO {
val result =
seerrService.api.tvApi
.tvTvIdSimilarGet(tvId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
_state.update { it.copy(similar = result) }
}
viewModelScope.launchIO {
val result =
seerrService.api.tvApi
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
_state.update { it.copy(recommended = result) }
}
}
}
val people =
tv.credits
?.cast
?.map { seerrService.createDiscoverItem(it) }
.orEmpty() +
val people =
tv.credits
?.crew
?.cast
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
.orEmpty() +
tv.credits
?.crew
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
_state.update { it.copy(people = people) }
val trailers =
tv.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty()
this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers)
val trailers =
tv.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty()
_state.update { it.copy(trailers = trailers) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error getting tv details")
_state.update { it.copy(tvSeries = DataLoadingState.Error(ex)) }
}
}
fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination)
}
private suspend fun updateSeasonStatus() {
tvSeries.value?.let { tv ->
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
}
private fun updateSeasonStatus(tv: TvDetails) {
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
}
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new
}
}
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new
}
}
}
Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability)
}
}
Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability)
}
seasons.setValueOnMain(requestSeasons)
}
}
_state.update { it.copy(seasons = requestSeasons) }
}
private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
canCancelRequest.update { canCancel }
val canCancel =
canUserCancelRequest(
user,
state.value.tvSeries.successValue
?.mediaInfo
?.requests,
)
_state.update { it.copy(canCancelRequest = canCancel) }
}
fun request(
@ -209,7 +203,7 @@ class DiscoverSeriesViewModel
is4k: Boolean,
) {
viewModelScope.launchIO {
tvSeries.value?.let { tv ->
state.value.tvSeries.successValue?.let { tv ->
val currentRequest =
tv.mediaInfo?.requests?.firstOrNull {
it.requestedBy?.id ==
@ -238,21 +232,35 @@ class DiscoverSeriesViewModel
)
}
fetchAndSetItem().await()
updateSeasonStatus()
updateCanCancel()
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
updateCanCancel()
}
}
}
}
fun cancelRequest(id: Int) {
viewModelScope.launchIO {
tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let {
state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
// TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
fetchAndSetItem().await()
updateCanCancel()
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
updateCanCancel()
}
}
}
}
}
data class DiscoverSeriesState(
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val seasons: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),
val people: List<DiscoverItem> = emptyList(),
val similar: List<DiscoverItem> = emptyList(),
val recommended: List<DiscoverItem> = emptyList(),
val canCancelRequest: Boolean = false,
)

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -43,11 +42,10 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
@ -66,27 +64,20 @@ fun EpisodeDetails(
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LifecycleResumeEffect(Unit) {
viewModel.init()
onPauseOrDispose { }
}
val item by viewModel.item.observeAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val state by viewModel.state.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
val canDelete by viewModel.canDelete.collectAsState()
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
val contextActions =
remember {
@ -119,75 +110,74 @@ fun EpisodeDetails(
)
}
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
when (val st = state.episode) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
item?.let { ep ->
LifecycleResumeEffect(ep) {
ep.data.seriesId?.let { seriesId ->
viewModel.maybePlayThemeSong(seriesId)
}
onPauseOrDispose {
viewModel.release()
}
is DataLoadingState.Success<BaseItem> -> {
val ep = st.data
LifecycleResumeEffect(ep) {
ep.data.seriesId?.let { seriesId ->
viewModel.maybePlayThemeSong(seriesId)
}
onPauseOrDispose {
viewModel.release()
}
EpisodeDetailsContent(
preferences = preferences,
ep = ep,
chosenStreams = chosenStreams,
playOnClick = {
viewModel.navigateTo(
Destination.Playback(
ep.id,
it.inWholeMilliseconds,
),
)
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(ep)
},
moreOnClick = {
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = canDelete,
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
watchOnClick = {
viewModel.setWatched(ep.id, !ep.played)
},
favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite)
},
canDelete = canDelete,
onConfirmDelete = { viewModel.deleteItem(ep) },
modifier = modifier,
)
}
EpisodeDetailsContent(
preferences = preferences,
ep = ep,
chosenStreams = state.chosenStreams,
playOnClick = {
viewModel.navigateTo(
Destination.Playback(
ep.id,
it.inWholeMilliseconds,
),
)
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(ep)
},
moreOnClick = {
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
item = ep,
chosenStreams = state.chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = canDelete,
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
watchOnClick = {
viewModel.setWatched(ep.id, !ep.played)
},
favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite)
},
canDelete = canDelete,
onConfirmDelete = { viewModel.deleteItem(ep) },
modifier = modifier,
)
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
userDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -1,9 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.episode
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
@ -22,26 +20,26 @@ import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class)
@ -67,53 +65,61 @@ class EpisodeViewModel
fun create(itemId: UUID): EpisodeViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
private val _state = MutableStateFlow(EpisodeState())
val state: StateFlow<EpisodeState> = _state
val canDelete = MutableStateFlow(false)
init {
init()
viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
mediaManagementService.collectCanDelete(
state.map { (it.episode as? DataLoadingState.Success<BaseItem?>)?.data },
) { canDelete ->
this@EpisodeViewModel.canDelete.update { canDelete }
}
}
}
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@EpisodeViewModel.item.setValueOnMain(item)
item
private fun fetchAndSetItem() {
viewModelScope.launchIO {
try {
val item =
api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api)
}
_state.update { it.copy(episode = DataLoadingState.Success(item)) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error getting episode %s", itemId)
showToast(context, "Error updating episode")
}
}
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await()
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item
chosenStreams.value = result
loading.value = LoadingState.Success
viewModelScope.launchIO {
try {
val prefs = userPreferencesService.getCurrent()
val item =
api.userLibraryApi.getItem(itemId).content.let {
BaseItem(it)
}
val chosenStreams =
itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
_state.update {
it.copy(
episode = DataLoadingState.Success(item),
chosenStreams = chosenStreams,
)
}
backdropService.submit(item)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error getting episode %s", itemId)
_state.update { it.copy(episode = DataLoadingState.Error(ex)) }
}
}
@ -145,9 +151,7 @@ class EpisodeViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
_state.update { it.copy(chosenStreams = chosen) }
}
}
@ -171,9 +175,7 @@ class EpisodeViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
_state.update { it.copy(chosenStreams = chosen) }
}
}
@ -195,14 +197,16 @@ class EpisodeViewModel
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
this@EpisodeViewModel.chosenStreams.setValueOnMain(result)
state.value.episode.let { item ->
if (item is DataLoadingState.Success<BaseItem>) {
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item.data,
userPreferencesService.getCurrent(),
)
_state.update { it.copy(chosenStreams = result) }
}
}
}
}
@ -213,3 +217,8 @@ class EpisodeViewModel
}
}
}
data class EpisodeState(
val episode: DataLoadingState<BaseItem> = DataLoadingState.Pending,
val chosenStreams: ChosenStreams? = null,
)

View file

@ -10,8 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
@ -44,13 +43,14 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import timber.log.Timber
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
@ -63,36 +63,51 @@ class DvrScheduleViewModel
private val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val active = MutableLiveData<List<BaseItem>>()
val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>()
private val _state = MutableStateFlow(DvrScheduleState())
val state: StateFlow<DvrScheduleState> = _state
init {
init()
}
fun init() {
// loading.value = LoadingState.Loading
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) {
val active =
api.liveTvApi
.getRecordings(
isInProgress = true,
fields = SlimItemFields,
limit = 100,
).content.items
.map { BaseItem.from(it, api, true) }
val scheduled =
api.liveTvApi
.getTimers(
isActive = false,
isScheduled = true,
).content.items
.map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings
.groupBy {
it.data.startDate!!.toLocalDate()
}
viewModelScope.launchIO {
try {
val active =
api.liveTvApi
.getRecordings(
isInProgress = true,
fields = SlimItemFields,
limit = 100,
).content.items
.map { BaseItem.from(it, api, true) }
val scheduled =
api.liveTvApi
.getTimers(
isActive = false,
isScheduled = true,
).content.items
.map {
BaseItem.from(
it.programInfo!!,
api,
true,
)
} // TODO this probably breaks for time based recordings
.groupBy {
it.data.startDate!!.toLocalDate()
}
withContext(Dispatchers.Main) {
this@DvrScheduleViewModel.active.value = active
this@DvrScheduleViewModel.scheduled.value = scheduled
loading.value = LoadingState.Success
_state.update {
it.copy(
loading = LoadingState.Success,
active = active,
scheduled = scheduled,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching DVR schedule")
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
}
@ -112,6 +127,12 @@ class DvrScheduleViewModel
}
}
data class DvrScheduleState(
val loading: LoadingState = LoadingState.Loading,
val active: List<BaseItem> = emptyList(),
val scheduled: Map<LocalDate, List<BaseItem>> = emptyMap(),
)
@Composable
fun DvrSchedule(
requestFocusAfterLoading: Boolean,
@ -122,12 +143,10 @@ fun DvrSchedule(
LaunchedEffect(Unit) {
viewModel.init()
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val active by viewModel.active.observeAsState(listOf())
val recordings by viewModel.scheduled.observeAsState(mapOf())
when (val state = loading) {
val state by viewModel.state.collectAsState()
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
ErrorMessage(st, modifier)
}
LoadingState.Loading,
@ -141,7 +160,7 @@ fun DvrSchedule(
val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) {
LaunchedEffect(Unit) {
if (active.isNotEmpty() || recordings.isNotEmpty()) {
if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) {
focusRequester.tryRequestFocus()
} else {
focusRequesterOnEmpty.tryRequestFocus()
@ -149,8 +168,8 @@ fun DvrSchedule(
}
}
DvrScheduleContent(
activeRecordings = active,
scheduledRecordings = recordings,
activeRecordings = state.active,
scheduledRecordings = state.scheduled,
onClickItem = {
showDialog = it
},

View file

@ -121,7 +121,7 @@ class LiveTvViewModel
val channelData by api.liveTvApi.getLiveTvChannels(
GetLiveTvChannelsRequest(
startIndex = 0,
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
enableFavoriteSorting = favoriteChannelsAtBeginning,
sortBy =
if (sortByRecentlyWatched) {
@ -229,7 +229,7 @@ class LiveTvViewModel
minEndDate = minEndDate,
channelIds = channelsToFetch.map { it.id },
sortBy = listOf(ItemSortBy.START_DATE),
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
fields = listOf(ItemFields.OVERVIEW),
)
val fetchedPrograms =

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -58,7 +57,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty
@ -97,14 +95,10 @@ fun MovieDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
val contextActions =
remember {
@ -258,7 +252,7 @@ fun MovieDetails(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
userDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -166,7 +166,7 @@ class MovieViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,

View file

@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -76,7 +75,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -188,7 +186,7 @@ class AlbumViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
itemId = itemId,
excludeArtistIds = album.data.albumArtists?.map { it.id },
fields = SlimItemFields,
@ -202,7 +200,7 @@ class AlbumViewModel
viewModelScope.launchIO {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
albumIds = listOf(itemId),
parentId = null,
fields = DefaultItemFields,
@ -328,7 +326,7 @@ fun AlbumDetailsPage(
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val focusManager = LocalFocusManager.current
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
val moreDialogActions =
remember {

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -71,7 +70,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -210,7 +208,7 @@ class ArtistViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
itemId = itemId,
excludeArtistIds = listOf(itemId),
fields = SlimItemFields,
@ -224,7 +222,7 @@ class ArtistViewModel
viewModelScope.launchIO {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
artistIds = listOf(itemId),
parentId = null,
fields = DefaultItemFields,
@ -319,7 +317,7 @@ fun ArtistDetailsPage(
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
val moreDialogActions =
remember {

View file

@ -48,7 +48,7 @@ class SearchForViewModel
try {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
searchTerm = query,
includeItemTypes = listOf(searchType),
recursive = true,

View file

@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -34,7 +33,6 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -79,7 +77,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty
@ -89,7 +86,6 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
@ -111,20 +107,9 @@ fun SeriesDetails(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val canDelete by viewModel.canDeleteSeries.collectAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
val discoverSeries by viewModel.discoverSeries.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val state by viewModel.state.collectAsState()
val playlistState by playlistViewModel.playlistState.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
@ -180,118 +165,116 @@ fun SeriesDetails(
}
}
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
when (val st = state.series) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
item?.let { item ->
LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage()
is DataLoadingState.Success<BaseItem> -> {
LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage()
onPauseOrDispose {}
}
val played = item.data.userData?.played ?: false
SeriesDetailsContent(
preferences = preferences,
series = item,
seasons = seasons,
trailers = trailers,
extras = extras,
people = people,
similar = similar,
played = played,
favorite = item.data.userData?.isFavorite ?: false,
canDelete = canDelete,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
},
onClickPerson = {
onPauseOrDispose {}
}
val series = st.data
val played = series.data.userData?.played ?: false
SeriesDetailsContent(
preferences = preferences,
series = series,
seasons = state.seasons,
trailers = state.trailers,
extras = state.extras,
people = state.people,
similar = state.similar,
played = played,
favorite = series.data.userData?.isFavorite ?: false,
canDelete = state.canDeleteSeries,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
},
onClickPerson = {
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
onLongClickItem = { index, season ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = season,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(season, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
overviewOnClick = {
overviewDialog = ItemDetailsDialogInfo(series)
},
playOnClick = { shuffle ->
if (shuffle) {
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
Destination.PlaybackList(
itemId = series.id,
shuffle = true,
),
)
} else {
viewModel.playNextUp()
}
},
watchOnClick = { showWatchConfirmation = true },
favoriteOnClick = {
val favorite = series.data.userData?.isFavorite ?: false
viewModel.setFavorite(series.id, !favorite, null)
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
discoverSeries = state.discoverSeries,
onClickDiscoverSeries = {
state.discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it))
}
},
discovered = state.discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
onShowContextMenu = {
showContextMenu = it
},
actions = contextActions,
)
if (showWatchConfirmation) {
ConfirmDialog(
title = series.name ?: "",
body =
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
onCancel = {
showWatchConfirmation = false
},
onLongClickItem = { index, season ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = season,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(season, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
onConfirm = {
viewModel.setWatchedSeries(!played)
showWatchConfirmation = false
},
overviewOnClick = {
overviewDialog = ItemDetailsDialogInfo(item)
},
playOnClick = { shuffle ->
if (shuffle) {
viewModel.navigateTo(
Destination.PlaybackList(
itemId = item.id,
shuffle = true,
),
)
} else {
viewModel.playNextUp()
}
},
watchOnClick = { showWatchConfirmation = true },
favoriteOnClick = {
val favorite = item.data.userData?.isFavorite ?: false
viewModel.setFavorite(item.id, !favorite, null)
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
discoverSeries = discoverSeries,
onClickDiscoverSeries = {
discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it))
}
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
onShowContextMenu = {
showContextMenu = it
},
actions = contextActions,
)
if (showWatchConfirmation) {
ConfirmDialog(
title = item.name ?: "",
body =
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
onCancel = {
showWatchConfirmation = false
},
onConfirm = {
viewModel.setWatchedSeries(!played)
showWatchConfirmation = false
},
)
}
}
}
}

View file

@ -6,21 +6,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.map
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ContextMenu
@ -32,10 +29,9 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.DataLoadingState
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@ -82,28 +78,21 @@ fun SeriesOverview(
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() }
val extrasRowFocusRequester = remember { FocusRequester() }
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(emptyList())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val seasonExtras by viewModel.extras.observeAsState(emptyList())
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
val episodeList = (episodes as? EpisodeList.Success)?.episodes
val state by viewModel.state.collectAsState()
val episodeList =
remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes }
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
val currentPosition by rememberUpdatedState(position)
LaunchedEffect(Unit) {
if (seasons.isNotEmpty()) {
seasons.getOrNull(position.seasonTabIndex)?.let {
if (state.seasons.isNotEmpty()) {
state.seasons.getOrNull(position.seasonTabIndex)?.let {
viewModel.loadEpisodes(it.id)
}
}
@ -112,7 +101,7 @@ fun SeriesOverview(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
var rowFocused by rememberInt()
@ -149,7 +138,7 @@ fun SeriesOverview(
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onClearChosenStreams = {
val focusedEpisode =
(episodes as? EpisodeList.Success)
(state.episodes as? EpisodeList.Success)
?.episodes
?.getOrNull(currentPosition.episodeRowIndex)
if (focusedEpisode != null) {
@ -159,9 +148,9 @@ fun SeriesOverview(
)
}
LaunchedEffect(position, episodes) {
LaunchedEffect(position, state.episodes) {
val focusedEpisode =
(episodes as? EpisodeList.Success)
(state.episodes as? EpisodeList.Success)
?.episodes
?.getOrNull(position.episodeRowIndex)
@ -170,93 +159,126 @@ fun SeriesOverview(
viewModel.lookupPeopleInEpisode(it)
}
}
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val chosenStreams = state.chosenStreams
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
when (val st = state.series) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
series?.let { series ->
is DataLoadingState.Success<BaseItem> -> {
RequestOrRestoreFocus(
when (rowFocused) {
EPISODE_ROW -> episodeRowFocusRequester
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
GUEST_STAR_ROW -> guestStarRowFocusRequester
EXTRAS_ROW -> extrasRowFocusRequester
else -> episodeRowFocusRequester
},
"series_overview",
)
LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage()
RequestOrRestoreFocus(
when (rowFocused) {
EPISODE_ROW -> episodeRowFocusRequester
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
GUEST_STAR_ROW -> guestStarRowFocusRequester
EXTRAS_ROW -> extrasRowFocusRequester
else -> episodeRowFocusRequester
},
"series_overview",
)
LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage()
onPauseOrDispose {
viewModel.release()
}
onPauseOrDispose {
viewModel.release()
}
}
SeriesOverviewContent(
preferences = preferences,
series = series,
seasons = seasons,
episodes = episodes,
chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode,
seasonExtras = seasonExtras,
position = position,
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester,
extrasRowFocusRequester = extrasRowFocusRequester,
onChangeSeason = { index ->
if (index != position.seasonTabIndex) {
seasons.getOrNull(index)?.let { season ->
viewModel.loadEpisodes(season.id)
viewModel.position.update {
SeriesOverviewPosition(index, 0)
}
SeriesOverviewContent(
preferences = preferences,
series = st.data,
seasons = state.seasons,
episodes = state.episodes,
chosenStreams = chosenStreams,
peopleInEpisode = state.peopleInEpisode.people,
seasonExtras = state.extras,
position = position,
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
castCrewRowFocusRequester = castCrewRowFocusRequester,
guestStarRowFocusRequester = guestStarRowFocusRequester,
extrasRowFocusRequester = extrasRowFocusRequester,
onChangeSeason = { index ->
if (index != position.seasonTabIndex) {
state.seasons.getOrNull(index)?.let { season ->
viewModel.loadEpisodes(season.id)
viewModel.position.update {
SeriesOverviewPosition(index, 0)
}
}
},
onFocusEpisode = { episodeIndex ->
viewModel.position.update {
it.copy(episodeRowIndex = episodeIndex)
}
},
onClick = {
rowFocused = EPISODE_ROW
val resumePosition =
it.data.userData
?.playbackPositionTicks
?.ticks ?: Duration.ZERO
}
},
onFocusEpisode = { episodeIndex ->
viewModel.position.update {
it.copy(episodeRowIndex = episodeIndex)
}
},
onClick = {
rowFocused = EPISODE_ROW
val resumePosition =
it.data.userData
?.playbackPositionTicks
?.ticks ?: Duration.ZERO
viewModel.navigateTo(
Destination.Playback(
it.id,
resumePosition.inWholeMilliseconds,
),
)
},
onLongClick = { ep ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
viewModel.navigateTo(
Destination.Playback(
it.id,
resumePosition.inWholeMilliseconds,
resume.inWholeMilliseconds,
),
)
},
onLongClick = { ep ->
}
},
watchOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
favoriteOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val favorite = it.data.userData?.isFavorite ?: false
viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex)
}
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
fromLongClick = false,
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
@ -266,71 +288,31 @@ fun SeriesOverview(
canRemoveNextUp = false,
actions = contextActions,
)
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
viewModel.navigateTo(
Destination.Playback(
it.id,
resume.inWholeMilliseconds,
),
)
}
},
watchOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
favoriteOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val favorite = it.data.userData?.isFavorite ?: false
viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex)
}
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = false,
item = ep,
chosenStreams = chosenStreams,
showGoTo = false,
showStreamChoices = true,
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
}
},
overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = ItemDetailsDialogInfo(it)
}
},
personOnClick = {
rowFocused =
if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
onClickExtra = { _, extra ->
rowFocused = EXTRAS_ROW
viewModel.navigateTo(extra.destination)
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
onConfirmDelete = viewModel::deleteItem,
modifier = modifier,
)
}
}
},
overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = ItemDetailsDialogInfo(it)
}
},
personOnClick = {
rowFocused =
if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
onClickExtra = { _, extra ->
rowFocused = EXTRAS_ROW
viewModel.navigateTo(extra.destination)
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
onConfirmDelete = viewModel::deleteItem,
modifier = modifier,
)
}
}
showContextMenu?.let { contextMenu ->
@ -345,7 +327,7 @@ fun SeriesOverview(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
userDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.launchDefault
@ -36,14 +35,13 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.lt
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.successValue
import com.google.common.cache.CacheBuilder
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -57,6 +55,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOf
@ -84,7 +83,7 @@ import java.util.UUID
class SeriesViewModel
@AssistedInject
constructor(
api: ApiClient,
private val api: ApiClient,
@param:ApplicationContext val context: Context,
val serverRepository: ServerRepository,
private val navigationManager: NavigationManager,
@ -103,7 +102,7 @@ class SeriesViewModel
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType,
) : ItemViewModel(api) {
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(
@ -113,40 +112,28 @@ class SeriesViewModel
): SeriesViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
private val _state = MutableStateFlow(SeriesState())
val state: StateFlow<SeriesState> = _state
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
init {
viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading series $seriesId",
) + Dispatchers.IO,
) {
viewModelScope.launchIO {
Timber.v("Start")
addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId)
val series =
api.userLibraryApi
.getItem(seriesId)
.content
.let { BaseItem(it) }
viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
canDeleteSeries.update { canDelete }
mediaManagementService.collectCanDelete(flowOf(series)) { canDelete ->
_state.update { it.copy(canDeleteSeries = canDelete) }
}
}
backdropService.submit(item)
backdropService.submit(series)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
val seasonsDeferred = getSeasons(series, seasonEpisodeIds?.seasonNumber)
val episodeListDeferred =
if (seriesPageType == SeriesPageType.OVERVIEW) {
@ -191,57 +178,58 @@ class SeriesViewModel
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
_state.update { it.copy(extras = extras) }
}
}
val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = remoteTrailers
this@SeriesViewModel.position.update {
it.copy(
episodeRowIndex =
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
)
}
this@SeriesViewModel.seasons.value = seasons
this@SeriesViewModel.episodes.value = episodes
loading.value = LoadingState.Success
val remoteTrailers = trailerService.getRemoteTrailers(series)
this@SeriesViewModel.position.update {
it.copy(
episodeRowIndex =
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
)
}
_state.update {
it.copy(
series = DataLoadingState.Success(series),
seasons = seasons,
episodes = episodes,
trailers = remoteTrailers,
)
}
if (seriesPageType == SeriesPageType.DETAILS) {
viewModelScope.launchIO {
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers
}
trailerService.getLocalTrailers(series).letNotEmpty { localTrailers ->
_state.update { it.copy(trailers = localTrailers + remoteTrailers) }
}
}
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
val people = peopleFavorites.getPeopleFor(series)
_state.update { it.copy(people = people) }
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(item.id)
this@SeriesViewModel.extras.setValueOnMain(extras)
val extras = extrasService.getExtras(series.id)
_state.update { it.copy(extras = extras) }
}
if (!similar.isInitialized) {
if (state.value.similar.isEmpty()) {
viewModelScope.launchIO {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
itemId = seriesId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.from(it, api, true) }
this@SeriesViewModel.similar.setValueOnMain(similar)
.map { BaseItem(it, true) }
_state.update { it.copy(similar = similar) }
}
}
viewModelScope.launchIO {
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
val results = seerrService.similar(series).orEmpty()
_state.update { it.copy(discovered = results) }
}
viewModelScope.launchIO {
seerrService.active.collectLatest { active ->
@ -249,7 +237,7 @@ class SeriesViewModel
if (active) {
try {
seerrService
.getTvSeries(item)
.getTvSeries(series)
?.let { seerrService.createDiscoverItem(it) }
} catch (ex: Exception) {
Timber.e(ex)
@ -258,7 +246,7 @@ class SeriesViewModel
} else {
null
}
discoverSeries.update { tv }
_state.update { it.copy(discoverSeries = tv) }
}
}
}
@ -270,8 +258,8 @@ class SeriesViewModel
deletedItem.item.id,
seriesId,
)
val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await()
_state.update { it.copy(seasons = seasons) }
}
}.catch { ex ->
Timber.e(ex, "Error refreshing after deleted item")
@ -280,7 +268,7 @@ class SeriesViewModel
}
fun onResumePage() {
item.value?.let { item ->
state.value.series.successValue?.let { item ->
viewModelScope.launchDefault { backdropService.submit(item) }
viewModelScope.launchDefault {
themeSongPlayer.playThemeFor(seriesId)
@ -289,11 +277,9 @@ class SeriesViewModel
}
fun refresh() {
item.value?.let { item ->
if (loading.value == LoadingState.Success) {
viewModelScope.launchIO {
(seasons.value as? ApiRequestPager<*>)?.refresh()
}
state.value.series.successValue?.let { item ->
viewModelScope.launchIO {
(state.value.seasons as? ApiRequestPager<*>)?.refresh()
}
}
}
@ -389,11 +375,15 @@ class SeriesViewModel
}
fun loadEpisodes(seasonId: UUID) {
val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success)
val currentEpisodes = (state.value.episodes as? EpisodeList.Success)
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
this@SeriesViewModel.episodes.value = EpisodeList.Loading
this@SeriesViewModel.extras.value = emptyList()
_state.update {
it.copy(
peopleInEpisode = PeopleInItem(),
episodes = EpisodeList.Loading,
extras = emptyList(),
)
}
}
viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes =
@ -403,13 +393,11 @@ class SeriesViewModel
Timber.e(e, "Error loading episodes for $seriesId for season $seasonId")
EpisodeList.Error(e)
}
withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes
}
_state.update { it.copy(episodes = episodes) }
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(seasonId)
this@SeriesViewModel.extras.setValueOnMain(extras)
_state.update { it.copy(extras = extras) }
}
}
@ -424,6 +412,30 @@ class SeriesViewModel
}
}
private fun updateSeries() {
viewModelScope.launchIO {
try {
val series =
api.userLibraryApi
.getItem(seriesId)
.content
.let(::BaseItem)
_state.update { it.copy(series = DataLoadingState.Success(series)) }
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(series)
_state.update { it.copy(people = people) }
}
viewModelScope.launchIO {
val seasons = getSeasons(series, null).await()
_state.update { it.copy(seasons = seasons) }
}
} catch (ex: Exception) {
Timber.e(ex, "Error updating series")
showToast(context, "Error updating series")
}
}
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
@ -433,11 +445,7 @@ class SeriesViewModel
if (listIndex != null) {
refreshEpisode(itemId, listIndex)
} else {
val item = fetchItem(seriesId)
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
}
updateSeries()
}
}
@ -446,32 +454,27 @@ class SeriesViewModel
played: Boolean,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
setWatched(seasonId, played, null)
val series = fetchItem(seriesId)
val seasons = getSeasons(series, null).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
updateSeries()
}
fun setWatchedSeries(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(seriesId, played)
val series = fetchItem(seriesId)
val seasons = getSeasons(series, null).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
updateSeries()
}
fun refreshEpisode(
itemId: UUID,
listIndex: Int,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val eps = episodes.value
val eps = state.value.episodes
if (eps is EpisodeList.Success) {
eps.episodes.refreshItem(listIndex, itemId)
withContext(Dispatchers.Main) {
episodes.value = eps
}
_state.update { it.copy(episodes = eps) }
}
// Kind of hack to ensure the backdrop is reloaded if needed
item.value?.let { backdropService.submit(it) }
state.value.series.successValue
?.let { backdropService.submit(it) }
}
/**
@ -506,7 +509,6 @@ class SeriesViewModel
navigationManager.navigateTo(destination)
}
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
private var chosenStreamsJob: Job? = null
fun lookUpChosenTracks(
@ -522,9 +524,7 @@ class SeriesViewModel
item,
userPreferencesService.getCurrent(),
)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
_state.update { it.copy(chosenStreams = result) }
}
}
@ -540,9 +540,7 @@ class SeriesViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
_state.update { it.copy(chosenStreams = chosen) }
}
}
@ -566,9 +564,7 @@ class SeriesViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
_state.update { it.copy(chosenStreams = chosen) }
}
}
@ -581,8 +577,8 @@ class SeriesViewModel
suspend fun lookupPeopleInEpisode(item: BaseItem) {
peopleInEpisodeJob?.cancel()
if (peopleInEpisode.value?.itemId != item.id) {
peopleInEpisode.setValueOnMain(PeopleInItem())
if (state.value.peopleInEpisode.itemId != item.id) {
_state.update { it.copy(peopleInEpisode = PeopleInItem()) }
val result =
peopleInEpisodeCache
.get(item.id) {
@ -600,7 +596,8 @@ class SeriesViewModel
peopleInEpisodeJob =
viewModelScope.launch(ExceptionHandler()) {
delay(250)
peopleInEpisode.setValueOnMain(result.await())
val peopleInEpisode = result.await()
_state.update { it.copy(peopleInEpisode = peopleInEpisode) }
}
}
}
@ -621,17 +618,17 @@ class SeriesViewModel
if (item.type == BaseItemKind.SERIES) {
navigationManager.goBack()
} else if (seriesPageType == SeriesPageType.DETAILS) {
this@SeriesViewModel.item.value?.let { series ->
state.value.series.successValue?.let { series ->
val seasons = getSeasons(series, null).await()
if (seasons.isEmpty()) {
navigationManager.goBack()
} else {
this@SeriesViewModel.seasons.setValueOnMain(seasons)
_state.update { it.copy(seasons = seasons) }
}
}
} else {
position.value.let { (_, episodeIndex) ->
val eps = episodes.value as? EpisodeList.Success
val eps = state.value.episodes as? EpisodeList.Success
if (eps != null) {
val pager = eps.episodes
val lastIndex = pager.lastIndex
@ -641,22 +638,28 @@ class SeriesViewModel
} else {
if (episodeIndex == lastIndex) {
// Deleted last episode, so need to move left
episodes.setValueOnMain(
EpisodeList.Success(
eps.seasonId,
pager,
episodeIndex - 1,
),
)
_state.update {
it.copy(
episodes =
EpisodeList.Success(
eps.seasonId,
pager,
episodeIndex - 1,
),
)
}
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
} else {
episodes.setValueOnMain(
EpisodeList.Success(
eps.seasonId,
pager,
episodeIndex,
),
)
_state.update {
it.copy(
episodes =
EpisodeList.Success(
eps.seasonId,
pager,
episodeIndex,
),
)
}
}
}
}
@ -744,3 +747,18 @@ private suspend fun findIndexOf(
}
return index
}
data class SeriesState(
val series: DataLoadingState<BaseItem> = DataLoadingState.Pending,
val seasons: List<BaseItem?> = emptyList(),
val episodes: EpisodeList = EpisodeList.Loading,
val trailers: List<Trailer> = emptyList(),
val extras: List<ExtrasItem> = emptyList(),
val people: List<Person> = emptyList(),
val similar: List<BaseItem> = emptyList(),
val canDeleteSeries: Boolean = false,
val peopleInEpisode: PeopleInItem = PeopleInItem(),
val discovered: List<DiscoverItem> = emptyList(),
val discoverSeries: DiscoverItem? = null,
val chosenStreams: ChosenStreams? = null,
)

View file

@ -23,7 +23,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@ -77,7 +76,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
@ -128,7 +126,7 @@ fun HomePage(
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val playlistState by playlistViewModel.playlistState.collectAsState()
var position by rememberPosition()
val onFocusPosition = remember { { it: RowColumn -> position = it } }

View file

@ -79,7 +79,7 @@ class HomeViewModel
val preferences = userPreferencesService.getCurrent()
val prefs = preferences.appPreferences.homePagePreferences
serverRepository.currentUserDto.value?.let { userDto ->
serverRepository.currentUserDto?.let { userDto ->
val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val settings =
@ -256,7 +256,7 @@ class HomeViewModel
fun removeFromNextUp(item: BaseItem) {
if (item.type == BaseItemKind.EPISODE) {
viewModelScope.launchDefault {
serverRepository.currentUser.value?.id?.let { userId ->
serverRepository.currentUser?.id?.let { userId ->
latestNextUpService.removeFromNextUp(userId, item)
init()
}

View file

@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -58,7 +57,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
@ -102,14 +100,17 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.preferences.SwitchColors
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.SearchRelevance
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -135,15 +136,8 @@ class SearchViewModel
val partialResult = voiceInputManager.partialResult
val seerrActive = seerrService.active
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val combinedResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state
private var currentQuery: String? = null
private var combinedMode = false
@ -159,38 +153,76 @@ class SearchViewModel
combinedMode = combined
if (query.isNotNullOrBlank()) {
if (combined) {
combinedResults.value = SearchResult.Searching
_state.update { it.copy(combinedResults = SearchResult.Searching) }
searchCombined(query)
} else {
movies.value = SearchResult.Searching
series.value = SearchResult.Searching
episodes.value = SearchResult.Searching
collections.value = SearchResult.Searching
searchInternal(query, BaseItemKind.MOVIE, movies)
searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.BOX_SET, collections)
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
searchInternal(query, BaseItemKind.AUDIO, songs)
_state.update {
it.copy(
movies = SearchResult.Searching,
series = SearchResult.Searching,
episodes = SearchResult.Searching,
collections = SearchResult.Searching,
albums = SearchResult.Searching,
artists = SearchResult.Searching,
songs = SearchResult.Searching,
)
}
searchInternal(
query,
BaseItemKind.MOVIE,
) { result, state -> state.copy(movies = result) }
searchInternal(
query,
BaseItemKind.SERIES,
) { result, state -> state.copy(series = result) }
searchInternal(query, BaseItemKind.EPISODE) { result, state ->
state.copy(
episodes = result,
)
}
searchInternal(query, BaseItemKind.BOX_SET) { result, state ->
state.copy(
collections = result,
)
}
searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state ->
state.copy(
albums = result,
)
}
searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state ->
state.copy(
artists = result,
)
}
searchInternal(
query,
BaseItemKind.AUDIO,
) { result, state -> state.copy(songs = result) }
}
searchSeerr(query)
} else {
movies.value = SearchResult.NoQuery
series.value = SearchResult.NoQuery
episodes.value = SearchResult.NoQuery
collections.value = SearchResult.NoQuery
seerrResults.value = SearchResult.NoQuery
combinedResults.value = SearchResult.NoQuery
_state.update {
it.copy(
combinedResults = SearchResult.NoQuery,
movies = SearchResult.NoQuery,
series = SearchResult.NoQuery,
episodes = SearchResult.NoQuery,
collections = SearchResult.NoQuery,
albums = SearchResult.NoQuery,
artists = SearchResult.NoQuery,
songs = SearchResult.NoQuery,
)
}
}
}
private fun searchInternal(
query: String,
type: BaseItemKind,
target: MutableLiveData<SearchResult>,
update: (SearchResult, SearchState) -> SearchState,
) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
viewModelScope.launchIO {
try {
val request =
GetItemsRequest(
@ -210,14 +242,12 @@ class SearchViewModel
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" },
)
withContext(Dispatchers.Main) {
target.value = SearchResult.Success(sorted)
}
_state.update { update.invoke(SearchResult.Success(sorted), it) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Exception searching for $type")
withContext(Dispatchers.Main) {
target.value = SearchResult.Error(ex)
}
_state.update { update.invoke(SearchResult.Error(ex), it) }
}
}
}
@ -249,15 +279,10 @@ class SearchViewModel
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" },
)
withContext(Dispatchers.Main) {
combinedResults.value = SearchResult.Success(sorted)
}
_state.update { it.copy(combinedResults = SearchResult.Success(sorted)) }
} catch (ex: Exception) {
Timber.e(ex, "Exception in combined search")
withContext(Dispatchers.Main) {
combinedResults.value = SearchResult.Error(ex)
}
_state.update { it.copy(combinedResults = SearchResult.Error(ex)) }
}
}
}
@ -285,13 +310,13 @@ class SearchViewModel
private fun searchSeerr(query: String) {
viewModelScope.launchIO {
if (seerrService.active.first()) {
seerrResults.setValueOnMain(SearchResult.Searching)
_state.update { it.copy(seerrResults = SearchResult.Searching) }
val results =
seerrService
.search(query)
.map { seerrService.createDiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
_state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) }
}
}
}
@ -324,6 +349,18 @@ sealed interface SearchResult {
) : SearchResult
}
data class SearchState(
val movies: SearchResult = SearchResult.NoQuery,
val series: SearchResult = SearchResult.NoQuery,
val episodes: SearchResult = SearchResult.NoQuery,
val collections: SearchResult = SearchResult.NoQuery,
val albums: SearchResult = SearchResult.NoQuery,
val artists: SearchResult = SearchResult.NoQuery,
val songs: SearchResult = SearchResult.NoQuery,
val seerrResults: SearchResult = SearchResult.NoQuery,
val combinedResults: SearchResult = SearchResult.NoQuery,
)
private const val SEARCH_ROW = 0
private const val TAB_ROW = SEARCH_ROW + 1
private const val MOVIE_ROW = TAB_ROW + 1
@ -348,15 +385,7 @@ fun SearchPage(
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery)
val state by viewModel.state.collectAsState()
// Start with current preferences, but collect updates when view options change
val prefs =
@ -441,12 +470,7 @@ fun SearchPage(
LaunchedEffect(
searchClicked,
movies,
collections,
series,
episodes,
seerrResults,
combinedResults,
state,
combinedMode,
selectedTab,
seerrActive,
@ -458,12 +482,20 @@ fun SearchPage(
val results =
if (isLibraryTab) {
if (combinedMode) {
listOf(combinedResults)
listOf(state.combinedResults)
} else {
listOf(movies, series, episodes, collections)
listOf(
state.movies,
state.series,
state.episodes,
state.collections,
state.albums,
state.artists,
state.songs,
)
}
} else {
listOf(seerrResults)
listOf(state.seerrResults)
}
val firstSuccess =
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
@ -609,7 +641,7 @@ fun SearchPage(
when {
isLibraryTab && combinedMode -> {
SearchCombinedResults(
result = combinedResults,
result = state.combinedResults,
focusRequester = focusRequesters[COMBINED_ROW],
onClickItem = onClickItem,
onPlayItem = onPlayItem,
@ -622,7 +654,7 @@ fun SearchPage(
!isLibraryTab && combinedMode -> {
SearchCombinedResults(
result = seerrResults,
result = state.seerrResults,
focusRequester = focusRequesters[SEERR_ROW],
onClickItem = onClickItem,
onPlayItem = onPlayItem,
@ -647,7 +679,7 @@ fun SearchPage(
) {
searchResultRow(
title = R.string.movies,
result = movies,
result = state.movies,
rowIndex = MOVIE_ROW,
position = position,
focusRequester = focusRequesters[MOVIE_ROW],
@ -657,7 +689,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.tv_shows,
result = series,
result = state.series,
rowIndex = SERIES_ROW,
position = position,
focusRequester = focusRequesters[SERIES_ROW],
@ -667,7 +699,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.episodes,
result = episodes,
result = state.episodes,
rowIndex = EPISODE_ROW,
position = position,
focusRequester = focusRequesters[EPISODE_ROW],
@ -689,7 +721,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.collections,
result = collections,
result = state.collections,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
@ -699,7 +731,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.albums,
result = albums,
result = state.albums,
rowIndex = ALBUM_ROW,
position = position,
focusRequester = focusRequesters[ALBUM_ROW],
@ -723,7 +755,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.artists,
result = artists,
result = state.artists,
rowIndex = ARTIST_ROW,
position = position,
focusRequester = focusRequesters[ARTIST_ROW],
@ -747,7 +779,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.songs,
result = songs,
result = state.songs,
rowIndex = SONG_ROW,
position = position,
focusRequester = focusRequesters[SONG_ROW],
@ -771,7 +803,7 @@ fun SearchPage(
)
searchResultRow(
title = R.string.discover,
result = seerrResults,
result = state.seerrResults,
rowIndex = SEERR_ROW,
position = position,
focusRequester = focusRequesters[SEERR_ROW],

View file

@ -94,7 +94,7 @@ class HomeSettingsViewModel
init {
addCloseable { saveToLocal() }
viewModelScope.launchIO {
val userDto = serverRepository.currentUserDto.value ?: return@launchIO
val userDto = serverRepository.currentUserDto ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
@ -122,7 +122,7 @@ class HomeSettingsViewModel
val limit = 8
val semaphore = Semaphore(4)
val rows =
serverRepository.currentUserDto.value?.let { userDto ->
serverRepository.currentUserDto?.let { userDto ->
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
state.value
.let { state ->
@ -508,7 +508,7 @@ class HomeSettingsViewModel
fun saveToRemote() {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
Timber.d("Saving home settings to remote")
val rows = state.value.rows.map { it.config }
val settings =
@ -527,7 +527,7 @@ class HomeSettingsViewModel
fun loadFromRemote() {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
Timber.d("Loading home settings from remote")
try {
_state.update { it.copy(loading = LoadingState.Loading) }
@ -561,7 +561,7 @@ class HomeSettingsViewModel
fun loadFromRemoteWeb() {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
Timber.d("Loading home settings from web")
try {
_state.update { it.copy(loading = LoadingState.Loading) }
@ -588,7 +588,7 @@ class HomeSettingsViewModel
fun saveToLocal() {
// This uses injected ioScope so that it will still run when the page is closing
ioScope.launchIO {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
val rows = state.value.rows.map { it.config }
val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
@ -655,7 +655,7 @@ class HomeSettingsViewModel
fun resetToDefault() =
viewModelScope.launchIO {
val userId = serverRepository.currentUser.value?.id ?: return@launchIO
val userId = serverRepository.currentUser?.id ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.createDefault(userId)
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0

View file

@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
@ -75,7 +74,7 @@ class RemovedNextUpContentViewModel
init {
viewModelScope.launchDefault {
serverRepository.currentUser.asFlow().collectLatest { user ->
serverRepository.currentUserFlow.collectLatest { user ->
_state.update { RemovedNextUpState() }
if (user == null) {
return@collectLatest
@ -105,7 +104,7 @@ class RemovedNextUpContentViewModel
}
fun remove(item: RemovedItem) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
viewModelScope.launchDefault {
mutex.withLock {
_state.update { it.copy(removedEnabled = false) }

View file

@ -35,7 +35,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -56,7 +55,6 @@ import androidx.compose.ui.unit.IntOffset
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
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.viewModelScope
@ -86,13 +84,15 @@ import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.CollectionType
@ -111,10 +111,10 @@ class NavDrawerViewModel
val backdropService: BackdropService,
private val musicService: MusicService,
) : ViewModel() {
val state = navDrawerService.state
val serviceState = navDrawerService.state
val selectedIndex = MutableLiveData(-1)
val moreExpanded = MutableLiveData(false)
private val _state = MutableStateFlow(NavDrawerState())
val state: StateFlow<NavDrawerState> = _state
fun onClickDrawerItem(
index: Int,
@ -130,7 +130,7 @@ class NavDrawerViewModel
}
NavDrawerItem.More -> {
setShowMore(!moreExpanded.value!!)
setShowMore(!state.value.moreExpanded)
}
NavDrawerItem.Discover -> {
@ -148,11 +148,11 @@ class NavDrawerViewModel
}
fun setIndex(index: Int) {
selectedIndex.value = index
_state.update { it.copy(selectedIndex = index) }
}
fun setShowMore(value: Boolean) {
moreExpanded.value = value
_state.update { it.copy(moreExpanded = value) }
}
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
@ -164,11 +164,11 @@ class NavDrawerViewModel
viewModelScope.launchDefault {
val asDestinations =
(
state.value.items +
serviceState.value.items +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
) + state.value.moreItems
) + serviceState.value.moreItems
).map {
if (it is ServerNavDrawerItem) {
it.destination
@ -202,7 +202,7 @@ class NavDrawerViewModel
}
Timber.v("Found $index => $key")
if (index != null) {
selectedIndex.setValueOnMain(index)
_state.update { it.copy(selectedIndex = index) }
break
}
}
@ -218,6 +218,11 @@ class NavDrawerViewModel
}
}
data class NavDrawerState(
val selectedIndex: Int = -1,
val moreExpanded: Boolean = false,
)
/**
* An item that can be shown in the nav drawer
*
@ -303,10 +308,11 @@ fun NavDrawer(
drawerState.setValue(DrawerValue.Open)
focusRequester.requestFocus()
}
val serviceState by viewModel.serviceState.collectAsState()
val state by viewModel.state.collectAsState()
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
val moreExpanded = state.moreExpanded
// A negative index is a built-in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
val selectedIndex = state.selectedIndex
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
viewModel.setShowMore(false)
@ -361,14 +367,14 @@ fun NavDrawer(
modifier = Modifier,
)
AnimatedVisibility(
visible = state.nowPlayingEnabled,
visible = serviceState.nowPlayingEnabled,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
) {
val interactionSource = remember { MutableInteractionSource() }
IconNavItem(
text = stringResource(R.string.now_playing),
subtext = state.nowPlayingTitle,
subtext = serviceState.nowPlayingTitle,
icon = Icons.Default.PlayArrow,
selected = selectedIndex == NOW_PLAYING_INDEX,
drawerOpen = isOpen,
@ -448,8 +454,8 @@ fun NavDrawer(
),
)
}
itemsIndexed(state.items) { index, it ->
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
itemsIndexed(serviceState.items) { index, it ->
if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
@ -469,9 +475,9 @@ fun NavDrawer(
)
}
}
if (state.moreItems.isNotEmpty()) {
if (serviceState.moreItems.isNotEmpty()) {
item {
val index = state.items.size
val index = serviceState.items.size
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = NavDrawerItem.More,
@ -492,10 +498,10 @@ fun NavDrawer(
}
}
if (moreExpanded) {
itemsIndexed(state.moreItems) { index, it ->
itemsIndexed(serviceState.moreItems) { index, it ->
val adjustedIndex =
remember(state) { (index + state.items.size + 1) }
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
remember(serviceState) { (index + serviceState.items.size + 1) }
if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,

View file

@ -59,6 +59,8 @@ fun DownloadSubtitlesContent(
modifier: Modifier = Modifier,
) {
when (val s = state) {
SubtitleSearchStatus.Inactive -> {}
SubtitleSearchStatus.Searching -> {
Wrapper {
Text(

View file

@ -142,7 +142,7 @@ class PlayExternalViewModel
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
}
val playbackConfig =
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {

View file

@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
@ -51,7 +50,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@ -69,8 +67,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -101,7 +97,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -125,9 +120,8 @@ fun PlaybackPage(
viewModel.release()
}
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val st = loading) {
val state by viewModel.state.collectAsState()
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
@ -141,7 +135,7 @@ fun PlaybackPage(
LoadingState.Success -> {
val playerState by viewModel.currentPlayer.collectAsState()
PlaybackPageContent(
playerState = playerState!!,
playerInstance = playerState!!,
preferences = preferences,
destination = destination,
viewModel = viewModel,
@ -154,41 +148,25 @@ fun PlaybackPage(
@OptIn(UnstableApi::class)
@Composable
fun PlaybackPageContent(
playerState: PlayerState,
playerInstance: PlayerInstance,
preferences: UserPreferences,
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel,
) {
val player = playerState.player
val playerBackend = playerState.backend
val state by viewModel.state.collectAsState()
val subtitleSearchState by viewModel.subtitleSearchState.collectAsState()
val player = playerInstance.player
val playerBackend = playerInstance.backend
val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState()
val userDto by viewModel.currentUserDto.collectAsState()
val currentPlayback by viewModel.currentPlayback.collectAsState()
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
userId = -1,
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.collectAsState()
val analyticsState by viewModel.analyticsState.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
val nextUp by viewModel.nextUp.observeAsState(null)
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null)
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
LaunchedEffect(player) {
if (playerBackend == PlayerBackend.MPV) {
@ -214,16 +192,16 @@ fun PlaybackPageContent(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
LaunchedEffect(subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
LaunchedEffect(state.currentPlayback?.subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay =
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
}
val presentationState = rememberPresentationState(player, false)
val playbackState by rememberPlaybackState(player)
val playbackState by rememberPlayerState(player)
var showBuffering by remember { mutableStateOf(false) }
LaunchedEffect(playbackState) {
if (playbackState == PlaybackState.BUFFERING) {
if (playbackState == PlayerState.BUFFERING) {
// Delay before showing the loading indicator
// So if buffering is quick, the UI won't flash
delay(250)
@ -259,7 +237,7 @@ fun PlaybackPageContent(
val keyHandler =
PlaybackKeyHandler(
player = player,
controlsEnabled = nextUp == null,
controlsEnabled = state.nextUp == null,
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
@ -318,7 +296,7 @@ fun PlaybackPageContent(
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
if (pos < player.maxSeekToPreviousPosition && state.playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
@ -328,17 +306,17 @@ fun PlaybackPageContent(
}
val showSegment =
currentSegment?.interacted == false &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
state.currentSegment?.interacted == false &&
state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
viewModel.updateSegment(currentSegment?.segment?.id, true)
viewModel.updateSegment(state.currentSegment?.segment?.id, true)
}
Box(
modifier
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
.background(if (state.nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
) {
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
val playerSize by animateFloatAsState(if (state.nextUp == null) 1f else .6f)
Box(
modifier =
Modifier
@ -427,12 +405,12 @@ fun PlaybackPageContent(
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
item = state.currentPlayback?.item,
player = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
nextEnabled = state.playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
@ -441,23 +419,23 @@ fun PlaybackPageContent(
onClickPlaybackDialogType = { playbackDialog = it },
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
currentPlayback = currentPlayback,
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
currentPlayback = state.currentPlayback,
chapters = state.currentMediaInfo.chapters,
trickplayInfo = state.currentMediaInfo.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
playlist = playlist,
playlist = state.playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment?.segment,
currentSegment = state.currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
analyticsState = analyticsState,
analyticsState = state.analyticsState,
)
val subtitleSettings =
remember(mediaInfo) {
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
if (mediaInfo?.videoStream?.hdr == true) {
remember(state.currentMediaInfo) {
Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}")
if (state.currentMediaInfo.videoStream?.hdr == true) {
preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences
} else {
preferences.appPreferences.interfacePreferences.subtitlesPreferences
@ -468,10 +446,14 @@ fun PlaybackPageContent(
// Subtitles
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
val isImageSubtitles =
remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null }
var cueCount by remember { mutableIntStateOf(0) }
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
val subtitleVisible =
skipIndicatorDuration == 0L &&
state.currentItemPlayback?.subtitleIndexEnabled == true &&
!presentationState.coverSurface
AndroidView(
factory = { context ->
@ -481,7 +463,7 @@ fun PlaybackPageContent(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
playerState.assHandler?.let { assHandler ->
playerInstance.assHandler?.let { assHandler ->
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
Timber.v("Adding AssSubtitleView")
addView(
@ -499,12 +481,12 @@ fun PlaybackPageContent(
}
},
update = { subtitleView ->
subtitleView.setCues(cues)
if (cues.size > cueCount) {
subtitleView.setCues(state.subtitleCues)
if (state.subtitleCues.size > cueCount) {
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
.apply(subtitleView)
cueCount = cues.size
cueCount = state.subtitleCues.size
}
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
(it as? AssSubtitleView)?.apply {
@ -552,7 +534,7 @@ fun PlaybackPageContent(
.padding(40.dp)
.align(Alignment.BottomEnd),
) {
currentSegment?.let { segment ->
state.currentSegment?.let { segment ->
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
@ -570,7 +552,7 @@ fun PlaybackPageContent(
}
// Next up episode
BackHandler(nextUp != null) {
BackHandler(state.nextUp != null) {
if (player.isPlaying) {
scope.launch(ExceptionHandler()) {
viewModel.cancelUpNextEpisode()
@ -580,12 +562,12 @@ fun PlaybackPageContent(
}
}
AnimatedVisibility(
nextUp != null,
state.nextUp != null,
modifier =
Modifier
.align(Alignment.BottomCenter),
) {
nextUp?.let {
state.nextUp?.let {
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
var timeLeft by remember {
mutableLongStateOf(
@ -642,7 +624,7 @@ fun PlaybackPageContent(
}
}
subtitleSearch?.let { state ->
if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) {
val wasPlaying = remember { player.isPlaying }
LaunchedEffect(Unit) {
player.pause()
@ -661,8 +643,8 @@ fun PlaybackPageContent(
),
) {
DownloadSubtitlesContent(
state = state,
language = subtitleSearchLanguage,
state = subtitleSearchState.status,
language = subtitleSearchState.language,
onSearch = { lang ->
viewModel.searchForSubtitles(lang)
},
@ -684,18 +666,18 @@ fun PlaybackPageContent(
settings =
PlaybackSettings(
showDebugInfo = showDebugInfo,
audioIndex = currentItemPlayback?.audioIndex,
audioStreams = mediaInfo?.audioStreams.orEmpty(),
subtitleIndex = currentItemPlayback?.subtitleIndex,
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
audioIndex = state.currentItemPlayback?.audioIndex,
audioStreams = state.currentMediaInfo.audioStreams,
subtitleIndex = state.currentItemPlayback?.subtitleIndex,
subtitleStreams = state.currentMediaInfo.subtitleStreams,
playbackSpeed = playbackSpeed,
contentScale = contentScale,
subtitleDelay = subtitleDelay,
subtitleDelay = state.currentPlayback?.subtitleDelay ?: Duration.ZERO,
hasSubtitleDownloadPermission =
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
// TODO Passing through audio prevents changing playback speed
// See https://github.com/damontecres/Wholphin/issues/164
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
),
onDismissRequest = {
playbackDialog =

View file

@ -1,42 +1,47 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.intl.Locale
import androidx.media3.common.Player
import androidx.media3.common.listenTo
import androidx.media3.common.text.Cue
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.util.LoadingState
import io.github.peerless2012.ass.media.AssHandler
import org.jellyfin.sdk.model.api.MediaSegmentDto
/**
* Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering.
*
* @see Player.State
* @see PlaybackState
*/
@Composable
fun rememberPlaybackState(player: Player): State<PlaybackState> {
val state = remember(player) { mutableStateOf(getPlaybackState(player.playbackState)) }
LaunchedEffect(player) {
player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) {
state.value = getPlaybackState(player.playbackState)
}
}
return state
}
data class PlaybackState(
val loading: LoadingState = LoadingState.Loading,
val currentMediaInfo: CurrentMediaInfo = CurrentMediaInfo.EMPTY,
val currentPlayback: CurrentPlayback? = null,
val currentItemPlayback: ItemPlayback? = null,
val currentSegment: MediaSegmentState? = null,
val analyticsState: AnalyticsState = AnalyticsState(),
val subtitleCues: List<Cue> = emptyList(),
val nextUp: BaseItem? = null,
val playlist: Playlist = Playlist(listOf()),
)
private fun getPlaybackState(
@Player.State value: Int,
): PlaybackState = PlaybackState.entries.first { it.value == value }
data class PlayerInstance(
val player: Player,
val backend: PlayerBackend,
val assHandler: AssHandler?,
)
/**
* Represents [Player.State] integers as an Enum
*/
enum class PlaybackState(
@param:Player.State val value: Int,
) {
IDLE(Player.STATE_IDLE),
BUFFERING(Player.STATE_BUFFERING),
READY(Player.STATE_READY),
ENDED(Player.STATE_ENDED),
}
data class MediaSegmentState(
val segment: MediaSegmentDto,
val interacted: Boolean,
)
data class AnalyticsState(
val bitrate: String = formatBitrate(0),
val bitrateEstimate: String = formatBitrate(0),
val droppedFrames: Int = 0,
)
data class SubtitleSearchState(
val status: SubtitleSearchStatus = SubtitleSearchStatus.Inactive,
val language: String = Locale.current.language,
)

View file

@ -5,9 +5,7 @@ import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.ui.text.intl.Locale
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
@ -17,7 +15,6 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DecoderCounters
@ -62,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -79,20 +75,20 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
@ -105,7 +101,6 @@ import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
@ -161,7 +156,7 @@ class PlaybackViewModel
fun create(destination: Destination): PlaybackViewModel
}
val currentPlayer = MutableStateFlow<PlayerState?>(null)
val currentPlayer = MutableStateFlow<PlayerInstance?>(null)
internal lateinit var player: Player
@ -174,15 +169,8 @@ class PlaybackViewModel
true,
)
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
val analyticsState = MutableStateFlow(AnalyticsState())
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
private val _state = MutableStateFlow(PlaybackState())
val state: StateFlow<PlaybackState> = _state
private lateinit var preferences: UserPreferences
internal lateinit var itemId: UUID
@ -191,14 +179,11 @@ class PlaybackViewModel
private var activityListener: TrackActivityPlaybackListener? = null
private val jobs = mutableListOf<Job>()
val nextUp = MutableLiveData<BaseItem?>()
private val isPlaylist = destination is Destination.PlaybackList
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
val subtitleSearchState = MutableStateFlow(SubtitleSearchState())
val currentUserDto = serverRepository.currentUserDto
val currentUserDto = serverRepository.currentUserDtoFlow
init {
viewModelScope.launchIO {
@ -261,7 +246,7 @@ class PlaybackViewModel
)
this.player = playerCreation.player
currentPlayer.update {
PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler)
}
configurePlayer()
}
@ -288,7 +273,7 @@ class PlaybackViewModel
*/
private suspend fun init() {
musicService.stop()
nextUp.setValueOnMain(null)
_state.update { it.copy(nextUp = null) }
this.preferences = userPreferencesService.getCurrent()
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() }
@ -335,7 +320,7 @@ class PlaybackViewModel
)
when (val r = playlistResult) {
is PlaylistCreationResult.Error -> {
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
_state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) }
return
}
@ -346,8 +331,8 @@ class PlaybackViewModel
return
}
if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = r.playlist
_state.update {
it.copy(playlist = r.playlist)
}
}
r.playlist.items.first()
@ -365,7 +350,7 @@ class PlaybackViewModel
api.userLibraryApi
.getIntros(
itemId = playlistItem.id,
userId = serverRepository.currentUser.value?.id,
userId = serverRepository.currentUser?.id,
).content.items
.map {
PlaylistItem.Intro(BaseItem(it))
@ -376,13 +361,9 @@ class PlaybackViewModel
val firstItem =
if (intros.isNotEmpty()) {
Timber.v("Got %s intros", intros.size)
val currentPlaylist =
this@PlaybackViewModel
.playlist.value
?.items
.orEmpty()
val newPlaylist = Playlist(intros + currentPlaylist)
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
_state.update {
it.copy(playlist = Playlist(intros + it.playlist.items))
}
intros.first()
} else {
playlistItem
@ -401,17 +382,21 @@ class PlaybackViewModel
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
val currentPlaylist =
this@PlaybackViewModel
.playlist.value
?.items
.orEmpty()
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
_state.update {
it.copy(
playlist = Playlist(it.playlist.items + result.playlist.items),
)
}
}
}
}
private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) {
_state.update {
it.copy(currentPlayback = block.invoke(it.currentPlayback))
}
}
/**
* Play an item
*
@ -436,7 +421,7 @@ class PlaybackViewModel
// New item, so we can clear the media segment tracker & subtitle cues
resetSegmentState()
this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf())
_state.update { it.copy(subtitleCues = emptyList()) }
viewModelScope.launchIO {
// Starting playback, so want to invalidate the last played timestamp for this item
@ -459,7 +444,7 @@ class PlaybackViewModel
// Use the provided playback parameters or else check if the database has some
val itemPlayback =
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
@ -496,7 +481,7 @@ class PlaybackViewModel
// Create the correct player for the media
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
val subtitleLanguagePreference =
serverRepository.currentUserDto.value
serverRepository.currentUserDto
?.configuration
?.subtitleLanguagePreference
val subtitleStreams =
@ -572,19 +557,20 @@ class PlaybackViewModel
}
val chapters = Chapter.fromDto(base, api)
_state.update {
it.copy(currentItemPlayback = itemPlaybackToUse)
}
updateCurrentMedia {
CurrentMediaInfo(
sourceId = mediaSource.id,
videoStream = videoStream,
audioStreams = audioStreams,
subtitleStreams = subtitleStreams,
chapters = chapters,
trickPlayInfo = trickPlayInfo,
)
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
updateCurrentMedia {
CurrentMediaInfo(
sourceId = mediaSource.id,
videoStream = videoStream,
audioStreams = audioStreams,
subtitleStreams = subtitleStreams,
chapters = chapters,
trickPlayInfo = trickPlayInfo,
)
}
changeStreams(
item,
itemPlaybackToUse,
@ -608,7 +594,7 @@ class PlaybackViewModel
@OptIn(UnstableApi::class)
internal suspend fun changeStreams(
item: BaseItem,
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = 0,
@ -618,7 +604,7 @@ class PlaybackViewModel
) = withContext(Dispatchers.IO) {
val itemId = item.id
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
val currentPlayback = state.value.currentPlayback
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
val wasSuccessful =
changeStreamsDirectPlay(
@ -649,7 +635,7 @@ class PlaybackViewModel
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences,
serverRepository.currentServer.value?.serverVersion,
serverRepository.currentServer?.serverVersion,
)
} else {
mpvDeviceProfile
@ -669,7 +655,7 @@ class PlaybackViewModel
),
)
if (response.errorCode != null) {
loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName))
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
return@withContext
}
val source = response.mediaSources.firstOrNull()
@ -694,9 +680,14 @@ class PlaybackViewModel
source.transcodingUrl?.let(api::createUrl)
}
if (mediaUrl.isNullOrBlank()) {
loading.setValueOnMain(
LoadingState.Error("Unable to get media URL from the server. Do you have permission to view and/or transcode?"),
)
_state.update {
it.copy(
loading =
LoadingState.Error(
"Unable to get media URL from the server. Do you have permission to view and/or transcode?",
),
)
}
return@withContext
}
val transcodeType =
@ -794,8 +785,12 @@ class PlaybackViewModel
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
loading.value = LoadingState.Success
this@PlaybackViewModel.currentPlayback.update { playback }
_state.update {
it.copy(
loading = LoadingState.Success,
currentPlayback = playback,
)
}
player.setMediaItem(
mediaItem,
positionMs,
@ -884,19 +879,18 @@ class PlaybackViewModel
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
_state.update { it.copy(currentItemPlayback = updated) }
}
} else {
_state.update { it.copy(currentItemPlayback = itemPlayback) }
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.update {
(it ?: currentPlayback).copy(
tracks = checkForSupport(player.currentTracks),
)
}
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
_state.update {
it.copy(
currentPlayback =
(it.currentPlayback ?: currentPlayback).copy(
tracks = checkForSupport(player.currentTracks),
),
)
}
loadSubtitleDelay()
return@withContext true
@ -913,14 +907,14 @@ class PlaybackViewModel
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = currentItem.item,
itemPlayback = currentItemPlayback.value!!,
itemPlayback = state.value.currentItemPlayback!!,
trackIndex = index,
type = MediaStreamType.AUDIO,
)
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
_state.update { it.copy(currentItemPlayback = itemPlayback) }
// Resolve ONLY_FORCED to actual track based on new audio language
val source = currentPlayback.value?.mediaSourceInfo
val source = state.value.currentPlayback?.mediaSourceInfo
val resolvedSubtitleIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
@ -951,14 +945,14 @@ class PlaybackViewModel
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = currentItem.item,
itemPlayback = currentItemPlayback.value!!,
itemPlayback = state.value.currentItemPlayback!!,
trackIndex = index,
type = MediaStreamType.SUBTITLE,
)
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
_state.update { it.copy(currentItemPlayback = itemPlayback) }
// Resolve ONLY_FORCED to actual track index for playback
val source = currentPlayback.value?.mediaSourceInfo
val source = state.value.currentPlayback?.mediaSourceInfo
val resolvedIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
@ -1004,8 +998,8 @@ class PlaybackViewModel
fun getTrickplayUrl(
index: Int,
trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo,
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo,
mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId,
): String? =
trickPlayInfo?.let {
val itemId = currentItem.id
@ -1021,7 +1015,7 @@ class PlaybackViewModel
if (playbackState == Player.STATE_ENDED) {
Timber.v("Playback state is STATE_ENDED")
viewModelScope.launchDefault {
when (val nextItem = playlist.value?.peek()) {
when (val nextItem = state.value.playlist.peek()) {
is PlaylistItem.Intro -> {
Timber.v("Next item is intro, so playing immediately")
playNextUp()
@ -1033,9 +1027,7 @@ class PlaybackViewModel
playNextUp()
} else {
Timber.v("Setting next up to ${nextItem.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem.item
}
_state.update { it.copy(nextUp = nextItem.item) }
}
}
@ -1060,7 +1052,7 @@ class PlaybackViewModel
segmentJob?.cancel()
autoSkippedSegments.clear()
outroShownSegments.clear()
currentSegment.value = null
_state.update { it.copy(currentSegment = null) }
}
/**
@ -1086,8 +1078,7 @@ class PlaybackViewModel
currentSegment.itemId == this@PlaybackViewModel.itemId
) {
if (currentSegment.id !=
this@PlaybackViewModel
.currentSegment.value
state.value.currentSegment
?.segment
?.id
) {
@ -1098,19 +1089,17 @@ class PlaybackViewModel
currentSegment.type,
)
}
val playlist = this@PlaybackViewModel.playlist.value
val playlist = state.value.playlist
if (currentSegment.type == MediaSegmentType.OUTRO &&
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
playlist != null && playlist.hasNext() &&
playlist.hasNext() &&
outroShownSegments.add(currentSegment.id)
) {
val nextItem = playlist.peek()
if (nextItem is PlaylistItem.Media) {
Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem.item
}
_state.update { it.copy(nextUp = nextItem.item) }
}
} else {
val behavior =
@ -1123,35 +1112,31 @@ class PlaybackViewModel
MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE
}
withContext(Dispatchers.Main) {
when (behavior) {
SkipSegmentBehavior.AUTO_SKIP -> {
if (autoSkippedSegments.add(currentSegment.id)) {
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
}
this@PlaybackViewModel.currentSegment.update {
val newSegment =
when (behavior) {
SkipSegmentBehavior.AUTO_SKIP -> {
if (autoSkippedSegments.add(currentSegment.id)) {
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
}
MediaSegmentState(currentSegment, true)
}
}
SkipSegmentBehavior.ASK_TO_SKIP -> {
this@PlaybackViewModel.currentSegment.update {
SkipSegmentBehavior.ASK_TO_SKIP -> {
MediaSegmentState(
currentSegment,
autoSkippedSegments.contains(currentSegment.id),
)
}
}
else -> {
this@PlaybackViewModel.currentSegment.value = null
else -> {
null
}
}
}
_state.update { it.copy(currentSegment = newSegment) }
}
}
} else if (currentSegment == null) {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentSegment.value = null
}
_state.update { it.copy(currentSegment = null) }
}
}
}
@ -1163,17 +1148,13 @@ class PlaybackViewModel
dismissed: Boolean,
) {
viewModelScope.launchDefault {
val segment = currentSegment.value?.segment
val segment = state.value.currentSegment?.segment
if (segment != null && segment.id == segmentId) {
autoSkippedSegments.add(segment.id)
if (dismissed) {
currentSegment.update {
it?.copy(interacted = true)
}
_state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) }
} else {
currentSegment.update {
null
}
_state.update { it.copy(currentSegment = null) }
onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
}
}
@ -1182,7 +1163,7 @@ class PlaybackViewModel
private fun listenForTranscodeReason(): Job =
viewModelScope.launchIO {
currentPlayback.collectLatest {
state.map { it.currentPlayback }.collectLatest {
if (it != null) {
try {
var transcodeInfo = it.transcodeInfo
@ -1197,13 +1178,13 @@ class PlaybackViewModel
if (transcodeInfo == null) delay(3.seconds)
}
Timber.v("transcodeInfo=$transcodeInfo")
currentPlayback.update { current ->
updateCurrentPlayback { current ->
current?.copy(transcodeInfo = transcodeInfo)
}
} catch (ex: Exception) {
if (ex !is CancellationException) {
Timber.w(ex, "Exception trying to get session info")
currentPlayback.update { current ->
updateCurrentPlayback { current ->
current?.copy(transcodeInfo = null)
}
}
@ -1233,7 +1214,7 @@ class PlaybackViewModel
}
fun playNextUp() {
playlist.value?.let {
state.value.playlist.let {
if (it.hasNext()) {
viewModelScope.launchDefault {
cancelUpNextEpisode()
@ -1248,7 +1229,7 @@ class PlaybackViewModel
}
fun playPrevious() {
playlist.value?.let {
state.value.playlist.let {
if (it.hasPrevious()) {
viewModelScope.launchDefault {
cancelUpNextEpisode()
@ -1263,11 +1244,11 @@ class PlaybackViewModel
}
suspend fun cancelUpNextEpisode() {
nextUp.setValueOnMain(null)
_state.update { it.copy(nextUp = null) }
}
fun playItemInPlaylist(item: BaseItem) {
playlist.value?.let { playlist ->
state.value.playlist.let { playlist ->
viewModelScope.launchIO {
val toPlay = playlist.advanceTo(item.id)
if (toPlay != null) {
@ -1283,7 +1264,7 @@ class PlaybackViewModel
}
override fun onTracksChanged(tracks: Tracks) {
currentPlayback.update {
updateCurrentPlayback {
it?.copy(
tracks = checkForSupport(tracks),
)
@ -1291,30 +1272,34 @@ class PlaybackViewModel
}
override fun onCues(cueGroup: CueGroup) {
subtitleCues.value = cueGroup.cues
_state.update { it.copy(subtitleCues = cueGroup.cues) }
}
override fun onPlayerError(error: PlaybackException) {
Timber.e(error, "Playback error")
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
currentPlayback.value?.let {
state.value.currentPlayback?.let {
when (it.playMethod) {
PlayMethod.TRANSCODE -> {
loading.setValueOnMain(
LoadingState.Error(
"Error during playback",
error,
),
)
_state.update {
it.copy(
loading =
LoadingState.Error(
"Error during playback",
error,
),
)
}
}
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
val currentItemPlayback = state.value.currentItemPlayback!!
changeStreams(
currentItem.item,
currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
currentItemPlayback.value?.subtitleIndex,
currentItemPlayback,
currentItemPlayback.audioIndex,
currentItemPlayback.subtitleIndex,
player.currentPosition,
false,
enableDirectPlay = false,
@ -1397,9 +1382,8 @@ class PlaybackViewModel
*/
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
withContext(Dispatchers.IO) {
mutex.withLock {
val newMediaInfo = block.invoke(currentMediaInfo.value!!)
currentMediaInfo.setValueOnMain(newMediaInfo)
_state.update {
it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo))
}
}
@ -1422,7 +1406,7 @@ class PlaybackViewModel
} else {
decoderName
}
currentPlayback.update {
updateCurrentPlayback {
when (type) {
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
@ -1447,7 +1431,7 @@ class PlaybackViewModel
decoderCounters: DecoderCounters,
) {
Timber.d("onVideoDisabled")
currentPlayback.update { it?.copy(videoDecoder = null) }
updateCurrentPlayback { it?.copy(videoDecoder = null) }
}
override fun onVideoInputFormatChanged(
@ -1491,21 +1475,21 @@ class PlaybackViewModel
decoderCounters: DecoderCounters,
) {
Timber.d("decoder: onAudioDisabled")
currentPlayback.update { it?.copy(audioDecoder = null) }
updateCurrentPlayback { it?.copy(audioDecoder = null) }
}
private var subtitleDelaySaveJob: Job? = null
fun updateSubtitleDelay(delta: Duration) {
subtitleDelaySaveJob?.cancel()
currentPlayback.update {
updateCurrentPlayback {
it?.let {
val newDelay = it.subtitleDelay + delta
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
subtitleDelaySaveJob =
viewModelScope.launchIO {
// Debounce & save
currentItemPlayback.value?.let { item ->
state.value.currentItemPlayback?.let { item ->
delay(1500)
itemPlaybackRepository.saveTrackModifications(
item.itemId,
@ -1520,7 +1504,7 @@ class PlaybackViewModel
}
suspend fun loadSubtitleDelay() {
currentItemPlayback.value?.let {
state.value.currentItemPlayback?.let {
if (it.subtitleIndexEnabled) {
val result =
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
@ -1531,7 +1515,7 @@ class PlaybackViewModel
it.subtitleIndex,
it.itemId,
)
currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
}
}
}
@ -1554,10 +1538,13 @@ class PlaybackViewModel
bitrateEstimate,
)
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
analyticsState.update {
_state.update {
it.copy(
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
analyticsState =
it.analyticsState.copy(
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
),
)
}
}
@ -1568,24 +1555,13 @@ class PlaybackViewModel
droppedFrames: Int,
elapsedMs: Long,
) {
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
_state.update {
it.copy(
analyticsState =
it.analyticsState.copy(
droppedFrames = it.analyticsState.droppedFrames + droppedFrames,
),
)
}
}
}
data class PlayerState(
val player: Player,
val backend: PlayerBackend,
val assHandler: AssHandler?,
)
data class MediaSegmentState(
val segment: MediaSegmentDto,
val interacted: Boolean,
)
data class AnalyticsState(
val bitrate: String = formatBitrate(0),
val bitrateEstimate: String = formatBitrate(0),
val droppedFrames: Int = 0,
)

View file

@ -0,0 +1,42 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.media3.common.Player
import androidx.media3.common.listenTo
/**
* Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering.
*
* @see Player.State
* @see PlayerState
*/
@Composable
fun rememberPlayerState(player: Player): State<PlayerState> {
val state = remember(player) { mutableStateOf(getPlayerState(player.playbackState)) }
LaunchedEffect(player) {
player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) {
state.value = getPlayerState(player.playbackState)
}
}
return state
}
private fun getPlayerState(
@Player.State value: Int,
): PlayerState = PlayerState.entries.first { it.value == value }
/**
* Represents [Player.State] integers as an Enum
*/
enum class PlayerState(
@param:Player.State val value: Int,
) {
IDLE(Player.STATE_IDLE),
BUFFERING(Player.STATE_BUFFERING),
READY(Player.STATE_READY),
ENDED(Player.STATE_ENDED),
}

View file

@ -9,10 +9,10 @@ import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -22,6 +22,8 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import timber.log.Timber
sealed interface SubtitleSearchStatus {
data object Inactive : SubtitleSearchStatus
data object Searching : SubtitleSearchStatus
data object Downloading : SubtitleSearchStatus
@ -40,11 +42,15 @@ sealed interface SubtitleSearchStatus {
* Trigger a search for subtitles in the given language for the currently playing media
*/
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
subtitleSearchStatus.value = SubtitleSearchStatus.Searching
subtitleSearchLanguage.value = language
subtitleSearchState.update {
it.copy(
status = SubtitleSearchStatus.Searching,
language = language,
)
}
viewModelScope.launchIO {
try {
currentItemPlayback.value?.itemId?.let {
state.value.currentItemPlayback?.itemId?.let {
Timber.v("Searching for remote subtitles for %s", it)
val results =
api.subtitleApi
@ -56,11 +62,11 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
.thenByDescending { it.downloadCount },
)
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results))
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) }
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while searching for subtitles")
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) }
}
}
}
@ -73,12 +79,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
wasPlaying: Boolean,
) {
if (subtitleId == null) {
subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null)
subtitleSearchState.update {
it.copy(
status =
SubtitleSearchStatus.Error(
"Subtitle has no ID",
null,
),
)
}
} else {
subtitleSearchStatus.value = SubtitleSearchStatus.Downloading
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) }
viewModelScope.launchIO {
try {
currentItemPlayback.value?.let {
state.value.currentItemPlayback?.let {
Timber.v(
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
it.itemId,
@ -89,8 +103,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
itemId = it.sourceId ?: it.itemId,
subtitleId = subtitleId,
)
val currentSource =
this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo
val currentSource = state.value.currentPlayback?.mediaSourceInfo
val currentSubtitleStreams =
currentSource
?.mediaStreams
@ -144,7 +157,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
stream.isExternal && stream.path !in externalPaths
}
if (newStream != null) {
var audioIndex = currentItemPlayback.value?.audioIndex
var audioIndex = it?.audioIndex
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
// User has picked a specific audio track
// Since, now adding a new external subtitle track, need to adjust the audio index as well
@ -161,7 +174,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
}
this@downloadAndSwitchSubtitles.changeStreams(
currentItem.item,
currentItemPlayback.value!!,
it,
audioIndex,
newStream.index,
onMain { player.currentPosition },
@ -169,7 +182,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
)
}
}
subtitleSearchStatus.setValueOnMain(null)
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) }
withContext(Dispatchers.Main) {
if (wasPlaying) {
player.play()
@ -178,12 +191,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
subtitleSearchState.update {
it.copy(
status =
SubtitleSearchStatus.Error(
null,
ex,
),
)
}
}
}
}
}
fun PlaybackViewModel.cancelSubtitleSearch() {
subtitleSearchStatus.value = null
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) }
}

View file

@ -61,7 +61,7 @@ class LocaleChoiceViewModel
init {
viewModelScope.launchDefault {
serverRepository.currentUser.value?.let {
serverRepository.currentUser?.let {
val availableLocales = extractAvailableLocales()
Timber.v("availableLocales=%s", availableLocales)
_state.update {
@ -73,7 +73,7 @@ class LocaleChoiceViewModel
}
}
viewModelScope.launchDefault {
serverRepository.currentUser.asFlow().collectLatest { user ->
serverRepository.currentUserFlow.collectLatest { user ->
val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
_state.update { it.copy(userLocale = userLocale) }
}

View file

@ -297,7 +297,7 @@ class NavDrawerPreferencesViewModel
init {
viewModelScope.launchDefault {
val state = navDrawerService.state.value
val user = serverRepository.currentUser.value
val user = serverRepository.currentUser
val seerr = seerrServerRepository.active.firstOrNull()
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
return@launchDefault
@ -337,8 +337,8 @@ class NavDrawerPreferencesViewModel
fun save() {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUserDto.value?.let { userDto ->
serverRepository.currentUser?.let { user ->
serverRepository.currentUserDto?.let { userDto ->
if (user.id == userDto.id) {
val toSave =
state.value.mapIndexed { index, item ->

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -100,7 +99,7 @@ fun PreferencesContent(
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) }
val currentUser by viewModel.currentUser.observeAsState()
val currentUser by viewModel.currentUser.collectAsState()
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
@ -126,16 +125,20 @@ fun PreferencesContent(
updateCache = false
}
val release by updateVM.release.observeAsState(null)
LaunchedEffect(Unit) {
val updateState by updateVM.state.collectAsState()
val release = updateState.release
LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) {
if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) {
updateVM.init(preferences.updateUrl)
updateVM.init()
}
}
val movementSounds = true
val installedVersion = updateVM.currentVersion
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
val updateAvailable =
remember(updateState.release) {
updateState.release?.version?.isGreaterThan(installedVersion) ?: false
}
val prefList =
when (preferenceScreenOption) {
@ -306,7 +309,7 @@ fun PreferencesContent(
)
}
} else {
updateVM.init(preferences.updateUrl)
updateVM.init()
}
},
onLongClick = {
@ -635,7 +638,7 @@ fun PreferencesContent(
}
SeerrDialogMode.Add -> {
val currentUser by seerrVm.currentUser.observeAsState()
val currentUser by seerrVm.currentUser.collectAsState(null)
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
val serverAddedMessage = stringResource(R.string.seerr_server_added)
LaunchedEffect(status) {

View file

@ -32,7 +32,9 @@ import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
@ -58,7 +60,13 @@ class PreferencesViewModel
private val updateChecker: UpdateChecker,
) : ViewModel(),
RememberTabManager by rememberTabManager {
val currentUser get() = serverRepository.currentUser
val currentUser
get() =
serverRepository.currentUserFlow.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null,
)
val seerrConnection = seerrServerRepository.connection

View file

@ -21,9 +21,8 @@ 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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -39,7 +38,6 @@ 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
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
@ -51,6 +49,8 @@ import com.github.damontecres.wholphin.services.DownloadCallback
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -58,47 +58,56 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
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.Version
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class UpdateViewModel
@Inject
constructor(
private val userPreferencesService: UserPreferencesService,
val updater: UpdateChecker,
val navigationManager: NavigationManager,
) : ViewModel(),
DownloadCallback {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null)
private val _state = MutableStateFlow(InstallUpdateState())
val state: StateFlow<InstallUpdateState> = _state
val downloading = MutableLiveData<Boolean>(false)
val contentLength = MutableLiveData<Long>(-1)
val bytesDownloaded = MutableLiveData<Long>(-1)
private val _bytesDownloaded = MutableStateFlow(0L)
val bytesDownloaded: StateFlow<Long> = _bytesDownloaded
val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) {
loading.value = LoadingState.Loading
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
fun init() {
_state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchIO {
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
try {
val release = updater.getLatestRelease(updateUrl)
_state.update {
it.copy(
loading = LoadingState.Success,
release = release,
contentLength = -1L,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching release from %s", updateUrl)
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
}
@ -107,61 +116,59 @@ class UpdateViewModel
fun installRelease(release: Release) {
downloadJob =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Failed to install update",
),
) {
downloading.setValueOnMain(true)
updater.installRelease(release, this@UpdateViewModel)
downloading.setValueOnMain(false)
viewModelScope.launchIO {
try {
_bytesDownloaded.value = 0L
_state.update { it.copy(downloading = true) }
updater.installRelease(release, this@UpdateViewModel)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error downloading")
_state.update { it.copy(loading = LoadingState.Error(ex)) }
} finally {
_state.update { it.copy(downloading = false) }
}
}
}
fun cancelDownload() {
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error",
),
) {
viewModelScope.launchIO {
downloadJob?.cancel()
withContext(Dispatchers.Main) {
downloading.value = false
contentLength.value = -1
bytesDownloaded.value = -1
_state.update {
it.copy(
downloading = false,
contentLength = -1L,
)
}
}
}
override fun contentLength(contentLength: Long) {
this@UpdateViewModel.contentLength.value = contentLength
_state.update { it.copy(contentLength = contentLength) }
}
override fun bytesDownloaded(bytes: Long) {
this@UpdateViewModel.bytesDownloaded.value = bytes
_bytesDownloaded.value = bytes
}
}
data class InstallUpdateState(
val loading: LoadingState = LoadingState.Pending,
val downloading: Boolean = false,
val release: Release? = null,
val contentLength: Long = -1L,
)
@Composable
fun InstallUpdatePage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: UpdateViewModel = hiltViewModel(),
) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null)
OneTimeLaunchedEffect { viewModel.init() }
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)
}
val state by viewModel.state.collectAsState()
var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) }
val launcher =
rememberLauncherForActivityResult(
@ -173,9 +180,9 @@ fun InstallUpdatePage(
// TODO
}
}
when (val state = loading) {
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
ErrorMessage(st, modifier)
}
LoadingState.Loading,
@ -185,26 +192,32 @@ fun InstallUpdatePage(
}
LoadingState.Success -> {
release?.let {
val release = state.release
if (release != null) {
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
release = it,
release = release,
onInstallRelease = {
if (!permissions) {
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
viewModel.installRelease(it)
viewModel.installRelease(release)
}
},
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier.dimAndBlur(isDownloading),
modifier = modifier.dimAndBlur(state.downloading),
)
} else {
Text(
text = "No release found! Check the update URL.",
)
}
if (isDownloading) {
if (state.downloading) {
val bytesDownloaded by viewModel.bytesDownloaded.collectAsState(0L)
DownloadDialog(
contentLength = contentLength,
contentLength = state.contentLength,
bytesDownloaded = bytesDownloaded,
onDismissRequest = {
viewModel.cancelDownload()

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -70,7 +69,7 @@ fun SwitchUserContent(
val state by viewModel.state.collectAsState()
val currentUser by viewModel.serverRepository.currentUser.observeAsState()
val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null)
var showAddUser by remember { mutableStateOf(false) }
var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) }
var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") }

View file

@ -29,7 +29,7 @@ class SwitchSeerrViewModel
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
) : ViewModel() {
val currentUser = serverRepository.currentUser
val currentUser = serverRepository.currentUserFlow
val currentSeerrServer = seerrServerRepository.currentServer
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -59,7 +58,6 @@ import coil3.request.ImageRequest
import coil3.request.transitionFactory
import coil3.size.Size
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -93,9 +91,9 @@ fun SlideshowPage(
),
) {
val context = LocalContext.current
val loading by viewModel.loading.collectAsState()
val state by viewModel.state.collectAsState()
when (val st = loading) {
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
@ -107,10 +105,10 @@ fun SlideshowPage(
}
LoadingState.Success -> {
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0)
val pager by viewModel.pager.observeAsState()
val loadingState = state.imageLoading
val imageFilter by viewModel.imageFilter.collectAsState()
val position = state.position
val pager = state.items
// val imageState by viewModel.image.observeAsState()
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
@ -150,7 +148,7 @@ fun SlideshowPage(
label = "image_panY",
)
val slideshowState by viewModel.slideshow.collectAsState()
val slideshowState by viewModel.state.collectAsState()
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
val focusRequester = remember { FocusRequester() }

View file

@ -3,10 +3,7 @@ package com.github.damontecres.wholphin.ui.slideshow
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player
import com.github.damontecres.wholphin.data.ChosenStreams
@ -25,9 +22,7 @@ import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -87,28 +82,18 @@ class SlideshowViewModel
/**
* Whether slideshow mode is on or off
*/
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false))
val slideshow: StateFlow<SlideshowState> = _slideshow
private val _state = MutableStateFlow(SlideshowState())
val state: StateFlow<SlideshowState> = _state
/**
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
*/
val slideshowActive = slideshow.map { it.enabled && !it.paused }
val slideshowActive = state.map { it.enabled && !it.paused }
var slideshowDelay by Delegates.notNull<Long>()
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
val position = MutableLiveData(0)
private val _image = MutableLiveData<ImageState>()
val image: LiveData<ImageState> = _image
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
private val _imageFilter = MutableLiveData(VideoFilter())
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
private val _imageFilter = MutableStateFlow(VideoFilter())
val imageFilter: StateFlow<VideoFilter> = _imageFilter
private var albumImageFilter = VideoFilter()
@ -152,7 +137,7 @@ class SlideshowViewModel
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
),
)
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
val filter =
playbackEffectDao
.getPlaybackEffect(
@ -168,21 +153,25 @@ class SlideshowViewModel
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
.init(slideshowSettings.index)
this@SlideshowViewModel._pager.setValueOnMain(pager)
loading.update { LoadingState.Success }
_state.update {
it.copy(
loading = LoadingState.Success,
items = pager,
)
}
updatePosition(slideshowSettings.index)?.join()
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
} catch (ex: Exception) {
Timber.e(ex, "Error")
loading.update { LoadingState.Error(ex) }
_state.update { it.copy(loading = LoadingState.Error(ex)) }
}
}
}
fun nextImage(): Boolean {
val size = pager.value?.size
val newPosition = position.value!! + 1
return if (size != null && newPosition < size) {
val size = state.value.items.size
val newPosition = state.value.position + 1
return if (newPosition < size) {
updatePosition(newPosition)
true
} else {
@ -191,7 +180,7 @@ class SlideshowViewModel
}
fun previousImage(): Boolean {
val newPosition = position.value!! - 1
val newPosition = state.value.position - 1
return if (newPosition >= 0) {
updatePosition(newPosition)
true
@ -201,14 +190,14 @@ class SlideshowViewModel
}
fun updatePosition(position: Int): Job? =
_pager.value?.let { pager ->
(state.value.items as? ApiRequestPager<*>)?.let { pager ->
viewModelScope.launchIO {
try {
val image =
if (position in pager.indices) pager.getBlocking(position) else null
Timber.v("Got image for $position: ${image != null}")
if (image != null) {
this@SlideshowViewModel.position.setValueOnMain(position)
_state.update { it.copy(position = position) }
val url =
if (image.data.mediaType == MediaType.VIDEO) {
@ -252,7 +241,7 @@ class SlideshowViewModel
updateImageFilter(albumImageFilter)
if (saveFilters) {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
val vf =
playbackEffectDao
.getPlaybackEffect(
@ -264,32 +253,38 @@ class SlideshowViewModel
Timber.d(
"Loaded VideoFilter for image ${image.id}",
)
withContext(Dispatchers.Main) {
// Pause throttling so that the image loads with the filter applied immediately
imageFilter.stopThrottling(true)
updateImageFilter(vf.videoFilter)
imageFilter.startThrottling()
}
updateImageFilter(vf.videoFilter)
}
withContext(Dispatchers.Main) {
_image.value = imageState
loadingState.value =
ImageLoadingState.Success(imageState)
_state.update {
it.copy(
image = imageState,
imageLoading = ImageLoadingState.Success(imageState),
)
}
}
}
} else {
withContext(Dispatchers.Main) {
_image.value = imageState
loadingState.value = ImageLoadingState.Success(imageState)
_state.update {
it.copy(
image = imageState,
imageLoading = ImageLoadingState.Success(imageState),
)
}
}
} else {
loadingState.setValueOnMain(ImageLoadingState.Error)
_state.update {
it.copy(
imageLoading = ImageLoadingState.Error,
)
}
}
} catch (ex: Exception) {
Timber.e(ex)
loadingState.setValueOnMain(ImageLoadingState.Error)
_state.update {
it.copy(
imageLoading = ImageLoadingState.Error,
)
}
}
}
}
@ -298,10 +293,10 @@ class SlideshowViewModel
fun startSlideshow() {
screensaverService.keepScreenOn(true)
_slideshow.update {
SlideshowState(enabled = true, paused = false)
_state.update {
it.copy(enabled = true, paused = false)
}
if (_image.value
if (state.value.image
?.image
?.data
?.mediaType != MediaType.VIDEO
@ -313,14 +308,14 @@ class SlideshowViewModel
fun stopSlideshow() {
screensaverService.keepScreenOn(false)
slideshowJob?.cancel()
_slideshow.update {
SlideshowState(enabled = false, paused = false)
_state.update {
it.copy(enabled = false, paused = false)
}
}
fun pauseSlideshow() {
Timber.v("pauseSlideshow")
_slideshow.update {
_state.update {
if (it.enabled) {
slideshowJob?.cancel()
it.copy(paused = true)
@ -332,7 +327,7 @@ class SlideshowViewModel
fun unpauseSlideshow() {
Timber.v("unpauseSlideshow")
_slideshow.update {
_state.update {
if (it.enabled) {
it.copy(paused = false)
} else {
@ -362,16 +357,16 @@ class SlideshowViewModel
fun updateImageFilter(newFilter: VideoFilter) {
viewModelScope.launchIO {
_imageFilter.setValueOnMain(newFilter)
_imageFilter.update { newFilter }
}
}
fun saveImageFilter() {
image.value?.let {
state.value.image?.let {
viewModelScope.launchIO {
val vf = _imageFilter.value
if (vf != null) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
playbackEffectDao
.insert(
PlaybackEffect(
@ -400,7 +395,7 @@ class SlideshowViewModel
val vf = _imageFilter.value
if (vf != null) {
albumImageFilter = vf
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUser?.let { user ->
playbackEffectDao
.insert(
PlaybackEffect(
@ -457,6 +452,11 @@ data class ImageState(
}
data class SlideshowState(
val enabled: Boolean,
val paused: Boolean,
val items: List<BaseItem?> = emptyList(),
val position: Int = 0,
val image: ImageState? = null,
val loading: LoadingState = LoadingState.Pending,
val imageLoading: ImageLoadingState = ImageLoadingState.Loading,
val enabled: Boolean = false,
val paused: Boolean = false,
)

View file

@ -1,82 +0,0 @@
package com.github.damontecres.wholphin.ui.util
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
/**
* LiveData throttling value emissions so they don't happen more often than [delayMs].
*
* From https://stackoverflow.com/a/62467521
*/
class ThrottledLiveData<T>(
source: LiveData<T>,
delayMs: Long,
) : MediatorLiveData<T>() {
val handler = Handler(Looper.getMainLooper())
var delayMs = delayMs
private set
private var isValueDelayed = false
private var delayedValue: T? = null
private var delayRunnable: Runnable? = null
set(value) {
field?.let { handler.removeCallbacks(it) }
value?.let { handler.postDelayed(it, delayMs) }
field = value
}
private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() }
init {
addSource(source) { newValue ->
if (delayRunnable == null) {
value = newValue
startDelay()
} else {
isValueDelayed = true
delayedValue = newValue
}
}
}
/** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */
fun startThrottling(newDelay: Long = 0L) {
require(newDelay >= 0L)
when {
newDelay > 0 -> delayMs = newDelay
delayMs < 0 -> delayMs *= -1
delayMs > 0 -> return
else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero")
}
}
/** Stop throttling, if [immediate] emit any pending value now. */
fun stopThrottling(immediate: Boolean = false) {
if (delayMs <= 0) return
delayMs *= -1
if (immediate) consumeDelayedValue()
}
override fun onInactive() {
super.onInactive()
consumeDelayedValue()
}
// start counting the delay or clear it if conditions are not met
private fun startDelay() {
delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null
}
private fun consumeDelayedValue(): Boolean {
delayRunnable = null
return if (isValueDelayed) {
value = delayedValue
delayedValue = null
isValueDelayed = false
true
} else {
false
}
}
}

View file

@ -1,27 +0,0 @@
package com.github.damontecres.wholphin.util
import androidx.lifecycle.MutableLiveData
/**
* A [MutableLiveData] that only notifies observers if the value does not equal the previous value
*/
class EqualityMutableLiveData<T> : MutableLiveData<T> {
constructor() : super()
constructor(value: T) : super(value)
override fun setValue(value: T?) {
if (value != getValue()) {
super.setValue(value)
}
}
fun setValueNoCheck(value: T?) {
super.setValue(value)
}
override fun postValue(value: T?) {
if (value != getValue()) {
super.postValue(value)
}
}
}

View file

@ -1,52 +0,0 @@
package com.github.damontecres.wholphin.util
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
/**
* A [CoroutineExceptionHandler] that is aware of a [LoadingState] and will set it to error if an exception occurs in the coroutine.
*/
class LoadingExceptionHandler(
private val loadingState: MutableLiveData<LoadingState>,
private val errorMessage: String?,
private val autoToast: Boolean = false,
) : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler
override fun handleException(
context: CoroutineContext,
exception: Throwable,
) {
if (exception is CancellationException) {
// Don't log/toast cancellations
return
}
Timber.e(exception, "Exception in coroutine")
runBlocking {
withContext(Dispatchers.Main) {
loadingState.value =
LoadingState.Error(
message = errorMessage,
exception = exception,
)
}
if (autoToast) {
showToast(
WholphinApplication.instance,
"Error: ${exception.message}",
Toast.LENGTH_LONG,
)
}
}
}
}

View file

@ -103,3 +103,5 @@ sealed interface DataLoadingState<out T> {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}
val <T> DataLoadingState<T>.successValue: T? get() = (this as? DataLoadingState.Success<T>)?.data

View file

@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.MutableLiveData
import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
@ -14,6 +13,7 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -28,7 +28,7 @@ import org.junit.Test
class LatestNextUpSchedulerServiceTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher()
private val currentLiveData = MutableLiveData<CurrentUser?>()
private val currentUser = MutableStateFlow<CurrentUser?>(null)
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
@ -38,7 +38,7 @@ class LatestNextUpSchedulerServiceTest {
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { mockActivity.lifecycle } returns lifecycleRegistry
every { mockServerRepository.current } returns currentLiveData
every { mockServerRepository.current } returns currentUser
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
@ -59,7 +59,7 @@ class LatestNextUpSchedulerServiceTest {
runTest {
createService()
currentLiveData.value = null
currentUser.value = null
advanceUntilIdle()
verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) }

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.MutableLiveData
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
@ -15,6 +14,7 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
@ -98,8 +98,8 @@ class SuggestionServiceTest {
@Test
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
runTest {
val currentUser = MutableLiveData<JellyfinUser?>(null)
every { mockServerRepository.currentUser } returns currentUser
val currentUser = MutableStateFlow<JellyfinUser?>(null)
every { mockServerRepository.currentUserFlow } returns currentUser
val service = createService()
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
@ -113,10 +113,10 @@ class SuggestionServiceTest {
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
@ -142,10 +142,10 @@ class SuggestionServiceTest {
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
@ -163,10 +163,10 @@ class SuggestionServiceTest {
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
@ -189,11 +189,11 @@ class SuggestionServiceTest {
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
val cachedId = UUID.randomUUID()
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany
listOf(null, CachedSuggestions(listOf(cachedId)))
mockEnqueueOnDemandWork()
@ -220,9 +220,9 @@ class SuggestionServiceTest {
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList())
val service = createService()
@ -244,10 +244,10 @@ class SuggestionServiceTest {
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns
@ -268,10 +268,10 @@ class SuggestionServiceTest {
val userId = UUID.randomUUID()
val libraryId = UUID.randomUUID()
val otherLibraryId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
@ -304,9 +304,9 @@ class SuggestionServiceTest {
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
val cachedId = UUID.randomUUID()
coEvery {
@ -339,9 +339,9 @@ class SuggestionServiceTest {
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockServerRepository.currentUserFlow } returns currentUser
val cachedId = UUID.randomUUID()
coEvery {

View file

@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.MutableLiveData
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.CurrentUser
@ -19,6 +18,7 @@ import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -35,7 +35,7 @@ import java.util.UUID
class SuggestionsSchedulerServiceTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher()
private val currentLiveData = MutableLiveData<CurrentUser?>()
private val currentUser = MutableStateFlow<CurrentUser?>(null)
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
@ -45,7 +45,7 @@ class SuggestionsSchedulerServiceTest {
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { mockActivity.lifecycle } returns lifecycleRegistry
every { mockServerRepository.current } returns currentLiveData
every { mockServerRepository.current } returns currentUser
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
@ -57,8 +57,8 @@ class SuggestionsSchedulerServiceTest {
context = mockActivity,
serverRepository = mockServerRepository,
workManager = mockWorkManager,
dispatcher = testDispatcher,
).also {
it.dispatcher = testDispatcher
it.initialDelaySecondsProvider = { 60L }
}
@ -74,7 +74,7 @@ class SuggestionsSchedulerServiceTest {
runTest {
mockWorkInfos(emptyList())
createService()
currentLiveData.value =
currentUser.value =
CurrentUser(
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
@ -88,13 +88,13 @@ class SuggestionsSchedulerServiceTest {
runTest {
mockWorkInfos(emptyList())
createService()
currentLiveData.value =
currentUser.value =
CurrentUser(
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
)
advanceUntilIdle()
currentLiveData.value = null
currentUser.value = null
advanceUntilIdle()
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
}
@ -113,7 +113,7 @@ class SuggestionsSchedulerServiceTest {
} returns mockk()
createService()
currentLiveData.value =
currentUser.value =
CurrentUser(
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
@ -138,7 +138,7 @@ class SuggestionsSchedulerServiceTest {
} returns mockk()
createService()
currentLiveData.value =
currentUser.value =
CurrentUser(
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
@ -159,7 +159,7 @@ class SuggestionsSchedulerServiceTest {
mockWorkInfos(listOf(workInfo))
createService()
currentLiveData.value =
currentUser.value =
CurrentUser(
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
@ -15,6 +14,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
@ -112,7 +112,7 @@ class SuggestionsWorkerTest {
every { mockApi.accessToken } returns null
val mockUser = mockk<CurrentUser>()
var restored = false
every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) }
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
restored = true
mockUser

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.test
import androidx.lifecycle.MutableLiveData
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
@ -773,20 +772,19 @@ private fun serverRepo(
): ServerRepository {
val mocked = mockk<ServerRepository>()
every { mocked.currentUserDto } returns
MutableLiveData(
UserDto(
id = UUID.randomUUID(),
hasPassword = true,
hasConfiguredPassword = true,
hasConfiguredEasyPassword = true,
configuration =
DefaultUserConfiguration.copy(
audioLanguagePreference = audioLang,
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
subtitleLanguagePreference = subtitleLang,
),
),
UserDto(
id = UUID.randomUUID(),
hasPassword = true,
hasConfiguredPassword = true,
hasConfiguredEasyPassword = true,
configuration =
DefaultUserConfiguration.copy(
audioLanguagePreference = audioLang,
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
subtitleLanguagePreference = subtitleLang,
),
)
return mocked
}

View file

@ -108,7 +108,7 @@ object TestModule {
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token ->
serverRepository.currentUser?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(

View file

@ -67,14 +67,12 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }