mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
982d9e6fe8
commit
0b450fc0ff
90 changed files with 2170 additions and 2283 deletions
|
|
@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
|||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -168,7 +169,7 @@ class MainActivity : AppCompatActivity() {
|
|||
navigationManager.backStack = NavBackStack(startDestination)
|
||||
}
|
||||
|
||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||
viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
|
||||
if (user?.hasPin == true) {
|
||||
window?.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
|
|
@ -435,7 +436,7 @@ class MainActivityViewModel
|
|||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val profileProtected =
|
||||
serverRepository.currentUser.value?.let {
|
||||
serverRepository.current.value?.user?.let {
|
||||
it.hasPin || it.requireLogin
|
||||
} == true
|
||||
if (prefs.signInAutomatically && !profileProtected) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -88,7 +87,7 @@ class WholphinDreamService :
|
|||
setViewTreeLifecycleOwner(this@WholphinDreamService)
|
||||
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
|
||||
setContent {
|
||||
val user by serverRepository.currentUser.observeAsState()
|
||||
val user by serverRepository.currentUserFlow.collectAsState(null)
|
||||
if (user != null) {
|
||||
var prefs by remember { mutableStateOf<AppPreferences?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class ItemPlaybackRepository
|
|||
item: BaseItem,
|
||||
prefs: UserPreferences,
|
||||
): ChosenStreams? =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
||||
|
|
@ -91,7 +91,7 @@ class ItemPlaybackRepository
|
|||
sourceId: UUID,
|
||||
): ItemPlayback? =
|
||||
withContext(Dispatchers.IO) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = user.rowId,
|
||||
|
|
@ -168,7 +168,7 @@ class ItemPlaybackRepository
|
|||
val toSave =
|
||||
if (itemPlayback.userId < 0) {
|
||||
val userRowId =
|
||||
serverRepository.currentUser.value
|
||||
serverRepository.currentUser
|
||||
?.rowId
|
||||
?.takeIf { it >= 0 }
|
||||
?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user")
|
||||
|
|
@ -184,7 +184,7 @@ class ItemPlaybackRepository
|
|||
itemId: UUID,
|
||||
trackIndex: Int,
|
||||
): ItemTrackModification? =
|
||||
serverRepository.currentUser.value?.rowId?.let { userId ->
|
||||
serverRepository.currentUser?.rowId?.let { userId ->
|
||||
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +193,7 @@ class ItemPlaybackRepository
|
|||
trackIndex: Int,
|
||||
delay: Duration,
|
||||
) {
|
||||
serverRepository.currentUser.value?.rowId?.let { userId ->
|
||||
serverRepository.currentUser?.rowId?.let { userId ->
|
||||
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
|
||||
itemPlaybackDao.saveItem(
|
||||
ItemTrackModification(
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import android.content.Context
|
|||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.map
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
|
|
@ -46,14 +46,17 @@ class ServerRepository
|
|||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||
val current: LiveData<CurrentUser?> = _current
|
||||
private var _current = MutableStateFlow<CurrentUser?>(null)
|
||||
val current: StateFlow<CurrentUser?> = _current
|
||||
|
||||
private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null)
|
||||
val currentUserDto: LiveData<UserDto?> = _currentUserDto
|
||||
private var _currentUserDto = MutableStateFlow<UserDto?>(null)
|
||||
val currentUserDto: UserDto? get() = _currentUserDto.value
|
||||
val currentUserDtoFlow: StateFlow<UserDto?> get() = _currentUserDto
|
||||
|
||||
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
|
||||
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
|
||||
val currentServer: JellyfinServer? get() = _current.value?.server
|
||||
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
|
||||
val currentUser: JellyfinUser? get() = _current.value?.user
|
||||
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
|
||||
|
||||
/**
|
||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||
|
|
@ -65,7 +68,7 @@ class ServerRepository
|
|||
serverDao.addOrUpdateServer(server)
|
||||
}
|
||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||
_current.setValueOnMain(null)
|
||||
_current.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -127,7 +130,7 @@ class ServerRepository
|
|||
userId: UUID?,
|
||||
): CurrentUser? {
|
||||
if (serverId == null || userId == null) {
|
||||
_current.setValueOnMain(null)
|
||||
_current.value = null
|
||||
return null
|
||||
}
|
||||
val serverAndUsers =
|
||||
|
|
@ -218,7 +221,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun removeUser(user: JellyfinUser) {
|
||||
if (currentUser.value?.id == user.id) {
|
||||
if (current.value?.user?.id == user.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
|
|
@ -237,7 +240,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun removeServer(server: JellyfinServer) {
|
||||
if (currentServer.value?.id == server.id) {
|
||||
if (current.value?.server?.id == server.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
|
|
@ -274,18 +277,19 @@ class ServerRepository
|
|||
) {
|
||||
val newUser = user.copy(pin = pin, requireLogin = requireLogin)
|
||||
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
||||
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
|
||||
val cur = current.value
|
||||
if (cur?.user?.id == updatedUser.id && cur.server?.id == user.serverId) {
|
||||
// Updating current user, so push out the change
|
||||
current.value?.let {
|
||||
val newCurrent = it.copy(user = updatedUser)
|
||||
_current.setValueOnMain(newCurrent)
|
||||
_current.value = newCurrent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authorizeQuickConnect(code: String): Boolean =
|
||||
withContext(ioDispatcher) {
|
||||
val userId = currentUser.value?.id
|
||||
val userId = current.value?.user?.id
|
||||
if (userId == null) {
|
||||
Timber.e("No user logged in for Quick Connect authorization")
|
||||
throw IllegalStateException("Must be logged in to authorize Quick Connect")
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
|
|
@ -152,7 +154,7 @@ class DatePlayedInvalidationService
|
|||
private val activity = (context as AppCompatActivity)
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) {
|
||||
serverRepository.current.collectLatestIn(activity.lifecycleScope) {
|
||||
datePlayedService.invalidateAll()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,8 +265,8 @@ class HomeSettingsService
|
|||
*/
|
||||
suspend fun createDefault(userId: UUID): HomePageResolvedSettings {
|
||||
Timber.v("Creating default settings")
|
||||
val user = serverRepository.currentUser.value?.takeIf { it.id == userId }
|
||||
val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId }
|
||||
val user = serverRepository.currentUser?.takeIf { it.id == userId }
|
||||
val userDto = serverRepository.currentUserDto?.takeIf { it.id == userId }
|
||||
val libraries =
|
||||
if (user != null) {
|
||||
navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false)
|
||||
|
|
@ -679,7 +679,7 @@ class HomeSettingsService
|
|||
val genreImages =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
scope = scope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genreIds,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.work.WorkManager
|
|||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -94,7 +95,7 @@ class LatestNextUpSchedulerService
|
|||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
|
||||
Timber.v("New user %s", user?.user?.id)
|
||||
if (user == null) {
|
||||
workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class MediaManagementService
|
|||
return enabled &&
|
||||
item.canDelete &&
|
||||
if (item.type == BaseItemKind.RECORDING) {
|
||||
serverRepository.currentUserDto.value
|
||||
serverRepository.currentUserDto
|
||||
?.policy
|
||||
?.enableLiveTvManagement == true
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class MediaReportService
|
|||
.content.mediaSources
|
||||
val sourcesJson = json.encodeToString(sources)
|
||||
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
|
||||
val serverVersion = serverRepository.currentServer.value?.serverVersion
|
||||
val serverVersion = serverRepository.currentServer?.serverVersion
|
||||
val deviceProfile =
|
||||
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
|
||||
val deviceProfileJson = json.encodeToString(deviceProfile)
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ class MusicService
|
|||
val items =
|
||||
api.instantMixApi
|
||||
.getInstantMixFromItem(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = itemId,
|
||||
limit = 200,
|
||||
fields = DefaultItemFields,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.asFlow
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
|
|
@ -58,9 +57,8 @@ class NavDrawerService
|
|||
|
||||
init {
|
||||
// Handle updating the nav drawer when the user changes
|
||||
serverRepository.currentUser
|
||||
.asFlow()
|
||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
||||
serverRepository.currentUserFlow
|
||||
.combine(serverRepository.currentUserDtoFlow) { user, userDto ->
|
||||
Pair(user, userDto)
|
||||
}.onEach { (user, userDto) ->
|
||||
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class PlaylistCreator
|
|||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = playlistId,
|
||||
fields = DefaultItemFields,
|
||||
startIndex = startIndex,
|
||||
|
|
@ -324,7 +324,7 @@ class PlaylistCreator
|
|||
name: String,
|
||||
initialItems: List<UUID>,
|
||||
): UUID? =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
api.playlistsApi
|
||||
.createPlaylist(
|
||||
CreatePlaylistDto(
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ class SeerrServerRepository
|
|||
server = seerrServerDao.getServer(url)
|
||||
}
|
||||
server?.server?.let { server ->
|
||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||
serverRepository.currentUser?.let { jellyfinUser ->
|
||||
// TODO test api key
|
||||
val user =
|
||||
SeerrUser(
|
||||
|
|
@ -126,7 +126,7 @@ class SeerrServerRepository
|
|||
server = seerrServerDao.getServer(url)
|
||||
}
|
||||
server?.server?.let { server ->
|
||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||
serverRepository.currentUser?.let { jellyfinUser ->
|
||||
// TODO Need to update server early so that cookies are saved
|
||||
seerrApi.update(server.url, null)
|
||||
val userConfig = seerrLogin(seerrApi.api, authMethod, username, password)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
|
|
@ -42,7 +43,7 @@ class ServerEventListener
|
|||
|
||||
init {
|
||||
activity.lifecycle.addObserver(this)
|
||||
serverRepository.current.observe(activity) {
|
||||
serverRepository.current.collectLatestIn(activity.lifecycleScope) {
|
||||
Timber.d("New user/server: %s", it)
|
||||
listenJob?.cancel()
|
||||
if (it != null) {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class StreamChoiceService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||
) {
|
||||
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration
|
||||
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
||||
|
||||
suspend fun updateAudio(
|
||||
dto: BaseItemDto,
|
||||
|
|
@ -58,7 +58,7 @@ class StreamChoiceService
|
|||
) {
|
||||
val seriesId = dto.seriesId
|
||||
if (seriesId != null) {
|
||||
val userId = serverRepository.currentUser.value!!.rowId
|
||||
val userId = serverRepository.currentUser!!.rowId
|
||||
val currentPlc =
|
||||
playbackLanguageChoiceDao.get(userId, seriesId)
|
||||
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
|
||||
|
|
@ -70,7 +70,7 @@ class StreamChoiceService
|
|||
|
||||
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
|
||||
dto.seriesId?.let {
|
||||
playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it)
|
||||
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -107,7 +107,13 @@ class StreamChoiceService
|
|||
plc: PlaybackLanguageChoice?,
|
||||
prefs: UserPreferences,
|
||||
): MediaStream? {
|
||||
val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) }
|
||||
val plc =
|
||||
plc ?: seriesId?.let {
|
||||
playbackLanguageChoiceDao.get(
|
||||
serverRepository.currentUser!!.rowId,
|
||||
it,
|
||||
)
|
||||
}
|
||||
return source.mediaStreams?.letNotEmpty { streams ->
|
||||
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
||||
chooseAudioStream(candidates, itemPlayback, plc, prefs)
|
||||
|
|
@ -158,7 +164,7 @@ class StreamChoiceService
|
|||
val plc =
|
||||
plc ?: seriesId?.let {
|
||||
playbackLanguageChoiceDao.get(
|
||||
serverRepository.currentUser.value!!.rowId,
|
||||
serverRepository.currentUser!!.rowId,
|
||||
it,
|
||||
)
|
||||
}
|
||||
|
|
@ -194,7 +200,7 @@ class StreamChoiceService
|
|||
}
|
||||
val itemPlayback =
|
||||
ItemPlayback(
|
||||
userId = serverRepository.currentUser.value!!.rowId,
|
||||
userId = serverRepository.currentUser!!.rowId,
|
||||
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
||||
subtitleIndex = TrackIndex.ONLY_FORCED,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
|
|
@ -52,8 +51,7 @@ class SuggestionService
|
|||
parentId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): Flow<SuggestionsResource> {
|
||||
return serverRepository.currentUser
|
||||
.asFlow()
|
||||
return serverRepository.currentUserFlow
|
||||
.flatMapLatest { user ->
|
||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||
val cachedSuggestions = cache.get(userId, parentId, itemKind)
|
||||
|
|
|
|||
|
|
@ -8,15 +8,14 @@ import androidx.work.Constraints
|
|||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
|
@ -35,6 +34,7 @@ class SuggestionsSchedulerService
|
|||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val workManager: WorkManager,
|
||||
@param:IoDispatcher private val dispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val activity =
|
||||
(context as? AppCompatActivity)
|
||||
|
|
@ -42,18 +42,18 @@ class SuggestionsSchedulerService
|
|||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||
)
|
||||
|
||||
// Exposed for testing
|
||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
Timber.v("New user %s", user?.user?.id)
|
||||
if (user == null) {
|
||||
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
} else {
|
||||
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||
scheduleWork(user.user.id, user.server.id)
|
||||
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||
serverRepository.current.collect { user ->
|
||||
Timber.v("New user %s", user?.user?.id)
|
||||
if (user == null) {
|
||||
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
} else {
|
||||
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||
scheduleWork(user.user.id, user.server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class UserPreferencesService
|
|||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||
|
||||
suspend fun getCurrent(): UserPreferences =
|
||||
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
|
||||
serverRepository.currentUserDto!!.configuration.let { userConfig ->
|
||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
UserPreferences(
|
||||
appPrefs,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
|
|
@ -39,7 +38,7 @@ class UserSwitchListener
|
|||
init {
|
||||
context as AppCompatActivity
|
||||
context.lifecycleScope.launchDefault {
|
||||
serverRepository.currentUser.asFlow().collect { user ->
|
||||
serverRepository.currentUserFlow.collect { user ->
|
||||
Timber.d("New user")
|
||||
seerrServerRepository.clear()
|
||||
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ object AppModule {
|
|||
.addInterceptor {
|
||||
val request = it.request()
|
||||
val newRequest =
|
||||
serverRepository.currentUser.value?.accessToken?.let { token ->
|
||||
serverRepository.current.value?.user?.accessToken?.let { token ->
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader(
|
||||
|
|
@ -170,7 +170,10 @@ object AppModule {
|
|||
appPreference: DataStore<AppPreferences>,
|
||||
@IoCoroutineScope scope: CoroutineScope,
|
||||
) = object : RememberTabManager {
|
||||
fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
|
||||
fun key(itemId: String): String =
|
||||
serverRepository.current.value.let {
|
||||
"${it?.server?.id}_${it?.user?.id}_$itemId"
|
||||
}
|
||||
|
||||
override fun getRememberedTab(
|
||||
preferences: UserPreferences,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.work.WorkManager
|
|||
import androidx.work.await
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
|
|
@ -43,7 +44,7 @@ class TvProviderSchedulerService
|
|||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
|
||||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||
if (supportsTvProvider) {
|
||||
if (user != null) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.media3.common.Player
|
||||
import coil3.request.ErrorResult
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
|
|
@ -397,11 +396,6 @@ fun CoroutineScope.launchDefault(
|
|||
*/
|
||||
fun UUID.toServerString() = this.toString().replace("-", "")
|
||||
|
||||
suspend fun <T> MutableLiveData<T>.setValueOnMain(value: T) =
|
||||
withContext(Dispatchers.Main) {
|
||||
this@setValueOnMain.value = value
|
||||
}
|
||||
|
||||
fun equalsNotNull(
|
||||
a: Any?,
|
||||
b: Any?,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ fun CollectionFolderGrid(
|
|||
preferences: UserPreferences,
|
||||
item: BaseItem?,
|
||||
title: String,
|
||||
loadingState: DataLoadingState<List<BaseItem?>>,
|
||||
items: DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection: SortAndDirection,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
|
|
@ -91,7 +91,7 @@ fun CollectionFolderGrid(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val pager = (loadingState as? DataLoadingState.Success)?.data
|
||||
val pager = (items as? DataLoadingState.Success)?.data
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
val headerRowFocusRequester = remember { FocusRequester() }
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ fun CollectionFolderGrid(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
CollectionFolderHeader(
|
||||
showHeader = showHeader || loadingState !is DataLoadingState.Success,
|
||||
showHeader = showHeader || items !is DataLoadingState.Success,
|
||||
showTitle = showTitle,
|
||||
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
||||
title = title,
|
||||
|
|
@ -147,7 +147,7 @@ fun CollectionFolderGrid(
|
|||
.padding(HeaderUtils.padding),
|
||||
)
|
||||
}
|
||||
when (val state = loadingState) {
|
||||
when (val state = items) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ fun CollectionFolderList(
|
|||
preferences: UserPreferences,
|
||||
item: BaseItem?,
|
||||
title: String,
|
||||
loadingState: DataLoadingState<List<BaseItem?>>,
|
||||
items: DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection: SortAndDirection,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
|
|
@ -108,7 +108,7 @@ fun CollectionFolderList(
|
|||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pager = (loadingState as? DataLoadingState.Success)?.data
|
||||
val pager = (items as? DataLoadingState.Success)?.data
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
val headerRowFocusRequester = remember { FocusRequester() }
|
||||
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
||||
|
|
@ -147,7 +147,7 @@ fun CollectionFolderList(
|
|||
|
||||
Column(modifier = modifier) {
|
||||
CollectionFolderHeader(
|
||||
showHeader = showHeader || loadingState !is DataLoadingState.Success,
|
||||
showHeader = showHeader || items !is DataLoadingState.Success,
|
||||
showTitle = showTitle,
|
||||
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
||||
title = title,
|
||||
|
|
@ -162,7 +162,7 @@ fun CollectionFolderList(
|
|||
onFilterChange = onFilterChange,
|
||||
modifier = Modifier.focusRequester(headerRowFocusRequester),
|
||||
)
|
||||
when (val state = loadingState) {
|
||||
when (val state = items) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
|
@ -19,12 +18,11 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -56,15 +54,12 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||
|
|
@ -74,6 +69,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.successValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -81,12 +77,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
|
@ -95,6 +94,7 @@ import org.jellyfin.sdk.model.api.MediaType
|
|||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
|
@ -104,7 +104,7 @@ class CollectionFolderViewModel
|
|||
@AssistedInject
|
||||
constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
api: ApiClient,
|
||||
private val api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
val serverRepository: ServerRepository,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
|
|
@ -117,13 +117,13 @@ class CollectionFolderViewModel
|
|||
private val musicService: MusicService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted val itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
@Assisted("recursive") private val recursive: Boolean,
|
||||
@Assisted private val collectionFilter: CollectionFolderFilter,
|
||||
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
||||
@Assisted defaultViewOptions: ViewOptions,
|
||||
) : ItemViewModel(api) {
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
|
|
@ -136,11 +136,8 @@ class CollectionFolderViewModel
|
|||
): CollectionFolderViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading)
|
||||
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
||||
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
||||
val viewOptions = MutableStateFlow<ViewOptions>(defaultViewOptions)
|
||||
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
|
||||
val state: StateFlow<CollectionFolderState> = _state
|
||||
|
||||
var position: Int
|
||||
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||
|
|
@ -150,19 +147,22 @@ class CollectionFolderViewModel
|
|||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
super.itemId = itemId
|
||||
try {
|
||||
val item =
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
api.userLibraryApi
|
||||
.getItem(itemId.toUUID())
|
||||
.content
|
||||
.let(::BaseItem)
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, itemId)
|
||||
}
|
||||
this@CollectionFolderViewModel.viewOptions.value =
|
||||
libraryDisplayInfo?.viewOptions ?: defaultViewOptions
|
||||
_state.update {
|
||||
it.copy(
|
||||
viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
|
||||
)
|
||||
}
|
||||
|
||||
val sortAndDirection =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
|
|
@ -178,12 +178,13 @@ class CollectionFolderViewModel
|
|||
collectionFilter.filter
|
||||
}
|
||||
|
||||
_state.update { it.copy(item = DataLoadingState.Success(item)) }
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
.join()
|
||||
// onResumePage()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during init")
|
||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
mediaManagementService.deletedItemFlow
|
||||
|
|
@ -200,7 +201,7 @@ class CollectionFolderViewModel
|
|||
) {
|
||||
try {
|
||||
val pager =
|
||||
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
position.let {
|
||||
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
|
||||
val item = pager?.get(it)
|
||||
|
|
@ -218,12 +219,12 @@ class CollectionFolderViewModel
|
|||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
newFilter: GetItemsFilter = this.filter.value!!,
|
||||
newSort: SortAndDirection = this.sortAndDirection.value!!,
|
||||
viewOptions: ViewOptions? = this.viewOptions.value,
|
||||
newFilter: GetItemsFilter = state.value.filter,
|
||||
newSort: SortAndDirection = state.value.sortAndDirection,
|
||||
viewOptions: ViewOptions? = state.value.viewOptions,
|
||||
) {
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
LibraryDisplayInfo(
|
||||
|
|
@ -241,7 +242,7 @@ class CollectionFolderViewModel
|
|||
}
|
||||
|
||||
fun saveViewOptions(viewOptions: ViewOptions) {
|
||||
this.viewOptions.value = viewOptions
|
||||
_state.update { it.copy(viewOptions = viewOptions) }
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
||||
if (!viewOptions.showBackdrop) {
|
||||
|
|
@ -255,8 +256,14 @@ class CollectionFolderViewModel
|
|||
recursive: Boolean,
|
||||
) {
|
||||
Timber.v("onFilterChange: filter=%s", newFilter)
|
||||
saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!)
|
||||
loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary)
|
||||
saveLibraryDisplayInfo(newFilter)
|
||||
loadResults(
|
||||
false,
|
||||
state.value.sortAndDirection,
|
||||
recursive,
|
||||
newFilter,
|
||||
useSeriesForPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
fun onSortChange(
|
||||
|
|
@ -281,21 +288,23 @@ class CollectionFolderViewModel
|
|||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = DataLoadingState.Loading,
|
||||
backgroundLoading = LoadingState.Loading,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
)
|
||||
}
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = DataLoadingState.Success(newPager),
|
||||
backgroundLoading = LoadingState.Success,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
|
|
@ -304,9 +313,7 @@ class CollectionFolderViewModel
|
|||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -359,7 +366,7 @@ class CollectionFolderViewModel
|
|||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
): GetItemsRequest {
|
||||
val item = item.value
|
||||
val item = state.value.item.successValue
|
||||
val includeItemTypes =
|
||||
item
|
||||
?.data
|
||||
|
|
@ -413,18 +420,15 @@ class CollectionFolderViewModel
|
|||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
FilterUtils.getFilterOptionValues(
|
||||
api,
|
||||
serverRepository.currentUser.value?.id,
|
||||
itemUuid,
|
||||
serverRepository.currentUser?.id,
|
||||
itemId.toUUID(),
|
||||
filterOption,
|
||||
)
|
||||
|
||||
suspend fun positionOfLetter(letter: Char): Int? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val sort = sortAndDirection.value
|
||||
val filter = filter.value
|
||||
if (sort == null || filter == null) {
|
||||
return@withContext null
|
||||
}
|
||||
val sort = state.value.sortAndDirection
|
||||
val filter = state.value.filter
|
||||
val request =
|
||||
createGetItemsRequest(
|
||||
sortAndDirection = sort,
|
||||
|
|
@ -447,7 +451,7 @@ class CollectionFolderViewModel
|
|||
played: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
(loading.value as? DataLoadingState.Success)?.let {
|
||||
(state.value.items as? DataLoadingState.Success)?.let {
|
||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
}
|
||||
|
|
@ -458,7 +462,7 @@ class CollectionFolderViewModel
|
|||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
(loading.value as? DataLoadingState.Success)?.let {
|
||||
(state.value.items as? DataLoadingState.Success)?.let {
|
||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
}
|
||||
|
|
@ -480,9 +484,9 @@ class CollectionFolderViewModel
|
|||
|
||||
fun onResumePage() {
|
||||
viewModelScope.launchIO {
|
||||
item.value?.let {
|
||||
Timber.v("onResumePage: %s", loading.value!!::class)
|
||||
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
|
||||
state.value.item.successValue?.let {
|
||||
Timber.v("onResumePage: %s", state.value.items::class)
|
||||
if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) {
|
||||
themeSongPlayer.playThemeFor(it.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -511,6 +515,15 @@ class CollectionFolderViewModel
|
|||
) = addToQueue(api, musicService, item, index)
|
||||
}
|
||||
|
||||
data class CollectionFolderState(
|
||||
val item: DataLoadingState<BaseItem> = DataLoadingState.Loading,
|
||||
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Loading,
|
||||
val backgroundLoading: LoadingState = LoadingState.Loading,
|
||||
val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT,
|
||||
val filter: GetItemsFilter = GetItemsFilter(),
|
||||
val viewOptions: ViewOptions,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows a collection folder as a grid
|
||||
*
|
||||
|
|
@ -623,18 +636,12 @@ fun CollectionFolderView(
|
|||
)
|
||||
},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
||||
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
|
||||
val item by viewModel.item.observeAsState()
|
||||
val viewOptions by viewModel.viewOptions.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val contextActions =
|
||||
|
|
@ -689,12 +696,12 @@ fun CollectionFolderView(
|
|||
actions.onClickPlayAll ?: { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
val destination =
|
||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
if (state.item.successValue?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = it,
|
||||
index = 0,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = CollectionFolderFilter(filter = state.filter),
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
|
|
@ -704,8 +711,8 @@ fun CollectionFolderView(
|
|||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
filter = state.filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
|
|
@ -719,8 +726,8 @@ fun CollectionFolderView(
|
|||
Destination.Slideshow(
|
||||
parentId = item.id,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = CollectionFolderFilter(filter = state.filter),
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
|
|
@ -732,7 +739,7 @@ fun CollectionFolderView(
|
|||
)
|
||||
}
|
||||
|
||||
when (val state = loading) {
|
||||
when (val st = state.item) {
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
|
|
@ -742,6 +749,7 @@ fun CollectionFolderView(
|
|||
is DataLoadingState.Error,
|
||||
is DataLoadingState.Success<*>,
|
||||
-> {
|
||||
val item = st.successValue
|
||||
val title =
|
||||
initialFilter.nameOverride
|
||||
?: item?.name
|
||||
|
|
@ -755,23 +763,23 @@ fun CollectionFolderView(
|
|||
viewModel.release()
|
||||
}
|
||||
}
|
||||
if (viewOptions.type == ViewOptionsType.GRID) {
|
||||
if (state.viewOptions.type == ViewOptionsType.GRID) {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
item = item,
|
||||
title = title,
|
||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection = sortAndDirection!!,
|
||||
items = state.items,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
viewModel.onSortChange(it, recursive, state.filter)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
currentFilter = filter,
|
||||
currentFilter = state.filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
},
|
||||
|
|
@ -785,7 +793,7 @@ fun CollectionFolderView(
|
|||
positionCallback?.invoke(columns, position)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = viewOptions,
|
||||
viewOptions = state.viewOptions,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
|
|
@ -798,17 +806,17 @@ fun CollectionFolderView(
|
|||
initialPosition = viewModel.position,
|
||||
item = item,
|
||||
title = title,
|
||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection = sortAndDirection!!,
|
||||
items = state.items,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
viewModel.onSortChange(it, recursive, state.filter)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
currentFilter = filter,
|
||||
currentFilter = state.filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
},
|
||||
|
|
@ -822,7 +830,7 @@ fun CollectionFolderView(
|
|||
positionCallback?.invoke(columns, position)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = viewOptions,
|
||||
viewOptions = state.viewOptions,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
|
|
@ -832,7 +840,7 @@ fun CollectionFolderView(
|
|||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
backgroundLoading == LoadingState.Loading,
|
||||
state.backgroundLoading == LoadingState.Loading,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
|
|
@ -854,7 +862,7 @@ fun CollectionFolderView(
|
|||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
viewModel.serverRepository.currentUserDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
@ -887,11 +895,11 @@ fun CollectionFolderView(
|
|||
}
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
viewOptions = viewOptions,
|
||||
viewOptions = state.viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onDismissRequest = {
|
||||
showViewOptions = false
|
||||
viewModel.saveViewOptions(viewOptions)
|
||||
viewModel.saveViewOptions(state.viewOptions)
|
||||
},
|
||||
onViewOptionsChange = viewModel::saveViewOptions,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.times
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -29,12 +28,11 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
|||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
|
@ -44,7 +42,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -80,54 +80,63 @@ class GenreViewModel
|
|||
): GenreViewModel
|
||||
}
|
||||
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val genres = MutableLiveData<List<Genre>>(listOf())
|
||||
private val _state = MutableStateFlow(GenreGridState())
|
||||
val state: StateFlow<GenreGridState> = _state
|
||||
|
||||
fun init(cardWidthPx: Int) {
|
||||
loading.value = LoadingState.Loading
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
BaseItem(it, false)
|
||||
}
|
||||
this@GenreViewModel.item.setValueOnMain(item)
|
||||
val request =
|
||||
GetGenresRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val genres =
|
||||
GetGenresRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
Genre(it.id, it.name ?: "", null)
|
||||
_state.update { it.copy(item = DataLoadingState.Loading) }
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
BaseItem(it, false)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@GenreViewModel.genres.value = genres
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
val genreToUrl =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
scope = viewModelScope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genres.map { it.id },
|
||||
parentId = itemId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
cardWidthPx = cardWidthPx,
|
||||
)
|
||||
val genresWithImages =
|
||||
genres.map {
|
||||
val request =
|
||||
GetGenresRequest(
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val genres =
|
||||
GetGenresRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
Genre(it.id, it.name ?: "", null)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
imageUrl = genreToUrl[it.id],
|
||||
item = DataLoadingState.Success(item),
|
||||
genres = genres,
|
||||
)
|
||||
}
|
||||
this@GenreViewModel.genres.setValueOnMain(genresWithImages)
|
||||
val genreToUrl =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
scope = viewModelScope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genres.map { it.id },
|
||||
parentId = itemId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
cardWidthPx = cardWidthPx,
|
||||
)
|
||||
val genresWithImages =
|
||||
genres.map {
|
||||
it.copy(
|
||||
imageUrl = genreToUrl[it.id],
|
||||
)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
genres = genresWithImages,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching genres")
|
||||
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +155,11 @@ class GenreViewModel
|
|||
}
|
||||
}
|
||||
|
||||
data class GenreGridState(
|
||||
val item: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||
val genres: List<Genre> = emptyList(),
|
||||
)
|
||||
|
||||
data class GenreCacheKey(
|
||||
val userId: UUID?,
|
||||
val parentId: UUID,
|
||||
|
|
@ -266,34 +280,33 @@ fun GenreCardGrid(
|
|||
OneTimeLaunchedEffect {
|
||||
viewModel.init(cardWidthPx)
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val genres by viewModel.genres.observeAsState(listOf())
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
when (val st = loading) {
|
||||
LoadingState.Pending,
|
||||
LoadingState.Loading,
|
||||
when (val st = state.item) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage(modifier.focusable())
|
||||
}
|
||||
|
||||
is LoadingState.Error -> {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier.focusable())
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
is DataLoadingState.Success<BaseItem> -> {
|
||||
Box(modifier = modifier) {
|
||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||
val item by viewModel.item.observeAsState(null)
|
||||
val item = st.data
|
||||
CardGrid(
|
||||
pager = genres,
|
||||
pager = state.genres,
|
||||
onClickItem = { _, genre ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
createGenreDestination(
|
||||
genreId = genre.id,
|
||||
genreName = genre.name,
|
||||
parentId = itemId,
|
||||
parentName = item?.title,
|
||||
parentName = item.title,
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -26,16 +25,18 @@ import com.github.damontecres.wholphin.ui.launchIO
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.RequestHandler
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
|
||||
class ItemGridViewModel
|
||||
|
|
@ -45,8 +46,8 @@ class ItemGridViewModel
|
|||
private val navigationManager: NavigationManager,
|
||||
@Assisted private val destination: Destination.ItemGrid<*>,
|
||||
) : ViewModel() {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
private val _state = MutableStateFlow(ItemGridState())
|
||||
val state: StateFlow<ItemGridState> = _state
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -54,22 +55,28 @@ class ItemGridViewModel
|
|||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) {
|
||||
val request = destination.request as Any
|
||||
val pager =
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
destination.requestHandler as RequestHandler<Any>,
|
||||
viewModelScope,
|
||||
useSeriesForPrimary = true,
|
||||
).init()
|
||||
if (pager.isNotEmpty()) {
|
||||
pager.getBlocking(0)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@ItemGridViewModel.items.value = pager
|
||||
this@ItemGridViewModel.loading.value = LoadingState.Success
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val request = destination.request as Any
|
||||
val pager =
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
destination.requestHandler as RequestHandler<Any>,
|
||||
viewModelScope,
|
||||
useSeriesForPrimary = true,
|
||||
).init()
|
||||
if (pager.isNotEmpty()) {
|
||||
pager.getBlocking(0)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(items = DataLoadingState.Success(pager))
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching items")
|
||||
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +86,10 @@ class ItemGridViewModel
|
|||
}
|
||||
}
|
||||
|
||||
data class ItemGridState(
|
||||
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Pending,
|
||||
)
|
||||
|
||||
/**
|
||||
* Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem]
|
||||
*/
|
||||
|
|
@ -91,20 +102,19 @@ fun ItemGrid(
|
|||
creationCallback = { it.create(destination) },
|
||||
),
|
||||
) {
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val items by viewModel.items.observeAsState(listOf())
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
val state by viewModel.state.collectAsState()
|
||||
when (val st = state.items) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
is DataLoadingState.Success<List<BaseItem?>> -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
|
|
@ -118,7 +128,7 @@ fun ItemGrid(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
CardGrid(
|
||||
pager = items,
|
||||
pager = st.data,
|
||||
onClickItem = { index: Int, item: BaseItem ->
|
||||
// TODO handle more types
|
||||
viewModel.navigateTo(Destination.Playback(item.id, 0))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -36,7 +35,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -351,7 +349,7 @@ fun RecommendedContent(
|
|||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
|
||||
OneTimeLaunchedEffect {
|
||||
viewModel.init()
|
||||
|
|
@ -451,7 +449,7 @@ fun RecommendedContent(
|
|||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
viewModel.serverRepository.currentUserDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.times
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -29,23 +28,25 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
|||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = StudioViewModel.Factory::class)
|
||||
|
|
@ -67,41 +68,46 @@ class StudioViewModel
|
|||
): StudioViewModel
|
||||
}
|
||||
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val studios = MutableLiveData<List<Studio>>(listOf())
|
||||
private val _state = MutableStateFlow(StudioGridState())
|
||||
val state: StateFlow<StudioGridState> = _state
|
||||
|
||||
fun init(cardWidthPx: Int) {
|
||||
loading.value = LoadingState.Loading
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
BaseItem(it, false)
|
||||
}
|
||||
this@StudioViewModel.item.setValueOnMain(item)
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val studios =
|
||||
GetStudiosRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
val imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
Studio(it.id, it.name ?: "", imageUrl)
|
||||
_state.update { it.copy(item = DataLoadingState.Loading) }
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||
BaseItem(it, false)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@StudioViewModel.studios.value = studios
|
||||
loading.value = LoadingState.Success
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = itemId,
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = includeItemTypes,
|
||||
)
|
||||
val studios =
|
||||
GetStudiosRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
val imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = it.id,
|
||||
imageType = ImageType.THUMB,
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
Studio(it.id, it.name ?: "", imageUrl)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
item = DataLoadingState.Success(item),
|
||||
studios = studios,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching studios")
|
||||
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +116,7 @@ class StudioViewModel
|
|||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
GetStudiosRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = itemId,
|
||||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
|
|
@ -133,6 +139,11 @@ data class Studio(
|
|||
override val sortName: String get() = name
|
||||
}
|
||||
|
||||
data class StudioGridState(
|
||||
val item: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||
val studios: List<Studio> = emptyList(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun StudioCardGrid(
|
||||
itemId: UUID,
|
||||
|
|
@ -162,34 +173,32 @@ fun StudioCardGrid(
|
|||
OneTimeLaunchedEffect {
|
||||
viewModel.init(cardWidthPx)
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val studios by viewModel.studios.observeAsState(listOf())
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
when (val st = loading) {
|
||||
LoadingState.Pending,
|
||||
LoadingState.Loading,
|
||||
when (val st = state.item) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
LoadingPage(modifier.focusable())
|
||||
}
|
||||
|
||||
is LoadingState.Error -> {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier.focusable())
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
is DataLoadingState.Success<BaseItem> -> {
|
||||
Box(modifier = modifier) {
|
||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||
val item by viewModel.item.observeAsState(null)
|
||||
CardGrid(
|
||||
pager = studios,
|
||||
pager = state.studios,
|
||||
onClickItem = { _, studio ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
createStudioDestination(
|
||||
studioId = studio.id,
|
||||
name = studio.name,
|
||||
parentId = itemId,
|
||||
parentName = item?.title,
|
||||
parentName = st.data.title,
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,17 @@ package com.github.damontecres.wholphin.ui.data
|
|||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
|
@ -30,16 +29,17 @@ class AddPlaylistViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val playlistCreator: PlaylistCreator,
|
||||
) : ViewModel() {
|
||||
val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending)
|
||||
val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending)
|
||||
|
||||
fun loadPlaylists(mediaType: MediaType?) {
|
||||
viewModelScope.launchIO {
|
||||
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading)
|
||||
this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading
|
||||
try {
|
||||
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
|
||||
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists))
|
||||
this@AddPlaylistViewModel.playlistState.value =
|
||||
PlaylistLoadingState.Success(playlists)
|
||||
} catch (ex: Exception) {
|
||||
playlistState.setValueOnMain(PlaylistLoadingState.Error(ex))
|
||||
playlistState.value = PlaylistLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
|
|
@ -41,10 +40,10 @@ import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
|
|||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.logTab
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import java.util.UUID
|
||||
|
|
@ -61,16 +60,16 @@ class LiveTvCollectionViewModel
|
|||
val backdropService: BackdropService,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
val recordingFolders = MutableLiveData<List<TabId>>()
|
||||
val recordingFolders = MutableStateFlow<List<TabId>>(emptyList())
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
val folders =
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(userId = serverRepository.currentUser.value?.id)
|
||||
.getRecordingFolders(userId = serverRepository.currentUser?.id)
|
||||
.content.items
|
||||
.map { TabId(it.name ?: "Recordings", it.id) }
|
||||
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
|
||||
recordingFolders.value = folders
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,7 +88,7 @@ fun CollectionFolderLiveTv(
|
|||
) {
|
||||
val rememberedTabIndex =
|
||||
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||
val folders by viewModel.recordingFolders.observeAsState(listOf())
|
||||
val folders by viewModel.recordingFolders.collectAsState()
|
||||
|
||||
val tvGuideStr = stringResource(R.string.tv_guide)
|
||||
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class CollectionFolderMusicViewModel
|
|||
viewModelScope.launchDefault {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = itemId,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
recursive = true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -10,13 +11,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
|
|
@ -50,6 +49,7 @@ fun CollectionFolderPhotoAlbum(
|
|||
},
|
||||
) {
|
||||
var showHeader by remember { mutableStateOf(true) }
|
||||
val state by viewModel.state.collectAsState()
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions =
|
||||
|
|
@ -63,11 +63,9 @@ fun CollectionFolderPhotoAlbum(
|
|||
index = index,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
filter = viewModel.filter.value ?: GetItemsFilter(),
|
||||
filter = state.filter,
|
||||
),
|
||||
sortAndDirection =
|
||||
viewModel.sortAndDirection.value
|
||||
?: SortAndDirection.DEFAULT,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
recursive = recursive,
|
||||
startSlideshow = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -28,7 +28,6 @@ import androidx.compose.ui.input.key.type
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -43,9 +42,8 @@ import com.github.damontecres.wholphin.ui.launchIO
|
|||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.util.versionCodeLong
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||
|
|
@ -66,8 +64,8 @@ class DebugViewModel
|
|||
val clientInfo: ClientInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
) : ViewModel() {
|
||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
||||
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
||||
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
|
||||
val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
|
||||
|
||||
val supportedModes by lazy {
|
||||
val displayManager =
|
||||
|
|
@ -106,16 +104,14 @@ class DebugViewModel
|
|||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.rowId?.let {
|
||||
serverRepository.currentUser?.rowId?.let {
|
||||
val results = itemPlaybackDao.getItems(it)
|
||||
withContext(Dispatchers.Main) {
|
||||
itemPlaybacks.value = results
|
||||
}
|
||||
itemPlaybacks.value = results
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val logcat = getLogCatLines()
|
||||
withContext(Dispatchers.Main) {
|
||||
this@DebugViewModel.logcat.value = logcat
|
||||
}
|
||||
this@DebugViewModel.logcat.value = logcat
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,8 +206,8 @@ fun DebugPage(
|
|||
}
|
||||
}
|
||||
|
||||
val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf())
|
||||
val logcat by viewModel.logcat.observeAsState(listOf())
|
||||
val itemPlaybacks by viewModel.itemPlaybacks.collectAsState()
|
||||
val logcat by viewModel.logcat.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
state = columnState,
|
||||
|
|
@ -341,17 +337,17 @@ fun DebugPage(
|
|||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Current server: ${viewModel.serverRepository.currentServer.value}",
|
||||
text = "Current server: ${viewModel.serverRepository.currentServer}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Current user: ${viewModel.serverRepository.currentUser.value}",
|
||||
text = "Current user: ${viewModel.serverRepository.currentUser}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}",
|
||||
text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -134,7 +133,7 @@ class HomeRowGridViewModel
|
|||
try {
|
||||
val preferences = userPreferencesService.getCurrent()
|
||||
val prefs = preferences.appPreferences.homePagePreferences
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
serverRepository.currentUserDto?.let { userDto ->
|
||||
val libraries =
|
||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val result =
|
||||
|
|
@ -244,7 +243,7 @@ fun HomeRowGrid(
|
|||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
|
|
@ -381,7 +380,7 @@ fun HomeRowGrid(
|
|||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
viewModel.serverRepository.currentUserDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -33,7 +32,7 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -48,7 +47,6 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
|||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
|
|
@ -68,7 +66,6 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||
|
|
@ -76,13 +73,16 @@ import com.github.damontecres.wholphin.util.ApiRequestPager
|
|||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
|
|
@ -91,50 +91,76 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
|||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@HiltViewModel(assistedFactory = PersonViewModel.Factory::class)
|
||||
class PersonViewModel
|
||||
@Inject
|
||||
@AssistedInject
|
||||
constructor(
|
||||
api: ApiClient,
|
||||
private val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val seerrService: SeerrService,
|
||||
) : LoadingItemViewModel(api) {
|
||||
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
@Assisted val itemId: UUID,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: UUID): PersonViewModel
|
||||
}
|
||||
|
||||
fun init(itemId: UUID) {
|
||||
viewModelScope.launchIO(
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error loading item $itemId",
|
||||
),
|
||||
) {
|
||||
super.init(itemId, null).await()?.let { person ->
|
||||
private val _state = MutableStateFlow(PersonState())
|
||||
val state: StateFlow<PersonState> = _state
|
||||
|
||||
private suspend fun updatePerson(): BaseItem {
|
||||
val person =
|
||||
api.userLibraryApi
|
||||
.getItem(itemId)
|
||||
.content
|
||||
.let { BaseItem(it) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
person = DataLoadingState.Success(person),
|
||||
)
|
||||
}
|
||||
return person
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val person = updatePerson()
|
||||
val dto = person.data
|
||||
if ((dto.movieCount ?: 0) > 0) {
|
||||
fetchRow(person.id, BaseItemKind.MOVIE, movies)
|
||||
fetchRow(person.id, BaseItemKind.MOVIE) { state, row ->
|
||||
state.copy(movies = row)
|
||||
}
|
||||
} else {
|
||||
movies.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||
_state.update { it.copy(movies = RowLoadingState.Success(emptyList())) }
|
||||
}
|
||||
if ((dto.seriesCount ?: 0) > 0) {
|
||||
fetchRow(person.id, BaseItemKind.SERIES, series)
|
||||
fetchRow(person.id, BaseItemKind.SERIES) { state, row ->
|
||||
state.copy(series = row)
|
||||
}
|
||||
} else {
|
||||
series.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||
_state.update { it.copy(series = RowLoadingState.Success(emptyList())) }
|
||||
}
|
||||
if ((dto.episodeCount ?: 0) > 0) {
|
||||
fetchRow(person.id, BaseItemKind.EPISODE, episodes)
|
||||
fetchRow(person.id, BaseItemKind.EPISODE) { state, row ->
|
||||
state.copy(episodes = row)
|
||||
}
|
||||
} else {
|
||||
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||
_state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(person).orEmpty()
|
||||
discovered.update { results }
|
||||
_state.update {
|
||||
it.copy(
|
||||
discovered = results,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching person %s", itemId)
|
||||
_state.update { it.copy(person = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,10 +168,12 @@ class PersonViewModel
|
|||
private fun fetchRow(
|
||||
itemId: UUID,
|
||||
type: BaseItemKind,
|
||||
target: MutableLiveData<RowLoadingState>,
|
||||
update: (PersonState, RowLoadingState) -> PersonState,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
target.setValueOnMain(RowLoadingState.Loading)
|
||||
_state.update {
|
||||
update.invoke(it, RowLoadingState.Loading)
|
||||
}
|
||||
try {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
|
|
@ -165,96 +193,105 @@ class PersonViewModel
|
|||
pageSize = 15,
|
||||
useSeriesForPrimary = false,
|
||||
).init()
|
||||
target.setValueOnMain(RowLoadingState.Success(pager))
|
||||
_state.update {
|
||||
update.invoke(it, RowLoadingState.Success(pager))
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching $type for $itemId")
|
||||
target.setValueOnMain(RowLoadingState.Error(ex))
|
||||
_state.update {
|
||||
update.invoke(it, RowLoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(favorite: Boolean) {
|
||||
viewModelScope.launchIO {
|
||||
itemUuid?.let {
|
||||
favoriteWatchManager.setFavorite(it, favorite)
|
||||
fetchAndSetItem(it)
|
||||
}
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
updatePerson()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PersonState(
|
||||
val person: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||
val movies: RowLoadingState = RowLoadingState.Pending,
|
||||
val series: RowLoadingState = RowLoadingState.Pending,
|
||||
val episodes: RowLoadingState = RowLoadingState.Pending,
|
||||
val discovered: List<DiscoverItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PersonPage(
|
||||
preferences: UserPreferences,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PersonViewModel = hiltViewModel(),
|
||||
viewModel: PersonViewModel =
|
||||
hiltViewModel<PersonViewModel, PersonViewModel.Factory>(
|
||||
creationCallback = { it.create(destination.itemId) },
|
||||
),
|
||||
) {
|
||||
OneTimeLaunchedEffect {
|
||||
viewModel.init(destination.itemId)
|
||||
}
|
||||
val person by viewModel.item.observeAsState()
|
||||
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
||||
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
||||
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
val state by viewModel.state.collectAsState()
|
||||
when (val st = state.person) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
person?.let { person ->
|
||||
var showOverviewDialog by remember { mutableStateOf(false) }
|
||||
val name = person.name ?: person.id.toString()
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) }
|
||||
PersonPageContent(
|
||||
preferences = preferences,
|
||||
name = name,
|
||||
overview = person.data.overview,
|
||||
imageUrl = imageUrl,
|
||||
birthdate = person.data.premiereDate?.toLocalDate(),
|
||||
deathdate = person.data.endDate?.toLocalDate(),
|
||||
birthPlace = person.data.productionLocations?.firstOrNull(),
|
||||
favorite = person.favorite,
|
||||
movies = movies,
|
||||
series = series,
|
||||
episodes = episodes,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
overviewOnClick = { showOverviewDialog = true },
|
||||
favoriteOnClick = {
|
||||
viewModel.setFavorite(!person.favorite)
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
AnimatedVisibility(showOverviewDialog) {
|
||||
ItemDetailsDialog(
|
||||
info =
|
||||
ItemDetailsDialogInfo(
|
||||
title = name,
|
||||
overview = person.data.overview,
|
||||
genres = listOf(),
|
||||
files = listOf(),
|
||||
),
|
||||
showFilePath = false,
|
||||
onDismissRequest = { showOverviewDialog = false },
|
||||
is DataLoadingState.Success<BaseItem> -> {
|
||||
val person = st.data
|
||||
var showOverviewDialog by remember { mutableStateOf(false) }
|
||||
val name = remember(person) { person.name ?: person.id.toString() }
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val imageUrl =
|
||||
remember {
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = person.id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
)
|
||||
}
|
||||
PersonPageContent(
|
||||
preferences = preferences,
|
||||
name = name,
|
||||
overview = person.data.overview,
|
||||
imageUrl = imageUrl,
|
||||
birthdate = person.data.premiereDate?.toLocalDate(),
|
||||
deathdate = person.data.endDate?.toLocalDate(),
|
||||
birthPlace = person.data.productionLocations?.firstOrNull(),
|
||||
favorite = person.favorite,
|
||||
movies = state.movies,
|
||||
series = state.series,
|
||||
episodes = state.episodes,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
overviewOnClick = { showOverviewDialog = true },
|
||||
favoriteOnClick = {
|
||||
viewModel.setFavorite(!person.favorite)
|
||||
},
|
||||
discovered = state.discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigationManager.navigateTo(item.destination)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
AnimatedVisibility(showOverviewDialog) {
|
||||
ItemDetailsDialog(
|
||||
info =
|
||||
ItemDetailsDialogInfo(
|
||||
title = name,
|
||||
overview = person.data.overview,
|
||||
genres = listOf(),
|
||||
files = listOf(),
|
||||
),
|
||||
showFilePath = false,
|
||||
onDismissRequest = { showOverviewDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.compose.runtime.Immutable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -168,7 +167,7 @@ class PlaylistViewModel
|
|||
.let { BaseItem(it, false) }
|
||||
state.update { it.copy(playlist = playlist) }
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, itemId)
|
||||
}
|
||||
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
|
||||
|
|
@ -198,7 +197,7 @@ class PlaylistViewModel
|
|||
)
|
||||
}
|
||||
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
||||
|
|
@ -304,7 +303,7 @@ class PlaylistViewModel
|
|||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
FilterUtils.getFilterOptionValues(
|
||||
api,
|
||||
serverRepository.currentUser.value?.id,
|
||||
serverRepository.currentUser?.id,
|
||||
itemId,
|
||||
filterOption,
|
||||
)
|
||||
|
|
@ -374,7 +373,7 @@ fun PlaylistDetails(
|
|||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val addPlaylistState by addToPlaylistViewModel.playlistState.collectAsState()
|
||||
|
||||
fun play(
|
||||
index: Int,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
|
@ -52,7 +51,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
|||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -79,7 +77,7 @@ fun CollectionDetails(
|
|||
// Dialogs
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
|
||||
|
|
@ -247,7 +245,7 @@ fun CollectionDetails(
|
|||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
viewModel.serverRepository.currentUserDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail.collection
|
|||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -99,16 +98,14 @@ class CollectionViewModel
|
|||
}
|
||||
|
||||
private val viewOptionsFlow =
|
||||
serverRepository.currentUser
|
||||
.asFlow()
|
||||
serverRepository.currentUserFlow
|
||||
.filterNotNull()
|
||||
.flatMapLatest {
|
||||
keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
|
||||
|
||||
private val libraryDisplayInfoFlow =
|
||||
serverRepository.currentUser
|
||||
.asFlow()
|
||||
serverRepository.currentUserFlow
|
||||
.filterNotNull()
|
||||
.flatMapLatest {
|
||||
libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
|
||||
|
|
@ -282,7 +279,7 @@ class CollectionViewModel
|
|||
}
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = itemId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
excludeItemTypes = excludeItemTypes,
|
||||
|
|
@ -298,7 +295,7 @@ class CollectionViewModel
|
|||
|
||||
fun changeSort(sortAndDirection: SortAndDirection) {
|
||||
viewModelScope.launchIO {
|
||||
val user = serverRepository.currentUser.value
|
||||
val user = serverRepository.currentUser
|
||||
val state = _state.value
|
||||
if (user != null) {
|
||||
libraryDisplayInfoDao.saveItem(
|
||||
|
|
@ -317,7 +314,7 @@ class CollectionViewModel
|
|||
|
||||
fun changeFilter(filter: GetItemsFilter) {
|
||||
viewModelScope.launchIO {
|
||||
val user = serverRepository.currentUser.value
|
||||
val user = serverRepository.currentUser
|
||||
val state = _state.value
|
||||
if (user != null) {
|
||||
libraryDisplayInfoDao.saveItem(
|
||||
|
|
@ -344,7 +341,7 @@ class CollectionViewModel
|
|||
backdropService.clearBackdrop()
|
||||
}
|
||||
}
|
||||
serverRepository.currentUser.value?.id?.let { userId ->
|
||||
serverRepository.currentUser?.id?.let { userId ->
|
||||
keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
|
||||
}
|
||||
}
|
||||
|
|
@ -353,7 +350,7 @@ class CollectionViewModel
|
|||
suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
FilterUtils.getFilterOptionValues(
|
||||
api,
|
||||
serverRepository.currentUser.value?.id,
|
||||
serverRepository.currentUser?.id,
|
||||
itemId,
|
||||
filterOption,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -64,8 +63,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -86,16 +85,9 @@ fun DiscoverMovieDetails(
|
|||
viewModel.init()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val item by viewModel.movie.observeAsState()
|
||||
val rating by viewModel.rating.observeAsState(null)
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val recommended by viewModel.recommended.observeAsState(listOf())
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val state by viewModel.state.collectAsState()
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||
val canCancel by viewModel.canCancelRequest.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
|
@ -103,98 +95,97 @@ fun DiscoverMovieDetails(
|
|||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
when (val st = state.movie) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { movie ->
|
||||
DiscoverMovieDetailsContent(
|
||||
preferences = preferences,
|
||||
movie = movie,
|
||||
userConfig = userConfig,
|
||||
rating = rating,
|
||||
canCancel = canCancel,
|
||||
people = people,
|
||||
trailers = trailers,
|
||||
similar = similar,
|
||||
recommended = recommended,
|
||||
requestOnClick = {
|
||||
movie.id?.let { id ->
|
||||
if (request4kEnabled) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = requestStr,
|
||||
onClick = { viewModel.request(id, false) },
|
||||
),
|
||||
DialogItem(
|
||||
text = request4kStr,
|
||||
onClick = { viewModel.request(id, true) },
|
||||
),
|
||||
is DataLoadingState.Success<MovieDetails> -> {
|
||||
val movie = st.data
|
||||
DiscoverMovieDetailsContent(
|
||||
preferences = preferences,
|
||||
movie = movie,
|
||||
userConfig = userConfig,
|
||||
rating = state.rating,
|
||||
canCancel = state.canCancelRequest,
|
||||
people = state.people,
|
||||
trailers = state.trailers,
|
||||
similar = state.similar,
|
||||
recommended = state.recommended,
|
||||
requestOnClick = {
|
||||
movie.id?.let { id ->
|
||||
if (request4kEnabled) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = requestStr,
|
||||
onClick = { viewModel.request(id, false) },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.request(id, false)
|
||||
}
|
||||
DialogItem(
|
||||
text = request4kStr,
|
||||
onClick = { viewModel.request(id, true) },
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.request(id, false)
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
movie.id?.let { viewModel.cancelRequest(it) }
|
||||
},
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onClickPerson = { item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = movie.title ?: resources.getString(R.string.unknown),
|
||||
overview = movie.overview,
|
||||
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
goToOnClick = {
|
||||
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
itemId = it,
|
||||
type = BaseItemKind.MOVIE,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
moreOnClick = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||
items = listOf(),
|
||||
)
|
||||
},
|
||||
onLongClickPerson = { index, person -> },
|
||||
onLongClickSimilar = { index, similar ->
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
movie.id?.let { viewModel.cancelRequest(it) }
|
||||
},
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onClickPerson = { item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = movie.title ?: resources.getString(R.string.unknown),
|
||||
overview = movie.overview,
|
||||
genres = movie.genres?.mapNotNull { it.name }.orEmpty(),
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
goToOnClick = {
|
||||
movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
itemId = it,
|
||||
type = BaseItemKind.MOVIE,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
moreOnClick = {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = movie.title + " (${movie.releaseDate ?: ""})",
|
||||
items = listOf(),
|
||||
)
|
||||
},
|
||||
onLongClickPerson = { index, person -> },
|
||||
onLongClickSimilar = { index, similar ->
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
|
||||
|
|
@ -23,9 +22,8 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
|
|||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.successValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -36,13 +34,13 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
|
||||
class DiscoverMovieViewModel
|
||||
|
|
@ -62,15 +60,8 @@ class DiscoverMovieViewModel
|
|||
fun create(item: DiscoverItem): DiscoverMovieViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val movie = MutableLiveData<MovieDetails?>(null)
|
||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>()
|
||||
val canCancelRequest = MutableStateFlow(false)
|
||||
private val _state = MutableStateFlow(DiscoverMovieState())
|
||||
val state: StateFlow<DiscoverMovieState> = _state
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val request4kEnabled =
|
||||
|
|
@ -80,85 +71,92 @@ class DiscoverMovieViewModel
|
|||
init()
|
||||
}
|
||||
|
||||
private fun fetchAndSetItem(): Deferred<MovieDetails> =
|
||||
viewModelScope.async(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||
this@DiscoverMovieViewModel.movie.setValueOnMain(movie)
|
||||
movie
|
||||
private fun fetchAndSetItem(): Deferred<MovieDetails?> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
try {
|
||||
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
|
||||
movie
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error updating movie details")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Init for movie %s", item.id)
|
||||
val movie = fetchAndSetItem().await()
|
||||
val discoveredItem = seerrService.createDiscoverItem(movie)
|
||||
backdropService.submit(discoveredItem)
|
||||
try {
|
||||
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
|
||||
val discoveredItem = seerrService.createDiscoverItem(movie)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateCanCancel()
|
||||
updateCanCancel()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
|
||||
rating.setValueOnMain(DiscoverRating(result))
|
||||
}
|
||||
if (!similar.isInitialized) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
|
||||
_state.update { it.copy(rating = DiscoverRating(result)) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
recommended.setValueOnMain(result)
|
||||
if (state.value.similar.isEmpty()) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
_state.update { it.copy(similar = result) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
_state.update { it.copy(recommended = result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val people =
|
||||
movie.credits
|
||||
?.cast
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty() +
|
||||
val people =
|
||||
movie.credits
|
||||
?.crew
|
||||
?.cast
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
this@DiscoverMovieViewModel.people.setValueOnMain(people)
|
||||
val trailers =
|
||||
movie.relatedVideos
|
||||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||
?.map {
|
||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||
}.orEmpty()
|
||||
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
|
||||
.orEmpty() +
|
||||
movie.credits
|
||||
?.crew
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
_state.update { it.copy(people = people) }
|
||||
val trailers =
|
||||
movie.relatedVideos
|
||||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||
?.map {
|
||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||
}.orEmpty()
|
||||
_state.update { it.copy(trailers = trailers) }
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error updating movie details")
|
||||
_state.update { it.copy(movie = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateCanCancel() {
|
||||
val user = userConfig.firstOrNull()
|
||||
val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests)
|
||||
canCancelRequest.update { canCancel }
|
||||
val canCancel =
|
||||
canUserCancelRequest(
|
||||
user,
|
||||
state.value.movie.successValue
|
||||
?.mediaInfo
|
||||
?.requests,
|
||||
)
|
||||
_state.update { it.copy(canCancelRequest = canCancel) }
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
|
|
@ -185,7 +183,7 @@ class DiscoverMovieViewModel
|
|||
|
||||
fun cancelRequest(id: Int) {
|
||||
viewModelScope.launchIO {
|
||||
movie.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||
state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||
// TODO handle multiple requests? Or just delete self's request?
|
||||
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||
fetchAndSetItem().await()
|
||||
|
|
@ -204,3 +202,14 @@ fun canUserCancelRequest(
|
|||
user.hasPermission(SeerrPermission.REQUEST) &&
|
||||
requests?.any { it.requestedBy?.id == user?.id } == true
|
||||
)
|
||||
|
||||
data class DiscoverMovieState(
|
||||
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
|
||||
val rating: DiscoverRating? = null,
|
||||
val seasons: List<RequestSeason> = emptyList(),
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
val people: List<DiscoverItem> = emptyList(),
|
||||
val similar: List<DiscoverItem> = emptyList(),
|
||||
val recommended: List<DiscoverItem> = emptyList(),
|
||||
val canCancelRequest: Boolean = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -66,8 +65,9 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.successValue
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -85,17 +85,8 @@ fun DiscoverSeriesDetails(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val item by viewModel.tvSeries.observeAsState()
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val recommended by viewModel.recommended.observeAsState(listOf())
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
val state by viewModel.state.collectAsState()
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||
val canCancel by viewModel.canCancelRequest.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
|
@ -105,74 +96,73 @@ fun DiscoverSeriesDetails(
|
|||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
when (val st = state.tvSeries) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { item ->
|
||||
val rating by viewModel.rating.observeAsState(null)
|
||||
DiscoverSeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
series = item,
|
||||
userConfig = userConfig,
|
||||
rating = rating,
|
||||
canCancel = canCancel,
|
||||
seasons = seasons,
|
||||
people = people,
|
||||
similar = similar,
|
||||
recommended = recommended,
|
||||
modifier = modifier,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onClickPerson = {
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||
},
|
||||
goToOnClick = {
|
||||
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
itemId = it,
|
||||
type = BaseItemKind.MOVIE,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = item.name ?: resources.getString(R.string.unknown),
|
||||
overview = item.overview,
|
||||
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
trailers = trailers,
|
||||
requestOnClick = {
|
||||
item.id?.let { id ->
|
||||
showRequestSeasonDialog = true
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
item.id?.let { viewModel.cancelRequest(it) }
|
||||
},
|
||||
moreOnClick = {
|
||||
},
|
||||
onLongClickPerson = { _, _ -> },
|
||||
onLongClickSimilar = { _, _ -> },
|
||||
)
|
||||
}
|
||||
is DataLoadingState.Success<TvDetails> -> {
|
||||
val item = st.data
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
DiscoverSeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
series = item,
|
||||
userConfig = userConfig,
|
||||
rating = state.rating,
|
||||
canCancel = state.canCancelRequest,
|
||||
seasons = state.seasons,
|
||||
people = state.people,
|
||||
similar = state.similar,
|
||||
recommended = state.recommended,
|
||||
modifier = modifier,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||
},
|
||||
onClickPerson = {
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||
},
|
||||
goToOnClick = {
|
||||
item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
itemId = it,
|
||||
type = BaseItemKind.MOVIE,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = item.name ?: resources.getString(R.string.unknown),
|
||||
overview = item.overview,
|
||||
genres = item.genres?.mapNotNull { it.name }.orEmpty(),
|
||||
files = listOf(),
|
||||
)
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
trailers = state.trailers,
|
||||
requestOnClick = {
|
||||
item.id?.let { id ->
|
||||
showRequestSeasonDialog = true
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
item.id?.let { viewModel.cancelRequest(it) }
|
||||
},
|
||||
moreOnClick = {
|
||||
},
|
||||
onLongClickPerson = { _, _ -> },
|
||||
onLongClickSimilar = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
|
|
@ -203,11 +193,13 @@ fun DiscoverSeriesDetails(
|
|||
}
|
||||
if (showRequestSeasonDialog) {
|
||||
RequestSeasonsDialog(
|
||||
title = item?.name ?: "",
|
||||
seasons = seasons,
|
||||
title = state.tvSeries.successValue?.name ?: "",
|
||||
seasons = state.seasons,
|
||||
request4kEnabled = request4kEnabled,
|
||||
onSubmit = { seasons, is4k ->
|
||||
item?.id?.let { viewModel.request(it, seasons, is4k) }
|
||||
state.tvSeries.successValue
|
||||
?.id
|
||||
?.let { viewModel.request(it, seasons, is4k) }
|
||||
showRequestSeasonDialog = false
|
||||
},
|
||||
onDismissRequest = { showRequestSeasonDialog = false },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
||||
|
|
@ -21,25 +20,24 @@ import com.github.damontecres.wholphin.services.SeerrService
|
|||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.successValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
|
||||
|
|
@ -61,16 +59,8 @@ class DiscoverSeriesViewModel
|
|||
fun create(item: DiscoverItem): DiscoverSeriesViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val tvSeries = MutableLiveData<TvDetails?>(null)
|
||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||
|
||||
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>()
|
||||
val canCancelRequest = MutableStateFlow(false)
|
||||
private val _state = MutableStateFlow(DiscoverSeriesState())
|
||||
val state: StateFlow<DiscoverSeriesState> = _state
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false }
|
||||
|
|
@ -79,128 +69,132 @@ class DiscoverSeriesViewModel
|
|||
init()
|
||||
}
|
||||
|
||||
private fun fetchAndSetItem(): Deferred<TvDetails> =
|
||||
viewModelScope.async(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv)
|
||||
tv
|
||||
private fun fetchAndSetItem(): Deferred<TvDetails?> =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
try {
|
||||
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
|
||||
tv
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error updating tv details")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Init for tv %s", item.id)
|
||||
val tv = fetchAndSetItem().await()
|
||||
val discoveredItem = seerrService.createDiscoverItem(tv)
|
||||
backdropService.submit(discoveredItem)
|
||||
try {
|
||||
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
|
||||
val discoveredItem = seerrService.createDiscoverItem(tv)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateSeasonStatus()
|
||||
updateCanCancel()
|
||||
updateSeasonStatus(tv)
|
||||
updateCanCancel()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
|
||||
rating.setValueOnMain(DiscoverRating(result))
|
||||
}
|
||||
if (!similar.isInitialized) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
|
||||
_state.update { it.copy(rating = DiscoverRating(result)) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
recommended.setValueOnMain(result)
|
||||
if (state.value.similar.isEmpty()) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
_state.update { it.copy(similar = result) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
_state.update { it.copy(recommended = result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val people =
|
||||
tv.credits
|
||||
?.cast
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty() +
|
||||
val people =
|
||||
tv.credits
|
||||
?.crew
|
||||
?.cast
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
|
||||
.orEmpty() +
|
||||
tv.credits
|
||||
?.crew
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
_state.update { it.copy(people = people) }
|
||||
|
||||
val trailers =
|
||||
tv.relatedVideos
|
||||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||
?.map {
|
||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||
}.orEmpty()
|
||||
this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers)
|
||||
val trailers =
|
||||
tv.relatedVideos
|
||||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||
?.map {
|
||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||
}.orEmpty()
|
||||
_state.update { it.copy(trailers = trailers) }
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error getting tv details")
|
||||
_state.update { it.copy(tvSeries = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
private suspend fun updateSeasonStatus() {
|
||||
tvSeries.value?.let { tv ->
|
||||
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
|
||||
tv.seasons?.forEach {
|
||||
it.seasonNumber?.let {
|
||||
seasonStatus[it] = SeerrAvailability.UNKNOWN
|
||||
}
|
||||
private fun updateSeasonStatus(tv: TvDetails) {
|
||||
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
|
||||
tv.seasons?.forEach {
|
||||
it.seasonNumber?.let {
|
||||
seasonStatus[it] = SeerrAvailability.UNKNOWN
|
||||
}
|
||||
val tvStatus =
|
||||
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
|
||||
tv.mediaInfo
|
||||
?.requests
|
||||
?.forEach {
|
||||
it.seasons?.mapNotNull { season ->
|
||||
season.seasonNumber?.let {
|
||||
val current = seasonStatus[season.seasonNumber]
|
||||
val new =
|
||||
SeerrAvailability
|
||||
.from(season.status)
|
||||
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
|
||||
if (current == null || new.status > current.status) {
|
||||
seasonStatus[season.seasonNumber] = new
|
||||
}
|
||||
}
|
||||
val tvStatus =
|
||||
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
|
||||
tv.mediaInfo
|
||||
?.requests
|
||||
?.forEach {
|
||||
it.seasons?.mapNotNull { season ->
|
||||
season.seasonNumber?.let {
|
||||
val current = seasonStatus[season.seasonNumber]
|
||||
val new =
|
||||
SeerrAvailability
|
||||
.from(season.status)
|
||||
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
|
||||
if (current == null || new.status > current.status) {
|
||||
seasonStatus[season.seasonNumber] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.v("seasonStatus=%s", seasonStatus)
|
||||
val requestSeasons =
|
||||
seasonStatus.mapNotNull { (seasonNumber, availability) ->
|
||||
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
|
||||
RequestSeason(it, availability)
|
||||
}
|
||||
}
|
||||
Timber.v("seasonStatus=%s", seasonStatus)
|
||||
val requestSeasons =
|
||||
seasonStatus.mapNotNull { (seasonNumber, availability) ->
|
||||
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
|
||||
RequestSeason(it, availability)
|
||||
}
|
||||
seasons.setValueOnMain(requestSeasons)
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(seasons = requestSeasons) }
|
||||
}
|
||||
|
||||
private suspend fun updateCanCancel() {
|
||||
val user = userConfig.firstOrNull()
|
||||
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
|
||||
canCancelRequest.update { canCancel }
|
||||
val canCancel =
|
||||
canUserCancelRequest(
|
||||
user,
|
||||
state.value.tvSeries.successValue
|
||||
?.mediaInfo
|
||||
?.requests,
|
||||
)
|
||||
_state.update { it.copy(canCancelRequest = canCancel) }
|
||||
}
|
||||
|
||||
fun request(
|
||||
|
|
@ -209,7 +203,7 @@ class DiscoverSeriesViewModel
|
|||
is4k: Boolean,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
tvSeries.value?.let { tv ->
|
||||
state.value.tvSeries.successValue?.let { tv ->
|
||||
val currentRequest =
|
||||
tv.mediaInfo?.requests?.firstOrNull {
|
||||
it.requestedBy?.id ==
|
||||
|
|
@ -238,21 +232,35 @@ class DiscoverSeriesViewModel
|
|||
)
|
||||
}
|
||||
|
||||
fetchAndSetItem().await()
|
||||
updateSeasonStatus()
|
||||
updateCanCancel()
|
||||
fetchAndSetItem().await()?.let {
|
||||
updateSeasonStatus(it)
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRequest(id: Int) {
|
||||
viewModelScope.launchIO {
|
||||
tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||
state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||
// TODO handle multiple requests? Or just delete self's request?
|
||||
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||
fetchAndSetItem().await()
|
||||
updateCanCancel()
|
||||
fetchAndSetItem().await()?.let {
|
||||
updateSeasonStatus(it)
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DiscoverSeriesState(
|
||||
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
|
||||
val rating: DiscoverRating? = null,
|
||||
val seasons: List<RequestSeason> = emptyList(),
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
val people: List<DiscoverItem> = emptyList(),
|
||||
val similar: List<DiscoverItem> = emptyList(),
|
||||
val recommended: List<DiscoverItem> = emptyList(),
|
||||
val canCancelRequest: Boolean = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -43,11 +42,10 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
|
@ -66,27 +64,20 @@ fun EpisodeDetails(
|
|||
),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.init()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val item by viewModel.item.observeAsState()
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
val canDelete by viewModel.canDelete.collectAsState()
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
.observeAsState()
|
||||
.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
|
||||
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
|
|
@ -119,75 +110,74 @@ fun EpisodeDetails(
|
|||
)
|
||||
}
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
when (val st = state.episode) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { ep ->
|
||||
LifecycleResumeEffect(ep) {
|
||||
ep.data.seriesId?.let { seriesId ->
|
||||
viewModel.maybePlayThemeSong(seriesId)
|
||||
}
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
is DataLoadingState.Success<BaseItem> -> {
|
||||
val ep = st.data
|
||||
LifecycleResumeEffect(ep) {
|
||||
ep.data.seriesId?.let { seriesId ->
|
||||
viewModel.maybePlayThemeSong(seriesId)
|
||||
}
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
EpisodeDetailsContent(
|
||||
preferences = preferences,
|
||||
ep = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
playOnClick = {
|
||||
viewModel.navigateTo(
|
||||
Destination.Playback(
|
||||
ep.id,
|
||||
it.inWholeMilliseconds,
|
||||
),
|
||||
)
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(ep)
|
||||
},
|
||||
moreOnClick = {
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
item = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = canDelete,
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
watchOnClick = {
|
||||
viewModel.setWatched(ep.id, !ep.played)
|
||||
},
|
||||
favoriteOnClick = {
|
||||
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||
},
|
||||
canDelete = canDelete,
|
||||
onConfirmDelete = { viewModel.deleteItem(ep) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
EpisodeDetailsContent(
|
||||
preferences = preferences,
|
||||
ep = ep,
|
||||
chosenStreams = state.chosenStreams,
|
||||
playOnClick = {
|
||||
viewModel.navigateTo(
|
||||
Destination.Playback(
|
||||
ep.id,
|
||||
it.inWholeMilliseconds,
|
||||
),
|
||||
)
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(ep)
|
||||
},
|
||||
moreOnClick = {
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
item = ep,
|
||||
chosenStreams = state.chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = canDelete,
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
watchOnClick = {
|
||||
viewModel.setWatched(ep.id, !ep.played)
|
||||
},
|
||||
favoriteOnClick = {
|
||||
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||
},
|
||||
canDelete = canDelete,
|
||||
onConfirmDelete = { viewModel.deleteItem(ep) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
userDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.episode
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||
|
|
@ -22,26 +20,26 @@ import com.github.damontecres.wholphin.services.deleteItem
|
|||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class)
|
||||
|
|
@ -67,53 +65,61 @@ class EpisodeViewModel
|
|||
fun create(itemId: UUID): EpisodeViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
private val _state = MutableStateFlow(EpisodeState())
|
||||
val state: StateFlow<EpisodeState> = _state
|
||||
|
||||
val canDelete = MutableStateFlow(false)
|
||||
|
||||
init {
|
||||
init()
|
||||
viewModelScope.launchDefault {
|
||||
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
|
||||
mediaManagementService.collectCanDelete(
|
||||
state.map { (it.episode as? DataLoadingState.Success<BaseItem?>)?.data },
|
||||
) { canDelete ->
|
||||
this@EpisodeViewModel.canDelete.update { canDelete }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
||||
viewModelScope.async(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId).content.let {
|
||||
BaseItem.from(it, api)
|
||||
}
|
||||
this@EpisodeViewModel.item.setValueOnMain(item)
|
||||
item
|
||||
private fun fetchAndSetItem() {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId).content.let {
|
||||
BaseItem.from(it, api)
|
||||
}
|
||||
_state.update { it.copy(episode = DataLoadingState.Success(item)) }
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error getting episode %s", itemId)
|
||||
showToast(context, "Error updating episode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val item = fetchAndSetItem().await()
|
||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@EpisodeViewModel.item.value = item
|
||||
chosenStreams.value = result
|
||||
loading.value = LoadingState.Success
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val item =
|
||||
api.userLibraryApi.getItem(itemId).content.let {
|
||||
BaseItem(it)
|
||||
}
|
||||
val chosenStreams =
|
||||
itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||
_state.update {
|
||||
it.copy(
|
||||
episode = DataLoadingState.Success(item),
|
||||
chosenStreams = chosenStreams,
|
||||
)
|
||||
}
|
||||
backdropService.submit(item)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error getting episode %s", itemId)
|
||||
_state.update { it.copy(episode = DataLoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,9 +151,7 @@ class EpisodeViewModel
|
|||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,9 +175,7 @@ class EpisodeViewModel
|
|||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,14 +197,16 @@ class EpisodeViewModel
|
|||
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
|
||||
viewModelScope.launchIO {
|
||||
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
|
||||
item.value?.let { item ->
|
||||
val result =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
itemId,
|
||||
item,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
this@EpisodeViewModel.chosenStreams.setValueOnMain(result)
|
||||
state.value.episode.let { item ->
|
||||
if (item is DataLoadingState.Success<BaseItem>) {
|
||||
val result =
|
||||
itemPlaybackRepository.getSelectedTracks(
|
||||
itemId,
|
||||
item.data,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
_state.update { it.copy(chosenStreams = result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,3 +217,8 @@ class EpisodeViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class EpisodeState(
|
||||
val episode: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||
val chosenStreams: ChosenStreams? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.ListItem
|
||||
|
|
@ -44,13 +43,14 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
|
|||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
|
@ -63,36 +63,51 @@ class DvrScheduleViewModel
|
|||
private val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val active = MutableLiveData<List<BaseItem>>()
|
||||
val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>()
|
||||
private val _state = MutableStateFlow(DvrScheduleState())
|
||||
val state: StateFlow<DvrScheduleState> = _state
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
fun init() {
|
||||
// loading.value = LoadingState.Loading
|
||||
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) {
|
||||
val active =
|
||||
api.liveTvApi
|
||||
.getRecordings(
|
||||
isInProgress = true,
|
||||
fields = SlimItemFields,
|
||||
limit = 100,
|
||||
).content.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
val scheduled =
|
||||
api.liveTvApi
|
||||
.getTimers(
|
||||
isActive = false,
|
||||
isScheduled = true,
|
||||
).content.items
|
||||
.map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings
|
||||
.groupBy {
|
||||
it.data.startDate!!.toLocalDate()
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val active =
|
||||
api.liveTvApi
|
||||
.getRecordings(
|
||||
isInProgress = true,
|
||||
fields = SlimItemFields,
|
||||
limit = 100,
|
||||
).content.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
val scheduled =
|
||||
api.liveTvApi
|
||||
.getTimers(
|
||||
isActive = false,
|
||||
isScheduled = true,
|
||||
).content.items
|
||||
.map {
|
||||
BaseItem.from(
|
||||
it.programInfo!!,
|
||||
api,
|
||||
true,
|
||||
)
|
||||
} // TODO this probably breaks for time based recordings
|
||||
.groupBy {
|
||||
it.data.startDate!!.toLocalDate()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@DvrScheduleViewModel.active.value = active
|
||||
this@DvrScheduleViewModel.scheduled.value = scheduled
|
||||
loading.value = LoadingState.Success
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Success,
|
||||
active = active,
|
||||
scheduled = scheduled,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching DVR schedule")
|
||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,6 +127,12 @@ class DvrScheduleViewModel
|
|||
}
|
||||
}
|
||||
|
||||
data class DvrScheduleState(
|
||||
val loading: LoadingState = LoadingState.Loading,
|
||||
val active: List<BaseItem> = emptyList(),
|
||||
val scheduled: Map<LocalDate, List<BaseItem>> = emptyMap(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DvrSchedule(
|
||||
requestFocusAfterLoading: Boolean,
|
||||
|
|
@ -122,12 +143,10 @@ fun DvrSchedule(
|
|||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val active by viewModel.active.observeAsState(listOf())
|
||||
val recordings by viewModel.scheduled.observeAsState(mapOf())
|
||||
when (val state = loading) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
when (val st = state.loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
|
|
@ -141,7 +160,7 @@ fun DvrSchedule(
|
|||
val focusRequester = remember { FocusRequester() }
|
||||
if (requestFocusAfterLoading) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (active.isNotEmpty() || recordings.isNotEmpty()) {
|
||||
if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) {
|
||||
focusRequester.tryRequestFocus()
|
||||
} else {
|
||||
focusRequesterOnEmpty.tryRequestFocus()
|
||||
|
|
@ -149,8 +168,8 @@ fun DvrSchedule(
|
|||
}
|
||||
}
|
||||
DvrScheduleContent(
|
||||
activeRecordings = active,
|
||||
scheduledRecordings = recordings,
|
||||
activeRecordings = state.active,
|
||||
scheduledRecordings = state.scheduled,
|
||||
onClickItem = {
|
||||
showDialog = it
|
||||
},
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class LiveTvViewModel
|
|||
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||
GetLiveTvChannelsRequest(
|
||||
startIndex = 0,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
enableFavoriteSorting = favoriteChannelsAtBeginning,
|
||||
sortBy =
|
||||
if (sortByRecentlyWatched) {
|
||||
|
|
@ -229,7 +229,7 @@ class LiveTvViewModel
|
|||
minEndDate = minEndDate,
|
||||
channelIds = channelsToFetch.map { it.id },
|
||||
sortBy = listOf(ItemSortBy.START_DATE),
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
fields = listOf(ItemFields.OVERVIEW),
|
||||
)
|
||||
val fetchedPrograms =
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -58,7 +57,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
|
|
@ -97,14 +95,10 @@ fun MovieDetails(
|
|||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
.observeAsState()
|
||||
.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
|
||||
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
|
|
@ -258,7 +252,7 @@ fun MovieDetails(
|
|||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
userDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ class MovieViewModel
|
|||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
GetSimilarItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = itemId,
|
||||
fields = SlimItemFields,
|
||||
limit = 25,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -76,7 +75,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails
|
|||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -188,7 +186,7 @@ class AlbumViewModel
|
|||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
GetSimilarItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = itemId,
|
||||
excludeArtistIds = album.data.albumArtists?.map { it.id },
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -202,7 +200,7 @@ class AlbumViewModel
|
|||
viewModelScope.launchIO {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
albumIds = listOf(itemId),
|
||||
parentId = null,
|
||||
fields = DefaultItemFields,
|
||||
|
|
@ -328,7 +326,7 @@ fun AlbumDetailsPage(
|
|||
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
val moreDialogActions =
|
||||
remember {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -71,7 +70,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails
|
|||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -210,7 +208,7 @@ class ArtistViewModel
|
|||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
GetSimilarItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = itemId,
|
||||
excludeArtistIds = listOf(itemId),
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -224,7 +222,7 @@ class ArtistViewModel
|
|||
viewModelScope.launchIO {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
artistIds = listOf(itemId),
|
||||
parentId = null,
|
||||
fields = DefaultItemFields,
|
||||
|
|
@ -319,7 +317,7 @@ fun ArtistDetailsPage(
|
|||
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
val moreDialogActions =
|
||||
remember {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class SearchForViewModel
|
|||
try {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
searchTerm = query,
|
||||
includeItemTypes = listOf(searchType),
|
||||
recursive = true,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.PlayArrow
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -34,7 +33,6 @@ import androidx.compose.ui.focus.focusRestorer
|
|||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -79,7 +77,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
|
|
@ -89,7 +86,6 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
|||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
|
|
@ -111,20 +107,9 @@ fun SeriesDetails(
|
|||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val item by viewModel.item.observeAsState()
|
||||
val canDelete by viewModel.canDeleteSeries.collectAsState()
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val extras by viewModel.extras.observeAsState(listOf())
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
val discoverSeries by viewModel.discoverSeries.collectAsState()
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val state by viewModel.state.collectAsState()
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
|
|
@ -180,118 +165,116 @@ fun SeriesDetails(
|
|||
}
|
||||
}
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
when (val st = state.series) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
item?.let { item ->
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.onResumePage()
|
||||
is DataLoadingState.Success<BaseItem> -> {
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val played = item.data.userData?.played ?: false
|
||||
SeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
series = item,
|
||||
seasons = seasons,
|
||||
trailers = trailers,
|
||||
extras = extras,
|
||||
people = people,
|
||||
similar = similar,
|
||||
played = played,
|
||||
favorite = item.data.userData?.isFavorite ?: false,
|
||||
canDelete = canDelete,
|
||||
modifier = modifier,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(item.destination())
|
||||
},
|
||||
onClickPerson = {
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
val series = st.data
|
||||
val played = series.data.userData?.played ?: false
|
||||
SeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
series = series,
|
||||
seasons = state.seasons,
|
||||
trailers = state.trailers,
|
||||
extras = state.extras,
|
||||
people = state.people,
|
||||
similar = state.similar,
|
||||
played = played,
|
||||
favorite = series.data.userData?.isFavorite ?: false,
|
||||
canDelete = state.canDeleteSeries,
|
||||
modifier = modifier,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(item.destination())
|
||||
},
|
||||
onClickPerson = {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
BaseItemKind.PERSON,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClickItem = { index, season ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = season,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(season, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog = ItemDetailsDialogInfo(series)
|
||||
},
|
||||
playOnClick = { shuffle ->
|
||||
if (shuffle) {
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
BaseItemKind.PERSON,
|
||||
Destination.PlaybackList(
|
||||
itemId = series.id,
|
||||
shuffle = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
},
|
||||
watchOnClick = { showWatchConfirmation = true },
|
||||
favoriteOnClick = {
|
||||
val favorite = series.data.userData?.isFavorite ?: false
|
||||
viewModel.setFavorite(series.id, !favorite, null)
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
onClickExtra = { _, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discoverSeries = state.discoverSeries,
|
||||
onClickDiscoverSeries = {
|
||||
state.discoverSeries?.let {
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||
}
|
||||
},
|
||||
discovered = state.discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
onShowContextMenu = {
|
||||
showContextMenu = it
|
||||
},
|
||||
actions = contextActions,
|
||||
)
|
||||
if (showWatchConfirmation) {
|
||||
ConfirmDialog(
|
||||
title = series.name ?: "",
|
||||
body =
|
||||
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
|
||||
onCancel = {
|
||||
showWatchConfirmation = false
|
||||
},
|
||||
onLongClickItem = { index, season ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = season,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(season, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
onConfirm = {
|
||||
viewModel.setWatchedSeries(!played)
|
||||
showWatchConfirmation = false
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog = ItemDetailsDialogInfo(item)
|
||||
},
|
||||
playOnClick = { shuffle ->
|
||||
if (shuffle) {
|
||||
viewModel.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = item.id,
|
||||
shuffle = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
},
|
||||
watchOnClick = { showWatchConfirmation = true },
|
||||
favoriteOnClick = {
|
||||
val favorite = item.data.userData?.isFavorite ?: false
|
||||
viewModel.setFavorite(item.id, !favorite, null)
|
||||
},
|
||||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
onClickExtra = { _, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discoverSeries = discoverSeries,
|
||||
onClickDiscoverSeries = {
|
||||
discoverSeries?.let {
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||
}
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
onShowContextMenu = {
|
||||
showContextMenu = it
|
||||
},
|
||||
actions = contextActions,
|
||||
)
|
||||
if (showWatchConfirmation) {
|
||||
ConfirmDialog(
|
||||
title = item.name ?: "",
|
||||
body =
|
||||
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
|
||||
onCancel = {
|
||||
showWatchConfirmation = false
|
||||
},
|
||||
onConfirm = {
|
||||
viewModel.setWatchedSeries(!played)
|
||||
showWatchConfirmation = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,18 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.map
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||
|
|
@ -32,10 +29,9 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -82,28 +78,21 @@ fun SeriesOverview(
|
|||
),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstItemFocusRequester = remember { FocusRequester() }
|
||||
val episodeRowFocusRequester = remember { FocusRequester() }
|
||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||
val guestStarRowFocusRequester = remember { FocusRequester() }
|
||||
val extrasRowFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val series by viewModel.item.observeAsState(null)
|
||||
val seasons by viewModel.seasons.observeAsState(emptyList())
|
||||
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
|
||||
val seasonExtras by viewModel.extras.observeAsState(emptyList())
|
||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
|
||||
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
||||
val state by viewModel.state.collectAsState()
|
||||
val episodeList =
|
||||
remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes }
|
||||
|
||||
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
||||
val currentPosition by rememberUpdatedState(position)
|
||||
LaunchedEffect(Unit) {
|
||||
if (seasons.isNotEmpty()) {
|
||||
seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||
if (state.seasons.isNotEmpty()) {
|
||||
state.seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||
viewModel.loadEpisodes(it.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +101,7 @@ fun SeriesOverview(
|
|||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
|
||||
var rowFocused by rememberInt()
|
||||
|
||||
|
|
@ -149,7 +138,7 @@ fun SeriesOverview(
|
|||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onClearChosenStreams = {
|
||||
val focusedEpisode =
|
||||
(episodes as? EpisodeList.Success)
|
||||
(state.episodes as? EpisodeList.Success)
|
||||
?.episodes
|
||||
?.getOrNull(currentPosition.episodeRowIndex)
|
||||
if (focusedEpisode != null) {
|
||||
|
|
@ -159,9 +148,9 @@ fun SeriesOverview(
|
|||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(position, episodes) {
|
||||
LaunchedEffect(position, state.episodes) {
|
||||
val focusedEpisode =
|
||||
(episodes as? EpisodeList.Success)
|
||||
(state.episodes as? EpisodeList.Success)
|
||||
?.episodes
|
||||
?.getOrNull(position.episodeRowIndex)
|
||||
|
||||
|
|
@ -170,93 +159,126 @@ fun SeriesOverview(
|
|||
viewModel.lookupPeopleInEpisode(it)
|
||||
}
|
||||
}
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
val chosenStreams = state.chosenStreams
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
.observeAsState()
|
||||
.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
|
||||
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
when (val st = state.series) {
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
series?.let { series ->
|
||||
is DataLoadingState.Success<BaseItem> -> {
|
||||
RequestOrRestoreFocus(
|
||||
when (rowFocused) {
|
||||
EPISODE_ROW -> episodeRowFocusRequester
|
||||
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
|
||||
GUEST_STAR_ROW -> guestStarRowFocusRequester
|
||||
EXTRAS_ROW -> extrasRowFocusRequester
|
||||
else -> episodeRowFocusRequester
|
||||
},
|
||||
"series_overview",
|
||||
)
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
RequestOrRestoreFocus(
|
||||
when (rowFocused) {
|
||||
EPISODE_ROW -> episodeRowFocusRequester
|
||||
CAST_AND_CREW_ROW -> castCrewRowFocusRequester
|
||||
GUEST_STAR_ROW -> guestStarRowFocusRequester
|
||||
EXTRAS_ROW -> extrasRowFocusRequester
|
||||
else -> episodeRowFocusRequester
|
||||
},
|
||||
"series_overview",
|
||||
)
|
||||
LifecycleResumeEffect(destination.itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
|
||||
SeriesOverviewContent(
|
||||
preferences = preferences,
|
||||
series = series,
|
||||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
chosenStreams = chosenStreams,
|
||||
peopleInEpisode = peopleInEpisode,
|
||||
seasonExtras = seasonExtras,
|
||||
position = position,
|
||||
firstItemFocusRequester = firstItemFocusRequester,
|
||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
||||
extrasRowFocusRequester = extrasRowFocusRequester,
|
||||
onChangeSeason = { index ->
|
||||
if (index != position.seasonTabIndex) {
|
||||
seasons.getOrNull(index)?.let { season ->
|
||||
viewModel.loadEpisodes(season.id)
|
||||
viewModel.position.update {
|
||||
SeriesOverviewPosition(index, 0)
|
||||
}
|
||||
SeriesOverviewContent(
|
||||
preferences = preferences,
|
||||
series = st.data,
|
||||
seasons = state.seasons,
|
||||
episodes = state.episodes,
|
||||
chosenStreams = chosenStreams,
|
||||
peopleInEpisode = state.peopleInEpisode.people,
|
||||
seasonExtras = state.extras,
|
||||
position = position,
|
||||
firstItemFocusRequester = firstItemFocusRequester,
|
||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
||||
extrasRowFocusRequester = extrasRowFocusRequester,
|
||||
onChangeSeason = { index ->
|
||||
if (index != position.seasonTabIndex) {
|
||||
state.seasons.getOrNull(index)?.let { season ->
|
||||
viewModel.loadEpisodes(season.id)
|
||||
viewModel.position.update {
|
||||
SeriesOverviewPosition(index, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFocusEpisode = { episodeIndex ->
|
||||
viewModel.position.update {
|
||||
it.copy(episodeRowIndex = episodeIndex)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
rowFocused = EPISODE_ROW
|
||||
val resumePosition =
|
||||
it.data.userData
|
||||
?.playbackPositionTicks
|
||||
?.ticks ?: Duration.ZERO
|
||||
}
|
||||
},
|
||||
onFocusEpisode = { episodeIndex ->
|
||||
viewModel.position.update {
|
||||
it.copy(episodeRowIndex = episodeIndex)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
rowFocused = EPISODE_ROW
|
||||
val resumePosition =
|
||||
it.data.userData
|
||||
?.playbackPositionTicks
|
||||
?.ticks ?: Duration.ZERO
|
||||
viewModel.navigateTo(
|
||||
Destination.Playback(
|
||||
it.id,
|
||||
resumePosition.inWholeMilliseconds,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClick = { ep ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
playOnClick = { resume ->
|
||||
rowFocused = EPISODE_ROW
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
viewModel.release()
|
||||
viewModel.navigateTo(
|
||||
Destination.Playback(
|
||||
it.id,
|
||||
resumePosition.inWholeMilliseconds,
|
||||
resume.inWholeMilliseconds,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClick = { ep ->
|
||||
}
|
||||
},
|
||||
watchOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
val played = it.data.userData?.played ?: false
|
||||
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
||||
}
|
||||
},
|
||||
favoriteOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
val favorite = it.data.userData?.isFavorite ?: false
|
||||
viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex)
|
||||
}
|
||||
},
|
||||
moreOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
fromLongClick = false,
|
||||
item = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
|
|
@ -266,71 +288,31 @@ fun SeriesOverview(
|
|||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
playOnClick = { resume ->
|
||||
rowFocused = EPISODE_ROW
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
viewModel.release()
|
||||
viewModel.navigateTo(
|
||||
Destination.Playback(
|
||||
it.id,
|
||||
resume.inWholeMilliseconds,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
watchOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
val played = it.data.userData?.played ?: false
|
||||
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
||||
}
|
||||
},
|
||||
favoriteOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
val favorite = it.data.userData?.isFavorite ?: false
|
||||
viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex)
|
||||
}
|
||||
},
|
||||
moreOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = false,
|
||||
item = ep,
|
||||
chosenStreams = chosenStreams,
|
||||
showGoTo = false,
|
||||
showStreamChoices = true,
|
||||
canDelete = viewModel.canDelete(ep, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
overviewDialog = ItemDetailsDialogInfo(it)
|
||||
}
|
||||
},
|
||||
personOnClick = {
|
||||
rowFocused =
|
||||
if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
BaseItemKind.PERSON,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClickExtra = { _, extra ->
|
||||
rowFocused = EXTRAS_ROW
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
|
||||
onConfirmDelete = viewModel::deleteItem,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
overviewDialog = ItemDetailsDialogInfo(it)
|
||||
}
|
||||
},
|
||||
personOnClick = {
|
||||
rowFocused =
|
||||
if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW
|
||||
viewModel.navigateTo(
|
||||
Destination.MediaItem(
|
||||
it.id,
|
||||
BaseItemKind.PERSON,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClickExtra = { _, extra ->
|
||||
rowFocused = EXTRAS_ROW
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
|
||||
onConfirmDelete = viewModel::deleteItem,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
|
|
@ -345,7 +327,7 @@ fun SeriesOverview(
|
|||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
userDto
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
|
|||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||
|
|
@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.services.TrailerService
|
|||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.gt
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -36,14 +35,13 @@ import com.github.damontecres.wholphin.ui.launchIO
|
|||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.lt
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.successValue
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
|
@ -57,6 +55,7 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -84,7 +83,7 @@ import java.util.UUID
|
|||
class SeriesViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
api: ApiClient,
|
||||
private val api: ApiClient,
|
||||
@param:ApplicationContext val context: Context,
|
||||
val serverRepository: ServerRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
|
|
@ -103,7 +102,7 @@ class SeriesViewModel
|
|||
@Assisted val seriesId: UUID,
|
||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||
@Assisted val seriesPageType: SeriesPageType,
|
||||
) : ItemViewModel(api) {
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
|
|
@ -113,40 +112,28 @@ class SeriesViewModel
|
|||
): SeriesViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
|
||||
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
||||
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
||||
val people = MutableLiveData<List<Person>>(listOf())
|
||||
val similar = MutableLiveData<List<BaseItem>>()
|
||||
val canDeleteSeries = MutableStateFlow(false)
|
||||
|
||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
|
||||
private val _state = MutableStateFlow(SeriesState())
|
||||
val state: StateFlow<SeriesState> = _state
|
||||
|
||||
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||
|
||||
init {
|
||||
viewModelScope.launch(
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error loading series $seriesId",
|
||||
) + Dispatchers.IO,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Start")
|
||||
addCloseable { themeSongPlayer.stop() }
|
||||
val item = fetchItem(seriesId)
|
||||
val series =
|
||||
api.userLibraryApi
|
||||
.getItem(seriesId)
|
||||
.content
|
||||
.let { BaseItem(it) }
|
||||
viewModelScope.launchDefault {
|
||||
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
|
||||
canDeleteSeries.update { canDelete }
|
||||
mediaManagementService.collectCanDelete(flowOf(series)) { canDelete ->
|
||||
_state.update { it.copy(canDeleteSeries = canDelete) }
|
||||
}
|
||||
}
|
||||
backdropService.submit(item)
|
||||
backdropService.submit(series)
|
||||
|
||||
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||
val seasonsDeferred = getSeasons(series, seasonEpisodeIds?.seasonNumber)
|
||||
|
||||
val episodeListDeferred =
|
||||
if (seriesPageType == SeriesPageType.OVERVIEW) {
|
||||
|
|
@ -191,57 +178,58 @@ class SeriesViewModel
|
|||
}
|
||||
viewModelScope.launchIO {
|
||||
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
|
||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||
_state.update { it.copy(extras = extras) }
|
||||
}
|
||||
}
|
||||
val remoteTrailers = trailerService.getRemoteTrailers(item)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@SeriesViewModel.trailers.value = remoteTrailers
|
||||
this@SeriesViewModel.position.update {
|
||||
it.copy(
|
||||
episodeRowIndex =
|
||||
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
|
||||
)
|
||||
}
|
||||
this@SeriesViewModel.seasons.value = seasons
|
||||
this@SeriesViewModel.episodes.value = episodes
|
||||
loading.value = LoadingState.Success
|
||||
val remoteTrailers = trailerService.getRemoteTrailers(series)
|
||||
this@SeriesViewModel.position.update {
|
||||
it.copy(
|
||||
episodeRowIndex =
|
||||
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
|
||||
)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
series = DataLoadingState.Success(series),
|
||||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
trailers = remoteTrailers,
|
||||
)
|
||||
}
|
||||
|
||||
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||
viewModelScope.launchIO {
|
||||
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
|
||||
withContext(Dispatchers.Main) {
|
||||
this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers
|
||||
}
|
||||
trailerService.getLocalTrailers(series).letNotEmpty { localTrailers ->
|
||||
_state.update { it.copy(trailers = localTrailers + remoteTrailers) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val people = peopleFavorites.getPeopleFor(item)
|
||||
this@SeriesViewModel.people.setValueOnMain(people)
|
||||
val people = peopleFavorites.getPeopleFor(series)
|
||||
_state.update { it.copy(people = people) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val extras = extrasService.getExtras(item.id)
|
||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||
val extras = extrasService.getExtras(series.id)
|
||||
_state.update { it.copy(extras = extras) }
|
||||
}
|
||||
if (!similar.isInitialized) {
|
||||
if (state.value.similar.isEmpty()) {
|
||||
viewModelScope.launchIO {
|
||||
val similar =
|
||||
api.libraryApi
|
||||
.getSimilarItems(
|
||||
GetSimilarItemsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = seriesId,
|
||||
fields = SlimItemFields,
|
||||
limit = 25,
|
||||
),
|
||||
).content.items
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
this@SeriesViewModel.similar.setValueOnMain(similar)
|
||||
.map { BaseItem(it, true) }
|
||||
_state.update { it.copy(similar = similar) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(item).orEmpty()
|
||||
discovered.update { results }
|
||||
val results = seerrService.similar(series).orEmpty()
|
||||
_state.update { it.copy(discovered = results) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
seerrService.active.collectLatest { active ->
|
||||
|
|
@ -249,7 +237,7 @@ class SeriesViewModel
|
|||
if (active) {
|
||||
try {
|
||||
seerrService
|
||||
.getTvSeries(item)
|
||||
.getTvSeries(series)
|
||||
?.let { seerrService.createDiscoverItem(it) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
|
|
@ -258,7 +246,7 @@ class SeriesViewModel
|
|||
} else {
|
||||
null
|
||||
}
|
||||
discoverSeries.update { tv }
|
||||
_state.update { it.copy(discoverSeries = tv) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -270,8 +258,8 @@ class SeriesViewModel
|
|||
deletedItem.item.id,
|
||||
seriesId,
|
||||
)
|
||||
val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await()
|
||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||
val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await()
|
||||
_state.update { it.copy(seasons = seasons) }
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error refreshing after deleted item")
|
||||
|
|
@ -280,7 +268,7 @@ class SeriesViewModel
|
|||
}
|
||||
|
||||
fun onResumePage() {
|
||||
item.value?.let { item ->
|
||||
state.value.series.successValue?.let { item ->
|
||||
viewModelScope.launchDefault { backdropService.submit(item) }
|
||||
viewModelScope.launchDefault {
|
||||
themeSongPlayer.playThemeFor(seriesId)
|
||||
|
|
@ -289,11 +277,9 @@ class SeriesViewModel
|
|||
}
|
||||
|
||||
fun refresh() {
|
||||
item.value?.let { item ->
|
||||
if (loading.value == LoadingState.Success) {
|
||||
viewModelScope.launchIO {
|
||||
(seasons.value as? ApiRequestPager<*>)?.refresh()
|
||||
}
|
||||
state.value.series.successValue?.let { item ->
|
||||
viewModelScope.launchIO {
|
||||
(state.value.seasons as? ApiRequestPager<*>)?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -389,11 +375,15 @@ class SeriesViewModel
|
|||
}
|
||||
|
||||
fun loadEpisodes(seasonId: UUID) {
|
||||
val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success)
|
||||
val currentEpisodes = (state.value.episodes as? EpisodeList.Success)
|
||||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
|
||||
this@SeriesViewModel.episodes.value = EpisodeList.Loading
|
||||
this@SeriesViewModel.extras.value = emptyList()
|
||||
_state.update {
|
||||
it.copy(
|
||||
peopleInEpisode = PeopleInItem(),
|
||||
episodes = EpisodeList.Loading,
|
||||
extras = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
||||
val episodes =
|
||||
|
|
@ -403,13 +393,11 @@ class SeriesViewModel
|
|||
Timber.e(e, "Error loading episodes for $seriesId for season $seasonId")
|
||||
EpisodeList.Error(e)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@SeriesViewModel.episodes.value = episodes
|
||||
}
|
||||
_state.update { it.copy(episodes = episodes) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val extras = extrasService.getExtras(seasonId)
|
||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
||||
_state.update { it.copy(extras = extras) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -424,6 +412,30 @@ class SeriesViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateSeries() {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val series =
|
||||
api.userLibraryApi
|
||||
.getItem(seriesId)
|
||||
.content
|
||||
.let(::BaseItem)
|
||||
_state.update { it.copy(series = DataLoadingState.Success(series)) }
|
||||
viewModelScope.launchIO {
|
||||
val people = peopleFavorites.getPeopleFor(series)
|
||||
_state.update { it.copy(people = people) }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val seasons = getSeasons(series, null).await()
|
||||
_state.update { it.copy(seasons = seasons) }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error updating series")
|
||||
showToast(context, "Error updating series")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
|
|
@ -433,11 +445,7 @@ class SeriesViewModel
|
|||
if (listIndex != null) {
|
||||
refreshEpisode(itemId, listIndex)
|
||||
} else {
|
||||
val item = fetchItem(seriesId)
|
||||
viewModelScope.launchIO {
|
||||
val people = peopleFavorites.getPeopleFor(item)
|
||||
this@SeriesViewModel.people.setValueOnMain(people)
|
||||
}
|
||||
updateSeries()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,32 +454,27 @@ class SeriesViewModel
|
|||
played: Boolean,
|
||||
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||
setWatched(seasonId, played, null)
|
||||
val series = fetchItem(seriesId)
|
||||
val seasons = getSeasons(series, null).await()
|
||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||
updateSeries()
|
||||
}
|
||||
|
||||
fun setWatchedSeries(played: Boolean) =
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(seriesId, played)
|
||||
val series = fetchItem(seriesId)
|
||||
val seasons = getSeasons(series, null).await()
|
||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||
updateSeries()
|
||||
}
|
||||
|
||||
fun refreshEpisode(
|
||||
itemId: UUID,
|
||||
listIndex: Int,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
val eps = episodes.value
|
||||
val eps = state.value.episodes
|
||||
if (eps is EpisodeList.Success) {
|
||||
eps.episodes.refreshItem(listIndex, itemId)
|
||||
withContext(Dispatchers.Main) {
|
||||
episodes.value = eps
|
||||
}
|
||||
_state.update { it.copy(episodes = eps) }
|
||||
}
|
||||
// Kind of hack to ensure the backdrop is reloaded if needed
|
||||
item.value?.let { backdropService.submit(it) }
|
||||
state.value.series.successValue
|
||||
?.let { backdropService.submit(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -506,7 +509,6 @@ class SeriesViewModel
|
|||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
private var chosenStreamsJob: Job? = null
|
||||
|
||||
fun lookUpChosenTracks(
|
||||
|
|
@ -522,9 +524,7 @@ class SeriesViewModel
|
|||
item,
|
||||
userPreferencesService.getCurrent(),
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = result
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = result) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -540,9 +540,7 @@ class SeriesViewModel
|
|||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -566,9 +564,7 @@ class SeriesViewModel
|
|||
result?.let {
|
||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chosenStreams.value = chosen
|
||||
}
|
||||
_state.update { it.copy(chosenStreams = chosen) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -581,8 +577,8 @@ class SeriesViewModel
|
|||
|
||||
suspend fun lookupPeopleInEpisode(item: BaseItem) {
|
||||
peopleInEpisodeJob?.cancel()
|
||||
if (peopleInEpisode.value?.itemId != item.id) {
|
||||
peopleInEpisode.setValueOnMain(PeopleInItem())
|
||||
if (state.value.peopleInEpisode.itemId != item.id) {
|
||||
_state.update { it.copy(peopleInEpisode = PeopleInItem()) }
|
||||
val result =
|
||||
peopleInEpisodeCache
|
||||
.get(item.id) {
|
||||
|
|
@ -600,7 +596,8 @@ class SeriesViewModel
|
|||
peopleInEpisodeJob =
|
||||
viewModelScope.launch(ExceptionHandler()) {
|
||||
delay(250)
|
||||
peopleInEpisode.setValueOnMain(result.await())
|
||||
val peopleInEpisode = result.await()
|
||||
_state.update { it.copy(peopleInEpisode = peopleInEpisode) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -621,17 +618,17 @@ class SeriesViewModel
|
|||
if (item.type == BaseItemKind.SERIES) {
|
||||
navigationManager.goBack()
|
||||
} else if (seriesPageType == SeriesPageType.DETAILS) {
|
||||
this@SeriesViewModel.item.value?.let { series ->
|
||||
state.value.series.successValue?.let { series ->
|
||||
val seasons = getSeasons(series, null).await()
|
||||
if (seasons.isEmpty()) {
|
||||
navigationManager.goBack()
|
||||
} else {
|
||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||
_state.update { it.copy(seasons = seasons) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
position.value.let { (_, episodeIndex) ->
|
||||
val eps = episodes.value as? EpisodeList.Success
|
||||
val eps = state.value.episodes as? EpisodeList.Success
|
||||
if (eps != null) {
|
||||
val pager = eps.episodes
|
||||
val lastIndex = pager.lastIndex
|
||||
|
|
@ -641,22 +638,28 @@ class SeriesViewModel
|
|||
} else {
|
||||
if (episodeIndex == lastIndex) {
|
||||
// Deleted last episode, so need to move left
|
||||
episodes.setValueOnMain(
|
||||
EpisodeList.Success(
|
||||
eps.seasonId,
|
||||
pager,
|
||||
episodeIndex - 1,
|
||||
),
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
episodes =
|
||||
EpisodeList.Success(
|
||||
eps.seasonId,
|
||||
pager,
|
||||
episodeIndex - 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
|
||||
} else {
|
||||
episodes.setValueOnMain(
|
||||
EpisodeList.Success(
|
||||
eps.seasonId,
|
||||
pager,
|
||||
episodeIndex,
|
||||
),
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
episodes =
|
||||
EpisodeList.Success(
|
||||
eps.seasonId,
|
||||
pager,
|
||||
episodeIndex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -744,3 +747,18 @@ private suspend fun findIndexOf(
|
|||
}
|
||||
return index
|
||||
}
|
||||
|
||||
data class SeriesState(
|
||||
val series: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||
val seasons: List<BaseItem?> = emptyList(),
|
||||
val episodes: EpisodeList = EpisodeList.Loading,
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
val extras: List<ExtrasItem> = emptyList(),
|
||||
val people: List<Person> = emptyList(),
|
||||
val similar: List<BaseItem> = emptyList(),
|
||||
val canDeleteSeries: Boolean = false,
|
||||
val peopleInEpisode: PeopleInItem = PeopleInItem(),
|
||||
val discovered: List<DiscoverItem> = emptyList(),
|
||||
val discoverSeries: DiscoverItem? = null,
|
||||
val chosenStreams: ChosenStreams? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
|
|
@ -77,7 +76,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -128,7 +126,7 @@ fun HomePage(
|
|||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||
var position by rememberPosition()
|
||||
|
||||
val onFocusPosition = remember { { it: RowColumn -> position = it } }
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class HomeViewModel
|
|||
val preferences = userPreferencesService.getCurrent()
|
||||
val prefs = preferences.appPreferences.homePagePreferences
|
||||
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
serverRepository.currentUserDto?.let { userDto ->
|
||||
val libraries =
|
||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val settings =
|
||||
|
|
@ -256,7 +256,7 @@ class HomeViewModel
|
|||
fun removeFromNextUp(item: BaseItem) {
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
viewModelScope.launchDefault {
|
||||
serverRepository.currentUser.value?.id?.let { userId ->
|
||||
serverRepository.currentUser?.id?.let { userId ->
|
||||
latestNextUpService.removeFromNextUp(userId, item)
|
||||
init()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -58,7 +57,6 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -102,14 +100,17 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.preferences.SwitchColors
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.SearchRelevance
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -135,15 +136,8 @@ class SearchViewModel
|
|||
val partialResult = voiceInputManager.partialResult
|
||||
val seerrActive = seerrService.active
|
||||
|
||||
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val combinedResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
private val _state = MutableStateFlow(SearchState())
|
||||
val state: StateFlow<SearchState> = _state
|
||||
|
||||
private var currentQuery: String? = null
|
||||
private var combinedMode = false
|
||||
|
|
@ -159,38 +153,76 @@ class SearchViewModel
|
|||
combinedMode = combined
|
||||
if (query.isNotNullOrBlank()) {
|
||||
if (combined) {
|
||||
combinedResults.value = SearchResult.Searching
|
||||
_state.update { it.copy(combinedResults = SearchResult.Searching) }
|
||||
searchCombined(query)
|
||||
} else {
|
||||
movies.value = SearchResult.Searching
|
||||
series.value = SearchResult.Searching
|
||||
episodes.value = SearchResult.Searching
|
||||
collections.value = SearchResult.Searching
|
||||
searchInternal(query, BaseItemKind.MOVIE, movies)
|
||||
searchInternal(query, BaseItemKind.SERIES, series)
|
||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
||||
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
|
||||
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
|
||||
searchInternal(query, BaseItemKind.AUDIO, songs)
|
||||
_state.update {
|
||||
it.copy(
|
||||
movies = SearchResult.Searching,
|
||||
series = SearchResult.Searching,
|
||||
episodes = SearchResult.Searching,
|
||||
collections = SearchResult.Searching,
|
||||
albums = SearchResult.Searching,
|
||||
artists = SearchResult.Searching,
|
||||
songs = SearchResult.Searching,
|
||||
)
|
||||
}
|
||||
searchInternal(
|
||||
query,
|
||||
BaseItemKind.MOVIE,
|
||||
) { result, state -> state.copy(movies = result) }
|
||||
searchInternal(
|
||||
query,
|
||||
BaseItemKind.SERIES,
|
||||
) { result, state -> state.copy(series = result) }
|
||||
searchInternal(query, BaseItemKind.EPISODE) { result, state ->
|
||||
state.copy(
|
||||
episodes = result,
|
||||
)
|
||||
}
|
||||
searchInternal(query, BaseItemKind.BOX_SET) { result, state ->
|
||||
state.copy(
|
||||
collections = result,
|
||||
)
|
||||
}
|
||||
searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state ->
|
||||
state.copy(
|
||||
albums = result,
|
||||
)
|
||||
}
|
||||
searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state ->
|
||||
state.copy(
|
||||
artists = result,
|
||||
)
|
||||
}
|
||||
searchInternal(
|
||||
query,
|
||||
BaseItemKind.AUDIO,
|
||||
) { result, state -> state.copy(songs = result) }
|
||||
}
|
||||
searchSeerr(query)
|
||||
} else {
|
||||
movies.value = SearchResult.NoQuery
|
||||
series.value = SearchResult.NoQuery
|
||||
episodes.value = SearchResult.NoQuery
|
||||
collections.value = SearchResult.NoQuery
|
||||
seerrResults.value = SearchResult.NoQuery
|
||||
combinedResults.value = SearchResult.NoQuery
|
||||
_state.update {
|
||||
it.copy(
|
||||
combinedResults = SearchResult.NoQuery,
|
||||
movies = SearchResult.NoQuery,
|
||||
series = SearchResult.NoQuery,
|
||||
episodes = SearchResult.NoQuery,
|
||||
collections = SearchResult.NoQuery,
|
||||
albums = SearchResult.NoQuery,
|
||||
artists = SearchResult.NoQuery,
|
||||
songs = SearchResult.NoQuery,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchInternal(
|
||||
query: String,
|
||||
type: BaseItemKind,
|
||||
target: MutableLiveData<SearchResult>,
|
||||
update: (SearchResult, SearchState) -> SearchState,
|
||||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
|
|
@ -210,14 +242,12 @@ class SearchViewModel
|
|||
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
||||
.thenBy { it.name ?: "" },
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
target.value = SearchResult.Success(sorted)
|
||||
}
|
||||
_state.update { update.invoke(SearchResult.Success(sorted), it) }
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception searching for $type")
|
||||
withContext(Dispatchers.Main) {
|
||||
target.value = SearchResult.Error(ex)
|
||||
}
|
||||
_state.update { update.invoke(SearchResult.Error(ex), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -249,15 +279,10 @@ class SearchViewModel
|
|||
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
||||
.thenBy { it.name ?: "" },
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
combinedResults.value = SearchResult.Success(sorted)
|
||||
}
|
||||
_state.update { it.copy(combinedResults = SearchResult.Success(sorted)) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception in combined search")
|
||||
withContext(Dispatchers.Main) {
|
||||
combinedResults.value = SearchResult.Error(ex)
|
||||
}
|
||||
_state.update { it.copy(combinedResults = SearchResult.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -285,13 +310,13 @@ class SearchViewModel
|
|||
private fun searchSeerr(query: String) {
|
||||
viewModelScope.launchIO {
|
||||
if (seerrService.active.first()) {
|
||||
seerrResults.setValueOnMain(SearchResult.Searching)
|
||||
_state.update { it.copy(seerrResults = SearchResult.Searching) }
|
||||
val results =
|
||||
seerrService
|
||||
.search(query)
|
||||
.map { seerrService.createDiscoverItem(it) }
|
||||
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
|
||||
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
|
||||
_state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -324,6 +349,18 @@ sealed interface SearchResult {
|
|||
) : SearchResult
|
||||
}
|
||||
|
||||
data class SearchState(
|
||||
val movies: SearchResult = SearchResult.NoQuery,
|
||||
val series: SearchResult = SearchResult.NoQuery,
|
||||
val episodes: SearchResult = SearchResult.NoQuery,
|
||||
val collections: SearchResult = SearchResult.NoQuery,
|
||||
val albums: SearchResult = SearchResult.NoQuery,
|
||||
val artists: SearchResult = SearchResult.NoQuery,
|
||||
val songs: SearchResult = SearchResult.NoQuery,
|
||||
val seerrResults: SearchResult = SearchResult.NoQuery,
|
||||
val combinedResults: SearchResult = SearchResult.NoQuery,
|
||||
)
|
||||
|
||||
private const val SEARCH_ROW = 0
|
||||
private const val TAB_ROW = SEARCH_ROW + 1
|
||||
private const val MOVIE_ROW = TAB_ROW + 1
|
||||
|
|
@ -348,15 +385,7 @@ fun SearchPage(
|
|||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
|
||||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
||||
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
||||
val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
|
||||
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
|
||||
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
|
||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
||||
val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery)
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// Start with current preferences, but collect updates when view options change
|
||||
val prefs =
|
||||
|
|
@ -441,12 +470,7 @@ fun SearchPage(
|
|||
|
||||
LaunchedEffect(
|
||||
searchClicked,
|
||||
movies,
|
||||
collections,
|
||||
series,
|
||||
episodes,
|
||||
seerrResults,
|
||||
combinedResults,
|
||||
state,
|
||||
combinedMode,
|
||||
selectedTab,
|
||||
seerrActive,
|
||||
|
|
@ -458,12 +482,20 @@ fun SearchPage(
|
|||
val results =
|
||||
if (isLibraryTab) {
|
||||
if (combinedMode) {
|
||||
listOf(combinedResults)
|
||||
listOf(state.combinedResults)
|
||||
} else {
|
||||
listOf(movies, series, episodes, collections)
|
||||
listOf(
|
||||
state.movies,
|
||||
state.series,
|
||||
state.episodes,
|
||||
state.collections,
|
||||
state.albums,
|
||||
state.artists,
|
||||
state.songs,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
listOf(seerrResults)
|
||||
listOf(state.seerrResults)
|
||||
}
|
||||
val firstSuccess =
|
||||
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
|
||||
|
|
@ -609,7 +641,7 @@ fun SearchPage(
|
|||
when {
|
||||
isLibraryTab && combinedMode -> {
|
||||
SearchCombinedResults(
|
||||
result = combinedResults,
|
||||
result = state.combinedResults,
|
||||
focusRequester = focusRequesters[COMBINED_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onPlayItem = onPlayItem,
|
||||
|
|
@ -622,7 +654,7 @@ fun SearchPage(
|
|||
|
||||
!isLibraryTab && combinedMode -> {
|
||||
SearchCombinedResults(
|
||||
result = seerrResults,
|
||||
result = state.seerrResults,
|
||||
focusRequester = focusRequesters[SEERR_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onPlayItem = onPlayItem,
|
||||
|
|
@ -647,7 +679,7 @@ fun SearchPage(
|
|||
) {
|
||||
searchResultRow(
|
||||
title = R.string.movies,
|
||||
result = movies,
|
||||
result = state.movies,
|
||||
rowIndex = MOVIE_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[MOVIE_ROW],
|
||||
|
|
@ -657,7 +689,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.tv_shows,
|
||||
result = series,
|
||||
result = state.series,
|
||||
rowIndex = SERIES_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[SERIES_ROW],
|
||||
|
|
@ -667,7 +699,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.episodes,
|
||||
result = episodes,
|
||||
result = state.episodes,
|
||||
rowIndex = EPISODE_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[EPISODE_ROW],
|
||||
|
|
@ -689,7 +721,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.collections,
|
||||
result = collections,
|
||||
result = state.collections,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||
|
|
@ -699,7 +731,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.albums,
|
||||
result = albums,
|
||||
result = state.albums,
|
||||
rowIndex = ALBUM_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[ALBUM_ROW],
|
||||
|
|
@ -723,7 +755,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.artists,
|
||||
result = artists,
|
||||
result = state.artists,
|
||||
rowIndex = ARTIST_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[ARTIST_ROW],
|
||||
|
|
@ -747,7 +779,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.songs,
|
||||
result = songs,
|
||||
result = state.songs,
|
||||
rowIndex = SONG_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[SONG_ROW],
|
||||
|
|
@ -771,7 +803,7 @@ fun SearchPage(
|
|||
)
|
||||
searchResultRow(
|
||||
title = R.string.discover,
|
||||
result = seerrResults,
|
||||
result = state.seerrResults,
|
||||
rowIndex = SEERR_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequesters[SEERR_ROW],
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ class HomeSettingsViewModel
|
|||
init {
|
||||
addCloseable { saveToLocal() }
|
||||
viewModelScope.launchIO {
|
||||
val userDto = serverRepository.currentUserDto.value ?: return@launchIO
|
||||
val userDto = serverRepository.currentUserDto ?: return@launchIO
|
||||
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||
val currentSettings =
|
||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||
|
|
@ -122,7 +122,7 @@ class HomeSettingsViewModel
|
|||
val limit = 8
|
||||
val semaphore = Semaphore(4)
|
||||
val rows =
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
serverRepository.currentUserDto?.let { userDto ->
|
||||
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
|
||||
state.value
|
||||
.let { state ->
|
||||
|
|
@ -508,7 +508,7 @@ class HomeSettingsViewModel
|
|||
|
||||
fun saveToRemote() {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
Timber.d("Saving home settings to remote")
|
||||
val rows = state.value.rows.map { it.config }
|
||||
val settings =
|
||||
|
|
@ -527,7 +527,7 @@ class HomeSettingsViewModel
|
|||
|
||||
fun loadFromRemote() {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
Timber.d("Loading home settings from remote")
|
||||
try {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
|
|
@ -561,7 +561,7 @@ class HomeSettingsViewModel
|
|||
|
||||
fun loadFromRemoteWeb() {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
Timber.d("Loading home settings from web")
|
||||
try {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
|
|
@ -588,7 +588,7 @@ class HomeSettingsViewModel
|
|||
fun saveToLocal() {
|
||||
// This uses injected ioScope so that it will still run when the page is closing
|
||||
ioScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val rows = state.value.rows.map { it.config }
|
||||
val settings =
|
||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
|
|
@ -655,7 +655,7 @@ class HomeSettingsViewModel
|
|||
|
||||
fun resetToDefault() =
|
||||
viewModelScope.launchIO {
|
||||
val userId = serverRepository.currentUser.value?.id ?: return@launchIO
|
||||
val userId = serverRepository.currentUser?.id ?: return@launchIO
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = homeSettingsService.createDefault(userId)
|
||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.ListItem
|
||||
|
|
@ -75,7 +74,7 @@ class RemovedNextUpContentViewModel
|
|||
|
||||
init {
|
||||
viewModelScope.launchDefault {
|
||||
serverRepository.currentUser.asFlow().collectLatest { user ->
|
||||
serverRepository.currentUserFlow.collectLatest { user ->
|
||||
_state.update { RemovedNextUpState() }
|
||||
if (user == null) {
|
||||
return@collectLatest
|
||||
|
|
@ -105,7 +104,7 @@ class RemovedNextUpContentViewModel
|
|||
}
|
||||
|
||||
fun remove(item: RemovedItem) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
viewModelScope.launchDefault {
|
||||
mutex.withLock {
|
||||
_state.update { it.copy(removedEnabled = false) }
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -56,7 +55,6 @@ import androidx.compose.ui.unit.IntOffset
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -86,13 +84,15 @@ import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
|||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
|
||||
import com.github.damontecres.wholphin.ui.spacedByWithFooter
|
||||
import com.github.damontecres.wholphin.ui.theme.LocalTheme
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
|
@ -111,10 +111,10 @@ class NavDrawerViewModel
|
|||
val backdropService: BackdropService,
|
||||
private val musicService: MusicService,
|
||||
) : ViewModel() {
|
||||
val state = navDrawerService.state
|
||||
val serviceState = navDrawerService.state
|
||||
|
||||
val selectedIndex = MutableLiveData(-1)
|
||||
val moreExpanded = MutableLiveData(false)
|
||||
private val _state = MutableStateFlow(NavDrawerState())
|
||||
val state: StateFlow<NavDrawerState> = _state
|
||||
|
||||
fun onClickDrawerItem(
|
||||
index: Int,
|
||||
|
|
@ -130,7 +130,7 @@ class NavDrawerViewModel
|
|||
}
|
||||
|
||||
NavDrawerItem.More -> {
|
||||
setShowMore(!moreExpanded.value!!)
|
||||
setShowMore(!state.value.moreExpanded)
|
||||
}
|
||||
|
||||
NavDrawerItem.Discover -> {
|
||||
|
|
@ -148,11 +148,11 @@ class NavDrawerViewModel
|
|||
}
|
||||
|
||||
fun setIndex(index: Int) {
|
||||
selectedIndex.value = index
|
||||
_state.update { it.copy(selectedIndex = index) }
|
||||
}
|
||||
|
||||
fun setShowMore(value: Boolean) {
|
||||
moreExpanded.value = value
|
||||
_state.update { it.copy(moreExpanded = value) }
|
||||
}
|
||||
|
||||
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
||||
|
|
@ -164,11 +164,11 @@ class NavDrawerViewModel
|
|||
viewModelScope.launchDefault {
|
||||
val asDestinations =
|
||||
(
|
||||
state.value.items +
|
||||
serviceState.value.items +
|
||||
listOf(
|
||||
NavDrawerItem.More,
|
||||
NavDrawerItem.Discover,
|
||||
) + state.value.moreItems
|
||||
) + serviceState.value.moreItems
|
||||
).map {
|
||||
if (it is ServerNavDrawerItem) {
|
||||
it.destination
|
||||
|
|
@ -202,7 +202,7 @@ class NavDrawerViewModel
|
|||
}
|
||||
Timber.v("Found $index => $key")
|
||||
if (index != null) {
|
||||
selectedIndex.setValueOnMain(index)
|
||||
_state.update { it.copy(selectedIndex = index) }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +218,11 @@ class NavDrawerViewModel
|
|||
}
|
||||
}
|
||||
|
||||
data class NavDrawerState(
|
||||
val selectedIndex: Int = -1,
|
||||
val moreExpanded: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* An item that can be shown in the nav drawer
|
||||
*
|
||||
|
|
@ -303,10 +308,11 @@ fun NavDrawer(
|
|||
drawerState.setValue(DrawerValue.Open)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
val serviceState by viewModel.serviceState.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
|
||||
val moreExpanded = state.moreExpanded
|
||||
// A negative index is a built-in page, >=0 is a library
|
||||
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
|
||||
val selectedIndex = state.selectedIndex
|
||||
|
||||
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
|
||||
viewModel.setShowMore(false)
|
||||
|
|
@ -361,14 +367,14 @@ fun NavDrawer(
|
|||
modifier = Modifier,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = state.nowPlayingEnabled,
|
||||
visible = serviceState.nowPlayingEnabled,
|
||||
enter = expandVertically(expandFrom = Alignment.Top),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top),
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
IconNavItem(
|
||||
text = stringResource(R.string.now_playing),
|
||||
subtext = state.nowPlayingTitle,
|
||||
subtext = serviceState.nowPlayingTitle,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
selected = selectedIndex == NOW_PLAYING_INDEX,
|
||||
drawerOpen = isOpen,
|
||||
|
|
@ -448,8 +454,8 @@ fun NavDrawer(
|
|||
),
|
||||
)
|
||||
}
|
||||
itemsIndexed(state.items) { index, it ->
|
||||
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
|
||||
itemsIndexed(serviceState.items) { index, it ->
|
||||
if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
NavItem(
|
||||
library = it,
|
||||
|
|
@ -469,9 +475,9 @@ fun NavDrawer(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (state.moreItems.isNotEmpty()) {
|
||||
if (serviceState.moreItems.isNotEmpty()) {
|
||||
item {
|
||||
val index = state.items.size
|
||||
val index = serviceState.items.size
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
NavItem(
|
||||
library = NavDrawerItem.More,
|
||||
|
|
@ -492,10 +498,10 @@ fun NavDrawer(
|
|||
}
|
||||
}
|
||||
if (moreExpanded) {
|
||||
itemsIndexed(state.moreItems) { index, it ->
|
||||
itemsIndexed(serviceState.moreItems) { index, it ->
|
||||
val adjustedIndex =
|
||||
remember(state) { (index + state.items.size + 1) }
|
||||
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
|
||||
remember(serviceState) { (index + serviceState.items.size + 1) }
|
||||
if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
NavItem(
|
||||
library = it,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ fun DownloadSubtitlesContent(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val s = state) {
|
||||
SubtitleSearchStatus.Inactive -> {}
|
||||
|
||||
SubtitleSearchStatus.Searching -> {
|
||||
Wrapper {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class PlayExternalViewModel
|
|||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||
}
|
||||
val playbackConfig =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
|
||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||
if (it.sourceId != null) {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
|
|
@ -51,7 +50,6 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
|
@ -69,8 +67,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
|||
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
|
|
@ -101,7 +97,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -125,9 +120,8 @@ fun PlaybackPage(
|
|||
viewModel.release()
|
||||
}
|
||||
}
|
||||
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
when (val st = loading) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
when (val st = state.loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
|
@ -141,7 +135,7 @@ fun PlaybackPage(
|
|||
LoadingState.Success -> {
|
||||
val playerState by viewModel.currentPlayer.collectAsState()
|
||||
PlaybackPageContent(
|
||||
playerState = playerState!!,
|
||||
playerInstance = playerState!!,
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
viewModel = viewModel,
|
||||
|
|
@ -154,41 +148,25 @@ fun PlaybackPage(
|
|||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlaybackPageContent(
|
||||
playerState: PlayerState,
|
||||
playerInstance: PlayerInstance,
|
||||
preferences: UserPreferences,
|
||||
destination: Destination,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlaybackViewModel,
|
||||
) {
|
||||
val player = playerState.player
|
||||
val playerBackend = playerState.backend
|
||||
val state by viewModel.state.collectAsState()
|
||||
val subtitleSearchState by viewModel.subtitleSearchState.collectAsState()
|
||||
val player = playerInstance.player
|
||||
val playerBackend = playerInstance.backend
|
||||
|
||||
val prefs = preferences.appPreferences.playbackPreferences
|
||||
val scope = rememberCoroutineScope()
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
||||
val userDto by viewModel.currentUserDto.observeAsState()
|
||||
val userDto by viewModel.currentUserDto.collectAsState()
|
||||
|
||||
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
||||
ItemPlayback(
|
||||
userId = -1,
|
||||
itemId = UUID.randomUUID(),
|
||||
),
|
||||
)
|
||||
val currentSegment by viewModel.currentSegment.collectAsState()
|
||||
val analyticsState by viewModel.analyticsState.collectAsState()
|
||||
|
||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||
|
||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||
|
||||
val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null)
|
||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||
|
||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||
LaunchedEffect(player) {
|
||||
if (playerBackend == PlayerBackend.MPV) {
|
||||
|
|
@ -214,16 +192,16 @@ fun PlaybackPageContent(
|
|||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||
|
||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||
LaunchedEffect(subtitleDelay) {
|
||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||
LaunchedEffect(state.currentPlayback?.subtitleDelay) {
|
||||
(player as? MpvPlayer)?.subtitleDelay =
|
||||
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||
}
|
||||
|
||||
val presentationState = rememberPresentationState(player, false)
|
||||
val playbackState by rememberPlaybackState(player)
|
||||
val playbackState by rememberPlayerState(player)
|
||||
var showBuffering by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(playbackState) {
|
||||
if (playbackState == PlaybackState.BUFFERING) {
|
||||
if (playbackState == PlayerState.BUFFERING) {
|
||||
// Delay before showing the loading indicator
|
||||
// So if buffering is quick, the UI won't flash
|
||||
delay(250)
|
||||
|
|
@ -259,7 +237,7 @@ fun PlaybackPageContent(
|
|||
val keyHandler =
|
||||
PlaybackKeyHandler(
|
||||
player = player,
|
||||
controlsEnabled = nextUp == null,
|
||||
controlsEnabled = state.nextUp == null,
|
||||
skipWithLeftRight = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
|
|
@ -318,7 +296,7 @@ fun PlaybackPageContent(
|
|||
|
||||
PlaybackAction.Previous -> {
|
||||
val pos = player.currentPosition
|
||||
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
||||
if (pos < player.maxSeekToPreviousPosition && state.playlist.hasPrevious()) {
|
||||
viewModel.playPrevious()
|
||||
} else {
|
||||
player.seekToPrevious()
|
||||
|
|
@ -328,17 +306,17 @@ fun PlaybackPageContent(
|
|||
}
|
||||
|
||||
val showSegment =
|
||||
currentSegment?.interacted == false &&
|
||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||
state.currentSegment?.interacted == false &&
|
||||
state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||
BackHandler(showSegment) {
|
||||
viewModel.updateSegment(currentSegment?.segment?.id, true)
|
||||
viewModel.updateSegment(state.currentSegment?.segment?.id, true)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
||||
.background(if (state.nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
|
||||
val playerSize by animateFloatAsState(if (state.nextUp == null) 1f else .6f)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -427,12 +405,12 @@ fun PlaybackPageContent(
|
|||
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent),
|
||||
item = currentPlayback?.item,
|
||||
item = state.currentPlayback?.item,
|
||||
player = player,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = playPauseState.showPlay,
|
||||
previousEnabled = true,
|
||||
nextEnabled = playlist.hasNext(),
|
||||
nextEnabled = state.playlist.hasNext(),
|
||||
seekEnabled = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
|
|
@ -441,23 +419,23 @@ fun PlaybackPageContent(
|
|||
onClickPlaybackDialogType = { playbackDialog = it },
|
||||
onSeekBarChange = seekBarState::onValueChange,
|
||||
showDebugInfo = showDebugInfo,
|
||||
currentPlayback = currentPlayback,
|
||||
chapters = mediaInfo?.chapters ?: listOf(),
|
||||
trickplayInfo = mediaInfo?.trickPlayInfo,
|
||||
currentPlayback = state.currentPlayback,
|
||||
chapters = state.currentMediaInfo.chapters,
|
||||
trickplayInfo = state.currentMediaInfo.trickPlayInfo,
|
||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||
playlist = playlist,
|
||||
playlist = state.playlist,
|
||||
onClickPlaylist = {
|
||||
viewModel.playItemInPlaylist(it)
|
||||
},
|
||||
currentSegment = currentSegment?.segment,
|
||||
currentSegment = state.currentSegment?.segment,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
analyticsState = analyticsState,
|
||||
analyticsState = state.analyticsState,
|
||||
)
|
||||
|
||||
val subtitleSettings =
|
||||
remember(mediaInfo) {
|
||||
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
|
||||
if (mediaInfo?.videoStream?.hdr == true) {
|
||||
remember(state.currentMediaInfo) {
|
||||
Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}")
|
||||
if (state.currentMediaInfo.videoStream?.hdr == true) {
|
||||
preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences
|
||||
} else {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
|
|
@ -468,10 +446,14 @@ fun PlaybackPageContent(
|
|||
|
||||
// Subtitles
|
||||
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
||||
val isImageSubtitles =
|
||||
remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null }
|
||||
var cueCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
|
||||
val subtitleVisible =
|
||||
skipIndicatorDuration == 0L &&
|
||||
state.currentItemPlayback?.subtitleIndexEnabled == true &&
|
||||
!presentationState.coverSurface
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
|
|
@ -481,7 +463,7 @@ fun PlaybackPageContent(
|
|||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
}
|
||||
playerState.assHandler?.let { assHandler ->
|
||||
playerInstance.assHandler?.let { assHandler ->
|
||||
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
|
||||
Timber.v("Adding AssSubtitleView")
|
||||
addView(
|
||||
|
|
@ -499,12 +481,12 @@ fun PlaybackPageContent(
|
|||
}
|
||||
},
|
||||
update = { subtitleView ->
|
||||
subtitleView.setCues(cues)
|
||||
if (cues.size > cueCount) {
|
||||
subtitleView.setCues(state.subtitleCues)
|
||||
if (state.subtitleCues.size > cueCount) {
|
||||
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
|
||||
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
||||
.apply(subtitleView)
|
||||
cueCount = cues.size
|
||||
cueCount = state.subtitleCues.size
|
||||
}
|
||||
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
|
||||
(it as? AssSubtitleView)?.apply {
|
||||
|
|
@ -552,7 +534,7 @@ fun PlaybackPageContent(
|
|||
.padding(40.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
) {
|
||||
currentSegment?.let { segment ->
|
||||
state.currentSegment?.let { segment ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
|
|
@ -570,7 +552,7 @@ fun PlaybackPageContent(
|
|||
}
|
||||
|
||||
// Next up episode
|
||||
BackHandler(nextUp != null) {
|
||||
BackHandler(state.nextUp != null) {
|
||||
if (player.isPlaying) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
viewModel.cancelUpNextEpisode()
|
||||
|
|
@ -580,12 +562,12 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
nextUp != null,
|
||||
state.nextUp != null,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
nextUp?.let {
|
||||
state.nextUp?.let {
|
||||
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
||||
var timeLeft by remember {
|
||||
mutableLongStateOf(
|
||||
|
|
@ -642,7 +624,7 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
subtitleSearch?.let { state ->
|
||||
if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) {
|
||||
val wasPlaying = remember { player.isPlaying }
|
||||
LaunchedEffect(Unit) {
|
||||
player.pause()
|
||||
|
|
@ -661,8 +643,8 @@ fun PlaybackPageContent(
|
|||
),
|
||||
) {
|
||||
DownloadSubtitlesContent(
|
||||
state = state,
|
||||
language = subtitleSearchLanguage,
|
||||
state = subtitleSearchState.status,
|
||||
language = subtitleSearchState.language,
|
||||
onSearch = { lang ->
|
||||
viewModel.searchForSubtitles(lang)
|
||||
},
|
||||
|
|
@ -684,18 +666,18 @@ fun PlaybackPageContent(
|
|||
settings =
|
||||
PlaybackSettings(
|
||||
showDebugInfo = showDebugInfo,
|
||||
audioIndex = currentItemPlayback?.audioIndex,
|
||||
audioStreams = mediaInfo?.audioStreams.orEmpty(),
|
||||
subtitleIndex = currentItemPlayback?.subtitleIndex,
|
||||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||
audioIndex = state.currentItemPlayback?.audioIndex,
|
||||
audioStreams = state.currentMediaInfo.audioStreams,
|
||||
subtitleIndex = state.currentItemPlayback?.subtitleIndex,
|
||||
subtitleStreams = state.currentMediaInfo.subtitleStreams,
|
||||
playbackSpeed = playbackSpeed,
|
||||
contentScale = contentScale,
|
||||
subtitleDelay = subtitleDelay,
|
||||
subtitleDelay = state.currentPlayback?.subtitleDelay ?: Duration.ZERO,
|
||||
hasSubtitleDownloadPermission =
|
||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||
// TODO Passing through audio prevents changing playback speed
|
||||
// See https://github.com/damontecres/Wholphin/issues/164
|
||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
|
||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog =
|
||||
|
|
|
|||
|
|
@ -1,42 +1,47 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.listenTo
|
||||
import androidx.media3.common.text.Cue
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Playlist
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
|
||||
/**
|
||||
* Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering.
|
||||
*
|
||||
* @see Player.State
|
||||
* @see PlaybackState
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPlaybackState(player: Player): State<PlaybackState> {
|
||||
val state = remember(player) { mutableStateOf(getPlaybackState(player.playbackState)) }
|
||||
LaunchedEffect(player) {
|
||||
player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) {
|
||||
state.value = getPlaybackState(player.playbackState)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
data class PlaybackState(
|
||||
val loading: LoadingState = LoadingState.Loading,
|
||||
val currentMediaInfo: CurrentMediaInfo = CurrentMediaInfo.EMPTY,
|
||||
val currentPlayback: CurrentPlayback? = null,
|
||||
val currentItemPlayback: ItemPlayback? = null,
|
||||
val currentSegment: MediaSegmentState? = null,
|
||||
val analyticsState: AnalyticsState = AnalyticsState(),
|
||||
val subtitleCues: List<Cue> = emptyList(),
|
||||
val nextUp: BaseItem? = null,
|
||||
val playlist: Playlist = Playlist(listOf()),
|
||||
)
|
||||
|
||||
private fun getPlaybackState(
|
||||
@Player.State value: Int,
|
||||
): PlaybackState = PlaybackState.entries.first { it.value == value }
|
||||
data class PlayerInstance(
|
||||
val player: Player,
|
||||
val backend: PlayerBackend,
|
||||
val assHandler: AssHandler?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents [Player.State] integers as an Enum
|
||||
*/
|
||||
enum class PlaybackState(
|
||||
@param:Player.State val value: Int,
|
||||
) {
|
||||
IDLE(Player.STATE_IDLE),
|
||||
BUFFERING(Player.STATE_BUFFERING),
|
||||
READY(Player.STATE_READY),
|
||||
ENDED(Player.STATE_ENDED),
|
||||
}
|
||||
data class MediaSegmentState(
|
||||
val segment: MediaSegmentDto,
|
||||
val interacted: Boolean,
|
||||
)
|
||||
|
||||
data class AnalyticsState(
|
||||
val bitrate: String = formatBitrate(0),
|
||||
val bitrateEstimate: String = formatBitrate(0),
|
||||
val droppedFrames: Int = 0,
|
||||
)
|
||||
|
||||
data class SubtitleSearchState(
|
||||
val status: SubtitleSearchStatus = SubtitleSearchStatus.Inactive,
|
||||
val language: String = Locale.current.language,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import android.media.MediaCodecList
|
|||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.C
|
||||
|
|
@ -17,7 +15,6 @@ import androidx.media3.common.MimeTypes
|
|||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DecoderCounters
|
||||
|
|
@ -62,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -79,20 +75,20 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
|
|
@ -105,7 +101,6 @@ import org.jellyfin.sdk.api.sockets.subscribe
|
|||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
|
|
@ -161,7 +156,7 @@ class PlaybackViewModel
|
|||
fun create(destination: Destination): PlaybackViewModel
|
||||
}
|
||||
|
||||
val currentPlayer = MutableStateFlow<PlayerState?>(null)
|
||||
val currentPlayer = MutableStateFlow<PlayerInstance?>(null)
|
||||
|
||||
internal lateinit var player: Player
|
||||
|
||||
|
|
@ -174,15 +169,8 @@ class PlaybackViewModel
|
|||
true,
|
||||
)
|
||||
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
|
||||
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
||||
val analyticsState = MutableStateFlow(AnalyticsState())
|
||||
|
||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||
private val _state = MutableStateFlow(PlaybackState())
|
||||
val state: StateFlow<PlaybackState> = _state
|
||||
|
||||
private lateinit var preferences: UserPreferences
|
||||
internal lateinit var itemId: UUID
|
||||
|
|
@ -191,14 +179,11 @@ class PlaybackViewModel
|
|||
private var activityListener: TrackActivityPlaybackListener? = null
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
||||
val nextUp = MutableLiveData<BaseItem?>()
|
||||
private val isPlaylist = destination is Destination.PlaybackList
|
||||
|
||||
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
||||
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
|
||||
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
||||
val subtitleSearchState = MutableStateFlow(SubtitleSearchState())
|
||||
|
||||
val currentUserDto = serverRepository.currentUserDto
|
||||
val currentUserDto = serverRepository.currentUserDtoFlow
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -261,7 +246,7 @@ class PlaybackViewModel
|
|||
)
|
||||
this.player = playerCreation.player
|
||||
currentPlayer.update {
|
||||
PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
|
||||
PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler)
|
||||
}
|
||||
configurePlayer()
|
||||
}
|
||||
|
|
@ -288,7 +273,7 @@ class PlaybackViewModel
|
|||
*/
|
||||
private suspend fun init() {
|
||||
musicService.stop()
|
||||
nextUp.setValueOnMain(null)
|
||||
_state.update { it.copy(nextUp = null) }
|
||||
this.preferences = userPreferencesService.getCurrent()
|
||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||
addCloseable { refreshRateService.resetRefreshRate() }
|
||||
|
|
@ -335,7 +320,7 @@ class PlaybackViewModel
|
|||
)
|
||||
when (val r = playlistResult) {
|
||||
is PlaylistCreationResult.Error -> {
|
||||
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
||||
_state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -346,8 +331,8 @@ class PlaybackViewModel
|
|||
return
|
||||
}
|
||||
if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = r.playlist
|
||||
_state.update {
|
||||
it.copy(playlist = r.playlist)
|
||||
}
|
||||
}
|
||||
r.playlist.items.first()
|
||||
|
|
@ -365,7 +350,7 @@ class PlaybackViewModel
|
|||
api.userLibraryApi
|
||||
.getIntros(
|
||||
itemId = playlistItem.id,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
userId = serverRepository.currentUser?.id,
|
||||
).content.items
|
||||
.map {
|
||||
PlaylistItem.Intro(BaseItem(it))
|
||||
|
|
@ -376,13 +361,9 @@ class PlaybackViewModel
|
|||
val firstItem =
|
||||
if (intros.isNotEmpty()) {
|
||||
Timber.v("Got %s intros", intros.size)
|
||||
val currentPlaylist =
|
||||
this@PlaybackViewModel
|
||||
.playlist.value
|
||||
?.items
|
||||
.orEmpty()
|
||||
val newPlaylist = Playlist(intros + currentPlaylist)
|
||||
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
||||
_state.update {
|
||||
it.copy(playlist = Playlist(intros + it.playlist.items))
|
||||
}
|
||||
intros.first()
|
||||
} else {
|
||||
playlistItem
|
||||
|
|
@ -401,17 +382,21 @@ class PlaybackViewModel
|
|||
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
||||
val result = playlistCreator.createFrom(queriedItem)
|
||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||
val currentPlaylist =
|
||||
this@PlaybackViewModel
|
||||
.playlist.value
|
||||
?.items
|
||||
.orEmpty()
|
||||
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
|
||||
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
||||
_state.update {
|
||||
it.copy(
|
||||
playlist = Playlist(it.playlist.items + result.playlist.items),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) {
|
||||
_state.update {
|
||||
it.copy(currentPlayback = block.invoke(it.currentPlayback))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play an item
|
||||
*
|
||||
|
|
@ -436,7 +421,7 @@ class PlaybackViewModel
|
|||
|
||||
// New item, so we can clear the media segment tracker & subtitle cues
|
||||
resetSegmentState()
|
||||
this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf())
|
||||
_state.update { it.copy(subtitleCues = emptyList()) }
|
||||
|
||||
viewModelScope.launchIO {
|
||||
// Starting playback, so want to invalidate the last played timestamp for this item
|
||||
|
|
@ -459,7 +444,7 @@ class PlaybackViewModel
|
|||
|
||||
// Use the provided playback parameters or else check if the database has some
|
||||
val itemPlayback =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||
if (it.sourceId != null) {
|
||||
|
|
@ -496,7 +481,7 @@ class PlaybackViewModel
|
|||
// Create the correct player for the media
|
||||
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
|
||||
val subtitleLanguagePreference =
|
||||
serverRepository.currentUserDto.value
|
||||
serverRepository.currentUserDto
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
val subtitleStreams =
|
||||
|
|
@ -572,19 +557,20 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
val chapters = Chapter.fromDto(base, api)
|
||||
_state.update {
|
||||
it.copy(currentItemPlayback = itemPlaybackToUse)
|
||||
}
|
||||
updateCurrentMedia {
|
||||
CurrentMediaInfo(
|
||||
sourceId = mediaSource.id,
|
||||
videoStream = videoStream,
|
||||
audioStreams = audioStreams,
|
||||
subtitleStreams = subtitleStreams,
|
||||
chapters = chapters,
|
||||
trickPlayInfo = trickPlayInfo,
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
||||
updateCurrentMedia {
|
||||
CurrentMediaInfo(
|
||||
sourceId = mediaSource.id,
|
||||
videoStream = videoStream,
|
||||
audioStreams = audioStreams,
|
||||
subtitleStreams = subtitleStreams,
|
||||
chapters = chapters,
|
||||
trickPlayInfo = trickPlayInfo,
|
||||
)
|
||||
}
|
||||
|
||||
changeStreams(
|
||||
item,
|
||||
itemPlaybackToUse,
|
||||
|
|
@ -608,7 +594,7 @@ class PlaybackViewModel
|
|||
@OptIn(UnstableApi::class)
|
||||
internal suspend fun changeStreams(
|
||||
item: BaseItem,
|
||||
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
|
||||
currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!,
|
||||
audioIndex: Int?,
|
||||
subtitleIndex: Int?,
|
||||
positionMs: Long = 0,
|
||||
|
|
@ -618,7 +604,7 @@ class PlaybackViewModel
|
|||
) = withContext(Dispatchers.IO) {
|
||||
val itemId = item.id
|
||||
|
||||
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
|
||||
val currentPlayback = state.value.currentPlayback
|
||||
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
|
||||
val wasSuccessful =
|
||||
changeStreamsDirectPlay(
|
||||
|
|
@ -649,7 +635,7 @@ class PlaybackViewModel
|
|||
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
|
||||
deviceProfileService.getOrCreateDeviceProfile(
|
||||
preferences.appPreferences.playbackPreferences,
|
||||
serverRepository.currentServer.value?.serverVersion,
|
||||
serverRepository.currentServer?.serverVersion,
|
||||
)
|
||||
} else {
|
||||
mpvDeviceProfile
|
||||
|
|
@ -669,7 +655,7 @@ class PlaybackViewModel
|
|||
),
|
||||
)
|
||||
if (response.errorCode != null) {
|
||||
loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName))
|
||||
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
|
||||
return@withContext
|
||||
}
|
||||
val source = response.mediaSources.firstOrNull()
|
||||
|
|
@ -694,9 +680,14 @@ class PlaybackViewModel
|
|||
source.transcodingUrl?.let(api::createUrl)
|
||||
}
|
||||
if (mediaUrl.isNullOrBlank()) {
|
||||
loading.setValueOnMain(
|
||||
LoadingState.Error("Unable to get media URL from the server. Do you have permission to view and/or transcode?"),
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading =
|
||||
LoadingState.Error(
|
||||
"Unable to get media URL from the server. Do you have permission to view and/or transcode?",
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
val transcodeType =
|
||||
|
|
@ -794,8 +785,12 @@ class PlaybackViewModel
|
|||
player.addListener(activityListener)
|
||||
this@PlaybackViewModel.activityListener = activityListener
|
||||
|
||||
loading.value = LoadingState.Success
|
||||
this@PlaybackViewModel.currentPlayback.update { playback }
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Success,
|
||||
currentPlayback = playback,
|
||||
)
|
||||
}
|
||||
player.setMediaItem(
|
||||
mediaItem,
|
||||
positionMs,
|
||||
|
|
@ -884,19 +879,18 @@ class PlaybackViewModel
|
|||
viewModelScope.launchIO {
|
||||
Timber.v("Saving user initiated item playback: %s", itemPlayback)
|
||||
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.currentItemPlayback.value = updated
|
||||
}
|
||||
_state.update { it.copy(currentItemPlayback = updated) }
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(currentItemPlayback = itemPlayback) }
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.currentPlayback.update {
|
||||
(it ?: currentPlayback).copy(
|
||||
tracks = checkForSupport(player.currentTracks),
|
||||
)
|
||||
}
|
||||
|
||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentPlayback =
|
||||
(it.currentPlayback ?: currentPlayback).copy(
|
||||
tracks = checkForSupport(player.currentTracks),
|
||||
),
|
||||
)
|
||||
}
|
||||
loadSubtitleDelay()
|
||||
return@withContext true
|
||||
|
|
@ -913,14 +907,14 @@ class PlaybackViewModel
|
|||
val itemPlayback =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = currentItem.item,
|
||||
itemPlayback = currentItemPlayback.value!!,
|
||||
itemPlayback = state.value.currentItemPlayback!!,
|
||||
trackIndex = index,
|
||||
type = MediaStreamType.AUDIO,
|
||||
)
|
||||
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
||||
_state.update { it.copy(currentItemPlayback = itemPlayback) }
|
||||
|
||||
// Resolve ONLY_FORCED to actual track based on new audio language
|
||||
val source = currentPlayback.value?.mediaSourceInfo
|
||||
val source = state.value.currentPlayback?.mediaSourceInfo
|
||||
val resolvedSubtitleIndex =
|
||||
if (source != null) {
|
||||
streamChoiceService.resolveSubtitleIndex(
|
||||
|
|
@ -951,14 +945,14 @@ class PlaybackViewModel
|
|||
val itemPlayback =
|
||||
itemPlaybackRepository.saveTrackSelection(
|
||||
item = currentItem.item,
|
||||
itemPlayback = currentItemPlayback.value!!,
|
||||
itemPlayback = state.value.currentItemPlayback!!,
|
||||
trackIndex = index,
|
||||
type = MediaStreamType.SUBTITLE,
|
||||
)
|
||||
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
||||
_state.update { it.copy(currentItemPlayback = itemPlayback) }
|
||||
|
||||
// Resolve ONLY_FORCED to actual track index for playback
|
||||
val source = currentPlayback.value?.mediaSourceInfo
|
||||
val source = state.value.currentPlayback?.mediaSourceInfo
|
||||
val resolvedIndex =
|
||||
if (source != null) {
|
||||
streamChoiceService.resolveSubtitleIndex(
|
||||
|
|
@ -1004,8 +998,8 @@ class PlaybackViewModel
|
|||
|
||||
fun getTrickplayUrl(
|
||||
index: Int,
|
||||
trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo,
|
||||
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
|
||||
trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo,
|
||||
mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId,
|
||||
): String? =
|
||||
trickPlayInfo?.let {
|
||||
val itemId = currentItem.id
|
||||
|
|
@ -1021,7 +1015,7 @@ class PlaybackViewModel
|
|||
if (playbackState == Player.STATE_ENDED) {
|
||||
Timber.v("Playback state is STATE_ENDED")
|
||||
viewModelScope.launchDefault {
|
||||
when (val nextItem = playlist.value?.peek()) {
|
||||
when (val nextItem = state.value.playlist.peek()) {
|
||||
is PlaylistItem.Intro -> {
|
||||
Timber.v("Next item is intro, so playing immediately")
|
||||
playNextUp()
|
||||
|
|
@ -1033,9 +1027,7 @@ class PlaybackViewModel
|
|||
playNextUp()
|
||||
} else {
|
||||
Timber.v("Setting next up to ${nextItem.id}")
|
||||
withContext(Dispatchers.Main) {
|
||||
nextUp.value = nextItem.item
|
||||
}
|
||||
_state.update { it.copy(nextUp = nextItem.item) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1060,7 +1052,7 @@ class PlaybackViewModel
|
|||
segmentJob?.cancel()
|
||||
autoSkippedSegments.clear()
|
||||
outroShownSegments.clear()
|
||||
currentSegment.value = null
|
||||
_state.update { it.copy(currentSegment = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1086,8 +1078,7 @@ class PlaybackViewModel
|
|||
currentSegment.itemId == this@PlaybackViewModel.itemId
|
||||
) {
|
||||
if (currentSegment.id !=
|
||||
this@PlaybackViewModel
|
||||
.currentSegment.value
|
||||
state.value.currentSegment
|
||||
?.segment
|
||||
?.id
|
||||
) {
|
||||
|
|
@ -1098,19 +1089,17 @@ class PlaybackViewModel
|
|||
currentSegment.type,
|
||||
)
|
||||
}
|
||||
val playlist = this@PlaybackViewModel.playlist.value
|
||||
val playlist = state.value.playlist
|
||||
|
||||
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
||||
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
|
||||
playlist != null && playlist.hasNext() &&
|
||||
playlist.hasNext() &&
|
||||
outroShownSegments.add(currentSegment.id)
|
||||
) {
|
||||
val nextItem = playlist.peek()
|
||||
if (nextItem is PlaylistItem.Media) {
|
||||
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
||||
withContext(Dispatchers.Main) {
|
||||
nextUp.value = nextItem.item
|
||||
}
|
||||
_state.update { it.copy(nextUp = nextItem.item) }
|
||||
}
|
||||
} else {
|
||||
val behavior =
|
||||
|
|
@ -1123,35 +1112,31 @@ class PlaybackViewModel
|
|||
MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
when (behavior) {
|
||||
SkipSegmentBehavior.AUTO_SKIP -> {
|
||||
if (autoSkippedSegments.add(currentSegment.id)) {
|
||||
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||
}
|
||||
this@PlaybackViewModel.currentSegment.update {
|
||||
val newSegment =
|
||||
when (behavior) {
|
||||
SkipSegmentBehavior.AUTO_SKIP -> {
|
||||
if (autoSkippedSegments.add(currentSegment.id)) {
|
||||
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||
}
|
||||
MediaSegmentState(currentSegment, true)
|
||||
}
|
||||
}
|
||||
|
||||
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
||||
this@PlaybackViewModel.currentSegment.update {
|
||||
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
||||
MediaSegmentState(
|
||||
currentSegment,
|
||||
autoSkippedSegments.contains(currentSegment.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
this@PlaybackViewModel.currentSegment.value = null
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(currentSegment = newSegment) }
|
||||
}
|
||||
}
|
||||
} else if (currentSegment == null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.currentSegment.value = null
|
||||
}
|
||||
_state.update { it.copy(currentSegment = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1163,17 +1148,13 @@ class PlaybackViewModel
|
|||
dismissed: Boolean,
|
||||
) {
|
||||
viewModelScope.launchDefault {
|
||||
val segment = currentSegment.value?.segment
|
||||
val segment = state.value.currentSegment?.segment
|
||||
if (segment != null && segment.id == segmentId) {
|
||||
autoSkippedSegments.add(segment.id)
|
||||
if (dismissed) {
|
||||
currentSegment.update {
|
||||
it?.copy(interacted = true)
|
||||
}
|
||||
_state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) }
|
||||
} else {
|
||||
currentSegment.update {
|
||||
null
|
||||
}
|
||||
_state.update { it.copy(currentSegment = null) }
|
||||
onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1182,7 +1163,7 @@ class PlaybackViewModel
|
|||
|
||||
private fun listenForTranscodeReason(): Job =
|
||||
viewModelScope.launchIO {
|
||||
currentPlayback.collectLatest {
|
||||
state.map { it.currentPlayback }.collectLatest {
|
||||
if (it != null) {
|
||||
try {
|
||||
var transcodeInfo = it.transcodeInfo
|
||||
|
|
@ -1197,13 +1178,13 @@ class PlaybackViewModel
|
|||
if (transcodeInfo == null) delay(3.seconds)
|
||||
}
|
||||
Timber.v("transcodeInfo=$transcodeInfo")
|
||||
currentPlayback.update { current ->
|
||||
updateCurrentPlayback { current ->
|
||||
current?.copy(transcodeInfo = transcodeInfo)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
if (ex !is CancellationException) {
|
||||
Timber.w(ex, "Exception trying to get session info")
|
||||
currentPlayback.update { current ->
|
||||
updateCurrentPlayback { current ->
|
||||
current?.copy(transcodeInfo = null)
|
||||
}
|
||||
}
|
||||
|
|
@ -1233,7 +1214,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
fun playNextUp() {
|
||||
playlist.value?.let {
|
||||
state.value.playlist.let {
|
||||
if (it.hasNext()) {
|
||||
viewModelScope.launchDefault {
|
||||
cancelUpNextEpisode()
|
||||
|
|
@ -1248,7 +1229,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
fun playPrevious() {
|
||||
playlist.value?.let {
|
||||
state.value.playlist.let {
|
||||
if (it.hasPrevious()) {
|
||||
viewModelScope.launchDefault {
|
||||
cancelUpNextEpisode()
|
||||
|
|
@ -1263,11 +1244,11 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
suspend fun cancelUpNextEpisode() {
|
||||
nextUp.setValueOnMain(null)
|
||||
_state.update { it.copy(nextUp = null) }
|
||||
}
|
||||
|
||||
fun playItemInPlaylist(item: BaseItem) {
|
||||
playlist.value?.let { playlist ->
|
||||
state.value.playlist.let { playlist ->
|
||||
viewModelScope.launchIO {
|
||||
val toPlay = playlist.advanceTo(item.id)
|
||||
if (toPlay != null) {
|
||||
|
|
@ -1283,7 +1264,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
currentPlayback.update {
|
||||
updateCurrentPlayback {
|
||||
it?.copy(
|
||||
tracks = checkForSupport(tracks),
|
||||
)
|
||||
|
|
@ -1291,30 +1272,34 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
subtitleCues.value = cueGroup.cues
|
||||
_state.update { it.copy(subtitleCues = cueGroup.cues) }
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Timber.e(error, "Playback error")
|
||||
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
||||
currentPlayback.value?.let {
|
||||
state.value.currentPlayback?.let {
|
||||
when (it.playMethod) {
|
||||
PlayMethod.TRANSCODE -> {
|
||||
loading.setValueOnMain(
|
||||
LoadingState.Error(
|
||||
"Error during playback",
|
||||
error,
|
||||
),
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading =
|
||||
LoadingState.Error(
|
||||
"Error during playback",
|
||||
error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
|
||||
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
|
||||
val currentItemPlayback = state.value.currentItemPlayback!!
|
||||
changeStreams(
|
||||
currentItem.item,
|
||||
currentItemPlayback.value!!,
|
||||
currentItemPlayback.value?.audioIndex,
|
||||
currentItemPlayback.value?.subtitleIndex,
|
||||
currentItemPlayback,
|
||||
currentItemPlayback.audioIndex,
|
||||
currentItemPlayback.subtitleIndex,
|
||||
player.currentPosition,
|
||||
false,
|
||||
enableDirectPlay = false,
|
||||
|
|
@ -1397,9 +1382,8 @@ class PlaybackViewModel
|
|||
*/
|
||||
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val newMediaInfo = block.invoke(currentMediaInfo.value!!)
|
||||
currentMediaInfo.setValueOnMain(newMediaInfo)
|
||||
_state.update {
|
||||
it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1422,7 +1406,7 @@ class PlaybackViewModel
|
|||
} else {
|
||||
decoderName
|
||||
}
|
||||
currentPlayback.update {
|
||||
updateCurrentPlayback {
|
||||
when (type) {
|
||||
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
|
||||
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
|
||||
|
|
@ -1447,7 +1431,7 @@ class PlaybackViewModel
|
|||
decoderCounters: DecoderCounters,
|
||||
) {
|
||||
Timber.d("onVideoDisabled")
|
||||
currentPlayback.update { it?.copy(videoDecoder = null) }
|
||||
updateCurrentPlayback { it?.copy(videoDecoder = null) }
|
||||
}
|
||||
|
||||
override fun onVideoInputFormatChanged(
|
||||
|
|
@ -1491,21 +1475,21 @@ class PlaybackViewModel
|
|||
decoderCounters: DecoderCounters,
|
||||
) {
|
||||
Timber.d("decoder: onAudioDisabled")
|
||||
currentPlayback.update { it?.copy(audioDecoder = null) }
|
||||
updateCurrentPlayback { it?.copy(audioDecoder = null) }
|
||||
}
|
||||
|
||||
private var subtitleDelaySaveJob: Job? = null
|
||||
|
||||
fun updateSubtitleDelay(delta: Duration) {
|
||||
subtitleDelaySaveJob?.cancel()
|
||||
currentPlayback.update {
|
||||
updateCurrentPlayback {
|
||||
it?.let {
|
||||
val newDelay = it.subtitleDelay + delta
|
||||
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
|
||||
subtitleDelaySaveJob =
|
||||
viewModelScope.launchIO {
|
||||
// Debounce & save
|
||||
currentItemPlayback.value?.let { item ->
|
||||
state.value.currentItemPlayback?.let { item ->
|
||||
delay(1500)
|
||||
itemPlaybackRepository.saveTrackModifications(
|
||||
item.itemId,
|
||||
|
|
@ -1520,7 +1504,7 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
suspend fun loadSubtitleDelay() {
|
||||
currentItemPlayback.value?.let {
|
||||
state.value.currentItemPlayback?.let {
|
||||
if (it.subtitleIndexEnabled) {
|
||||
val result =
|
||||
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
|
||||
|
|
@ -1531,7 +1515,7 @@ class PlaybackViewModel
|
|||
it.subtitleIndex,
|
||||
it.itemId,
|
||||
)
|
||||
currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
||||
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1554,10 +1538,13 @@ class PlaybackViewModel
|
|||
bitrateEstimate,
|
||||
)
|
||||
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
|
||||
analyticsState.update {
|
||||
_state.update {
|
||||
it.copy(
|
||||
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
||||
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
||||
analyticsState =
|
||||
it.analyticsState.copy(
|
||||
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
||||
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1568,24 +1555,13 @@ class PlaybackViewModel
|
|||
droppedFrames: Int,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
|
||||
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
analyticsState =
|
||||
it.analyticsState.copy(
|
||||
droppedFrames = it.analyticsState.droppedFrames + droppedFrames,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PlayerState(
|
||||
val player: Player,
|
||||
val backend: PlayerBackend,
|
||||
val assHandler: AssHandler?,
|
||||
)
|
||||
|
||||
data class MediaSegmentState(
|
||||
val segment: MediaSegmentDto,
|
||||
val interacted: Boolean,
|
||||
)
|
||||
|
||||
data class AnalyticsState(
|
||||
val bitrate: String = formatBitrate(0),
|
||||
val bitrateEstimate: String = formatBitrate(0),
|
||||
val droppedFrames: Int = 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -9,10 +9,10 @@ import com.github.damontecres.wholphin.data.model.PlaylistItem
|
|||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
|
|
@ -22,6 +22,8 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
|||
import timber.log.Timber
|
||||
|
||||
sealed interface SubtitleSearchStatus {
|
||||
data object Inactive : SubtitleSearchStatus
|
||||
|
||||
data object Searching : SubtitleSearchStatus
|
||||
|
||||
data object Downloading : SubtitleSearchStatus
|
||||
|
|
@ -40,11 +42,15 @@ sealed interface SubtitleSearchStatus {
|
|||
* Trigger a search for subtitles in the given language for the currently playing media
|
||||
*/
|
||||
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
|
||||
subtitleSearchStatus.value = SubtitleSearchStatus.Searching
|
||||
subtitleSearchLanguage.value = language
|
||||
subtitleSearchState.update {
|
||||
it.copy(
|
||||
status = SubtitleSearchStatus.Searching,
|
||||
language = language,
|
||||
)
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
currentItemPlayback.value?.itemId?.let {
|
||||
state.value.currentItemPlayback?.itemId?.let {
|
||||
Timber.v("Searching for remote subtitles for %s", it)
|
||||
val results =
|
||||
api.subtitleApi
|
||||
|
|
@ -56,11 +62,11 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu
|
|||
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
|
||||
.thenByDescending { it.downloadCount },
|
||||
)
|
||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results))
|
||||
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception while searching for subtitles")
|
||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
||||
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,12 +79,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
wasPlaying: Boolean,
|
||||
) {
|
||||
if (subtitleId == null) {
|
||||
subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null)
|
||||
subtitleSearchState.update {
|
||||
it.copy(
|
||||
status =
|
||||
SubtitleSearchStatus.Error(
|
||||
"Subtitle has no ID",
|
||||
null,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
subtitleSearchStatus.value = SubtitleSearchStatus.Downloading
|
||||
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) }
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
currentItemPlayback.value?.let {
|
||||
state.value.currentItemPlayback?.let {
|
||||
Timber.v(
|
||||
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
|
||||
it.itemId,
|
||||
|
|
@ -89,8 +103,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
itemId = it.sourceId ?: it.itemId,
|
||||
subtitleId = subtitleId,
|
||||
)
|
||||
val currentSource =
|
||||
this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo
|
||||
val currentSource = state.value.currentPlayback?.mediaSourceInfo
|
||||
val currentSubtitleStreams =
|
||||
currentSource
|
||||
?.mediaStreams
|
||||
|
|
@ -144,7 +157,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
stream.isExternal && stream.path !in externalPaths
|
||||
}
|
||||
if (newStream != null) {
|
||||
var audioIndex = currentItemPlayback.value?.audioIndex
|
||||
var audioIndex = it?.audioIndex
|
||||
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
|
||||
// User has picked a specific audio track
|
||||
// Since, now adding a new external subtitle track, need to adjust the audio index as well
|
||||
|
|
@ -161,7 +174,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
}
|
||||
this@downloadAndSwitchSubtitles.changeStreams(
|
||||
currentItem.item,
|
||||
currentItemPlayback.value!!,
|
||||
it,
|
||||
audioIndex,
|
||||
newStream.index,
|
||||
onMain { player.currentPosition },
|
||||
|
|
@ -169,7 +182,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
)
|
||||
}
|
||||
}
|
||||
subtitleSearchStatus.setValueOnMain(null)
|
||||
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) }
|
||||
withContext(Dispatchers.Main) {
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
|
|
@ -178,12 +191,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
||||
subtitleSearchState.update {
|
||||
it.copy(
|
||||
status =
|
||||
SubtitleSearchStatus.Error(
|
||||
null,
|
||||
ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PlaybackViewModel.cancelSubtitleSearch() {
|
||||
subtitleSearchStatus.value = null
|
||||
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class LocaleChoiceViewModel
|
|||
|
||||
init {
|
||||
viewModelScope.launchDefault {
|
||||
serverRepository.currentUser.value?.let {
|
||||
serverRepository.currentUser?.let {
|
||||
val availableLocales = extractAvailableLocales()
|
||||
Timber.v("availableLocales=%s", availableLocales)
|
||||
_state.update {
|
||||
|
|
@ -73,7 +73,7 @@ class LocaleChoiceViewModel
|
|||
}
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
serverRepository.currentUser.asFlow().collectLatest { user ->
|
||||
serverRepository.currentUserFlow.collectLatest { user ->
|
||||
val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
|
||||
_state.update { it.copy(userLocale = userLocale) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ class NavDrawerPreferencesViewModel
|
|||
init {
|
||||
viewModelScope.launchDefault {
|
||||
val state = navDrawerService.state.value
|
||||
val user = serverRepository.currentUser.value
|
||||
val user = serverRepository.currentUser
|
||||
val seerr = seerrServerRepository.active.firstOrNull()
|
||||
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
|
||||
return@launchDefault
|
||||
|
|
@ -337,8 +337,8 @@ class NavDrawerPreferencesViewModel
|
|||
|
||||
fun save() {
|
||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
serverRepository.currentUserDto?.let { userDto ->
|
||||
if (user.id == userDto.id) {
|
||||
val toSave =
|
||||
state.value.mapIndexed { index, item ->
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -100,7 +99,7 @@ fun PreferencesContent(
|
|||
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
||||
val state = rememberLazyListState()
|
||||
var preferences by remember { mutableStateOf(initialPreferences) }
|
||||
val currentUser by viewModel.currentUser.observeAsState()
|
||||
val currentUser by viewModel.currentUser.collectAsState()
|
||||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||
var showPinFlow by remember { mutableStateOf(false) }
|
||||
var showVersionDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -126,16 +125,20 @@ fun PreferencesContent(
|
|||
updateCache = false
|
||||
}
|
||||
|
||||
val release by updateVM.release.observeAsState(null)
|
||||
LaunchedEffect(Unit) {
|
||||
val updateState by updateVM.state.collectAsState()
|
||||
val release = updateState.release
|
||||
LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) {
|
||||
if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) {
|
||||
updateVM.init(preferences.updateUrl)
|
||||
updateVM.init()
|
||||
}
|
||||
}
|
||||
|
||||
val movementSounds = true
|
||||
val installedVersion = updateVM.currentVersion
|
||||
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
|
||||
val updateAvailable =
|
||||
remember(updateState.release) {
|
||||
updateState.release?.version?.isGreaterThan(installedVersion) ?: false
|
||||
}
|
||||
|
||||
val prefList =
|
||||
when (preferenceScreenOption) {
|
||||
|
|
@ -306,7 +309,7 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
updateVM.init(preferences.updateUrl)
|
||||
updateVM.init()
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
|
|
@ -635,7 +638,7 @@ fun PreferencesContent(
|
|||
}
|
||||
|
||||
SeerrDialogMode.Add -> {
|
||||
val currentUser by seerrVm.currentUser.observeAsState()
|
||||
val currentUser by seerrVm.currentUser.collectAsState(null)
|
||||
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
||||
val serverAddedMessage = stringResource(R.string.seerr_server_added)
|
||||
LaunchedEffect(status) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ import com.github.damontecres.wholphin.util.RememberTabManager
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
|
|
@ -58,7 +60,13 @@ class PreferencesViewModel
|
|||
private val updateChecker: UpdateChecker,
|
||||
) : ViewModel(),
|
||||
RememberTabManager by rememberTabManager {
|
||||
val currentUser get() = serverRepository.currentUser
|
||||
val currentUser
|
||||
get() =
|
||||
serverRepository.currentUserFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
null,
|
||||
)
|
||||
|
||||
val seerrConnection = seerrServerRepository.connection
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -39,7 +38,6 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -51,6 +49,8 @@ import com.github.damontecres.wholphin.services.DownloadCallback
|
|||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.Release
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -58,47 +58,56 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
|||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||
import com.github.damontecres.wholphin.ui.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UpdateViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
val updater: UpdateChecker,
|
||||
val navigationManager: NavigationManager,
|
||||
) : ViewModel(),
|
||||
DownloadCallback {
|
||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val release = MutableLiveData<Release?>(null)
|
||||
private val _state = MutableStateFlow(InstallUpdateState())
|
||||
val state: StateFlow<InstallUpdateState> = _state
|
||||
|
||||
val downloading = MutableLiveData<Boolean>(false)
|
||||
val contentLength = MutableLiveData<Long>(-1)
|
||||
val bytesDownloaded = MutableLiveData<Long>(-1)
|
||||
private val _bytesDownloaded = MutableStateFlow(0L)
|
||||
val bytesDownloaded: StateFlow<Long> = _bytesDownloaded
|
||||
|
||||
val currentVersion = updater.getInstalledVersion()
|
||||
|
||||
fun init(updateUrl: String) {
|
||||
loading.value = LoadingState.Loading
|
||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
|
||||
val release = updater.getLatestRelease(updateUrl)
|
||||
withContext(Dispatchers.Main) {
|
||||
contentLength.value = -1
|
||||
bytesDownloaded.value = -1
|
||||
this@UpdateViewModel.release.value = release
|
||||
loading.value = LoadingState.Success
|
||||
fun init() {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchIO {
|
||||
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||
try {
|
||||
val release = updater.getLatestRelease(updateUrl)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Success,
|
||||
release = release,
|
||||
contentLength = -1L,
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching release from %s", updateUrl)
|
||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,61 +116,59 @@ class UpdateViewModel
|
|||
|
||||
fun installRelease(release: Release) {
|
||||
downloadJob =
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Failed to install update",
|
||||
),
|
||||
) {
|
||||
downloading.setValueOnMain(true)
|
||||
updater.installRelease(release, this@UpdateViewModel)
|
||||
downloading.setValueOnMain(false)
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
_bytesDownloaded.value = 0L
|
||||
_state.update { it.copy(downloading = true) }
|
||||
updater.installRelease(release, this@UpdateViewModel)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error downloading")
|
||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
} finally {
|
||||
_state.update { it.copy(downloading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelDownload() {
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loading,
|
||||
"Error",
|
||||
),
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
downloadJob?.cancel()
|
||||
withContext(Dispatchers.Main) {
|
||||
downloading.value = false
|
||||
contentLength.value = -1
|
||||
bytesDownloaded.value = -1
|
||||
_state.update {
|
||||
it.copy(
|
||||
downloading = false,
|
||||
contentLength = -1L,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun contentLength(contentLength: Long) {
|
||||
this@UpdateViewModel.contentLength.value = contentLength
|
||||
_state.update { it.copy(contentLength = contentLength) }
|
||||
}
|
||||
|
||||
override fun bytesDownloaded(bytes: Long) {
|
||||
this@UpdateViewModel.bytesDownloaded.value = bytes
|
||||
_bytesDownloaded.value = bytes
|
||||
}
|
||||
}
|
||||
|
||||
data class InstallUpdateState(
|
||||
val loading: LoadingState = LoadingState.Pending,
|
||||
val downloading: Boolean = false,
|
||||
val release: Release? = null,
|
||||
val contentLength: Long = -1L,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InstallUpdatePage(
|
||||
preferences: UserPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: UpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||
val release by viewModel.release.observeAsState(null)
|
||||
OneTimeLaunchedEffect { viewModel.init() }
|
||||
|
||||
val isDownloading by viewModel.downloading.observeAsState(false)
|
||||
val contentLength by viewModel.contentLength.observeAsState(-1L)
|
||||
val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(preferences.appPreferences.updateUrl)
|
||||
}
|
||||
val state by viewModel.state.collectAsState()
|
||||
var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
|
|
@ -173,9 +180,9 @@ fun InstallUpdatePage(
|
|||
// TODO
|
||||
}
|
||||
}
|
||||
when (val state = loading) {
|
||||
when (val st = state.loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
||||
LoadingState.Loading,
|
||||
|
|
@ -185,26 +192,32 @@ fun InstallUpdatePage(
|
|||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
release?.let {
|
||||
val release = state.release
|
||||
if (release != null) {
|
||||
InstallUpdatePageContent(
|
||||
currentVersion = viewModel.currentVersion,
|
||||
release = it,
|
||||
release = release,
|
||||
onInstallRelease = {
|
||||
if (!permissions) {
|
||||
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
viewModel.installRelease(it)
|
||||
viewModel.installRelease(release)
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
viewModel.navigationManager.goBack()
|
||||
},
|
||||
modifier = modifier.dimAndBlur(isDownloading),
|
||||
modifier = modifier.dimAndBlur(state.downloading),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "No release found! Check the update URL.",
|
||||
)
|
||||
}
|
||||
if (isDownloading) {
|
||||
if (state.downloading) {
|
||||
val bytesDownloaded by viewModel.bytesDownloaded.collectAsState(0L)
|
||||
DownloadDialog(
|
||||
contentLength = contentLength,
|
||||
contentLength = state.contentLength,
|
||||
bytesDownloaded = bytesDownloaded,
|
||||
onDismissRequest = {
|
||||
viewModel.cancelDownload()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -70,7 +69,7 @@ fun SwitchUserContent(
|
|||
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
val currentUser by viewModel.serverRepository.currentUser.observeAsState()
|
||||
val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null)
|
||||
var showAddUser by remember { mutableStateOf(false) }
|
||||
var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) }
|
||||
var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") }
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class SwitchSeerrViewModel
|
|||
private val seerrService: SeerrService,
|
||||
private val serverRepository: ServerRepository,
|
||||
) : ViewModel() {
|
||||
val currentUser = serverRepository.currentUser
|
||||
val currentUser = serverRepository.currentUserFlow
|
||||
val currentSeerrServer = seerrServerRepository.currentServer
|
||||
|
||||
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -59,7 +58,6 @@ import coil3.request.ImageRequest
|
|||
import coil3.request.transitionFactory
|
||||
import coil3.size.Size
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -93,9 +91,9 @@ fun SlideshowPage(
|
|||
),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
when (val st = loading) {
|
||||
when (val st = state.loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(st, modifier)
|
||||
}
|
||||
|
|
@ -107,10 +105,10 @@ fun SlideshowPage(
|
|||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
||||
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
||||
val position by viewModel.position.observeAsState(0)
|
||||
val pager by viewModel.pager.observeAsState()
|
||||
val loadingState = state.imageLoading
|
||||
val imageFilter by viewModel.imageFilter.collectAsState()
|
||||
val position = state.position
|
||||
val pager = state.items
|
||||
// val imageState by viewModel.image.observeAsState()
|
||||
|
||||
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||
|
|
@ -150,7 +148,7 @@ fun SlideshowPage(
|
|||
label = "image_panY",
|
||||
)
|
||||
|
||||
val slideshowState by viewModel.slideshow.collectAsState()
|
||||
val slideshowState by viewModel.state.collectAsState()
|
||||
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ package com.github.damontecres.wholphin.ui.slideshow
|
|||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.Player
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
|
|
@ -25,9 +22,7 @@ import com.github.damontecres.wholphin.ui.PhotoItemFields
|
|||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
|
|
@ -87,28 +82,18 @@ class SlideshowViewModel
|
|||
/**
|
||||
* Whether slideshow mode is on or off
|
||||
*/
|
||||
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false))
|
||||
val slideshow: StateFlow<SlideshowState> = _slideshow
|
||||
private val _state = MutableStateFlow(SlideshowState())
|
||||
val state: StateFlow<SlideshowState> = _state
|
||||
|
||||
/**
|
||||
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
|
||||
*/
|
||||
val slideshowActive = slideshow.map { it.enabled && !it.paused }
|
||||
val slideshowActive = state.map { it.enabled && !it.paused }
|
||||
|
||||
var slideshowDelay by Delegates.notNull<Long>()
|
||||
|
||||
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
|
||||
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
|
||||
val position = MutableLiveData(0)
|
||||
|
||||
private val _image = MutableLiveData<ImageState>()
|
||||
val image: LiveData<ImageState> = _image
|
||||
|
||||
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
|
||||
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
||||
private val _imageFilter = MutableLiveData(VideoFilter())
|
||||
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
||||
private val _imageFilter = MutableStateFlow(VideoFilter())
|
||||
val imageFilter: StateFlow<VideoFilter> = _imageFilter
|
||||
|
||||
private var albumImageFilter = VideoFilter()
|
||||
|
||||
|
|
@ -152,7 +137,7 @@ class SlideshowViewModel
|
|||
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||
),
|
||||
)
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val filter =
|
||||
playbackEffectDao
|
||||
.getPlaybackEffect(
|
||||
|
|
@ -168,21 +153,25 @@ class SlideshowViewModel
|
|||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||
.init(slideshowSettings.index)
|
||||
this@SlideshowViewModel._pager.setValueOnMain(pager)
|
||||
loading.update { LoadingState.Success }
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Success,
|
||||
items = pager,
|
||||
)
|
||||
}
|
||||
updatePosition(slideshowSettings.index)?.join()
|
||||
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error")
|
||||
loading.update { LoadingState.Error(ex) }
|
||||
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nextImage(): Boolean {
|
||||
val size = pager.value?.size
|
||||
val newPosition = position.value!! + 1
|
||||
return if (size != null && newPosition < size) {
|
||||
val size = state.value.items.size
|
||||
val newPosition = state.value.position + 1
|
||||
return if (newPosition < size) {
|
||||
updatePosition(newPosition)
|
||||
true
|
||||
} else {
|
||||
|
|
@ -191,7 +180,7 @@ class SlideshowViewModel
|
|||
}
|
||||
|
||||
fun previousImage(): Boolean {
|
||||
val newPosition = position.value!! - 1
|
||||
val newPosition = state.value.position - 1
|
||||
return if (newPosition >= 0) {
|
||||
updatePosition(newPosition)
|
||||
true
|
||||
|
|
@ -201,14 +190,14 @@ class SlideshowViewModel
|
|||
}
|
||||
|
||||
fun updatePosition(position: Int): Job? =
|
||||
_pager.value?.let { pager ->
|
||||
(state.value.items as? ApiRequestPager<*>)?.let { pager ->
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val image =
|
||||
if (position in pager.indices) pager.getBlocking(position) else null
|
||||
Timber.v("Got image for $position: ${image != null}")
|
||||
if (image != null) {
|
||||
this@SlideshowViewModel.position.setValueOnMain(position)
|
||||
_state.update { it.copy(position = position) }
|
||||
|
||||
val url =
|
||||
if (image.data.mediaType == MediaType.VIDEO) {
|
||||
|
|
@ -252,7 +241,7 @@ class SlideshowViewModel
|
|||
updateImageFilter(albumImageFilter)
|
||||
if (saveFilters) {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val vf =
|
||||
playbackEffectDao
|
||||
.getPlaybackEffect(
|
||||
|
|
@ -264,32 +253,38 @@ class SlideshowViewModel
|
|||
Timber.d(
|
||||
"Loaded VideoFilter for image ${image.id}",
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
// Pause throttling so that the image loads with the filter applied immediately
|
||||
imageFilter.stopThrottling(true)
|
||||
updateImageFilter(vf.videoFilter)
|
||||
imageFilter.startThrottling()
|
||||
}
|
||||
updateImageFilter(vf.videoFilter)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
_image.value = imageState
|
||||
loadingState.value =
|
||||
ImageLoadingState.Success(imageState)
|
||||
_state.update {
|
||||
it.copy(
|
||||
image = imageState,
|
||||
imageLoading = ImageLoadingState.Success(imageState),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
_image.value = imageState
|
||||
loadingState.value = ImageLoadingState.Success(imageState)
|
||||
_state.update {
|
||||
it.copy(
|
||||
image = imageState,
|
||||
imageLoading = ImageLoadingState.Success(imageState),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadingState.setValueOnMain(ImageLoadingState.Error)
|
||||
_state.update {
|
||||
it.copy(
|
||||
imageLoading = ImageLoadingState.Error,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
loadingState.setValueOnMain(ImageLoadingState.Error)
|
||||
_state.update {
|
||||
it.copy(
|
||||
imageLoading = ImageLoadingState.Error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -298,10 +293,10 @@ class SlideshowViewModel
|
|||
|
||||
fun startSlideshow() {
|
||||
screensaverService.keepScreenOn(true)
|
||||
_slideshow.update {
|
||||
SlideshowState(enabled = true, paused = false)
|
||||
_state.update {
|
||||
it.copy(enabled = true, paused = false)
|
||||
}
|
||||
if (_image.value
|
||||
if (state.value.image
|
||||
?.image
|
||||
?.data
|
||||
?.mediaType != MediaType.VIDEO
|
||||
|
|
@ -313,14 +308,14 @@ class SlideshowViewModel
|
|||
fun stopSlideshow() {
|
||||
screensaverService.keepScreenOn(false)
|
||||
slideshowJob?.cancel()
|
||||
_slideshow.update {
|
||||
SlideshowState(enabled = false, paused = false)
|
||||
_state.update {
|
||||
it.copy(enabled = false, paused = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseSlideshow() {
|
||||
Timber.v("pauseSlideshow")
|
||||
_slideshow.update {
|
||||
_state.update {
|
||||
if (it.enabled) {
|
||||
slideshowJob?.cancel()
|
||||
it.copy(paused = true)
|
||||
|
|
@ -332,7 +327,7 @@ class SlideshowViewModel
|
|||
|
||||
fun unpauseSlideshow() {
|
||||
Timber.v("unpauseSlideshow")
|
||||
_slideshow.update {
|
||||
_state.update {
|
||||
if (it.enabled) {
|
||||
it.copy(paused = false)
|
||||
} else {
|
||||
|
|
@ -362,16 +357,16 @@ class SlideshowViewModel
|
|||
|
||||
fun updateImageFilter(newFilter: VideoFilter) {
|
||||
viewModelScope.launchIO {
|
||||
_imageFilter.setValueOnMain(newFilter)
|
||||
_imageFilter.update { newFilter }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImageFilter() {
|
||||
image.value?.let {
|
||||
state.value.image?.let {
|
||||
viewModelScope.launchIO {
|
||||
val vf = _imageFilter.value
|
||||
if (vf != null) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
playbackEffectDao
|
||||
.insert(
|
||||
PlaybackEffect(
|
||||
|
|
@ -400,7 +395,7 @@ class SlideshowViewModel
|
|||
val vf = _imageFilter.value
|
||||
if (vf != null) {
|
||||
albumImageFilter = vf
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
serverRepository.currentUser?.let { user ->
|
||||
playbackEffectDao
|
||||
.insert(
|
||||
PlaybackEffect(
|
||||
|
|
@ -457,6 +452,11 @@ data class ImageState(
|
|||
}
|
||||
|
||||
data class SlideshowState(
|
||||
val enabled: Boolean,
|
||||
val paused: Boolean,
|
||||
val items: List<BaseItem?> = emptyList(),
|
||||
val position: Int = 0,
|
||||
val image: ImageState? = null,
|
||||
val loading: LoadingState = LoadingState.Pending,
|
||||
val imageLoading: ImageLoadingState = ImageLoadingState.Loading,
|
||||
val enabled: Boolean = false,
|
||||
val paused: Boolean = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,3 +103,5 @@ sealed interface DataLoadingState<out T> {
|
|||
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||
}
|
||||
}
|
||||
|
||||
val <T> DataLoadingState<T>.successValue: T? get() = (this as? DataLoadingState.Success<T>)?.data
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -14,6 +13,7 @@ import io.mockk.mockk
|
|||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
|
|
@ -28,7 +28,7 @@ import org.junit.Test
|
|||
class LatestNextUpSchedulerServiceTest {
|
||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
||||
private val currentUser = MutableStateFlow<CurrentUser?>(null)
|
||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||
|
|
@ -38,7 +38,7 @@ class LatestNextUpSchedulerServiceTest {
|
|||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { mockActivity.lifecycle } returns lifecycleRegistry
|
||||
every { mockServerRepository.current } returns currentLiveData
|
||||
every { mockServerRepository.current } returns currentUser
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ class LatestNextUpSchedulerServiceTest {
|
|||
runTest {
|
||||
createService()
|
||||
|
||||
currentLiveData.value = null
|
||||
currentUser.value = null
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkInfo
|
||||
|
|
@ -15,6 +14,7 @@ import io.mockk.mockk
|
|||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
|
|
@ -98,8 +98,8 @@ class SuggestionServiceTest {
|
|||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
|
||||
runTest {
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
|
||||
|
|
@ -113,10 +113,10 @@ class SuggestionServiceTest {
|
|||
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
mockEnqueueOnDemandWork()
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
|
||||
|
|
@ -142,10 +142,10 @@ class SuggestionServiceTest {
|
|||
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
mockEnqueueOnDemandWork()
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
|
||||
|
|
@ -163,10 +163,10 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
mockEnqueueOnDemandWork()
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
|
||||
|
|
@ -189,11 +189,11 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||
val cachedId = UUID.randomUUID()
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany
|
||||
listOf(null, CachedSuggestions(listOf(cachedId)))
|
||||
mockEnqueueOnDemandWork()
|
||||
|
|
@ -220,9 +220,9 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList())
|
||||
|
||||
val service = createService()
|
||||
|
|
@ -244,10 +244,10 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
mockEnqueueOnDemandWork()
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns
|
||||
|
|
@ -268,10 +268,10 @@ class SuggestionServiceTest {
|
|||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
val otherLibraryId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE)
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
mockEnqueueOnDemandWork()
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
|
||||
|
||||
|
|
@ -304,9 +304,9 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
|
|
@ -339,9 +339,9 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
|
|
@ -19,6 +18,7 @@ import io.mockk.slot
|
|||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
|
|
@ -35,7 +35,7 @@ import java.util.UUID
|
|||
class SuggestionsSchedulerServiceTest {
|
||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
||||
private val currentUser = MutableStateFlow<CurrentUser?>(null)
|
||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||
|
|
@ -45,7 +45,7 @@ class SuggestionsSchedulerServiceTest {
|
|||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { mockActivity.lifecycle } returns lifecycleRegistry
|
||||
every { mockServerRepository.current } returns currentLiveData
|
||||
every { mockServerRepository.current } returns currentUser
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
}
|
||||
|
||||
|
|
@ -57,8 +57,8 @@ class SuggestionsSchedulerServiceTest {
|
|||
context = mockActivity,
|
||||
serverRepository = mockServerRepository,
|
||||
workManager = mockWorkManager,
|
||||
dispatcher = testDispatcher,
|
||||
).also {
|
||||
it.dispatcher = testDispatcher
|
||||
it.initialDelaySecondsProvider = { 60L }
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ class SuggestionsSchedulerServiceTest {
|
|||
runTest {
|
||||
mockWorkInfos(emptyList())
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
currentUser.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
|
|
@ -88,13 +88,13 @@ class SuggestionsSchedulerServiceTest {
|
|||
runTest {
|
||||
mockWorkInfos(emptyList())
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
currentUser.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
currentLiveData.value = null
|
||||
currentUser.value = null
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ class SuggestionsSchedulerServiceTest {
|
|||
} returns mockk()
|
||||
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
currentUser.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
|
|
@ -138,7 +138,7 @@ class SuggestionsSchedulerServiceTest {
|
|||
} returns mockk()
|
||||
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
currentUser.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
|
|
@ -159,7 +159,7 @@ class SuggestionsSchedulerServiceTest {
|
|||
mockWorkInfos(listOf(workInfo))
|
||||
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
currentUser.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
|
@ -15,6 +14,7 @@ import io.mockk.every
|
|||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -112,7 +112,7 @@ class SuggestionsWorkerTest {
|
|||
every { mockApi.accessToken } returns null
|
||||
val mockUser = mockk<CurrentUser>()
|
||||
var restored = false
|
||||
every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) }
|
||||
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
|
||||
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
||||
restored = true
|
||||
mockUser
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
|
|
@ -773,20 +772,19 @@ private fun serverRepo(
|
|||
): ServerRepository {
|
||||
val mocked = mockk<ServerRepository>()
|
||||
every { mocked.currentUserDto } returns
|
||||
MutableLiveData(
|
||||
UserDto(
|
||||
id = UUID.randomUUID(),
|
||||
hasPassword = true,
|
||||
hasConfiguredPassword = true,
|
||||
hasConfiguredEasyPassword = true,
|
||||
configuration =
|
||||
DefaultUserConfiguration.copy(
|
||||
audioLanguagePreference = audioLang,
|
||||
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
|
||||
subtitleLanguagePreference = subtitleLang,
|
||||
),
|
||||
),
|
||||
UserDto(
|
||||
id = UUID.randomUUID(),
|
||||
hasPassword = true,
|
||||
hasConfiguredPassword = true,
|
||||
hasConfiguredEasyPassword = true,
|
||||
configuration =
|
||||
DefaultUserConfiguration.copy(
|
||||
audioLanguagePreference = audioLang,
|
||||
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
|
||||
subtitleLanguagePreference = subtitleLang,
|
||||
),
|
||||
)
|
||||
|
||||
return mocked
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ object TestModule {
|
|||
.addInterceptor {
|
||||
val request = it.request()
|
||||
val newRequest =
|
||||
serverRepository.currentUser.value?.accessToken?.let { token ->
|
||||
serverRepository.currentUser?.accessToken?.let { token ->
|
||||
request
|
||||
.newBuilder()
|
||||
.addHeader(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue