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.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material) implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences) 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.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -168,7 +169,7 @@ class MainActivity : AppCompatActivity() {
navigationManager.backStack = NavBackStack(startDestination) navigationManager.backStack = NavBackStack(startDestination)
} }
viewModel.serverRepository.currentUser.observe(this) { user -> viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
if (user?.hasPin == true) { if (user?.hasPin == true) {
window?.setFlags( window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE,
@ -435,7 +436,7 @@ class MainActivityViewModel
val prefs = val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected = val profileProtected =
serverRepository.currentUser.value?.let { serverRepository.current.value?.user?.let {
it.hasPin || it.requireLogin it.hasPin || it.requireLogin
} == true } == true
if (prefs.signInAutomatically && !profileProtected) { 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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -88,7 +87,7 @@ class WholphinDreamService :
setViewTreeLifecycleOwner(this@WholphinDreamService) setViewTreeLifecycleOwner(this@WholphinDreamService)
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
setContent { setContent {
val user by serverRepository.currentUser.observeAsState() val user by serverRepository.currentUserFlow.collectAsState(null)
if (user != null) { if (user != null) {
var prefs by remember { mutableStateOf<AppPreferences?>(null) } var prefs by remember { mutableStateOf<AppPreferences?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View file

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

View file

@ -4,18 +4,18 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import androidx.datastore.core.DataStore 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.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher 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.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers 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.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
@ -46,14 +46,17 @@ class ServerRepository
val userPreferencesDataStore: DataStore<AppPreferences>, val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) { ) {
private var _current = EqualityMutableLiveData<CurrentUser?>(null) private var _current = MutableStateFlow<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current val current: StateFlow<CurrentUser?> = _current
private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null) private var _currentUserDto = MutableStateFlow<UserDto?>(null)
val currentUserDto: LiveData<UserDto?> = _currentUserDto val currentUserDto: UserDto? get() = _currentUserDto.value
val currentUserDtoFlow: StateFlow<UserDto?> get() = _currentUserDto
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server } val currentServer: JellyfinServer? get() = _current.value?.server
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user } 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 * 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) serverDao.addOrUpdateServer(server)
} }
apiClient.update(baseUrl = server.url, accessToken = null) apiClient.update(baseUrl = server.url, accessToken = null)
_current.setValueOnMain(null) _current.value = null
} }
/** /**
@ -127,7 +130,7 @@ class ServerRepository
userId: UUID?, userId: UUID?,
): CurrentUser? { ): CurrentUser? {
if (serverId == null || userId == null) { if (serverId == null || userId == null) {
_current.setValueOnMain(null) _current.value = null
return null return null
} }
val serverAndUsers = val serverAndUsers =
@ -218,7 +221,7 @@ class ServerRepository
} }
suspend fun removeUser(user: JellyfinUser) { suspend fun removeUser(user: JellyfinUser) {
if (currentUser.value?.id == user.id) { if (current.value?.user?.id == user.id) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = null _current.value = null
} }
@ -237,7 +240,7 @@ class ServerRepository
} }
suspend fun removeServer(server: JellyfinServer) { suspend fun removeServer(server: JellyfinServer) {
if (currentServer.value?.id == server.id) { if (current.value?.server?.id == server.id) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = null _current.value = null
} }
@ -274,18 +277,19 @@ class ServerRepository
) { ) {
val newUser = user.copy(pin = pin, requireLogin = requireLogin) val newUser = user.copy(pin = pin, requireLogin = requireLogin)
val updatedUser = serverDao.addOrUpdateUser(newUser) 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 // Updating current user, so push out the change
current.value?.let { current.value?.let {
val newCurrent = it.copy(user = updatedUser) val newCurrent = it.copy(user = updatedUser)
_current.setValueOnMain(newCurrent) _current.value = newCurrent
} }
} }
} }
suspend fun authorizeQuickConnect(code: String): Boolean = suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(ioDispatcher) { withContext(ioDispatcher) {
val userId = currentUser.value?.id val userId = current.value?.user?.id
if (userId == null) { if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization") Timber.e("No user logged in for Quick Connect authorization")
throw IllegalStateException("Must be logged in to authorize Quick Connect") 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 android.content.Context
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
@ -152,7 +154,7 @@ class DatePlayedInvalidationService
private val activity = (context as AppCompatActivity) private val activity = (context as AppCompatActivity)
init { init {
serverRepository.current.observe(activity) { serverRepository.current.collectLatestIn(activity.lifecycleScope) {
datePlayedService.invalidateAll() datePlayedService.invalidateAll()
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -94,7 +94,7 @@ class SeerrServerRepository
server = seerrServerDao.getServer(url) server = seerrServerDao.getServer(url)
} }
server?.server?.let { server -> server?.server?.let { server ->
serverRepository.currentUser.value?.let { jellyfinUser -> serverRepository.currentUser?.let { jellyfinUser ->
// TODO test api key // TODO test api key
val user = val user =
SeerrUser( SeerrUser(
@ -126,7 +126,7 @@ class SeerrServerRepository
server = seerrServerDao.getServer(url) server = seerrServerDao.getServer(url)
} }
server?.server?.let { server -> server?.server?.let { server ->
serverRepository.currentUser.value?.let { jellyfinUser -> serverRepository.currentUser?.let { jellyfinUser ->
// TODO Need to update server early so that cookies are saved // TODO Need to update server early so that cookies are saved
seerrApi.update(server.url, null) seerrApi.update(server.url, null)
val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) 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.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser 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.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
@ -42,7 +43,7 @@ class ServerEventListener
init { init {
activity.lifecycle.addObserver(this) activity.lifecycle.addObserver(this)
serverRepository.current.observe(activity) { serverRepository.current.collectLatestIn(activity.lifecycleScope) {
Timber.d("New user/server: %s", it) Timber.d("New user/server: %s", it)
listenJob?.cancel() listenJob?.cancel()
if (it != null) { if (it != null) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -116,7 +116,7 @@ object AppModule {
.addInterceptor { .addInterceptor {
val request = it.request() val request = it.request()
val newRequest = val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token -> serverRepository.current.value?.user?.accessToken?.let { token ->
request request
.newBuilder() .newBuilder()
.addHeader( .addHeader(
@ -170,7 +170,10 @@ object AppModule {
appPreference: DataStore<AppPreferences>, appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope, @IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager { ) = 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( override fun getRememberedTab(
preferences: UserPreferences, preferences: UserPreferences,

View file

@ -13,6 +13,7 @@ import androidx.work.WorkManager
import androidx.work.await import androidx.work.await
import androidx.work.workDataOf import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository 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.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
@ -43,7 +44,7 @@ class TvProviderSchedulerService
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
init { init {
serverRepository.current.observe(activity) { user -> serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
if (supportsTvProvider) { if (supportsTvProvider) {
if (user != null) { 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.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.media3.common.Player import androidx.media3.common.Player
import coil3.request.ErrorResult import coil3.request.ErrorResult
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
@ -397,11 +396,6 @@ fun CoroutineScope.launchDefault(
*/ */
fun UUID.toServerString() = this.toString().replace("-", "") fun UUID.toServerString() = this.toString().replace("-", "")
suspend fun <T> MutableLiveData<T>.setValueOnMain(value: T) =
withContext(Dispatchers.Main) {
this@setValueOnMain.value = value
}
fun equalsNotNull( fun equalsNotNull(
a: Any?, a: Any?,
b: Any?, b: Any?,

View file

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

View file

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

View file

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

View file

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

View file

@ -5,15 +5,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme 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.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.RequestHandler
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException
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.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
class ItemGridViewModel class ItemGridViewModel
@ -45,8 +46,8 @@ class ItemGridViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
@Assisted private val destination: Destination.ItemGrid<*>, @Assisted private val destination: Destination.ItemGrid<*>,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) private val _state = MutableStateFlow(ItemGridState())
val items = MutableLiveData<List<BaseItem?>>(listOf()) val state: StateFlow<ItemGridState> = _state
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -54,7 +55,8 @@ class ItemGridViewModel
} }
init { init {
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) { viewModelScope.launchIO {
try {
val request = destination.request as Any val request = destination.request as Any
val pager = val pager =
ApiRequestPager( ApiRequestPager(
@ -67,9 +69,14 @@ class ItemGridViewModel
if (pager.isNotEmpty()) { if (pager.isNotEmpty()) {
pager.getBlocking(0) pager.getBlocking(0)
} }
withContext(Dispatchers.Main) { _state.update {
this@ItemGridViewModel.items.value = pager it.copy(items = DataLoadingState.Success(pager))
this@ItemGridViewModel.loading.value = LoadingState.Success }
} 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] * 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) }, creationCallback = { it.create(destination) },
), ),
) { ) {
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val state by viewModel.state.collectAsState()
val items by viewModel.items.observeAsState(listOf()) when (val st = state.items) {
when (val state = loading) { is DataLoadingState.Error -> {
is LoadingState.Error -> { ErrorMessage(st, modifier)
ErrorMessage(state, modifier)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<List<BaseItem?>> -> {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
@ -118,7 +128,7 @@ fun ItemGrid(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
CardGrid( CardGrid(
pager = items, pager = st.data,
onClickItem = { index: Int, item: BaseItem -> onClickItem = { index: Int, item: BaseItem ->
// TODO handle more types // TODO handle more types
viewModel.navigateTo(Destination.Playback(item.id, 0)) 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -351,7 +349,7 @@ fun RecommendedContent(
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.collectAsState()
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init() viewModel.init()
@ -451,7 +449,7 @@ fun RecommendedContent(
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
showFilePath = showFilePath =
viewModel.serverRepository.currentUserDto.value viewModel.serverRepository.currentUserDto
?.policy ?.policy
?.isAdministrator == true, ?.isAdministrator == true,
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },

View file

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

View file

@ -2,18 +2,17 @@ package com.github.damontecres.wholphin.ui.data
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.launchIO 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.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -30,16 +29,17 @@ class AddPlaylistViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val playlistCreator: PlaylistCreator, private val playlistCreator: PlaylistCreator,
) : ViewModel() { ) : ViewModel() {
val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending) val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending)
fun loadPlaylists(mediaType: MediaType?) { fun loadPlaylists(mediaType: MediaType?) {
viewModelScope.launchIO { viewModelScope.launchIO {
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading) this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading
try { try {
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists)) this@AddPlaylistViewModel.playlistState.value =
PlaylistLoadingState.Success(playlists)
} catch (ex: Exception) { } 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.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R 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.launchIO
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import java.util.UUID import java.util.UUID
@ -61,16 +60,16 @@ class LiveTvCollectionViewModel
val backdropService: BackdropService, val backdropService: BackdropService,
) : ViewModel(), ) : ViewModel(),
RememberTabManager by rememberTabManager { RememberTabManager by rememberTabManager {
val recordingFolders = MutableLiveData<List<TabId>>() val recordingFolders = MutableStateFlow<List<TabId>>(emptyList())
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
val folders = val folders =
api.liveTvApi api.liveTvApi
.getRecordingFolders(userId = serverRepository.currentUser.value?.id) .getRecordingFolders(userId = serverRepository.currentUser?.id)
.content.items .content.items
.map { TabId(it.name ?: "Recordings", it.id) } .map { TabId(it.name ?: "Recordings", it.id) }
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) recordingFolders.value = folders
} }
} }
} }
@ -89,7 +88,7 @@ fun CollectionFolderLiveTv(
) { ) {
val rememberedTabIndex = val rememberedTabIndex =
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } 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 tvGuideStr = stringResource(R.string.tv_guide)
val tvDvrStr = stringResource(R.string.tv_dvr_schedule) val tvDvrStr = stringResource(R.string.tv_dvr_schedule)

View file

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

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide 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.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
@ -50,6 +49,7 @@ fun CollectionFolderPhotoAlbum(
}, },
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
val state by viewModel.state.collectAsState()
CollectionFolderView( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions =
@ -63,11 +63,9 @@ fun CollectionFolderPhotoAlbum(
index = index, index = index,
filter = filter =
CollectionFolderFilter( CollectionFolderFilter(
filter = viewModel.filter.value ?: GetItemsFilter(), filter = state.filter,
), ),
sortAndDirection = sortAndDirection = state.sortAndDirection,
viewModel.sortAndDirection.value
?: SortAndDirection.DEFAULT,
recursive = recursive, recursive = recursive,
startSlideshow = false, 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.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme 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.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong import org.acra.util.versionCodeLong
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.api.client.extensions.clientLogApi
@ -66,8 +64,8 @@ class DebugViewModel
val clientInfo: ClientInfo, val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo, val deviceInfo: DeviceInfo,
) : ViewModel() { ) : ViewModel() {
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf()) val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
val logcat = MutableLiveData<List<LogcatLine>>(listOf()) val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
val supportedModes by lazy { val supportedModes by lazy {
val displayManager = val displayManager =
@ -106,18 +104,16 @@ class DebugViewModel
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let { serverRepository.currentUser?.rowId?.let {
val results = itemPlaybackDao.getItems(it) val results = itemPlaybackDao.getItems(it)
withContext(Dispatchers.Main) {
itemPlaybacks.value = results itemPlaybacks.value = results
} }
} }
viewModelScope.launchIO {
val logcat = getLogCatLines() val logcat = getLogCatLines()
withContext(Dispatchers.Main) {
this@DebugViewModel.logcat.value = logcat this@DebugViewModel.logcat.value = logcat
} }
} }
}
companion object { companion object {
fun getLogCatLines(): List<LogcatLine> { fun getLogCatLines(): List<LogcatLine> {
@ -210,8 +206,8 @@ fun DebugPage(
} }
} }
val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf()) val itemPlaybacks by viewModel.itemPlaybacks.collectAsState()
val logcat by viewModel.logcat.observeAsState(listOf()) val logcat by viewModel.logcat.collectAsState()
LazyColumn( LazyColumn(
state = columnState, state = columnState,
@ -341,17 +337,17 @@ fun DebugPage(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Current server: ${viewModel.serverRepository.currentServer.value}", text = "Current server: ${viewModel.serverRepository.currentServer}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "Current user: ${viewModel.serverRepository.currentUser.value}", text = "Current user: ${viewModel.serverRepository.currentUser}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}", text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -134,7 +133,7 @@ class HomeRowGridViewModel
try { try {
val preferences = userPreferencesService.getCurrent() val preferences = userPreferencesService.getCurrent()
val prefs = preferences.appPreferences.homePagePreferences val prefs = preferences.appPreferences.homePagePreferences
serverRepository.currentUserDto.value?.let { userDto -> serverRepository.currentUserDto?.let { userDto ->
val libraries = val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val result = val result =
@ -244,7 +243,7 @@ fun HomeRowGrid(
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } 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 = val contextActions =
remember { remember {
@ -381,7 +380,7 @@ fun HomeRowGrid(
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
showFilePath = showFilePath =
viewModel.serverRepository.currentUserDto.value viewModel.serverRepository.currentUserDto
?.policy ?.policy
?.isAdministrator == true, ?.isAdministrator == true,
onDismissRequest = { overviewDialog = null }, 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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition 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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ResStringProvider 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.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.GetItemsRequestHandler 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 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient 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.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -91,61 +91,89 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = PersonViewModel.Factory::class)
class PersonViewModel class PersonViewModel
@Inject @AssistedInject
constructor( constructor(
api: ApiClient, private val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val seerrService: SeerrService, private val seerrService: SeerrService,
) : LoadingItemViewModel(api) { @Assisted val itemId: UUID,
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) ) : ViewModel() {
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) @AssistedFactory
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) interface Factory {
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) fun create(itemId: UUID): PersonViewModel
}
fun init(itemId: UUID) { private val _state = MutableStateFlow(PersonState())
viewModelScope.launchIO( val state: StateFlow<PersonState> = _state
LoadingExceptionHandler(
loading, private suspend fun updatePerson(): BaseItem {
"Error loading item $itemId", val person =
), api.userLibraryApi
) { .getItem(itemId)
super.init(itemId, null).await()?.let { person -> .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 val dto = person.data
if ((dto.movieCount ?: 0) > 0) { if ((dto.movieCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.MOVIE, movies) fetchRow(person.id, BaseItemKind.MOVIE) { state, row ->
state.copy(movies = row)
}
} else { } else {
movies.setValueOnMain(RowLoadingState.Success(listOf())) _state.update { it.copy(movies = RowLoadingState.Success(emptyList())) }
} }
if ((dto.seriesCount ?: 0) > 0) { if ((dto.seriesCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.SERIES, series) fetchRow(person.id, BaseItemKind.SERIES) { state, row ->
state.copy(series = row)
}
} else { } else {
series.setValueOnMain(RowLoadingState.Success(listOf())) _state.update { it.copy(series = RowLoadingState.Success(emptyList())) }
} }
if ((dto.episodeCount ?: 0) > 0) { if ((dto.episodeCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.EPISODE, episodes) fetchRow(person.id, BaseItemKind.EPISODE) { state, row ->
state.copy(episodes = row)
}
} else { } else {
episodes.setValueOnMain(RowLoadingState.Success(listOf())) _state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) }
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val results = seerrService.similar(person).orEmpty() 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)) }
}
} }
} }
private fun fetchRow( private fun fetchRow(
itemId: UUID, itemId: UUID,
type: BaseItemKind, type: BaseItemKind,
target: MutableLiveData<RowLoadingState>, update: (PersonState, RowLoadingState) -> PersonState,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
target.setValueOnMain(RowLoadingState.Loading) _state.update {
update.invoke(it, RowLoadingState.Loading)
}
try { try {
val request = val request =
GetItemsRequest( GetItemsRequest(
@ -165,58 +193,68 @@ class PersonViewModel
pageSize = 15, pageSize = 15,
useSeriesForPrimary = false, useSeriesForPrimary = false,
).init() ).init()
target.setValueOnMain(RowLoadingState.Success(pager)) _state.update {
update.invoke(it, RowLoadingState.Success(pager))
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching $type for $itemId") 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) { fun setFavorite(favorite: Boolean) {
viewModelScope.launchIO { viewModelScope.launchIO {
itemUuid?.let { favoriteWatchManager.setFavorite(itemId, favorite)
favoriteWatchManager.setFavorite(it, favorite) updatePerson()
fetchAndSetItem(it)
}
} }
} }
} }
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 @Composable
fun PersonPage( fun PersonPage(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PersonViewModel = hiltViewModel(), viewModel: PersonViewModel =
hiltViewModel<PersonViewModel, PersonViewModel.Factory>(
creationCallback = { it.create(destination.itemId) },
),
) { ) {
OneTimeLaunchedEffect { val state by viewModel.state.collectAsState()
viewModel.init(destination.itemId) when (val st = state.person) {
} is DataLoadingState.Error -> {
val person by viewModel.item.observeAsState() ErrorMessage(st, modifier)
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)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<BaseItem> -> {
person?.let { person -> val person = st.data
var showOverviewDialog by remember { mutableStateOf(false) } var showOverviewDialog by remember { mutableStateOf(false) }
val name = person.name ?: person.id.toString() val name = remember(person) { person.name ?: person.id.toString() }
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) } val imageUrl =
remember {
imageUrlService.getItemImageUrl(
itemId = person.id,
imageType = ImageType.PRIMARY,
)
}
PersonPageContent( PersonPageContent(
preferences = preferences, preferences = preferences,
name = name, name = name,
@ -226,9 +264,9 @@ fun PersonPage(
deathdate = person.data.endDate?.toLocalDate(), deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(), birthPlace = person.data.productionLocations?.firstOrNull(),
favorite = person.favorite, favorite = person.favorite,
movies = movies, movies = state.movies,
series = series, series = state.series,
episodes = episodes, episodes = state.episodes,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
@ -236,7 +274,7 @@ fun PersonPage(
favoriteOnClick = { favoriteOnClick = {
viewModel.setFavorite(!person.favorite) viewModel.setFavorite(!person.favorite)
}, },
discovered = discovered, discovered = state.discovered,
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigationManager.navigateTo(item.destination) viewModel.navigationManager.navigateTo(item.destination)
}, },
@ -258,7 +296,6 @@ fun PersonPage(
} }
} }
} }
}
private const val HEADER_ROW = 0 private const val HEADER_ROW = 0
private const val MOVIE_ROW = 1 private const val MOVIE_ROW = 1

View file

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

View file

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

View file

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

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -86,16 +85,9 @@ fun DiscoverMovieDetails(
viewModel.init() viewModel.init()
onPauseOrDispose { } onPauseOrDispose { }
} }
val item by viewModel.movie.observeAsState() val state by viewModel.state.collectAsState()
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 userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
val canCancel by viewModel.canCancelRequest.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -103,29 +95,29 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request) val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) val request4kStr = stringResource(R.string.request_4k)
when (val state = loading) { when (val st = state.movie) {
is LoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<MovieDetails> -> {
item?.let { movie -> val movie = st.data
DiscoverMovieDetailsContent( DiscoverMovieDetailsContent(
preferences = preferences, preferences = preferences,
movie = movie, movie = movie,
userConfig = userConfig, userConfig = userConfig,
rating = rating, rating = state.rating,
canCancel = canCancel, canCancel = state.canCancelRequest,
people = people, people = state.people,
trailers = trailers, trailers = state.trailers,
similar = similar, similar = state.similar,
recommended = recommended, recommended = state.recommended,
requestOnClick = { requestOnClick = {
movie.id?.let { id -> movie.id?.let { id ->
if (request4kEnabled) { if (request4kEnabled) {
@ -196,7 +188,6 @@ fun DiscoverMovieDetails(
) )
} }
} }
}
overviewDialog?.let { info -> overviewDialog?.let { info ->
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.discover package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.successValue
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -36,13 +34,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update 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.ApiClient
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
class DiscoverMovieViewModel class DiscoverMovieViewModel
@ -62,15 +60,8 @@ class DiscoverMovieViewModel
fun create(item: DiscoverItem): DiscoverMovieViewModel fun create(item: DiscoverItem): DiscoverMovieViewModel
} }
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) private val _state = MutableStateFlow(DiscoverMovieState())
val movie = MutableLiveData<MovieDetails?>(null) val state: StateFlow<DiscoverMovieState> = _state
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)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = val request4kEnabled =
@ -80,42 +71,37 @@ class DiscoverMovieViewModel
init() init()
} }
private fun fetchAndSetItem(): Deferred<MovieDetails> = private fun fetchAndSetItem(): Deferred<MovieDetails?> =
viewModelScope.async( viewModelScope.async(Dispatchers.IO) {
Dispatchers.IO + try {
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
this@DiscoverMovieViewModel.movie.setValueOnMain(movie) _state.update { it.copy(movie = DataLoadingState.Success(movie)) }
movie movie
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error updating movie details")
null
}
} }
fun init(): Job = fun init(): Job =
viewModelScope.launch( viewModelScope.launchIO {
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
Timber.v("Init for movie %s", item.id) Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await() try {
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
val discoveredItem = seerrService.createDiscoverItem(movie) val discoveredItem = seerrService.createDiscoverItem(movie)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
updateCanCancel() updateCanCancel()
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
viewModelScope.launchIO { viewModelScope.launchIO {
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) val result =
rating.setValueOnMain(DiscoverRating(result)) seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
_state.update { it.copy(rating = DiscoverRating(result)) }
} }
if (!similar.isInitialized) { if (state.value.similar.isEmpty()) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
@ -123,7 +109,7 @@ class DiscoverMovieViewModel
.results .results
?.map { seerrService.createDiscoverItem(it) } ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
similar.setValueOnMain(result) _state.update { it.copy(similar = result) }
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
@ -132,7 +118,7 @@ class DiscoverMovieViewModel
.results .results
?.map { seerrService.createDiscoverItem(it) } ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
recommended.setValueOnMain(result) _state.update { it.copy(recommended = result) }
} }
} }
val people = val people =
@ -144,7 +130,7 @@ class DiscoverMovieViewModel
?.crew ?.crew
?.map { seerrService.createDiscoverItem(it) } ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
this@DiscoverMovieViewModel.people.setValueOnMain(people) _state.update { it.copy(people = people) }
val trailers = val trailers =
movie.relatedVideos movie.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER } ?.filter { it.type == RelatedVideo.Type.TRAILER }
@ -152,13 +138,25 @@ class DiscoverMovieViewModel
?.map { ?.map {
RemoteTrailer(it.name!!, it.url!!, it.site) RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty() }.orEmpty()
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) _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() { private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull() val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests) val canCancel =
canCancelRequest.update { canCancel } canUserCancelRequest(
user,
state.value.movie.successValue
?.mediaInfo
?.requests,
)
_state.update { it.copy(canCancelRequest = canCancel) }
} }
fun navigateTo(destination: Destination) { fun navigateTo(destination: Destination) {
@ -185,7 +183,7 @@ class DiscoverMovieViewModel
fun cancelRequest(id: Int) { fun cancelRequest(id: Int) {
viewModelScope.launchIO { 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? // TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
fetchAndSetItem().await() fetchAndSetItem().await()
@ -204,3 +202,14 @@ fun canUserCancelRequest(
user.hasPermission(SeerrPermission.REQUEST) && user.hasPermission(SeerrPermission.REQUEST) &&
requests?.any { it.requestedBy?.id == user?.id } == true 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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.rememberInt
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.successValue
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -85,17 +85,8 @@ fun DiscoverSeriesDetails(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current val resources = LocalResources.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val state by viewModel.state.collectAsState()
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 request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
val canCancel by viewModel.canCancelRequest.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -105,30 +96,30 @@ fun DiscoverSeriesDetails(
val requestStr = stringResource(R.string.request) val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) val request4kStr = stringResource(R.string.request_4k)
when (val state = loading) { when (val st = state.tvSeries) {
is LoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<TvDetails> -> {
item?.let { item -> val item = st.data
val rating by viewModel.rating.observeAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
DiscoverSeriesDetailsContent( DiscoverSeriesDetailsContent(
preferences = preferences, preferences = preferences,
series = item, series = item,
userConfig = userConfig, userConfig = userConfig,
rating = rating, rating = state.rating,
canCancel = canCancel, canCancel = state.canCancelRequest,
seasons = seasons, seasons = state.seasons,
people = people, people = state.people,
similar = similar, similar = state.similar,
recommended = recommended, recommended = state.recommended,
modifier = modifier, modifier = modifier,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigateTo(Destination.DiscoveredItem(item)) viewModel.navigateTo(Destination.DiscoveredItem(item))
@ -158,7 +149,7 @@ fun DiscoverSeriesDetails(
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
}, },
trailers = trailers, trailers = state.trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> item.id?.let { id ->
showRequestSeasonDialog = true showRequestSeasonDialog = true
@ -174,7 +165,6 @@ fun DiscoverSeriesDetails(
) )
} }
} }
}
overviewDialog?.let { info -> overviewDialog?.let { info ->
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
@ -203,11 +193,13 @@ fun DiscoverSeriesDetails(
} }
if (showRequestSeasonDialog) { if (showRequestSeasonDialog) {
RequestSeasonsDialog( RequestSeasonsDialog(
title = item?.name ?: "", title = state.tvSeries.successValue?.name ?: "",
seasons = seasons, seasons = state.seasons,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k -> onSubmit = { seasons, is4k ->
item?.id?.let { viewModel.request(it, seasons, is4k) } state.tvSeries.successValue
?.id
?.let { viewModel.request(it, seasons, is4k) }
showRequestSeasonDialog = false showRequestSeasonDialog = false
}, },
onDismissRequest = { showRequestSeasonDialog = false }, onDismissRequest = { showRequestSeasonDialog = false },

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.discover package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.successValue
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update 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.ApiClient
import timber.log.Timber import timber.log.Timber
@ -61,16 +59,8 @@ class DiscoverSeriesViewModel
fun create(item: DiscoverItem): DiscoverSeriesViewModel fun create(item: DiscoverItem): DiscoverSeriesViewModel
} }
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) private val _state = MutableStateFlow(DiscoverSeriesState())
val tvSeries = MutableLiveData<TvDetails?>(null) val state: StateFlow<DiscoverSeriesState> = _state
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)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false }
@ -79,43 +69,37 @@ class DiscoverSeriesViewModel
init() init()
} }
private fun fetchAndSetItem(): Deferred<TvDetails> = private fun fetchAndSetItem(): Deferred<TvDetails?> =
viewModelScope.async( viewModelScope.async(Dispatchers.IO) {
Dispatchers.IO + try {
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
tv tv
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error updating tv details")
null
}
} }
fun init(): Job = fun init(): Job =
viewModelScope.launch( viewModelScope.launchIO {
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
Timber.v("Init for tv %s", item.id) Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await() try {
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
val discoveredItem = seerrService.createDiscoverItem(tv) val discoveredItem = seerrService.createDiscoverItem(tv)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
updateSeasonStatus() updateSeasonStatus(tv)
updateCanCancel() updateCanCancel()
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
viewModelScope.launchIO { viewModelScope.launchIO {
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
rating.setValueOnMain(DiscoverRating(result)) _state.update { it.copy(rating = DiscoverRating(result)) }
} }
if (!similar.isInitialized) { if (state.value.similar.isEmpty()) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.tvApi seerrService.api.tvApi
@ -123,7 +107,7 @@ class DiscoverSeriesViewModel
.results .results
?.map { seerrService.createDiscoverItem(it) } ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
similar.setValueOnMain(result) _state.update { it.copy(similar = result) }
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
@ -132,7 +116,7 @@ class DiscoverSeriesViewModel
.results .results
?.map { seerrService.createDiscoverItem(it) } ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
recommended.setValueOnMain(result) _state.update { it.copy(recommended = result) }
} }
} }
val people = val people =
@ -144,7 +128,7 @@ class DiscoverSeriesViewModel
?.crew ?.crew
?.map { seerrService.createDiscoverItem(it) } ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people) _state.update { it.copy(people = people) }
val trailers = val trailers =
tv.relatedVideos tv.relatedVideos
@ -153,15 +137,20 @@ class DiscoverSeriesViewModel
?.map { ?.map {
RemoteTrailer(it.name!!, it.url!!, it.site) RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty() }.orEmpty()
this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers) _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) { fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
private suspend fun updateSeasonStatus() { private fun updateSeasonStatus(tv: TvDetails) {
tvSeries.value?.let { tv ->
val seasonStatus = mutableMapOf<Int, SeerrAvailability>() val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach { tv.seasons?.forEach {
it.seasonNumber?.let { it.seasonNumber?.let {
@ -193,14 +182,19 @@ class DiscoverSeriesViewModel
RequestSeason(it, availability) RequestSeason(it, availability)
} }
} }
seasons.setValueOnMain(requestSeasons) _state.update { it.copy(seasons = requestSeasons) }
}
} }
private suspend fun updateCanCancel() { private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull() val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) val canCancel =
canCancelRequest.update { canCancel } canUserCancelRequest(
user,
state.value.tvSeries.successValue
?.mediaInfo
?.requests,
)
_state.update { it.copy(canCancelRequest = canCancel) }
} }
fun request( fun request(
@ -209,7 +203,7 @@ class DiscoverSeriesViewModel
is4k: Boolean, is4k: Boolean,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
tvSeries.value?.let { tv -> state.value.tvSeries.successValue?.let { tv ->
val currentRequest = val currentRequest =
tv.mediaInfo?.requests?.firstOrNull { tv.mediaInfo?.requests?.firstOrNull {
it.requestedBy?.id == it.requestedBy?.id ==
@ -238,21 +232,35 @@ class DiscoverSeriesViewModel
) )
} }
fetchAndSetItem().await() fetchAndSetItem().await()?.let {
updateSeasonStatus() updateSeasonStatus(it)
updateCanCancel() updateCanCancel()
} }
} }
} }
}
fun cancelRequest(id: Int) { fun cancelRequest(id: Int) {
viewModelScope.launchIO { 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? // TODO handle multiple requests? Or just delete self's request?
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
fetchAndSetItem().await() fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
updateCanCancel() 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -66,27 +64,20 @@ fun EpisodeDetails(
), ),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.init() viewModel.init()
onPauseOrDispose { } onPauseOrDispose { }
} }
val item by viewModel.item.observeAsState() val state by viewModel.state.collectAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } 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 canDelete by viewModel.canDelete.collectAsState()
val preferredSubtitleLanguage = val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
viewModel.serverRepository.currentUserDto val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val contextActions = val contextActions =
remember { remember {
@ -119,19 +110,19 @@ fun EpisodeDetails(
) )
} }
when (val state = loading) { when (val st = state.episode) {
is LoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<BaseItem> -> {
item?.let { ep -> val ep = st.data
LifecycleResumeEffect(ep) { LifecycleResumeEffect(ep) {
ep.data.seriesId?.let { seriesId -> ep.data.seriesId?.let { seriesId ->
viewModel.maybePlayThemeSong(seriesId) viewModel.maybePlayThemeSong(seriesId)
@ -143,7 +134,7 @@ fun EpisodeDetails(
EpisodeDetailsContent( EpisodeDetailsContent(
preferences = preferences, preferences = preferences,
ep = ep, ep = ep,
chosenStreams = chosenStreams, chosenStreams = state.chosenStreams,
playOnClick = { playOnClick = {
viewModel.navigateTo( viewModel.navigateTo(
Destination.Playback( Destination.Playback(
@ -161,7 +152,7 @@ fun EpisodeDetails(
ContextMenu.ForBaseItem( ContextMenu.ForBaseItem(
fromLongClick = false, fromLongClick = false,
item = ep, item = ep,
chosenStreams = chosenStreams, chosenStreams = state.chosenStreams,
showGoTo = false, showGoTo = false,
showStreamChoices = true, showStreamChoices = true,
canDelete = canDelete, canDelete = canDelete,
@ -182,12 +173,11 @@ fun EpisodeDetails(
) )
} }
} }
}
overviewDialog?.let { info -> overviewDialog?.let { info ->
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
showFilePath = showFilePath =
viewModel.serverRepository.currentUserDto.value userDto
?.policy ?.policy
?.isAdministrator == true, ?.isAdministrator == true,
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },

View file

@ -1,9 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.episode package com.github.damontecres.wholphin.ui.detail.episode
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository 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.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import java.util.UUID import java.util.UUID
@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class) @HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class)
@ -67,53 +65,61 @@ class EpisodeViewModel
fun create(itemId: UUID): EpisodeViewModel fun create(itemId: UUID): EpisodeViewModel
} }
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) private val _state = MutableStateFlow(EpisodeState())
val item = MutableLiveData<BaseItem?>(null) val state: StateFlow<EpisodeState> = _state
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val canDelete = MutableStateFlow(false) val canDelete = MutableStateFlow(false)
init { init {
init() init()
viewModelScope.launchDefault { viewModelScope.launchDefault {
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete -> mediaManagementService.collectCanDelete(
state.map { (it.episode as? DataLoadingState.Success<BaseItem?>)?.data },
) { canDelete ->
this@EpisodeViewModel.canDelete.update { canDelete } this@EpisodeViewModel.canDelete.update { canDelete }
} }
} }
} }
private fun fetchAndSetItem(): Deferred<BaseItem> = private fun fetchAndSetItem() {
viewModelScope.async( viewModelScope.launchIO {
Dispatchers.IO + try {
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val item = val item =
api.userLibraryApi.getItem(itemId).content.let { api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api) BaseItem.from(it, api)
} }
this@EpisodeViewModel.item.setValueOnMain(item) _state.update { it.copy(episode = DataLoadingState.Success(item)) }
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 = fun init(): Job =
viewModelScope.launch( viewModelScope.launchIO {
Dispatchers.IO + try {
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await() val item =
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) api.userLibraryApi.getItem(itemId).content.let {
withContext(Dispatchers.Main) { BaseItem(it)
this@EpisodeViewModel.item.value = item }
chosenStreams.value = result val chosenStreams =
loading.value = LoadingState.Success itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
_state.update {
it.copy(
episode = DataLoadingState.Success(item),
chosenStreams = chosenStreams,
)
}
backdropService.submit(item) 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 { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { _state.update { it.copy(chosenStreams = chosen) }
chosenStreams.value = chosen
}
} }
} }
@ -171,9 +175,7 @@ class EpisodeViewModel
result?.let { result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
} }
withContext(Dispatchers.Main) { _state.update { it.copy(chosenStreams = chosen) }
chosenStreams.value = chosen
}
} }
} }
@ -195,14 +197,16 @@ class EpisodeViewModel
fun clearChosenStreams(chosenStreams: ChosenStreams?) { fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO { viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams) itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item -> state.value.episode.let { item ->
if (item is DataLoadingState.Success<BaseItem>) {
val result = val result =
itemPlaybackRepository.getSelectedTracks( itemPlaybackRepository.getSelectedTracks(
itemId, itemId,
item, item.data,
userPreferencesService.getCurrent(), userPreferencesService.getCurrent(),
) )
this@EpisodeViewModel.chosenStreams.setValueOnMain(result) _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.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
@ -63,13 +63,16 @@ class DvrScheduleViewModel
private val api: ApiClient, private val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) private val _state = MutableStateFlow(DvrScheduleState())
val active = MutableLiveData<List<BaseItem>>() val state: StateFlow<DvrScheduleState> = _state
val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>()
init {
init()
}
fun init() { fun init() {
// loading.value = LoadingState.Loading viewModelScope.launchIO {
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) { try {
val active = val active =
api.liveTvApi api.liveTvApi
.getRecordings( .getRecordings(
@ -84,15 +87,27 @@ class DvrScheduleViewModel
isActive = false, isActive = false,
isScheduled = true, isScheduled = true,
).content.items ).content.items
.map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings .map {
BaseItem.from(
it.programInfo!!,
api,
true,
)
} // TODO this probably breaks for time based recordings
.groupBy { .groupBy {
it.data.startDate!!.toLocalDate() it.data.startDate!!.toLocalDate()
} }
withContext(Dispatchers.Main) { _state.update {
this@DvrScheduleViewModel.active.value = active it.copy(
this@DvrScheduleViewModel.scheduled.value = scheduled loading = LoadingState.Success,
loading.value = 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 @Composable
fun DvrSchedule( fun DvrSchedule(
requestFocusAfterLoading: Boolean, requestFocusAfterLoading: Boolean,
@ -122,12 +143,10 @@ fun DvrSchedule(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() viewModel.init()
} }
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val state by viewModel.state.collectAsState()
val active by viewModel.active.observeAsState(listOf()) when (val st = state.loading) {
val recordings by viewModel.scheduled.observeAsState(mapOf())
when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, LoadingState.Loading,
@ -141,7 +160,7 @@ fun DvrSchedule(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
if (requestFocusAfterLoading) { if (requestFocusAfterLoading) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (active.isNotEmpty() || recordings.isNotEmpty()) { if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} else { } else {
focusRequesterOnEmpty.tryRequestFocus() focusRequesterOnEmpty.tryRequestFocus()
@ -149,8 +168,8 @@ fun DvrSchedule(
} }
} }
DvrScheduleContent( DvrScheduleContent(
activeRecordings = active, activeRecordings = state.active,
scheduledRecordings = recordings, scheduledRecordings = state.scheduled,
onClickItem = { onClickItem = {
showDialog = it showDialog = it
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.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.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty 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.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
@ -111,20 +107,9 @@ fun SeriesDetails(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current 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 state by viewModel.state.collectAsState()
val canDelete by viewModel.canDeleteSeries.collectAsState() val playlistState by playlistViewModel.playlistState.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)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
@ -180,37 +165,36 @@ fun SeriesDetails(
} }
} }
when (val state = loading) { when (val st = state.series) {
is LoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<BaseItem> -> {
item?.let { item ->
LifecycleResumeEffect(destination.itemId) { LifecycleResumeEffect(destination.itemId) {
viewModel.onResumePage() viewModel.onResumePage()
onPauseOrDispose {} onPauseOrDispose {}
} }
val series = st.data
val played = item.data.userData?.played ?: false val played = series.data.userData?.played ?: false
SeriesDetailsContent( SeriesDetailsContent(
preferences = preferences, preferences = preferences,
series = item, series = series,
seasons = seasons, seasons = state.seasons,
trailers = trailers, trailers = state.trailers,
extras = extras, extras = state.extras,
people = people, people = state.people,
similar = similar, similar = state.similar,
played = played, played = played,
favorite = item.data.userData?.isFavorite ?: false, favorite = series.data.userData?.isFavorite ?: false,
canDelete = canDelete, canDelete = state.canDeleteSeries,
modifier = modifier, modifier = modifier,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigateTo(item.destination()) viewModel.navigateTo(item.destination())
@ -238,13 +222,13 @@ fun SeriesDetails(
) )
}, },
overviewOnClick = { overviewOnClick = {
overviewDialog = ItemDetailsDialogInfo(item) overviewDialog = ItemDetailsDialogInfo(series)
}, },
playOnClick = { shuffle -> playOnClick = { shuffle ->
if (shuffle) { if (shuffle) {
viewModel.navigateTo( viewModel.navigateTo(
Destination.PlaybackList( Destination.PlaybackList(
itemId = item.id, itemId = series.id,
shuffle = true, shuffle = true,
), ),
) )
@ -254,8 +238,8 @@ fun SeriesDetails(
}, },
watchOnClick = { showWatchConfirmation = true }, watchOnClick = { showWatchConfirmation = true },
favoriteOnClick = { favoriteOnClick = {
val favorite = item.data.userData?.isFavorite ?: false val favorite = series.data.userData?.isFavorite ?: false
viewModel.setFavorite(item.id, !favorite, null) viewModel.setFavorite(series.id, !favorite, null)
}, },
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
@ -263,13 +247,13 @@ fun SeriesDetails(
onClickExtra = { _, extra -> onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination) viewModel.navigateTo(extra.destination)
}, },
discoverSeries = discoverSeries, discoverSeries = state.discoverSeries,
onClickDiscoverSeries = { onClickDiscoverSeries = {
discoverSeries?.let { state.discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it)) viewModel.navigateTo(Destination.DiscoveredItem(it))
} }
}, },
discovered = discovered, discovered = state.discovered,
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
}, },
@ -280,7 +264,7 @@ fun SeriesDetails(
) )
if (showWatchConfirmation) { if (showWatchConfirmation) {
ConfirmDialog( ConfirmDialog(
title = item.name ?: "", title = series.name ?: "",
body = body =
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
onCancel = { onCancel = {
@ -294,7 +278,6 @@ fun SeriesDetails(
} }
} }
} }
}
showContextMenu?.let { contextMenu -> showContextMenu?.let { contextMenu ->
ContextMenuDialog( ContextMenuDialog(
onDismissRequest = { showContextMenu = null }, onDismissRequest = { showContextMenu = null },

View file

@ -6,21 +6,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.map
import com.github.damontecres.wholphin.R 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ContextMenu 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.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt 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.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
@ -82,28 +78,21 @@ fun SeriesOverview(
), ),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() }
val guestStarRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() }
val extrasRowFocusRequester = remember { FocusRequester() } val extrasRowFocusRequester = remember { FocusRequester() }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val state by viewModel.state.collectAsState()
val episodeList =
val series by viewModel.item.observeAsState(null) remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes }
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 position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
val currentPosition by rememberUpdatedState(position) val currentPosition by rememberUpdatedState(position)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (seasons.isNotEmpty()) { if (state.seasons.isNotEmpty()) {
seasons.getOrNull(position.seasonTabIndex)?.let { state.seasons.getOrNull(position.seasonTabIndex)?.let {
viewModel.loadEpisodes(it.id) viewModel.loadEpisodes(it.id)
} }
} }
@ -112,7 +101,7 @@ fun SeriesOverview(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(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() var rowFocused by rememberInt()
@ -149,7 +138,7 @@ fun SeriesOverview(
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onClearChosenStreams = { onClearChosenStreams = {
val focusedEpisode = val focusedEpisode =
(episodes as? EpisodeList.Success) (state.episodes as? EpisodeList.Success)
?.episodes ?.episodes
?.getOrNull(currentPosition.episodeRowIndex) ?.getOrNull(currentPosition.episodeRowIndex)
if (focusedEpisode != null) { if (focusedEpisode != null) {
@ -159,9 +148,9 @@ fun SeriesOverview(
) )
} }
LaunchedEffect(position, episodes) { LaunchedEffect(position, state.episodes) {
val focusedEpisode = val focusedEpisode =
(episodes as? EpisodeList.Success) (state.episodes as? EpisodeList.Success)
?.episodes ?.episodes
?.getOrNull(position.episodeRowIndex) ?.getOrNull(position.episodeRowIndex)
@ -170,29 +159,23 @@ fun SeriesOverview(
viewModel.lookupPeopleInEpisode(it) viewModel.lookupPeopleInEpisode(it)
} }
} }
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams = state.chosenStreams
val preferredSubtitleLanguage = val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
viewModel.serverRepository.currentUserDto val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
when (val state = loading) { when (val st = state.series) {
is LoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, DataLoadingState.Loading,
LoadingState.Pending, DataLoadingState.Pending,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
} }
LoadingState.Success -> { is DataLoadingState.Success<BaseItem> -> {
series?.let { series ->
RequestOrRestoreFocus( RequestOrRestoreFocus(
when (rowFocused) { when (rowFocused) {
EPISODE_ROW -> episodeRowFocusRequester EPISODE_ROW -> episodeRowFocusRequester
@ -213,12 +196,12 @@ fun SeriesOverview(
SeriesOverviewContent( SeriesOverviewContent(
preferences = preferences, preferences = preferences,
series = series, series = st.data,
seasons = seasons, seasons = state.seasons,
episodes = episodes, episodes = state.episodes,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
peopleInEpisode = peopleInEpisode, peopleInEpisode = state.peopleInEpisode.people,
seasonExtras = seasonExtras, seasonExtras = state.extras,
position = position, position = position,
firstItemFocusRequester = firstItemFocusRequester, firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester,
@ -227,7 +210,7 @@ fun SeriesOverview(
extrasRowFocusRequester = extrasRowFocusRequester, extrasRowFocusRequester = extrasRowFocusRequester,
onChangeSeason = { index -> onChangeSeason = { index ->
if (index != position.seasonTabIndex) { if (index != position.seasonTabIndex) {
seasons.getOrNull(index)?.let { season -> state.seasons.getOrNull(index)?.let { season ->
viewModel.loadEpisodes(season.id) viewModel.loadEpisodes(season.id)
viewModel.position.update { viewModel.position.update {
SeriesOverviewPosition(index, 0) SeriesOverviewPosition(index, 0)
@ -332,7 +315,6 @@ fun SeriesOverview(
) )
} }
} }
}
showContextMenu?.let { contextMenu -> showContextMenu?.let { contextMenu ->
ContextMenuDialog( ContextMenuDialog(
onDismissRequest = { showContextMenu = null }, onDismissRequest = { showContextMenu = null },
@ -345,7 +327,7 @@ fun SeriesOverview(
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
showFilePath = showFilePath =
viewModel.serverRepository.currentUserDto.value userDto
?.policy ?.policy
?.isAdministrator == true, ?.isAdministrator == true,
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },

View file

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

View file

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

View file

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

View file

@ -94,7 +94,7 @@ class HomeSettingsViewModel
init { init {
addCloseable { saveToLocal() } addCloseable { saveToLocal() }
viewModelScope.launchIO { viewModelScope.launchIO {
val userDto = serverRepository.currentUserDto.value ?: return@launchIO val userDto = serverRepository.currentUserDto ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings = val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
@ -122,7 +122,7 @@ class HomeSettingsViewModel
val limit = 8 val limit = 8
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
val rows = val rows =
serverRepository.currentUserDto.value?.let { userDto -> serverRepository.currentUserDto?.let { userDto ->
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
state.value state.value
.let { state -> .let { state ->
@ -508,7 +508,7 @@ class HomeSettingsViewModel
fun saveToRemote() { fun saveToRemote() {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
Timber.d("Saving home settings to remote") Timber.d("Saving home settings to remote")
val rows = state.value.rows.map { it.config } val rows = state.value.rows.map { it.config }
val settings = val settings =
@ -527,7 +527,7 @@ class HomeSettingsViewModel
fun loadFromRemote() { fun loadFromRemote() {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
Timber.d("Loading home settings from remote") Timber.d("Loading home settings from remote")
try { try {
_state.update { it.copy(loading = LoadingState.Loading) } _state.update { it.copy(loading = LoadingState.Loading) }
@ -561,7 +561,7 @@ class HomeSettingsViewModel
fun loadFromRemoteWeb() { fun loadFromRemoteWeb() {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
Timber.d("Loading home settings from web") Timber.d("Loading home settings from web")
try { try {
_state.update { it.copy(loading = LoadingState.Loading) } _state.update { it.copy(loading = LoadingState.Loading) }
@ -588,7 +588,7 @@ class HomeSettingsViewModel
fun saveToLocal() { fun saveToLocal() {
// This uses injected ioScope so that it will still run when the page is closing // This uses injected ioScope so that it will still run when the page is closing
ioScope.launchIO { ioScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
val rows = state.value.rows.map { it.config } val rows = state.value.rows.map { it.config }
val settings = val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
@ -655,7 +655,7 @@ class HomeSettingsViewModel
fun resetToDefault() = fun resetToDefault() =
viewModelScope.launchIO { viewModelScope.launchIO {
val userId = serverRepository.currentUser.value?.id ?: return@launchIO val userId = serverRepository.currentUser?.id ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) } _state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.createDefault(userId) val result = homeSettingsService.createDefault(userId)
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
@ -75,7 +74,7 @@ class RemovedNextUpContentViewModel
init { init {
viewModelScope.launchDefault { viewModelScope.launchDefault {
serverRepository.currentUser.asFlow().collectLatest { user -> serverRepository.currentUserFlow.collectLatest { user ->
_state.update { RemovedNextUpState() } _state.update { RemovedNextUpState() }
if (user == null) { if (user == null) {
return@collectLatest return@collectLatest
@ -105,7 +104,7 @@ class RemovedNextUpContentViewModel
} }
fun remove(item: RemovedItem) { fun remove(item: RemovedItem) {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
viewModelScope.launchDefault { viewModelScope.launchDefault {
mutex.withLock { mutex.withLock {
_state.update { it.copy(removedEnabled = false) } _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.ReadOnlyComposable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.viewModelScope 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.ifElse
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption 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.setup.UserIconCardImage
import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -111,10 +111,10 @@ class NavDrawerViewModel
val backdropService: BackdropService, val backdropService: BackdropService,
private val musicService: MusicService, private val musicService: MusicService,
) : ViewModel() { ) : ViewModel() {
val state = navDrawerService.state val serviceState = navDrawerService.state
val selectedIndex = MutableLiveData(-1) private val _state = MutableStateFlow(NavDrawerState())
val moreExpanded = MutableLiveData(false) val state: StateFlow<NavDrawerState> = _state
fun onClickDrawerItem( fun onClickDrawerItem(
index: Int, index: Int,
@ -130,7 +130,7 @@ class NavDrawerViewModel
} }
NavDrawerItem.More -> { NavDrawerItem.More -> {
setShowMore(!moreExpanded.value!!) setShowMore(!state.value.moreExpanded)
} }
NavDrawerItem.Discover -> { NavDrawerItem.Discover -> {
@ -148,11 +148,11 @@ class NavDrawerViewModel
} }
fun setIndex(index: Int) { fun setIndex(index: Int) {
selectedIndex.value = index _state.update { it.copy(selectedIndex = index) }
} }
fun setShowMore(value: Boolean) { fun setShowMore(value: Boolean) {
moreExpanded.value = value _state.update { it.copy(moreExpanded = value) }
} }
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
@ -164,11 +164,11 @@ class NavDrawerViewModel
viewModelScope.launchDefault { viewModelScope.launchDefault {
val asDestinations = val asDestinations =
( (
state.value.items + serviceState.value.items +
listOf( listOf(
NavDrawerItem.More, NavDrawerItem.More,
NavDrawerItem.Discover, NavDrawerItem.Discover,
) + state.value.moreItems ) + serviceState.value.moreItems
).map { ).map {
if (it is ServerNavDrawerItem) { if (it is ServerNavDrawerItem) {
it.destination it.destination
@ -202,7 +202,7 @@ class NavDrawerViewModel
} }
Timber.v("Found $index => $key") Timber.v("Found $index => $key")
if (index != null) { if (index != null) {
selectedIndex.setValueOnMain(index) _state.update { it.copy(selectedIndex = index) }
break 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 * An item that can be shown in the nav drawer
* *
@ -303,10 +308,11 @@ fun NavDrawer(
drawerState.setValue(DrawerValue.Open) drawerState.setValue(DrawerValue.Open)
focusRequester.requestFocus() focusRequester.requestFocus()
} }
val serviceState by viewModel.serviceState.collectAsState()
val state by viewModel.state.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 // 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) { BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
viewModel.setShowMore(false) viewModel.setShowMore(false)
@ -361,14 +367,14 @@ fun NavDrawer(
modifier = Modifier, modifier = Modifier,
) )
AnimatedVisibility( AnimatedVisibility(
visible = state.nowPlayingEnabled, visible = serviceState.nowPlayingEnabled,
enter = expandVertically(expandFrom = Alignment.Top), enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top), exit = shrinkVertically(shrinkTowards = Alignment.Top),
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
IconNavItem( IconNavItem(
text = stringResource(R.string.now_playing), text = stringResource(R.string.now_playing),
subtext = state.nowPlayingTitle, subtext = serviceState.nowPlayingTitle,
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
selected = selectedIndex == NOW_PLAYING_INDEX, selected = selectedIndex == NOW_PLAYING_INDEX,
drawerOpen = isOpen, drawerOpen = isOpen,
@ -448,8 +454,8 @@ fun NavDrawer(
), ),
) )
} }
itemsIndexed(state.items) { index, it -> itemsIndexed(serviceState.items) { index, it ->
if (it !is NavDrawerItem.Discover || state.discoverEnabled) { if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
NavItem( NavItem(
library = it, library = it,
@ -469,9 +475,9 @@ fun NavDrawer(
) )
} }
} }
if (state.moreItems.isNotEmpty()) { if (serviceState.moreItems.isNotEmpty()) {
item { item {
val index = state.items.size val index = serviceState.items.size
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
NavItem( NavItem(
library = NavDrawerItem.More, library = NavDrawerItem.More,
@ -492,10 +498,10 @@ fun NavDrawer(
} }
} }
if (moreExpanded) { if (moreExpanded) {
itemsIndexed(state.moreItems) { index, it -> itemsIndexed(serviceState.moreItems) { index, it ->
val adjustedIndex = val adjustedIndex =
remember(state) { (index + state.items.size + 1) } remember(serviceState) { (index + serviceState.items.size + 1) }
if (it !is NavDrawerItem.Discover || state.discoverEnabled) { if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
NavItem( NavItem(
library = it, library = it,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -297,7 +297,7 @@ class NavDrawerPreferencesViewModel
init { init {
viewModelScope.launchDefault { viewModelScope.launchDefault {
val state = navDrawerService.state.value val state = navDrawerService.state.value
val user = serverRepository.currentUser.value val user = serverRepository.currentUser
val seerr = seerrServerRepository.active.firstOrNull() val seerr = seerrServerRepository.active.firstOrNull()
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
return@launchDefault return@launchDefault
@ -337,8 +337,8 @@ class NavDrawerPreferencesViewModel
fun save() { fun save() {
viewModelScope.launchIO(ExceptionHandler(true)) { viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
serverRepository.currentUserDto.value?.let { userDto -> serverRepository.currentUserDto?.let { userDto ->
if (user.id == userDto.id) { if (user.id == userDto.id) {
val toSave = val toSave =
state.value.mapIndexed { index, item -> 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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -100,7 +99,7 @@ fun PreferencesContent(
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState() val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) } 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) val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) } var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) }
@ -126,16 +125,20 @@ fun PreferencesContent(
updateCache = false updateCache = false
} }
val release by updateVM.release.observeAsState(null) val updateState by updateVM.state.collectAsState()
LaunchedEffect(Unit) { val release = updateState.release
LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) {
if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) { if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) {
updateVM.init(preferences.updateUrl) updateVM.init()
} }
} }
val movementSounds = true val movementSounds = true
val installedVersion = updateVM.currentVersion val installedVersion = updateVM.currentVersion
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false val updateAvailable =
remember(updateState.release) {
updateState.release?.version?.isGreaterThan(installedVersion) ?: false
}
val prefList = val prefList =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -306,7 +309,7 @@ fun PreferencesContent(
) )
} }
} else { } else {
updateVM.init(preferences.updateUrl) updateVM.init()
} }
}, },
onLongClick = { onLongClick = {
@ -635,7 +638,7 @@ fun PreferencesContent(
} }
SeerrDialogMode.Add -> { SeerrDialogMode.Add -> {
val currentUser by seerrVm.currentUser.observeAsState() val currentUser by seerrVm.currentUser.collectAsState(null)
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
val serverAddedMessage = stringResource(R.string.seerr_server_added) val serverAddedMessage = stringResource(R.string.seerr_server_added)
LaunchedEffect(status) { 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
@ -58,7 +60,13 @@ class PreferencesViewModel
private val updateChecker: UpdateChecker, private val updateChecker: UpdateChecker,
) : ViewModel(), ) : ViewModel(),
RememberTabManager by rememberTabManager { RememberTabManager by rememberTabManager {
val currentUser get() = serverRepository.currentUser val currentUser
get() =
serverRepository.currentUserFlow.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null,
)
val seerrConnection = seerrServerRepository.connection 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme 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.NavigationManager
import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.UpdateChecker 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.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.formatBytes 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.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job 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.launch
import kotlinx.coroutines.withContext import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class UpdateViewModel class UpdateViewModel
@Inject @Inject
constructor( constructor(
private val userPreferencesService: UserPreferencesService,
val updater: UpdateChecker, val updater: UpdateChecker,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel(), ) : ViewModel(),
DownloadCallback { DownloadCallback {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) private val _state = MutableStateFlow(InstallUpdateState())
val release = MutableLiveData<Release?>(null) val state: StateFlow<InstallUpdateState> = _state
val downloading = MutableLiveData<Boolean>(false) private val _bytesDownloaded = MutableStateFlow(0L)
val contentLength = MutableLiveData<Long>(-1) val bytesDownloaded: StateFlow<Long> = _bytesDownloaded
val bytesDownloaded = MutableLiveData<Long>(-1)
val currentVersion = updater.getInstalledVersion() val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) { fun init() {
loading.value = LoadingState.Loading _state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { viewModelScope.launchIO {
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
try {
val release = updater.getLatestRelease(updateUrl) val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) { _state.update {
contentLength.value = -1 it.copy(
bytesDownloaded.value = -1 loading = LoadingState.Success,
this@UpdateViewModel.release.value = release release = release,
loading.value = LoadingState.Success 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) { fun installRelease(release: Release) {
downloadJob = downloadJob =
viewModelScope.launch( viewModelScope.launchIO {
Dispatchers.IO + try {
LoadingExceptionHandler( _bytesDownloaded.value = 0L
loading, _state.update { it.copy(downloading = true) }
"Failed to install update",
),
) {
downloading.setValueOnMain(true)
updater.installRelease(release, this@UpdateViewModel) updater.installRelease(release, this@UpdateViewModel)
downloading.setValueOnMain(false) } 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() { fun cancelDownload() {
viewModelScope.launch( viewModelScope.launchIO {
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error",
),
) {
downloadJob?.cancel() downloadJob?.cancel()
withContext(Dispatchers.Main) { _state.update {
downloading.value = false it.copy(
contentLength.value = -1 downloading = false,
bytesDownloaded.value = -1 contentLength = -1L,
)
} }
} }
} }
override fun contentLength(contentLength: Long) { override fun contentLength(contentLength: Long) {
this@UpdateViewModel.contentLength.value = contentLength _state.update { it.copy(contentLength = contentLength) }
} }
override fun bytesDownloaded(bytes: Long) { 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 @Composable
fun InstallUpdatePage( fun InstallUpdatePage(
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: UpdateViewModel = hiltViewModel(), viewModel: UpdateViewModel = hiltViewModel(),
) { ) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending) OneTimeLaunchedEffect { viewModel.init() }
val release by viewModel.release.observeAsState(null)
val isDownloading by viewModel.downloading.observeAsState(false) val state by viewModel.state.collectAsState()
val contentLength by viewModel.contentLength.observeAsState(-1L)
val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1)
LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl)
}
var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) } var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) }
val launcher = val launcher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
@ -173,9 +180,9 @@ fun InstallUpdatePage(
// TODO // TODO
} }
} }
when (val state = loading) { when (val st = state.loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(st, modifier)
} }
LoadingState.Loading, LoadingState.Loading,
@ -185,26 +192,32 @@ fun InstallUpdatePage(
} }
LoadingState.Success -> { LoadingState.Success -> {
release?.let { val release = state.release
if (release != null) {
InstallUpdatePageContent( InstallUpdatePageContent(
currentVersion = viewModel.currentVersion, currentVersion = viewModel.currentVersion,
release = it, release = release,
onInstallRelease = { onInstallRelease = {
if (!permissions) { if (!permissions) {
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else { } else {
viewModel.installRelease(it) viewModel.installRelease(release)
} }
}, },
onCancel = { onCancel = {
viewModel.navigationManager.goBack() 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( DownloadDialog(
contentLength = contentLength, contentLength = state.contentLength,
bytesDownloaded = bytesDownloaded, bytesDownloaded = bytesDownloaded,
onDismissRequest = { onDismissRequest = {
viewModel.cancelDownload() viewModel.cancelDownload()

View file

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

View file

@ -29,7 +29,7 @@ class SwitchSeerrViewModel
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
) : ViewModel() { ) : ViewModel() {
val currentUser = serverRepository.currentUser val currentUser = serverRepository.currentUserFlow
val currentSeerrServer = seerrServerRepository.currentServer val currentSeerrServer = seerrServerRepository.currentServer
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) 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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -59,7 +58,6 @@ import coil3.request.ImageRequest
import coil3.request.transitionFactory import coil3.request.transitionFactory
import coil3.size.Size import coil3.size.Size
import com.github.damontecres.wholphin.R 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.AppColors
import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -93,9 +91,9 @@ fun SlideshowPage(
), ),
) { ) {
val context = LocalContext.current 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 -> { is LoadingState.Error -> {
ErrorMessage(st, modifier) ErrorMessage(st, modifier)
} }
@ -107,10 +105,10 @@ fun SlideshowPage(
} }
LoadingState.Success -> { LoadingState.Success -> {
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) val loadingState = state.imageLoading
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val imageFilter by viewModel.imageFilter.collectAsState()
val position by viewModel.position.observeAsState(0) val position = state.position
val pager by viewModel.pager.observeAsState() val pager = state.items
// val imageState by viewModel.image.observeAsState() // val imageState by viewModel.image.observeAsState()
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
@ -150,7 +148,7 @@ fun SlideshowPage(
label = "image_panY", label = "image_panY",
) )
val slideshowState by viewModel.slideshow.collectAsState() val slideshowState by viewModel.state.collectAsState()
val slideshowActive by viewModel.slideshowActive.collectAsState(false) val slideshowActive by viewModel.slideshowActive.collectAsState(false)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }

View file

@ -3,10 +3,7 @@ package com.github.damontecres.wholphin.ui.slideshow
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player import androidx.media3.common.Player
import com.github.damontecres.wholphin.data.ChosenStreams 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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain 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.showToast
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -87,28 +82,18 @@ class SlideshowViewModel
/** /**
* Whether slideshow mode is on or off * Whether slideshow mode is on or off
*/ */
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false)) private val _state = MutableStateFlow(SlideshowState())
val slideshow: StateFlow<SlideshowState> = _slideshow val state: StateFlow<SlideshowState> = _state
/** /**
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused * 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>() var slideshowDelay by Delegates.notNull<Long>()
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>() private val _imageFilter = MutableStateFlow(VideoFilter())
val pager: LiveData<List<BaseItem?>> = _pager.map { it } val imageFilter: StateFlow<VideoFilter> = _imageFilter
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 var albumImageFilter = VideoFilter() private var albumImageFilter = VideoFilter()
@ -152,7 +137,7 @@ class SlideshowViewModel
sortOrder = listOf(slideshowSettings.sortAndDirection.direction), sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
), ),
) )
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
val filter = val filter =
playbackEffectDao playbackEffectDao
.getPlaybackEffect( .getPlaybackEffect(
@ -168,21 +153,25 @@ class SlideshowViewModel
val pager = val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
.init(slideshowSettings.index) .init(slideshowSettings.index)
this@SlideshowViewModel._pager.setValueOnMain(pager) _state.update {
loading.update { LoadingState.Success } it.copy(
loading = LoadingState.Success,
items = pager,
)
}
updatePosition(slideshowSettings.index)?.join() updatePosition(slideshowSettings.index)?.join()
if (slideshowSettings.startSlideshow) onMain { startSlideshow() } if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error") Timber.e(ex, "Error")
loading.update { LoadingState.Error(ex) } _state.update { it.copy(loading = LoadingState.Error(ex)) }
} }
} }
} }
fun nextImage(): Boolean { fun nextImage(): Boolean {
val size = pager.value?.size val size = state.value.items.size
val newPosition = position.value!! + 1 val newPosition = state.value.position + 1
return if (size != null && newPosition < size) { return if (newPosition < size) {
updatePosition(newPosition) updatePosition(newPosition)
true true
} else { } else {
@ -191,7 +180,7 @@ class SlideshowViewModel
} }
fun previousImage(): Boolean { fun previousImage(): Boolean {
val newPosition = position.value!! - 1 val newPosition = state.value.position - 1
return if (newPosition >= 0) { return if (newPosition >= 0) {
updatePosition(newPosition) updatePosition(newPosition)
true true
@ -201,14 +190,14 @@ class SlideshowViewModel
} }
fun updatePosition(position: Int): Job? = fun updatePosition(position: Int): Job? =
_pager.value?.let { pager -> (state.value.items as? ApiRequestPager<*>)?.let { pager ->
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
val image = val image =
if (position in pager.indices) pager.getBlocking(position) else null if (position in pager.indices) pager.getBlocking(position) else null
Timber.v("Got image for $position: ${image != null}") Timber.v("Got image for $position: ${image != null}")
if (image != null) { if (image != null) {
this@SlideshowViewModel.position.setValueOnMain(position) _state.update { it.copy(position = position) }
val url = val url =
if (image.data.mediaType == MediaType.VIDEO) { if (image.data.mediaType == MediaType.VIDEO) {
@ -252,7 +241,7 @@ class SlideshowViewModel
updateImageFilter(albumImageFilter) updateImageFilter(albumImageFilter)
if (saveFilters) { if (saveFilters) {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
val vf = val vf =
playbackEffectDao playbackEffectDao
.getPlaybackEffect( .getPlaybackEffect(
@ -264,32 +253,38 @@ class SlideshowViewModel
Timber.d( Timber.d(
"Loaded VideoFilter for image ${image.id}", "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) updateImageFilter(vf.videoFilter)
imageFilter.startThrottling()
} }
} _state.update {
withContext(Dispatchers.Main) { it.copy(
_image.value = imageState image = imageState,
loadingState.value = imageLoading = ImageLoadingState.Success(imageState),
ImageLoadingState.Success(imageState) )
} }
} }
} }
} else { } else {
withContext(Dispatchers.Main) { _state.update {
_image.value = imageState it.copy(
loadingState.value = ImageLoadingState.Success(imageState) image = imageState,
imageLoading = ImageLoadingState.Success(imageState),
)
} }
} }
} else { } else {
loadingState.setValueOnMain(ImageLoadingState.Error) _state.update {
it.copy(
imageLoading = ImageLoadingState.Error,
)
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex) Timber.e(ex)
loadingState.setValueOnMain(ImageLoadingState.Error) _state.update {
it.copy(
imageLoading = ImageLoadingState.Error,
)
}
} }
} }
} }
@ -298,10 +293,10 @@ class SlideshowViewModel
fun startSlideshow() { fun startSlideshow() {
screensaverService.keepScreenOn(true) screensaverService.keepScreenOn(true)
_slideshow.update { _state.update {
SlideshowState(enabled = true, paused = false) it.copy(enabled = true, paused = false)
} }
if (_image.value if (state.value.image
?.image ?.image
?.data ?.data
?.mediaType != MediaType.VIDEO ?.mediaType != MediaType.VIDEO
@ -313,14 +308,14 @@ class SlideshowViewModel
fun stopSlideshow() { fun stopSlideshow() {
screensaverService.keepScreenOn(false) screensaverService.keepScreenOn(false)
slideshowJob?.cancel() slideshowJob?.cancel()
_slideshow.update { _state.update {
SlideshowState(enabled = false, paused = false) it.copy(enabled = false, paused = false)
} }
} }
fun pauseSlideshow() { fun pauseSlideshow() {
Timber.v("pauseSlideshow") Timber.v("pauseSlideshow")
_slideshow.update { _state.update {
if (it.enabled) { if (it.enabled) {
slideshowJob?.cancel() slideshowJob?.cancel()
it.copy(paused = true) it.copy(paused = true)
@ -332,7 +327,7 @@ class SlideshowViewModel
fun unpauseSlideshow() { fun unpauseSlideshow() {
Timber.v("unpauseSlideshow") Timber.v("unpauseSlideshow")
_slideshow.update { _state.update {
if (it.enabled) { if (it.enabled) {
it.copy(paused = false) it.copy(paused = false)
} else { } else {
@ -362,16 +357,16 @@ class SlideshowViewModel
fun updateImageFilter(newFilter: VideoFilter) { fun updateImageFilter(newFilter: VideoFilter) {
viewModelScope.launchIO { viewModelScope.launchIO {
_imageFilter.setValueOnMain(newFilter) _imageFilter.update { newFilter }
} }
} }
fun saveImageFilter() { fun saveImageFilter() {
image.value?.let { state.value.image?.let {
viewModelScope.launchIO { viewModelScope.launchIO {
val vf = _imageFilter.value val vf = _imageFilter.value
if (vf != null) { if (vf != null) {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
playbackEffectDao playbackEffectDao
.insert( .insert(
PlaybackEffect( PlaybackEffect(
@ -400,7 +395,7 @@ class SlideshowViewModel
val vf = _imageFilter.value val vf = _imageFilter.value
if (vf != null) { if (vf != null) {
albumImageFilter = vf albumImageFilter = vf
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser?.let { user ->
playbackEffectDao playbackEffectDao
.insert( .insert(
PlaybackEffect( PlaybackEffect(
@ -457,6 +452,11 @@ data class ImageState(
} }
data class SlideshowState( data class SlideshowState(
val enabled: Boolean, val items: List<BaseItem?> = emptyList(),
val paused: Boolean, 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(" - ") 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.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.MutableLiveData
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -14,6 +13,7 @@ import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
@ -28,7 +28,7 @@ import org.junit.Test
class LatestNextUpSchedulerServiceTest { class LatestNextUpSchedulerServiceTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val currentLiveData = MutableLiveData<CurrentUser?>() private val currentUser = MutableStateFlow<CurrentUser?>(null)
private val mockActivity = mockk<AppCompatActivity>(relaxed = true) private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
private val mockServerRepository = mockk<ServerRepository>(relaxed = true) private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
private val mockWorkManager = mockk<WorkManager>(relaxed = true) private val mockWorkManager = mockk<WorkManager>(relaxed = true)
@ -38,7 +38,7 @@ class LatestNextUpSchedulerServiceTest {
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
every { mockActivity.lifecycle } returns lifecycleRegistry every { mockActivity.lifecycle } returns lifecycleRegistry
every { mockServerRepository.current } returns currentLiveData every { mockServerRepository.current } returns currentUser
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
} }
@ -59,7 +59,7 @@ class LatestNextUpSchedulerServiceTest {
runTest { runTest {
createService() createService()
currentLiveData.value = null currentUser.value = null
advanceUntilIdle() advanceUntilIdle()
verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) }

View file

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

View file

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

View file

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

View file

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

View file

@ -108,7 +108,7 @@ object TestModule {
.addInterceptor { .addInterceptor {
val request = it.request() val request = it.request()
val newRequest = val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token -> serverRepository.currentUser?.accessToken?.let { token ->
request request
.newBuilder() .newBuilder()
.addHeader( .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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } 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-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } 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-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } 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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }