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
|
|
@ -273,11 +273,9 @@ dependencies {
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.runtime)
|
implementation(libs.androidx.compose.runtime)
|
||||||
implementation(libs.androidx.compose.runtime.livedata)
|
|
||||||
implementation(libs.androidx.tv.foundation)
|
implementation(libs.androidx.tv.foundation)
|
||||||
implementation(libs.androidx.tv.material)
|
implementation(libs.androidx.tv.material)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -168,7 +169,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
navigationManager.backStack = NavBackStack(startDestination)
|
navigationManager.backStack = NavBackStack(startDestination)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
|
||||||
if (user?.hasPin == true) {
|
if (user?.hasPin == true) {
|
||||||
window?.setFlags(
|
window?.setFlags(
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
|
@ -435,7 +436,7 @@ class MainActivityViewModel
|
||||||
val prefs =
|
val prefs =
|
||||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
val profileProtected =
|
val profileProtected =
|
||||||
serverRepository.currentUser.value?.let {
|
serverRepository.current.value?.user?.let {
|
||||||
it.hasPin || it.requireLogin
|
it.hasPin || it.requireLogin
|
||||||
} == true
|
} == true
|
||||||
if (prefs.signInAutomatically && !profileProtected) {
|
if (prefs.signInAutomatically && !profileProtected) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -88,7 +87,7 @@ class WholphinDreamService :
|
||||||
setViewTreeLifecycleOwner(this@WholphinDreamService)
|
setViewTreeLifecycleOwner(this@WholphinDreamService)
|
||||||
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
|
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
|
||||||
setContent {
|
setContent {
|
||||||
val user by serverRepository.currentUser.observeAsState()
|
val user by serverRepository.currentUserFlow.collectAsState(null)
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
var prefs by remember { mutableStateOf<AppPreferences?>(null) }
|
var prefs by remember { mutableStateOf<AppPreferences?>(null) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class ItemPlaybackRepository
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): ChosenStreams? =
|
): ChosenStreams? =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
|
||||||
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
|
||||||
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
|
||||||
|
|
@ -91,7 +91,7 @@ class ItemPlaybackRepository
|
||||||
sourceId: UUID,
|
sourceId: UUID,
|
||||||
): ItemPlayback? =
|
): ItemPlayback? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
ItemPlayback(
|
ItemPlayback(
|
||||||
userId = user.rowId,
|
userId = user.rowId,
|
||||||
|
|
@ -168,7 +168,7 @@ class ItemPlaybackRepository
|
||||||
val toSave =
|
val toSave =
|
||||||
if (itemPlayback.userId < 0) {
|
if (itemPlayback.userId < 0) {
|
||||||
val userRowId =
|
val userRowId =
|
||||||
serverRepository.currentUser.value
|
serverRepository.currentUser
|
||||||
?.rowId
|
?.rowId
|
||||||
?.takeIf { it >= 0 }
|
?.takeIf { it >= 0 }
|
||||||
?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user")
|
?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user")
|
||||||
|
|
@ -184,7 +184,7 @@ class ItemPlaybackRepository
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
trackIndex: Int,
|
trackIndex: Int,
|
||||||
): ItemTrackModification? =
|
): ItemTrackModification? =
|
||||||
serverRepository.currentUser.value?.rowId?.let { userId ->
|
serverRepository.currentUser?.rowId?.let { userId ->
|
||||||
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +193,7 @@ class ItemPlaybackRepository
|
||||||
trackIndex: Int,
|
trackIndex: Int,
|
||||||
delay: Duration,
|
delay: Duration,
|
||||||
) {
|
) {
|
||||||
serverRepository.currentUser.value?.rowId?.let { userId ->
|
serverRepository.currentUser?.rowId?.let { userId ->
|
||||||
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
|
Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay)
|
||||||
itemPlaybackDao.saveItem(
|
itemPlaybackDao.saveItem(
|
||||||
ItemTrackModification(
|
ItemTrackModification(
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,18 @@ import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.map
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
|
@ -46,14 +46,17 @@ class ServerRepository
|
||||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
private var _current = MutableStateFlow<CurrentUser?>(null)
|
||||||
val current: LiveData<CurrentUser?> = _current
|
val current: StateFlow<CurrentUser?> = _current
|
||||||
|
|
||||||
private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null)
|
private var _currentUserDto = MutableStateFlow<UserDto?>(null)
|
||||||
val currentUserDto: LiveData<UserDto?> = _currentUserDto
|
val currentUserDto: UserDto? get() = _currentUserDto.value
|
||||||
|
val currentUserDtoFlow: StateFlow<UserDto?> get() = _currentUserDto
|
||||||
|
|
||||||
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
|
val currentServer: JellyfinServer? get() = _current.value?.server
|
||||||
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
|
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
|
||||||
|
val currentUser: JellyfinUser? get() = _current.value?.user
|
||||||
|
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||||
|
|
@ -65,7 +68,7 @@ class ServerRepository
|
||||||
serverDao.addOrUpdateServer(server)
|
serverDao.addOrUpdateServer(server)
|
||||||
}
|
}
|
||||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||||
_current.setValueOnMain(null)
|
_current.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -127,7 +130,7 @@ class ServerRepository
|
||||||
userId: UUID?,
|
userId: UUID?,
|
||||||
): CurrentUser? {
|
): CurrentUser? {
|
||||||
if (serverId == null || userId == null) {
|
if (serverId == null || userId == null) {
|
||||||
_current.setValueOnMain(null)
|
_current.value = null
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val serverAndUsers =
|
val serverAndUsers =
|
||||||
|
|
@ -218,7 +221,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeUser(user: JellyfinUser) {
|
suspend fun removeUser(user: JellyfinUser) {
|
||||||
if (currentUser.value?.id == user.id) {
|
if (current.value?.user?.id == user.id) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +240,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeServer(server: JellyfinServer) {
|
suspend fun removeServer(server: JellyfinServer) {
|
||||||
if (currentServer.value?.id == server.id) {
|
if (current.value?.server?.id == server.id) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -274,18 +277,19 @@ class ServerRepository
|
||||||
) {
|
) {
|
||||||
val newUser = user.copy(pin = pin, requireLogin = requireLogin)
|
val newUser = user.copy(pin = pin, requireLogin = requireLogin)
|
||||||
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
||||||
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
|
val cur = current.value
|
||||||
|
if (cur?.user?.id == updatedUser.id && cur.server?.id == user.serverId) {
|
||||||
// Updating current user, so push out the change
|
// Updating current user, so push out the change
|
||||||
current.value?.let {
|
current.value?.let {
|
||||||
val newCurrent = it.copy(user = updatedUser)
|
val newCurrent = it.copy(user = updatedUser)
|
||||||
_current.setValueOnMain(newCurrent)
|
_current.value = newCurrent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun authorizeQuickConnect(code: String): Boolean =
|
suspend fun authorizeQuickConnect(code: String): Boolean =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
val userId = currentUser.value?.id
|
val userId = current.value?.user?.id
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
Timber.e("No user logged in for Quick Connect authorization")
|
Timber.e("No user logged in for Quick Connect authorization")
|
||||||
throw IllegalStateException("Must be logged in to authorize Quick Connect")
|
throw IllegalStateException("Must be logged in to authorize Quick Connect")
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
|
@ -152,7 +154,7 @@ class DatePlayedInvalidationService
|
||||||
private val activity = (context as AppCompatActivity)
|
private val activity = (context as AppCompatActivity)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.observe(activity) {
|
serverRepository.current.collectLatestIn(activity.lifecycleScope) {
|
||||||
datePlayedService.invalidateAll()
|
datePlayedService.invalidateAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -265,8 +265,8 @@ class HomeSettingsService
|
||||||
*/
|
*/
|
||||||
suspend fun createDefault(userId: UUID): HomePageResolvedSettings {
|
suspend fun createDefault(userId: UUID): HomePageResolvedSettings {
|
||||||
Timber.v("Creating default settings")
|
Timber.v("Creating default settings")
|
||||||
val user = serverRepository.currentUser.value?.takeIf { it.id == userId }
|
val user = serverRepository.currentUser?.takeIf { it.id == userId }
|
||||||
val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId }
|
val userDto = serverRepository.currentUserDto?.takeIf { it.id == userId }
|
||||||
val libraries =
|
val libraries =
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false)
|
navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false)
|
||||||
|
|
@ -679,7 +679,7 @@ class HomeSettingsService
|
||||||
val genreImages =
|
val genreImages =
|
||||||
getGenreImageMap(
|
getGenreImageMap(
|
||||||
api = api,
|
api = api,
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
imageUrlService = imageUrlService,
|
imageUrlService = imageUrlService,
|
||||||
genres = genreIds,
|
genres = genreIds,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -94,7 +95,7 @@ class LatestNextUpSchedulerService
|
||||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.observe(activity) { user ->
|
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
|
||||||
Timber.v("New user %s", user?.user?.id)
|
Timber.v("New user %s", user?.user?.id)
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME)
|
workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class MediaManagementService
|
||||||
return enabled &&
|
return enabled &&
|
||||||
item.canDelete &&
|
item.canDelete &&
|
||||||
if (item.type == BaseItemKind.RECORDING) {
|
if (item.type == BaseItemKind.RECORDING) {
|
||||||
serverRepository.currentUserDto.value
|
serverRepository.currentUserDto
|
||||||
?.policy
|
?.policy
|
||||||
?.enableLiveTvManagement == true
|
?.enableLiveTvManagement == true
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class MediaReportService
|
||||||
.content.mediaSources
|
.content.mediaSources
|
||||||
val sourcesJson = json.encodeToString(sources)
|
val sourcesJson = json.encodeToString(sources)
|
||||||
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
|
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
|
||||||
val serverVersion = serverRepository.currentServer.value?.serverVersion
|
val serverVersion = serverRepository.currentServer?.serverVersion
|
||||||
val deviceProfile =
|
val deviceProfile =
|
||||||
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
|
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
|
||||||
val deviceProfileJson = json.encodeToString(deviceProfile)
|
val deviceProfileJson = json.encodeToString(deviceProfile)
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ class MusicService
|
||||||
val items =
|
val items =
|
||||||
api.instantMixApi
|
api.instantMixApi
|
||||||
.getInstantMixFromItem(
|
.getInstantMixFromItem(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
limit = 200,
|
limit = 200,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
|
@ -58,9 +57,8 @@ class NavDrawerService
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Handle updating the nav drawer when the user changes
|
// Handle updating the nav drawer when the user changes
|
||||||
serverRepository.currentUser
|
serverRepository.currentUserFlow
|
||||||
.asFlow()
|
.combine(serverRepository.currentUserDtoFlow) { user, userDto ->
|
||||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
|
||||||
Pair(user, userDto)
|
Pair(user, userDto)
|
||||||
}.onEach { (user, userDto) ->
|
}.onEach { (user, userDto) ->
|
||||||
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
|
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class PlaylistCreator
|
||||||
val request =
|
val request =
|
||||||
filter.applyTo(
|
filter.applyTo(
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = playlistId,
|
parentId = playlistId,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
startIndex = startIndex,
|
startIndex = startIndex,
|
||||||
|
|
@ -324,7 +324,7 @@ class PlaylistCreator
|
||||||
name: String,
|
name: String,
|
||||||
initialItems: List<UUID>,
|
initialItems: List<UUID>,
|
||||||
): UUID? =
|
): UUID? =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
api.playlistsApi
|
api.playlistsApi
|
||||||
.createPlaylist(
|
.createPlaylist(
|
||||||
CreatePlaylistDto(
|
CreatePlaylistDto(
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ class SeerrServerRepository
|
||||||
server = seerrServerDao.getServer(url)
|
server = seerrServerDao.getServer(url)
|
||||||
}
|
}
|
||||||
server?.server?.let { server ->
|
server?.server?.let { server ->
|
||||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
serverRepository.currentUser?.let { jellyfinUser ->
|
||||||
// TODO test api key
|
// TODO test api key
|
||||||
val user =
|
val user =
|
||||||
SeerrUser(
|
SeerrUser(
|
||||||
|
|
@ -126,7 +126,7 @@ class SeerrServerRepository
|
||||||
server = seerrServerDao.getServer(url)
|
server = seerrServerDao.getServer(url)
|
||||||
}
|
}
|
||||||
server?.server?.let { server ->
|
server?.server?.let { server ->
|
||||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
serverRepository.currentUser?.let { jellyfinUser ->
|
||||||
// TODO Need to update server early so that cookies are saved
|
// TODO Need to update server early so that cookies are saved
|
||||||
seerrApi.update(server.url, null)
|
seerrApi.update(server.url, null)
|
||||||
val userConfig = seerrLogin(seerrApi.api, authMethod, username, password)
|
val userConfig = seerrLogin(seerrApi.api, authMethod, username, password)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
|
@ -42,7 +43,7 @@ class ServerEventListener
|
||||||
|
|
||||||
init {
|
init {
|
||||||
activity.lifecycle.addObserver(this)
|
activity.lifecycle.addObserver(this)
|
||||||
serverRepository.current.observe(activity) {
|
serverRepository.current.collectLatestIn(activity.lifecycleScope) {
|
||||||
Timber.d("New user/server: %s", it)
|
Timber.d("New user/server: %s", it)
|
||||||
listenJob?.cancel()
|
listenJob?.cancel()
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class StreamChoiceService
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||||
) {
|
) {
|
||||||
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration
|
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
|
||||||
|
|
||||||
suspend fun updateAudio(
|
suspend fun updateAudio(
|
||||||
dto: BaseItemDto,
|
dto: BaseItemDto,
|
||||||
|
|
@ -58,7 +58,7 @@ class StreamChoiceService
|
||||||
) {
|
) {
|
||||||
val seriesId = dto.seriesId
|
val seriesId = dto.seriesId
|
||||||
if (seriesId != null) {
|
if (seriesId != null) {
|
||||||
val userId = serverRepository.currentUser.value!!.rowId
|
val userId = serverRepository.currentUser!!.rowId
|
||||||
val currentPlc =
|
val currentPlc =
|
||||||
playbackLanguageChoiceDao.get(userId, seriesId)
|
playbackLanguageChoiceDao.get(userId, seriesId)
|
||||||
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
|
?: PlaybackLanguageChoice(userId, seriesId, dto.id)
|
||||||
|
|
@ -70,7 +70,7 @@ class StreamChoiceService
|
||||||
|
|
||||||
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
|
suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
|
||||||
dto.seriesId?.let {
|
dto.seriesId?.let {
|
||||||
playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it)
|
playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -107,7 +107,13 @@ class StreamChoiceService
|
||||||
plc: PlaybackLanguageChoice?,
|
plc: PlaybackLanguageChoice?,
|
||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
): MediaStream? {
|
): MediaStream? {
|
||||||
val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) }
|
val plc =
|
||||||
|
plc ?: seriesId?.let {
|
||||||
|
playbackLanguageChoiceDao.get(
|
||||||
|
serverRepository.currentUser!!.rowId,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
return source.mediaStreams?.letNotEmpty { streams ->
|
return source.mediaStreams?.letNotEmpty { streams ->
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
|
||||||
chooseAudioStream(candidates, itemPlayback, plc, prefs)
|
chooseAudioStream(candidates, itemPlayback, plc, prefs)
|
||||||
|
|
@ -158,7 +164,7 @@ class StreamChoiceService
|
||||||
val plc =
|
val plc =
|
||||||
plc ?: seriesId?.let {
|
plc ?: seriesId?.let {
|
||||||
playbackLanguageChoiceDao.get(
|
playbackLanguageChoiceDao.get(
|
||||||
serverRepository.currentUser.value!!.rowId,
|
serverRepository.currentUser!!.rowId,
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +200,7 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
ItemPlayback(
|
ItemPlayback(
|
||||||
userId = serverRepository.currentUser.value!!.rowId,
|
userId = serverRepository.currentUser!!.rowId,
|
||||||
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
|
||||||
subtitleIndex = TrackIndex.ONLY_FORCED,
|
subtitleIndex = TrackIndex.ONLY_FORCED,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
|
|
@ -52,8 +51,7 @@ class SuggestionService
|
||||||
parentId: UUID,
|
parentId: UUID,
|
||||||
itemKind: BaseItemKind,
|
itemKind: BaseItemKind,
|
||||||
): Flow<SuggestionsResource> {
|
): Flow<SuggestionsResource> {
|
||||||
return serverRepository.currentUser
|
return serverRepository.currentUserFlow
|
||||||
.asFlow()
|
|
||||||
.flatMapLatest { user ->
|
.flatMapLatest { user ->
|
||||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||||
val cachedSuggestions = cache.get(userId, parentId, itemKind)
|
val cachedSuggestions = cache.get(userId, parentId, itemKind)
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,14 @@ import androidx.work.Constraints
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
import dagger.hilt.android.scopes.ActivityScoped
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -35,6 +34,7 @@ class SuggestionsSchedulerService
|
||||||
@param:ActivityContext private val context: Context,
|
@param:ActivityContext private val context: Context,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val workManager: WorkManager,
|
private val workManager: WorkManager,
|
||||||
|
@param:IoDispatcher private val dispatcher: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
private val activity =
|
private val activity =
|
||||||
(context as? AppCompatActivity)
|
(context as? AppCompatActivity)
|
||||||
|
|
@ -42,12 +42,11 @@ class SuggestionsSchedulerService
|
||||||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exposed for testing
|
|
||||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
|
||||||
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.observe(activity) { user ->
|
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||||
|
serverRepository.current.collect { user ->
|
||||||
Timber.v("New user %s", user?.user?.id)
|
Timber.v("New user %s", user?.user?.id)
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||||
|
|
@ -58,6 +57,7 @@ class SuggestionsSchedulerService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun scheduleWork(
|
private suspend fun scheduleWork(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class UserPreferencesService
|
||||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||||
|
|
||||||
suspend fun getCurrent(): UserPreferences =
|
suspend fun getCurrent(): UserPreferences =
|
||||||
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
|
serverRepository.currentUserDto!!.configuration.let { userConfig ->
|
||||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
UserPreferences(
|
UserPreferences(
|
||||||
appPrefs,
|
appPrefs,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
|
|
@ -39,7 +38,7 @@ class UserSwitchListener
|
||||||
init {
|
init {
|
||||||
context as AppCompatActivity
|
context as AppCompatActivity
|
||||||
context.lifecycleScope.launchDefault {
|
context.lifecycleScope.launchDefault {
|
||||||
serverRepository.currentUser.asFlow().collect { user ->
|
serverRepository.currentUserFlow.collect { user ->
|
||||||
Timber.d("New user")
|
Timber.d("New user")
|
||||||
seerrServerRepository.clear()
|
seerrServerRepository.clear()
|
||||||
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ object AppModule {
|
||||||
.addInterceptor {
|
.addInterceptor {
|
||||||
val request = it.request()
|
val request = it.request()
|
||||||
val newRequest =
|
val newRequest =
|
||||||
serverRepository.currentUser.value?.accessToken?.let { token ->
|
serverRepository.current.value?.user?.accessToken?.let { token ->
|
||||||
request
|
request
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addHeader(
|
.addHeader(
|
||||||
|
|
@ -170,7 +170,10 @@ object AppModule {
|
||||||
appPreference: DataStore<AppPreferences>,
|
appPreference: DataStore<AppPreferences>,
|
||||||
@IoCoroutineScope scope: CoroutineScope,
|
@IoCoroutineScope scope: CoroutineScope,
|
||||||
) = object : RememberTabManager {
|
) = object : RememberTabManager {
|
||||||
fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
|
fun key(itemId: String): String =
|
||||||
|
serverRepository.current.value.let {
|
||||||
|
"${it?.server?.id}_${it?.user?.id}_$itemId"
|
||||||
|
}
|
||||||
|
|
||||||
override fun getRememberedTab(
|
override fun getRememberedTab(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.work.WorkManager
|
||||||
import androidx.work.await
|
import androidx.work.await
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
|
@ -43,7 +44,7 @@ class TvProviderSchedulerService
|
||||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.observe(activity) { user ->
|
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
|
||||||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||||
if (supportsTvProvider) {
|
if (supportsTvProvider) {
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import coil3.request.ErrorResult
|
import coil3.request.ErrorResult
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
|
@ -397,11 +396,6 @@ fun CoroutineScope.launchDefault(
|
||||||
*/
|
*/
|
||||||
fun UUID.toServerString() = this.toString().replace("-", "")
|
fun UUID.toServerString() = this.toString().replace("-", "")
|
||||||
|
|
||||||
suspend fun <T> MutableLiveData<T>.setValueOnMain(value: T) =
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
this@setValueOnMain.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun equalsNotNull(
|
fun equalsNotNull(
|
||||||
a: Any?,
|
a: Any?,
|
||||||
b: Any?,
|
b: Any?,
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ fun CollectionFolderGrid(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
title: String,
|
title: String,
|
||||||
loadingState: DataLoadingState<List<BaseItem?>>,
|
items: DataLoadingState<List<BaseItem?>>,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
|
|
@ -91,7 +91,7 @@ fun CollectionFolderGrid(
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val pager = (loadingState as? DataLoadingState.Success)?.data
|
val pager = (items as? DataLoadingState.Success)?.data
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
val headerRowFocusRequester = remember { FocusRequester() }
|
val headerRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
|
@ -119,7 +119,7 @@ fun CollectionFolderGrid(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
CollectionFolderHeader(
|
CollectionFolderHeader(
|
||||||
showHeader = showHeader || loadingState !is DataLoadingState.Success,
|
showHeader = showHeader || items !is DataLoadingState.Success,
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -147,7 +147,7 @@ fun CollectionFolderGrid(
|
||||||
.padding(HeaderUtils.padding),
|
.padding(HeaderUtils.padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (val state = loadingState) {
|
when (val state = items) {
|
||||||
DataLoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
DataLoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
-> {
|
-> {
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ fun CollectionFolderList(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
title: String,
|
title: String,
|
||||||
loadingState: DataLoadingState<List<BaseItem?>>,
|
items: DataLoadingState<List<BaseItem?>>,
|
||||||
sortAndDirection: SortAndDirection,
|
sortAndDirection: SortAndDirection,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
|
|
@ -108,7 +108,7 @@ fun CollectionFolderList(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val pager = (loadingState as? DataLoadingState.Success)?.data
|
val pager = (items as? DataLoadingState.Success)?.data
|
||||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
val headerRowFocusRequester = remember { FocusRequester() }
|
val headerRowFocusRequester = remember { FocusRequester() }
|
||||||
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
||||||
|
|
@ -147,7 +147,7 @@ fun CollectionFolderList(
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
CollectionFolderHeader(
|
CollectionFolderHeader(
|
||||||
showHeader = showHeader || loadingState !is DataLoadingState.Success,
|
showHeader = showHeader || items !is DataLoadingState.Success,
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -162,7 +162,7 @@ fun CollectionFolderList(
|
||||||
onFilterChange = onFilterChange,
|
onFilterChange = onFilterChange,
|
||||||
modifier = Modifier.focusRequester(headerRowFocusRequester),
|
modifier = Modifier.focusRequester(headerRowFocusRequester),
|
||||||
)
|
)
|
||||||
when (val state = loadingState) {
|
when (val state = items) {
|
||||||
DataLoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
DataLoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
-> {
|
-> {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
|
@ -19,12 +18,11 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -56,15 +54,12 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||||
|
|
@ -74,6 +69,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.successValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -81,12 +77,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
@ -95,6 +94,7 @@ import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -104,7 +104,7 @@ class CollectionFolderViewModel
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
api: ApiClient,
|
private val api: ApiClient,
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
|
|
@ -117,13 +117,13 @@ class CollectionFolderViewModel
|
||||||
private val musicService: MusicService,
|
private val musicService: MusicService,
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
@Assisted itemId: String,
|
@Assisted val itemId: String,
|
||||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||||
@Assisted("recursive") private val recursive: Boolean,
|
@Assisted("recursive") private val recursive: Boolean,
|
||||||
@Assisted private val collectionFilter: CollectionFolderFilter,
|
@Assisted private val collectionFilter: CollectionFolderFilter,
|
||||||
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
||||||
@Assisted defaultViewOptions: ViewOptions,
|
@Assisted defaultViewOptions: ViewOptions,
|
||||||
) : ItemViewModel(api) {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
|
|
@ -136,11 +136,8 @@ class CollectionFolderViewModel
|
||||||
): CollectionFolderViewModel
|
): CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading)
|
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
|
||||||
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val state: StateFlow<CollectionFolderState> = _state
|
||||||
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
|
||||||
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
|
||||||
val viewOptions = MutableStateFlow<ViewOptions>(defaultViewOptions)
|
|
||||||
|
|
||||||
var position: Int
|
var position: Int
|
||||||
get() = savedStateHandle.get<Int>("position") ?: 0
|
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||||
|
|
@ -150,19 +147,22 @@ class CollectionFolderViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
super.itemId = itemId
|
|
||||||
try {
|
try {
|
||||||
val item =
|
val item =
|
||||||
itemId.toUUIDOrNull()?.let {
|
api.userLibraryApi
|
||||||
fetchItem(it)
|
.getItem(itemId.toUUID())
|
||||||
}
|
.content
|
||||||
|
.let(::BaseItem)
|
||||||
|
|
||||||
val libraryDisplayInfo =
|
val libraryDisplayInfo =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
libraryDisplayInfoDao.getItem(user, itemId)
|
libraryDisplayInfoDao.getItem(user, itemId)
|
||||||
}
|
}
|
||||||
this@CollectionFolderViewModel.viewOptions.value =
|
_state.update {
|
||||||
libraryDisplayInfo?.viewOptions ?: defaultViewOptions
|
it.copy(
|
||||||
|
viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val sortAndDirection =
|
val sortAndDirection =
|
||||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||||
|
|
@ -178,12 +178,13 @@ class CollectionFolderViewModel
|
||||||
collectionFilter.filter
|
collectionFilter.filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_state.update { it.copy(item = DataLoadingState.Success(item)) }
|
||||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||||
.join()
|
.join()
|
||||||
// onResumePage()
|
// onResumePage()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error during init")
|
Timber.e(ex, "Error during init")
|
||||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mediaManagementService.deletedItemFlow
|
mediaManagementService.deletedItemFlow
|
||||||
|
|
@ -200,7 +201,7 @@ class CollectionFolderViewModel
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val pager =
|
val pager =
|
||||||
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||||
position.let {
|
position.let {
|
||||||
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
|
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
|
||||||
val item = pager?.get(it)
|
val item = pager?.get(it)
|
||||||
|
|
@ -218,12 +219,12 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLibraryDisplayInfo(
|
private fun saveLibraryDisplayInfo(
|
||||||
newFilter: GetItemsFilter = this.filter.value!!,
|
newFilter: GetItemsFilter = state.value.filter,
|
||||||
newSort: SortAndDirection = this.sortAndDirection.value!!,
|
newSort: SortAndDirection = state.value.sortAndDirection,
|
||||||
viewOptions: ViewOptions? = this.viewOptions.value,
|
viewOptions: ViewOptions? = state.value.viewOptions,
|
||||||
) {
|
) {
|
||||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val libraryDisplayInfo =
|
val libraryDisplayInfo =
|
||||||
LibraryDisplayInfo(
|
LibraryDisplayInfo(
|
||||||
|
|
@ -241,7 +242,7 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveViewOptions(viewOptions: ViewOptions) {
|
fun saveViewOptions(viewOptions: ViewOptions) {
|
||||||
this.viewOptions.value = viewOptions
|
_state.update { it.copy(viewOptions = viewOptions) }
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
saveLibraryDisplayInfo(viewOptions = viewOptions)
|
||||||
if (!viewOptions.showBackdrop) {
|
if (!viewOptions.showBackdrop) {
|
||||||
|
|
@ -255,8 +256,14 @@ class CollectionFolderViewModel
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
) {
|
) {
|
||||||
Timber.v("onFilterChange: filter=%s", newFilter)
|
Timber.v("onFilterChange: filter=%s", newFilter)
|
||||||
saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!)
|
saveLibraryDisplayInfo(newFilter)
|
||||||
loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary)
|
loadResults(
|
||||||
|
false,
|
||||||
|
state.value.sortAndDirection,
|
||||||
|
recursive,
|
||||||
|
newFilter,
|
||||||
|
useSeriesForPrimary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSortChange(
|
fun onSortChange(
|
||||||
|
|
@ -281,21 +288,23 @@ class CollectionFolderViewModel
|
||||||
filter: GetItemsFilter,
|
filter: GetItemsFilter,
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
) = viewModelScope.launch(Dispatchers.IO) {
|
) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
if (resetState) {
|
it.copy(
|
||||||
loading.value = DataLoadingState.Loading
|
items = DataLoadingState.Loading,
|
||||||
}
|
backgroundLoading = LoadingState.Loading,
|
||||||
backgroundLoading.value = LoadingState.Loading
|
sortAndDirection = sortAndDirection,
|
||||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
filter = filter,
|
||||||
this@CollectionFolderViewModel.filter.value = filter
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val newPager =
|
val newPager =
|
||||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
loading.value = DataLoadingState.Success(newPager)
|
it.copy(
|
||||||
backgroundLoading.value = LoadingState.Success
|
items = DataLoadingState.Success(newPager),
|
||||||
|
backgroundLoading = LoadingState.Success,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(
|
Timber.e(
|
||||||
|
|
@ -304,9 +313,7 @@ class CollectionFolderViewModel
|
||||||
sortAndDirection,
|
sortAndDirection,
|
||||||
filter,
|
filter,
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
|
||||||
loading.value = DataLoadingState.Error(ex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,7 +366,7 @@ class CollectionFolderViewModel
|
||||||
recursive: Boolean,
|
recursive: Boolean,
|
||||||
filter: GetItemsFilter,
|
filter: GetItemsFilter,
|
||||||
): GetItemsRequest {
|
): GetItemsRequest {
|
||||||
val item = item.value
|
val item = state.value.item.successValue
|
||||||
val includeItemTypes =
|
val includeItemTypes =
|
||||||
item
|
item
|
||||||
?.data
|
?.data
|
||||||
|
|
@ -413,18 +420,15 @@ class CollectionFolderViewModel
|
||||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
FilterUtils.getFilterOptionValues(
|
FilterUtils.getFilterOptionValues(
|
||||||
api,
|
api,
|
||||||
serverRepository.currentUser.value?.id,
|
serverRepository.currentUser?.id,
|
||||||
itemUuid,
|
itemId.toUUID(),
|
||||||
filterOption,
|
filterOption,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun positionOfLetter(letter: Char): Int? =
|
suspend fun positionOfLetter(letter: Char): Int? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val sort = sortAndDirection.value
|
val sort = state.value.sortAndDirection
|
||||||
val filter = filter.value
|
val filter = state.value.filter
|
||||||
if (sort == null || filter == null) {
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
val request =
|
val request =
|
||||||
createGetItemsRequest(
|
createGetItemsRequest(
|
||||||
sortAndDirection = sort,
|
sortAndDirection = sort,
|
||||||
|
|
@ -447,7 +451,7 @@ class CollectionFolderViewModel
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setWatched(itemId, played)
|
favoriteWatchManager.setWatched(itemId, played)
|
||||||
(loading.value as? DataLoadingState.Success)?.let {
|
(state.value.items as? DataLoadingState.Success)?.let {
|
||||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +462,7 @@ class CollectionFolderViewModel
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
(loading.value as? DataLoadingState.Success)?.let {
|
(state.value.items as? DataLoadingState.Success)?.let {
|
||||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -480,9 +484,9 @@ class CollectionFolderViewModel
|
||||||
|
|
||||||
fun onResumePage() {
|
fun onResumePage() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
item.value?.let {
|
state.value.item.successValue?.let {
|
||||||
Timber.v("onResumePage: %s", loading.value!!::class)
|
Timber.v("onResumePage: %s", state.value.items::class)
|
||||||
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
|
if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) {
|
||||||
themeSongPlayer.playThemeFor(it.id)
|
themeSongPlayer.playThemeFor(it.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -511,6 +515,15 @@ class CollectionFolderViewModel
|
||||||
) = addToQueue(api, musicService, item, index)
|
) = addToQueue(api, musicService, item, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CollectionFolderState(
|
||||||
|
val item: DataLoadingState<BaseItem> = DataLoadingState.Loading,
|
||||||
|
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Loading,
|
||||||
|
val backgroundLoading: LoadingState = LoadingState.Loading,
|
||||||
|
val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT,
|
||||||
|
val filter: GetItemsFilter = GetItemsFilter(),
|
||||||
|
val viewOptions: ViewOptions,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a collection folder as a grid
|
* Shows a collection folder as a grid
|
||||||
*
|
*
|
||||||
|
|
@ -623,18 +636,12 @@ fun CollectionFolderView(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val state by viewModel.state.collectAsState()
|
||||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
|
||||||
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
|
||||||
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
|
|
||||||
val item by viewModel.item.observeAsState()
|
|
||||||
val viewOptions by viewModel.viewOptions.collectAsState()
|
|
||||||
|
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val contextActions =
|
val contextActions =
|
||||||
|
|
@ -689,12 +696,12 @@ fun CollectionFolderView(
|
||||||
actions.onClickPlayAll ?: { shuffle ->
|
actions.onClickPlayAll ?: { shuffle ->
|
||||||
itemId.toUUIDOrNull()?.let {
|
itemId.toUUIDOrNull()?.let {
|
||||||
val destination =
|
val destination =
|
||||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
if (state.item.successValue?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||||
Destination.Slideshow(
|
Destination.Slideshow(
|
||||||
parentId = it,
|
parentId = it,
|
||||||
index = 0,
|
index = 0,
|
||||||
filter = CollectionFolderFilter(filter = filter),
|
filter = CollectionFolderFilter(filter = state.filter),
|
||||||
sortAndDirection = sortAndDirection,
|
sortAndDirection = state.sortAndDirection,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
startSlideshow = true,
|
startSlideshow = true,
|
||||||
)
|
)
|
||||||
|
|
@ -704,8 +711,8 @@ fun CollectionFolderView(
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
shuffle = shuffle,
|
shuffle = shuffle,
|
||||||
recursive = recursive,
|
recursive = recursive,
|
||||||
sortAndDirection = sortAndDirection,
|
sortAndDirection = state.sortAndDirection,
|
||||||
filter = filter,
|
filter = state.filter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel.navigateTo(destination)
|
viewModel.navigateTo(destination)
|
||||||
|
|
@ -719,8 +726,8 @@ fun CollectionFolderView(
|
||||||
Destination.Slideshow(
|
Destination.Slideshow(
|
||||||
parentId = item.id,
|
parentId = item.id,
|
||||||
index = index,
|
index = index,
|
||||||
filter = CollectionFolderFilter(filter = filter),
|
filter = CollectionFolderFilter(filter = state.filter),
|
||||||
sortAndDirection = sortAndDirection,
|
sortAndDirection = state.sortAndDirection,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
startSlideshow = true,
|
startSlideshow = true,
|
||||||
)
|
)
|
||||||
|
|
@ -732,7 +739,7 @@ fun CollectionFolderView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.item) {
|
||||||
DataLoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
DataLoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
|
|
@ -742,6 +749,7 @@ fun CollectionFolderView(
|
||||||
is DataLoadingState.Error,
|
is DataLoadingState.Error,
|
||||||
is DataLoadingState.Success<*>,
|
is DataLoadingState.Success<*>,
|
||||||
-> {
|
-> {
|
||||||
|
val item = st.successValue
|
||||||
val title =
|
val title =
|
||||||
initialFilter.nameOverride
|
initialFilter.nameOverride
|
||||||
?: item?.name
|
?: item?.name
|
||||||
|
|
@ -755,23 +763,23 @@ fun CollectionFolderView(
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (viewOptions.type == ViewOptionsType.GRID) {
|
if (state.viewOptions.type == ViewOptionsType.GRID) {
|
||||||
CollectionFolderGrid(
|
CollectionFolderGrid(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
initialPosition = viewModel.position,
|
initialPosition = viewModel.position,
|
||||||
item = item,
|
item = item,
|
||||||
title = title,
|
title = title,
|
||||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
items = state.items,
|
||||||
sortAndDirection = sortAndDirection!!,
|
sortAndDirection = state.sortAndDirection,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||||
onClickItem = gridActions.onClickItem,
|
onClickItem = gridActions.onClickItem,
|
||||||
onLongClickItem = gridActions.onLongClickItem!!,
|
onLongClickItem = gridActions.onLongClickItem!!,
|
||||||
onSortChange = {
|
onSortChange = {
|
||||||
viewModel.onSortChange(it, recursive, filter)
|
viewModel.onSortChange(it, recursive, state.filter)
|
||||||
},
|
},
|
||||||
filterOptions = filterOptions,
|
filterOptions = filterOptions,
|
||||||
currentFilter = filter,
|
currentFilter = state.filter,
|
||||||
onFilterChange = {
|
onFilterChange = {
|
||||||
viewModel.onFilterChange(it, recursive)
|
viewModel.onFilterChange(it, recursive)
|
||||||
},
|
},
|
||||||
|
|
@ -785,7 +793,7 @@ fun CollectionFolderView(
|
||||||
positionCallback?.invoke(columns, position)
|
positionCallback?.invoke(columns, position)
|
||||||
},
|
},
|
||||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||||
viewOptions = viewOptions,
|
viewOptions = state.viewOptions,
|
||||||
onChangeBackdrop = viewModel::updateBackdrop,
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
playEnabled = playEnabled,
|
playEnabled = playEnabled,
|
||||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||||
|
|
@ -798,17 +806,17 @@ fun CollectionFolderView(
|
||||||
initialPosition = viewModel.position,
|
initialPosition = viewModel.position,
|
||||||
item = item,
|
item = item,
|
||||||
title = title,
|
title = title,
|
||||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
items = state.items,
|
||||||
sortAndDirection = sortAndDirection!!,
|
sortAndDirection = state.sortAndDirection,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||||
onClickItem = gridActions.onClickItem,
|
onClickItem = gridActions.onClickItem,
|
||||||
onLongClickItem = gridActions.onLongClickItem!!,
|
onLongClickItem = gridActions.onLongClickItem!!,
|
||||||
onSortChange = {
|
onSortChange = {
|
||||||
viewModel.onSortChange(it, recursive, filter)
|
viewModel.onSortChange(it, recursive, state.filter)
|
||||||
},
|
},
|
||||||
filterOptions = filterOptions,
|
filterOptions = filterOptions,
|
||||||
currentFilter = filter,
|
currentFilter = state.filter,
|
||||||
onFilterChange = {
|
onFilterChange = {
|
||||||
viewModel.onFilterChange(it, recursive)
|
viewModel.onFilterChange(it, recursive)
|
||||||
},
|
},
|
||||||
|
|
@ -822,7 +830,7 @@ fun CollectionFolderView(
|
||||||
positionCallback?.invoke(columns, position)
|
positionCallback?.invoke(columns, position)
|
||||||
},
|
},
|
||||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||||
viewOptions = viewOptions,
|
viewOptions = state.viewOptions,
|
||||||
onChangeBackdrop = viewModel::updateBackdrop,
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
playEnabled = playEnabled,
|
playEnabled = playEnabled,
|
||||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||||
|
|
@ -832,7 +840,7 @@ fun CollectionFolderView(
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
backgroundLoading == LoadingState.Loading,
|
state.backgroundLoading == LoadingState.Loading,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
|
|
@ -854,7 +862,7 @@ fun CollectionFolderView(
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
viewModel.serverRepository.currentUserDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
@ -887,11 +895,11 @@ fun CollectionFolderView(
|
||||||
}
|
}
|
||||||
AnimatedVisibility(showViewOptions) {
|
AnimatedVisibility(showViewOptions) {
|
||||||
ViewOptionsDialog(
|
ViewOptionsDialog(
|
||||||
viewOptions = viewOptions,
|
viewOptions = state.viewOptions,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showViewOptions = false
|
showViewOptions = false
|
||||||
viewModel.saveViewOptions(viewOptions)
|
viewModel.saveViewOptions(state.viewOptions)
|
||||||
},
|
},
|
||||||
onViewOptionsChange = viewModel::saveViewOptions,
|
onViewOptionsChange = viewModel::saveViewOptions,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.times
|
import androidx.compose.ui.unit.times
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -29,12 +28,11 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import com.mayakapps.kache.InMemoryKache
|
import com.mayakapps.kache.InMemoryKache
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
|
|
@ -44,7 +42,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -80,21 +80,20 @@ class GenreViewModel
|
||||||
): GenreViewModel
|
): GenreViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
private val _state = MutableStateFlow(GenreGridState())
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val state: StateFlow<GenreGridState> = _state
|
||||||
val genres = MutableLiveData<List<Genre>>(listOf())
|
|
||||||
|
|
||||||
fun init(cardWidthPx: Int) {
|
fun init(cardWidthPx: Int) {
|
||||||
loading.value = LoadingState.Loading
|
_state.update { it.copy(item = DataLoadingState.Loading) }
|
||||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
val item =
|
val item =
|
||||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||||
BaseItem(it, false)
|
BaseItem(it, false)
|
||||||
}
|
}
|
||||||
this@GenreViewModel.item.setValueOnMain(item)
|
|
||||||
val request =
|
val request =
|
||||||
GetGenresRequest(
|
GetGenresRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
|
|
@ -106,14 +105,16 @@ class GenreViewModel
|
||||||
.map {
|
.map {
|
||||||
Genre(it.id, it.name ?: "", null)
|
Genre(it.id, it.name ?: "", null)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@GenreViewModel.genres.value = genres
|
it.copy(
|
||||||
loading.value = LoadingState.Success
|
item = DataLoadingState.Success(item),
|
||||||
|
genres = genres,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val genreToUrl =
|
val genreToUrl =
|
||||||
getGenreImageMap(
|
getGenreImageMap(
|
||||||
api = api,
|
api = api,
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
imageUrlService = imageUrlService,
|
imageUrlService = imageUrlService,
|
||||||
genres = genres.map { it.id },
|
genres = genres.map { it.id },
|
||||||
|
|
@ -127,7 +128,15 @@ class GenreViewModel
|
||||||
imageUrl = genreToUrl[it.id],
|
imageUrl = genreToUrl[it.id],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this@GenreViewModel.genres.setValueOnMain(genresWithImages)
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
genres = genresWithImages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching genres")
|
||||||
|
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +155,11 @@ class GenreViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class GenreGridState(
|
||||||
|
val item: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||||
|
val genres: List<Genre> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
data class GenreCacheKey(
|
data class GenreCacheKey(
|
||||||
val userId: UUID?,
|
val userId: UUID?,
|
||||||
val parentId: UUID,
|
val parentId: UUID,
|
||||||
|
|
@ -266,34 +280,33 @@ fun GenreCardGrid(
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init(cardWidthPx)
|
viewModel.init(cardWidthPx)
|
||||||
}
|
}
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val state by viewModel.state.collectAsState()
|
||||||
val genres by viewModel.genres.observeAsState(listOf())
|
|
||||||
|
|
||||||
val gridFocusRequester = remember { FocusRequester() }
|
val gridFocusRequester = remember { FocusRequester() }
|
||||||
when (val st = loading) {
|
when (val st = state.item) {
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier.focusable())
|
LoadingPage(modifier.focusable())
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(st, modifier.focusable())
|
ErrorMessage(st, modifier.focusable())
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||||
val item by viewModel.item.observeAsState(null)
|
val item = st.data
|
||||||
CardGrid(
|
CardGrid(
|
||||||
pager = genres,
|
pager = state.genres,
|
||||||
onClickItem = { _, genre ->
|
onClickItem = { _, genre ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
createGenreDestination(
|
createGenreDestination(
|
||||||
genreId = genre.id,
|
genreId = genre.id,
|
||||||
genreName = genre.name,
|
genreName = genre.name,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
parentName = item?.title,
|
parentName = item.title,
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -26,16 +25,18 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import com.github.damontecres.wholphin.util.RequestHandler
|
import com.github.damontecres.wholphin.util.RequestHandler
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
|
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
|
||||||
class ItemGridViewModel
|
class ItemGridViewModel
|
||||||
|
|
@ -45,8 +46,8 @@ class ItemGridViewModel
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
@Assisted private val destination: Destination.ItemGrid<*>,
|
@Assisted private val destination: Destination.ItemGrid<*>,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
private val _state = MutableStateFlow(ItemGridState())
|
||||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
val state: StateFlow<ItemGridState> = _state
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -54,7 +55,8 @@ class ItemGridViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) {
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
val request = destination.request as Any
|
val request = destination.request as Any
|
||||||
val pager =
|
val pager =
|
||||||
ApiRequestPager(
|
ApiRequestPager(
|
||||||
|
|
@ -67,9 +69,14 @@ class ItemGridViewModel
|
||||||
if (pager.isNotEmpty()) {
|
if (pager.isNotEmpty()) {
|
||||||
pager.getBlocking(0)
|
pager.getBlocking(0)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@ItemGridViewModel.items.value = pager
|
it.copy(items = DataLoadingState.Success(pager))
|
||||||
this@ItemGridViewModel.loading.value = LoadingState.Success
|
}
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching items")
|
||||||
|
_state.update { it.copy(items = DataLoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +86,10 @@ class ItemGridViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ItemGridState(
|
||||||
|
val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Pending,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem]
|
* Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem]
|
||||||
*/
|
*/
|
||||||
|
|
@ -91,20 +102,19 @@ fun ItemGrid(
|
||||||
creationCallback = { it.create(destination) },
|
creationCallback = { it.create(destination) },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val state by viewModel.state.collectAsState()
|
||||||
val items by viewModel.items.observeAsState(listOf())
|
when (val st = state.items) {
|
||||||
when (val state = loading) {
|
is DataLoadingState.Error -> {
|
||||||
is LoadingState.Error -> {
|
ErrorMessage(st, modifier)
|
||||||
ErrorMessage(state, modifier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<List<BaseItem?>> -> {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
|
|
@ -118,7 +128,7 @@ fun ItemGrid(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
CardGrid(
|
CardGrid(
|
||||||
pager = items,
|
pager = st.data,
|
||||||
onClickItem = { index: Int, item: BaseItem ->
|
onClickItem = { index: Int, item: BaseItem ->
|
||||||
// TODO handle more types
|
// TODO handle more types
|
||||||
viewModel.navigateTo(Destination.Playback(item.id, 0))
|
viewModel.navigateTo(Destination.Playback(item.id, 0))
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -36,7 +35,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -351,7 +349,7 @@ fun RecommendedContent(
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
|
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
|
|
@ -451,7 +449,7 @@ fun RecommendedContent(
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
viewModel.serverRepository.currentUserDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.times
|
import androidx.compose.ui.unit.times
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -29,23 +28,25 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = StudioViewModel.Factory::class)
|
@HiltViewModel(assistedFactory = StudioViewModel.Factory::class)
|
||||||
|
|
@ -67,21 +68,20 @@ class StudioViewModel
|
||||||
): StudioViewModel
|
): StudioViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
private val _state = MutableStateFlow(StudioGridState())
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val state: StateFlow<StudioGridState> = _state
|
||||||
val studios = MutableLiveData<List<Studio>>(listOf())
|
|
||||||
|
|
||||||
fun init(cardWidthPx: Int) {
|
fun init(cardWidthPx: Int) {
|
||||||
loading.value = LoadingState.Loading
|
_state.update { it.copy(item = DataLoadingState.Loading) }
|
||||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
val item =
|
val item =
|
||||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||||
BaseItem(it, false)
|
BaseItem(it, false)
|
||||||
}
|
}
|
||||||
this@StudioViewModel.item.setValueOnMain(item)
|
|
||||||
val request =
|
val request =
|
||||||
GetStudiosRequest(
|
GetStudiosRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
|
|
@ -99,9 +99,15 @@ class StudioViewModel
|
||||||
)
|
)
|
||||||
Studio(it.id, it.name ?: "", imageUrl)
|
Studio(it.id, it.name ?: "", imageUrl)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@StudioViewModel.studios.value = studios
|
it.copy(
|
||||||
loading.value = LoadingState.Success
|
item = DataLoadingState.Success(item),
|
||||||
|
studios = studios,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching studios")
|
||||||
|
_state.update { it.copy(item = DataLoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +116,7 @@ class StudioViewModel
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val request =
|
val request =
|
||||||
GetStudiosRequest(
|
GetStudiosRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
nameLessThan = letter.toString(),
|
nameLessThan = letter.toString(),
|
||||||
limit = 0,
|
limit = 0,
|
||||||
|
|
@ -133,6 +139,11 @@ data class Studio(
|
||||||
override val sortName: String get() = name
|
override val sortName: String get() = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class StudioGridState(
|
||||||
|
val item: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||||
|
val studios: List<Studio> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StudioCardGrid(
|
fun StudioCardGrid(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
@ -162,34 +173,32 @@ fun StudioCardGrid(
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init(cardWidthPx)
|
viewModel.init(cardWidthPx)
|
||||||
}
|
}
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val state by viewModel.state.collectAsState()
|
||||||
val studios by viewModel.studios.observeAsState(listOf())
|
|
||||||
|
|
||||||
val gridFocusRequester = remember { FocusRequester() }
|
val gridFocusRequester = remember { FocusRequester() }
|
||||||
when (val st = loading) {
|
when (val st = state.item) {
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier.focusable())
|
LoadingPage(modifier.focusable())
|
||||||
}
|
}
|
||||||
|
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(st, modifier.focusable())
|
ErrorMessage(st, modifier.focusable())
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
||||||
val item by viewModel.item.observeAsState(null)
|
|
||||||
CardGrid(
|
CardGrid(
|
||||||
pager = studios,
|
pager = state.studios,
|
||||||
onClickItem = { _, studio ->
|
onClickItem = { _, studio ->
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
createStudioDestination(
|
createStudioDestination(
|
||||||
studioId = studio.id,
|
studioId = studio.id,
|
||||||
name = studio.name,
|
name = studio.name,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
parentName = item?.title,
|
parentName = st.data.title,
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,17 @@ package com.github.damontecres.wholphin.ui.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -30,16 +29,17 @@ class AddPlaylistViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val playlistCreator: PlaylistCreator,
|
private val playlistCreator: PlaylistCreator,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending)
|
val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
fun loadPlaylists(mediaType: MediaType?) {
|
fun loadPlaylists(mediaType: MediaType?) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading)
|
this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading
|
||||||
try {
|
try {
|
||||||
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
|
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
|
||||||
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists))
|
this@AddPlaylistViewModel.playlistState.value =
|
||||||
|
PlaylistLoadingState.Success(playlists)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
playlistState.setValueOnMain(PlaylistLoadingState.Error(ex))
|
playlistState.value = PlaylistLoadingState.Error(ex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -41,10 +40,10 @@ import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.logTab
|
import com.github.damontecres.wholphin.ui.logTab
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -61,16 +60,16 @@ class LiveTvCollectionViewModel
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
RememberTabManager by rememberTabManager {
|
RememberTabManager by rememberTabManager {
|
||||||
val recordingFolders = MutableLiveData<List<TabId>>()
|
val recordingFolders = MutableStateFlow<List<TabId>>(emptyList())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val folders =
|
val folders =
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
.getRecordingFolders(userId = serverRepository.currentUser.value?.id)
|
.getRecordingFolders(userId = serverRepository.currentUser?.id)
|
||||||
.content.items
|
.content.items
|
||||||
.map { TabId(it.name ?: "Recordings", it.id) }
|
.map { TabId(it.name ?: "Recordings", it.id) }
|
||||||
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
|
recordingFolders.value = folders
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +88,7 @@ fun CollectionFolderLiveTv(
|
||||||
) {
|
) {
|
||||||
val rememberedTabIndex =
|
val rememberedTabIndex =
|
||||||
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
|
||||||
val folders by viewModel.recordingFolders.observeAsState(listOf())
|
val folders by viewModel.recordingFolders.collectAsState()
|
||||||
|
|
||||||
val tvGuideStr = stringResource(R.string.tv_guide)
|
val tvGuideStr = stringResource(R.string.tv_guide)
|
||||||
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
|
val tvDvrStr = stringResource(R.string.tv_dvr_schedule)
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ class CollectionFolderMusicViewModel
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -10,13 +11,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
|
||||||
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
|
@ -50,6 +49,7 @@ fun CollectionFolderPhotoAlbum(
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
var showHeader by remember { mutableStateOf(true) }
|
var showHeader by remember { mutableStateOf(true) }
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
CollectionFolderView(
|
CollectionFolderView(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
actions =
|
actions =
|
||||||
|
|
@ -63,11 +63,9 @@ fun CollectionFolderPhotoAlbum(
|
||||||
index = index,
|
index = index,
|
||||||
filter =
|
filter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
filter = viewModel.filter.value ?: GetItemsFilter(),
|
filter = state.filter,
|
||||||
),
|
),
|
||||||
sortAndDirection =
|
sortAndDirection = state.sortAndDirection,
|
||||||
viewModel.sortAndDirection.value
|
|
||||||
?: SortAndDirection.DEFAULT,
|
|
||||||
recursive = recursive,
|
recursive = recursive,
|
||||||
startSlideshow = false,
|
startSlideshow = false,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -28,7 +28,6 @@ import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -43,9 +42,8 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.acra.util.versionCodeLong
|
import org.acra.util.versionCodeLong
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||||
|
|
@ -66,8 +64,8 @@ class DebugViewModel
|
||||||
val clientInfo: ClientInfo,
|
val clientInfo: ClientInfo,
|
||||||
val deviceInfo: DeviceInfo,
|
val deviceInfo: DeviceInfo,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
|
val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList())
|
||||||
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
|
val logcat = MutableStateFlow<List<LogcatLine>>(emptyList())
|
||||||
|
|
||||||
val supportedModes by lazy {
|
val supportedModes by lazy {
|
||||||
val displayManager =
|
val displayManager =
|
||||||
|
|
@ -106,18 +104,16 @@ class DebugViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.rowId?.let {
|
serverRepository.currentUser?.rowId?.let {
|
||||||
val results = itemPlaybackDao.getItems(it)
|
val results = itemPlaybackDao.getItems(it)
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
itemPlaybacks.value = results
|
itemPlaybacks.value = results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
val logcat = getLogCatLines()
|
val logcat = getLogCatLines()
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
this@DebugViewModel.logcat.value = logcat
|
this@DebugViewModel.logcat.value = logcat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getLogCatLines(): List<LogcatLine> {
|
fun getLogCatLines(): List<LogcatLine> {
|
||||||
|
|
@ -210,8 +206,8 @@ fun DebugPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf())
|
val itemPlaybacks by viewModel.itemPlaybacks.collectAsState()
|
||||||
val logcat by viewModel.logcat.observeAsState(listOf())
|
val logcat by viewModel.logcat.collectAsState()
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = columnState,
|
state = columnState,
|
||||||
|
|
@ -341,17 +337,17 @@ fun DebugPage(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Current server: ${viewModel.serverRepository.currentServer.value}",
|
text = "Current server: ${viewModel.serverRepository.currentServer}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Current user: ${viewModel.serverRepository.currentUser.value}",
|
text = "Current user: ${viewModel.serverRepository.currentUser}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}",
|
text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -134,7 +133,7 @@ class HomeRowGridViewModel
|
||||||
try {
|
try {
|
||||||
val preferences = userPreferencesService.getCurrent()
|
val preferences = userPreferencesService.getCurrent()
|
||||||
val prefs = preferences.appPreferences.homePagePreferences
|
val prefs = preferences.appPreferences.homePagePreferences
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto?.let { userDto ->
|
||||||
val libraries =
|
val libraries =
|
||||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -244,7 +243,7 @@ fun HomeRowGrid(
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
|
|
||||||
val contextActions =
|
val contextActions =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -381,7 +380,7 @@ fun HomeRowGrid(
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
viewModel.serverRepository.currentUserDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
|
||||||
|
|
@ -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.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -33,7 +32,7 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -48,7 +47,6 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SeerrService
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
|
@ -68,7 +66,6 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||||
|
|
@ -76,13 +73,16 @@ import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import com.github.damontecres.wholphin.util.RowLoadingState
|
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
@ -91,61 +91,89 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = PersonViewModel.Factory::class)
|
||||||
class PersonViewModel
|
class PersonViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
private val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
) : LoadingItemViewModel(api) {
|
@Assisted val itemId: UUID,
|
||||||
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
) : ViewModel() {
|
||||||
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
@AssistedFactory
|
||||||
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
interface Factory {
|
||||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
fun create(itemId: UUID): PersonViewModel
|
||||||
|
}
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
private val _state = MutableStateFlow(PersonState())
|
||||||
viewModelScope.launchIO(
|
val state: StateFlow<PersonState> = _state
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
private suspend fun updatePerson(): BaseItem {
|
||||||
"Error loading item $itemId",
|
val person =
|
||||||
),
|
api.userLibraryApi
|
||||||
) {
|
.getItem(itemId)
|
||||||
super.init(itemId, null).await()?.let { person ->
|
.content
|
||||||
|
.let { BaseItem(it) }
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
person = DataLoadingState.Success(person),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return person
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
val person = updatePerson()
|
||||||
val dto = person.data
|
val dto = person.data
|
||||||
if ((dto.movieCount ?: 0) > 0) {
|
if ((dto.movieCount ?: 0) > 0) {
|
||||||
fetchRow(person.id, BaseItemKind.MOVIE, movies)
|
fetchRow(person.id, BaseItemKind.MOVIE) { state, row ->
|
||||||
|
state.copy(movies = row)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
movies.setValueOnMain(RowLoadingState.Success(listOf()))
|
_state.update { it.copy(movies = RowLoadingState.Success(emptyList())) }
|
||||||
}
|
}
|
||||||
if ((dto.seriesCount ?: 0) > 0) {
|
if ((dto.seriesCount ?: 0) > 0) {
|
||||||
fetchRow(person.id, BaseItemKind.SERIES, series)
|
fetchRow(person.id, BaseItemKind.SERIES) { state, row ->
|
||||||
|
state.copy(series = row)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
series.setValueOnMain(RowLoadingState.Success(listOf()))
|
_state.update { it.copy(series = RowLoadingState.Success(emptyList())) }
|
||||||
}
|
}
|
||||||
if ((dto.episodeCount ?: 0) > 0) {
|
if ((dto.episodeCount ?: 0) > 0) {
|
||||||
fetchRow(person.id, BaseItemKind.EPISODE, episodes)
|
fetchRow(person.id, BaseItemKind.EPISODE) { state, row ->
|
||||||
|
state.copy(episodes = row)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
_state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) }
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val results = seerrService.similar(person).orEmpty()
|
val results = seerrService.similar(person).orEmpty()
|
||||||
discovered.update { results }
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
discovered = results,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching person %s", itemId)
|
||||||
|
_state.update { it.copy(person = DataLoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchRow(
|
private fun fetchRow(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
type: BaseItemKind,
|
type: BaseItemKind,
|
||||||
target: MutableLiveData<RowLoadingState>,
|
update: (PersonState, RowLoadingState) -> PersonState,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
target.setValueOnMain(RowLoadingState.Loading)
|
_state.update {
|
||||||
|
update.invoke(it, RowLoadingState.Loading)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
|
@ -165,58 +193,68 @@ class PersonViewModel
|
||||||
pageSize = 15,
|
pageSize = 15,
|
||||||
useSeriesForPrimary = false,
|
useSeriesForPrimary = false,
|
||||||
).init()
|
).init()
|
||||||
target.setValueOnMain(RowLoadingState.Success(pager))
|
_state.update {
|
||||||
|
update.invoke(it, RowLoadingState.Success(pager))
|
||||||
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error fetching $type for $itemId")
|
Timber.e(ex, "Error fetching $type for $itemId")
|
||||||
target.setValueOnMain(RowLoadingState.Error(ex))
|
_state.update {
|
||||||
|
update.invoke(it, RowLoadingState.Error(ex))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFavorite(favorite: Boolean) {
|
fun setFavorite(favorite: Boolean) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
itemUuid?.let {
|
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||||
favoriteWatchManager.setFavorite(it, favorite)
|
updatePerson()
|
||||||
fetchAndSetItem(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class PersonState(
|
||||||
|
val person: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||||
|
val movies: RowLoadingState = RowLoadingState.Pending,
|
||||||
|
val series: RowLoadingState = RowLoadingState.Pending,
|
||||||
|
val episodes: RowLoadingState = RowLoadingState.Pending,
|
||||||
|
val discovered: List<DiscoverItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonPage(
|
fun PersonPage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PersonViewModel = hiltViewModel(),
|
viewModel: PersonViewModel =
|
||||||
|
hiltViewModel<PersonViewModel, PersonViewModel.Factory>(
|
||||||
|
creationCallback = { it.create(destination.itemId) },
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
OneTimeLaunchedEffect {
|
val state by viewModel.state.collectAsState()
|
||||||
viewModel.init(destination.itemId)
|
when (val st = state.person) {
|
||||||
}
|
is DataLoadingState.Error -> {
|
||||||
val person by viewModel.item.observeAsState()
|
ErrorMessage(st, modifier)
|
||||||
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
|
||||||
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
|
||||||
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
|
|
||||||
val discovered by viewModel.discovered.collectAsState()
|
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
|
||||||
when (val state = loading) {
|
|
||||||
is LoadingState.Error -> {
|
|
||||||
ErrorMessage(state, modifier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
person?.let { person ->
|
val person = st.data
|
||||||
var showOverviewDialog by remember { mutableStateOf(false) }
|
var showOverviewDialog by remember { mutableStateOf(false) }
|
||||||
val name = person.name ?: person.id.toString()
|
val name = remember(person) { person.name ?: person.id.toString() }
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) }
|
val imageUrl =
|
||||||
|
remember {
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = person.id,
|
||||||
|
imageType = ImageType.PRIMARY,
|
||||||
|
)
|
||||||
|
}
|
||||||
PersonPageContent(
|
PersonPageContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
name = name,
|
name = name,
|
||||||
|
|
@ -226,9 +264,9 @@ fun PersonPage(
|
||||||
deathdate = person.data.endDate?.toLocalDate(),
|
deathdate = person.data.endDate?.toLocalDate(),
|
||||||
birthPlace = person.data.productionLocations?.firstOrNull(),
|
birthPlace = person.data.productionLocations?.firstOrNull(),
|
||||||
favorite = person.favorite,
|
favorite = person.favorite,
|
||||||
movies = movies,
|
movies = state.movies,
|
||||||
series = series,
|
series = state.series,
|
||||||
episodes = episodes,
|
episodes = state.episodes,
|
||||||
onClickItem = { index, item ->
|
onClickItem = { index, item ->
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
|
|
@ -236,7 +274,7 @@ fun PersonPage(
|
||||||
favoriteOnClick = {
|
favoriteOnClick = {
|
||||||
viewModel.setFavorite(!person.favorite)
|
viewModel.setFavorite(!person.favorite)
|
||||||
},
|
},
|
||||||
discovered = discovered,
|
discovered = state.discovered,
|
||||||
onClickDiscover = { index, item ->
|
onClickDiscover = { index, item ->
|
||||||
viewModel.navigationManager.navigateTo(item.destination)
|
viewModel.navigationManager.navigateTo(item.destination)
|
||||||
},
|
},
|
||||||
|
|
@ -257,7 +295,6 @@ fun PersonPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -168,7 +167,7 @@ class PlaylistViewModel
|
||||||
.let { BaseItem(it, false) }
|
.let { BaseItem(it, false) }
|
||||||
state.update { it.copy(playlist = playlist) }
|
state.update { it.copy(playlist = playlist) }
|
||||||
val libraryDisplayInfo =
|
val libraryDisplayInfo =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
libraryDisplayInfoDao.getItem(user, itemId)
|
libraryDisplayInfoDao.getItem(user, itemId)
|
||||||
}
|
}
|
||||||
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
|
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
|
||||||
|
|
@ -198,7 +197,7 @@ class PlaylistViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val libraryDisplayInfo =
|
val libraryDisplayInfo =
|
||||||
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
||||||
|
|
@ -304,7 +303,7 @@ class PlaylistViewModel
|
||||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
FilterUtils.getFilterOptionValues(
|
FilterUtils.getFilterOptionValues(
|
||||||
api,
|
api,
|
||||||
serverRepository.currentUser.value?.id,
|
serverRepository.currentUser?.id,
|
||||||
itemId,
|
itemId,
|
||||||
filterOption,
|
filterOption,
|
||||||
)
|
)
|
||||||
|
|
@ -374,7 +373,7 @@ fun PlaylistDetails(
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val addPlaylistState by addToPlaylistViewModel.playlistState.collectAsState()
|
||||||
|
|
||||||
fun play(
|
fun play(
|
||||||
index: Int,
|
index: Int,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
|
@ -52,7 +51,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
|
@ -79,7 +77,7 @@ fun CollectionDetails(
|
||||||
// Dialogs
|
// Dialogs
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
||||||
|
|
@ -247,7 +245,7 @@ fun CollectionDetails(
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
viewModel.serverRepository.currentUserDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -99,16 +98,14 @@ class CollectionViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewOptionsFlow =
|
private val viewOptionsFlow =
|
||||||
serverRepository.currentUser
|
serverRepository.currentUserFlow
|
||||||
.asFlow()
|
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatMapLatest {
|
.flatMapLatest {
|
||||||
keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
|
keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
|
||||||
|
|
||||||
private val libraryDisplayInfoFlow =
|
private val libraryDisplayInfoFlow =
|
||||||
serverRepository.currentUser
|
serverRepository.currentUserFlow
|
||||||
.asFlow()
|
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatMapLatest {
|
.flatMapLatest {
|
||||||
libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
|
libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
|
||||||
|
|
@ -282,7 +279,7 @@ class CollectionViewModel
|
||||||
}
|
}
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
excludeItemTypes = excludeItemTypes,
|
excludeItemTypes = excludeItemTypes,
|
||||||
|
|
@ -298,7 +295,7 @@ class CollectionViewModel
|
||||||
|
|
||||||
fun changeSort(sortAndDirection: SortAndDirection) {
|
fun changeSort(sortAndDirection: SortAndDirection) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val user = serverRepository.currentUser.value
|
val user = serverRepository.currentUser
|
||||||
val state = _state.value
|
val state = _state.value
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
libraryDisplayInfoDao.saveItem(
|
libraryDisplayInfoDao.saveItem(
|
||||||
|
|
@ -317,7 +314,7 @@ class CollectionViewModel
|
||||||
|
|
||||||
fun changeFilter(filter: GetItemsFilter) {
|
fun changeFilter(filter: GetItemsFilter) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val user = serverRepository.currentUser.value
|
val user = serverRepository.currentUser
|
||||||
val state = _state.value
|
val state = _state.value
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
libraryDisplayInfoDao.saveItem(
|
libraryDisplayInfoDao.saveItem(
|
||||||
|
|
@ -344,7 +341,7 @@ class CollectionViewModel
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serverRepository.currentUser.value?.id?.let { userId ->
|
serverRepository.currentUser?.id?.let { userId ->
|
||||||
keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
|
keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +350,7 @@ class CollectionViewModel
|
||||||
suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
FilterUtils.getFilterOptionValues(
|
FilterUtils.getFilterOptionValues(
|
||||||
api,
|
api,
|
||||||
serverRepository.currentUser.value?.id,
|
serverRepository.currentUser?.id,
|
||||||
itemId,
|
itemId,
|
||||||
filterOption,
|
filterOption,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -64,8 +63,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -86,16 +85,9 @@ fun DiscoverMovieDetails(
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
val item by viewModel.movie.observeAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val rating by viewModel.rating.observeAsState(null)
|
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
|
||||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
|
||||||
val recommended by viewModel.recommended.observeAsState(listOf())
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
|
||||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||||
val canCancel by viewModel.canCancelRequest.collectAsState()
|
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
@ -103,29 +95,29 @@ fun DiscoverMovieDetails(
|
||||||
val requestStr = stringResource(R.string.request)
|
val requestStr = stringResource(R.string.request)
|
||||||
val request4kStr = stringResource(R.string.request_4k)
|
val request4kStr = stringResource(R.string.request_4k)
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.movie) {
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<MovieDetails> -> {
|
||||||
item?.let { movie ->
|
val movie = st.data
|
||||||
DiscoverMovieDetailsContent(
|
DiscoverMovieDetailsContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
movie = movie,
|
movie = movie,
|
||||||
userConfig = userConfig,
|
userConfig = userConfig,
|
||||||
rating = rating,
|
rating = state.rating,
|
||||||
canCancel = canCancel,
|
canCancel = state.canCancelRequest,
|
||||||
people = people,
|
people = state.people,
|
||||||
trailers = trailers,
|
trailers = state.trailers,
|
||||||
similar = similar,
|
similar = state.similar,
|
||||||
recommended = recommended,
|
recommended = state.recommended,
|
||||||
requestOnClick = {
|
requestOnClick = {
|
||||||
movie.id?.let { id ->
|
movie.id?.let { id ->
|
||||||
if (request4kEnabled) {
|
if (request4kEnabled) {
|
||||||
|
|
@ -196,7 +188,6 @@ fun DiscoverMovieDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.discover
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
|
import com.github.damontecres.wholphin.api.seerr.model.MediaRequest
|
||||||
|
|
@ -23,9 +22,8 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.successValue
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -36,13 +34,13 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
|
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
|
||||||
class DiscoverMovieViewModel
|
class DiscoverMovieViewModel
|
||||||
|
|
@ -62,15 +60,8 @@ class DiscoverMovieViewModel
|
||||||
fun create(item: DiscoverItem): DiscoverMovieViewModel
|
fun create(item: DiscoverItem): DiscoverMovieViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _state = MutableStateFlow(DiscoverMovieState())
|
||||||
val movie = MutableLiveData<MovieDetails?>(null)
|
val state: StateFlow<DiscoverMovieState> = _state
|
||||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
|
||||||
|
|
||||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
|
||||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
|
||||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
|
||||||
val recommended = MutableLiveData<List<DiscoverItem>>()
|
|
||||||
val canCancelRequest = MutableStateFlow(false)
|
|
||||||
|
|
||||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||||
val request4kEnabled =
|
val request4kEnabled =
|
||||||
|
|
@ -80,42 +71,37 @@ class DiscoverMovieViewModel
|
||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAndSetItem(): Deferred<MovieDetails> =
|
private fun fetchAndSetItem(): Deferred<MovieDetails?> =
|
||||||
viewModelScope.async(
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
Dispatchers.IO +
|
try {
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error fetching movie",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||||
this@DiscoverMovieViewModel.movie.setValueOnMain(movie)
|
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
|
||||||
movie
|
movie
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error updating movie details")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(): Job =
|
fun init(): Job =
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error fetching movie",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Timber.v("Init for movie %s", item.id)
|
Timber.v("Init for movie %s", item.id)
|
||||||
val movie = fetchAndSetItem().await()
|
try {
|
||||||
|
val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id)
|
||||||
|
_state.update { it.copy(movie = DataLoadingState.Success(movie)) }
|
||||||
val discoveredItem = seerrService.createDiscoverItem(movie)
|
val discoveredItem = seerrService.createDiscoverItem(movie)
|
||||||
backdropService.submit(discoveredItem)
|
backdropService.submit(discoveredItem)
|
||||||
|
|
||||||
updateCanCancel()
|
updateCanCancel()
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
loading.value = LoadingState.Success
|
|
||||||
}
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
|
val result =
|
||||||
rating.setValueOnMain(DiscoverRating(result))
|
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id)
|
||||||
|
_state.update { it.copy(rating = DiscoverRating(result)) }
|
||||||
}
|
}
|
||||||
if (!similar.isInitialized) {
|
if (state.value.similar.isEmpty()) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result =
|
val result =
|
||||||
seerrService.api.moviesApi
|
seerrService.api.moviesApi
|
||||||
|
|
@ -123,7 +109,7 @@ class DiscoverMovieViewModel
|
||||||
.results
|
.results
|
||||||
?.map { seerrService.createDiscoverItem(it) }
|
?.map { seerrService.createDiscoverItem(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
similar.setValueOnMain(result)
|
_state.update { it.copy(similar = result) }
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -132,7 +118,7 @@ class DiscoverMovieViewModel
|
||||||
.results
|
.results
|
||||||
?.map { seerrService.createDiscoverItem(it) }
|
?.map { seerrService.createDiscoverItem(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
recommended.setValueOnMain(result)
|
_state.update { it.copy(recommended = result) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val people =
|
val people =
|
||||||
|
|
@ -144,7 +130,7 @@ class DiscoverMovieViewModel
|
||||||
?.crew
|
?.crew
|
||||||
?.map { seerrService.createDiscoverItem(it) }
|
?.map { seerrService.createDiscoverItem(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
this@DiscoverMovieViewModel.people.setValueOnMain(people)
|
_state.update { it.copy(people = people) }
|
||||||
val trailers =
|
val trailers =
|
||||||
movie.relatedVideos
|
movie.relatedVideos
|
||||||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||||
|
|
@ -152,13 +138,25 @@ class DiscoverMovieViewModel
|
||||||
?.map {
|
?.map {
|
||||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
|
_state.update { it.copy(trailers = trailers) }
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error updating movie details")
|
||||||
|
_state.update { it.copy(movie = DataLoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateCanCancel() {
|
private suspend fun updateCanCancel() {
|
||||||
val user = userConfig.firstOrNull()
|
val user = userConfig.firstOrNull()
|
||||||
val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests)
|
val canCancel =
|
||||||
canCancelRequest.update { canCancel }
|
canUserCancelRequest(
|
||||||
|
user,
|
||||||
|
state.value.movie.successValue
|
||||||
|
?.mediaInfo
|
||||||
|
?.requests,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(canCancelRequest = canCancel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigateTo(destination: Destination) {
|
fun navigateTo(destination: Destination) {
|
||||||
|
|
@ -185,7 +183,7 @@ class DiscoverMovieViewModel
|
||||||
|
|
||||||
fun cancelRequest(id: Int) {
|
fun cancelRequest(id: Int) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
movie.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||||
// TODO handle multiple requests? Or just delete self's request?
|
// TODO handle multiple requests? Or just delete self's request?
|
||||||
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||||
fetchAndSetItem().await()
|
fetchAndSetItem().await()
|
||||||
|
|
@ -204,3 +202,14 @@ fun canUserCancelRequest(
|
||||||
user.hasPermission(SeerrPermission.REQUEST) &&
|
user.hasPermission(SeerrPermission.REQUEST) &&
|
||||||
requests?.any { it.requestedBy?.id == user?.id } == true
|
requests?.any { it.requestedBy?.id == user?.id } == true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DiscoverMovieState(
|
||||||
|
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
|
||||||
|
val rating: DiscoverRating? = null,
|
||||||
|
val seasons: List<RequestSeason> = emptyList(),
|
||||||
|
val trailers: List<Trailer> = emptyList(),
|
||||||
|
val people: List<DiscoverItem> = emptyList(),
|
||||||
|
val similar: List<DiscoverItem> = emptyList(),
|
||||||
|
val recommended: List<DiscoverItem> = emptyList(),
|
||||||
|
val canCancelRequest: Boolean = false,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -66,8 +65,9 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.successValue
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -85,17 +85,8 @@ fun DiscoverSeriesDetails(
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
val item by viewModel.tvSeries.observeAsState()
|
|
||||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
|
||||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
|
||||||
val recommended by viewModel.recommended.observeAsState(listOf())
|
|
||||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
|
||||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||||
val canCancel by viewModel.canCancelRequest.collectAsState()
|
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
@ -105,30 +96,30 @@ fun DiscoverSeriesDetails(
|
||||||
val requestStr = stringResource(R.string.request)
|
val requestStr = stringResource(R.string.request)
|
||||||
val request4kStr = stringResource(R.string.request_4k)
|
val request4kStr = stringResource(R.string.request_4k)
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.tvSeries) {
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<TvDetails> -> {
|
||||||
item?.let { item ->
|
val item = st.data
|
||||||
val rating by viewModel.rating.observeAsState(null)
|
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||||
DiscoverSeriesDetailsContent(
|
DiscoverSeriesDetailsContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
series = item,
|
series = item,
|
||||||
userConfig = userConfig,
|
userConfig = userConfig,
|
||||||
rating = rating,
|
rating = state.rating,
|
||||||
canCancel = canCancel,
|
canCancel = state.canCancelRequest,
|
||||||
seasons = seasons,
|
seasons = state.seasons,
|
||||||
people = people,
|
people = state.people,
|
||||||
similar = similar,
|
similar = state.similar,
|
||||||
recommended = recommended,
|
recommended = state.recommended,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClickItem = { index, item ->
|
onClickItem = { index, item ->
|
||||||
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
viewModel.navigateTo(Destination.DiscoveredItem(item))
|
||||||
|
|
@ -158,7 +149,7 @@ fun DiscoverSeriesDetails(
|
||||||
trailerOnClick = {
|
trailerOnClick = {
|
||||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||||
},
|
},
|
||||||
trailers = trailers,
|
trailers = state.trailers,
|
||||||
requestOnClick = {
|
requestOnClick = {
|
||||||
item.id?.let { id ->
|
item.id?.let { id ->
|
||||||
showRequestSeasonDialog = true
|
showRequestSeasonDialog = true
|
||||||
|
|
@ -174,7 +165,6 @@ fun DiscoverSeriesDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
|
|
@ -203,11 +193,13 @@ fun DiscoverSeriesDetails(
|
||||||
}
|
}
|
||||||
if (showRequestSeasonDialog) {
|
if (showRequestSeasonDialog) {
|
||||||
RequestSeasonsDialog(
|
RequestSeasonsDialog(
|
||||||
title = item?.name ?: "",
|
title = state.tvSeries.successValue?.name ?: "",
|
||||||
seasons = seasons,
|
seasons = state.seasons,
|
||||||
request4kEnabled = request4kEnabled,
|
request4kEnabled = request4kEnabled,
|
||||||
onSubmit = { seasons, is4k ->
|
onSubmit = { seasons, is4k ->
|
||||||
item?.id?.let { viewModel.request(it, seasons, is4k) }
|
state.tvSeries.successValue
|
||||||
|
?.id
|
||||||
|
?.let { viewModel.request(it, seasons, is4k) }
|
||||||
showRequestSeasonDialog = false
|
showRequestSeasonDialog = false
|
||||||
},
|
},
|
||||||
onDismissRequest = { showRequestSeasonDialog = false },
|
onDismissRequest = { showRequestSeasonDialog = false },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.discover
|
package com.github.damontecres.wholphin.ui.detail.discover
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
||||||
|
|
@ -21,25 +20,24 @@ import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.successValue
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -61,16 +59,8 @@ class DiscoverSeriesViewModel
|
||||||
fun create(item: DiscoverItem): DiscoverSeriesViewModel
|
fun create(item: DiscoverItem): DiscoverSeriesViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _state = MutableStateFlow(DiscoverSeriesState())
|
||||||
val tvSeries = MutableLiveData<TvDetails?>(null)
|
val state: StateFlow<DiscoverSeriesState> = _state
|
||||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
|
||||||
|
|
||||||
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
|
|
||||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
|
||||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
|
||||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
|
||||||
val recommended = MutableLiveData<List<DiscoverItem>>()
|
|
||||||
val canCancelRequest = MutableStateFlow(false)
|
|
||||||
|
|
||||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||||
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false }
|
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false }
|
||||||
|
|
@ -79,43 +69,37 @@ class DiscoverSeriesViewModel
|
||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAndSetItem(): Deferred<TvDetails> =
|
private fun fetchAndSetItem(): Deferred<TvDetails?> =
|
||||||
viewModelScope.async(
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
Dispatchers.IO +
|
try {
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error fetching movie",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||||
this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv)
|
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
|
||||||
tv
|
tv
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error updating tv details")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(): Job =
|
fun init(): Job =
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error fetching movie",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Timber.v("Init for tv %s", item.id)
|
Timber.v("Init for tv %s", item.id)
|
||||||
val tv = fetchAndSetItem().await()
|
try {
|
||||||
|
val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id)
|
||||||
|
_state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) }
|
||||||
val discoveredItem = seerrService.createDiscoverItem(tv)
|
val discoveredItem = seerrService.createDiscoverItem(tv)
|
||||||
backdropService.submit(discoveredItem)
|
backdropService.submit(discoveredItem)
|
||||||
|
|
||||||
updateSeasonStatus()
|
updateSeasonStatus(tv)
|
||||||
updateCanCancel()
|
updateCanCancel()
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
loading.value = LoadingState.Success
|
|
||||||
}
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
|
val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)
|
||||||
rating.setValueOnMain(DiscoverRating(result))
|
_state.update { it.copy(rating = DiscoverRating(result)) }
|
||||||
}
|
}
|
||||||
if (!similar.isInitialized) {
|
if (state.value.similar.isEmpty()) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result =
|
val result =
|
||||||
seerrService.api.tvApi
|
seerrService.api.tvApi
|
||||||
|
|
@ -123,7 +107,7 @@ class DiscoverSeriesViewModel
|
||||||
.results
|
.results
|
||||||
?.map { seerrService.createDiscoverItem(it) }
|
?.map { seerrService.createDiscoverItem(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
similar.setValueOnMain(result)
|
_state.update { it.copy(similar = result) }
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -132,7 +116,7 @@ class DiscoverSeriesViewModel
|
||||||
.results
|
.results
|
||||||
?.map { seerrService.createDiscoverItem(it) }
|
?.map { seerrService.createDiscoverItem(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
recommended.setValueOnMain(result)
|
_state.update { it.copy(recommended = result) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val people =
|
val people =
|
||||||
|
|
@ -144,7 +128,7 @@ class DiscoverSeriesViewModel
|
||||||
?.crew
|
?.crew
|
||||||
?.map { seerrService.createDiscoverItem(it) }
|
?.map { seerrService.createDiscoverItem(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
|
_state.update { it.copy(people = people) }
|
||||||
|
|
||||||
val trailers =
|
val trailers =
|
||||||
tv.relatedVideos
|
tv.relatedVideos
|
||||||
|
|
@ -153,15 +137,20 @@ class DiscoverSeriesViewModel
|
||||||
?.map {
|
?.map {
|
||||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers)
|
_state.update { it.copy(trailers = trailers) }
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error getting tv details")
|
||||||
|
_state.update { it.copy(tvSeries = DataLoadingState.Error(ex)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigateTo(destination: Destination) {
|
fun navigateTo(destination: Destination) {
|
||||||
navigationManager.navigateTo(destination)
|
navigationManager.navigateTo(destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateSeasonStatus() {
|
private fun updateSeasonStatus(tv: TvDetails) {
|
||||||
tvSeries.value?.let { tv ->
|
|
||||||
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
|
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
|
||||||
tv.seasons?.forEach {
|
tv.seasons?.forEach {
|
||||||
it.seasonNumber?.let {
|
it.seasonNumber?.let {
|
||||||
|
|
@ -193,14 +182,19 @@ class DiscoverSeriesViewModel
|
||||||
RequestSeason(it, availability)
|
RequestSeason(it, availability)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seasons.setValueOnMain(requestSeasons)
|
_state.update { it.copy(seasons = requestSeasons) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateCanCancel() {
|
private suspend fun updateCanCancel() {
|
||||||
val user = userConfig.firstOrNull()
|
val user = userConfig.firstOrNull()
|
||||||
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
|
val canCancel =
|
||||||
canCancelRequest.update { canCancel }
|
canUserCancelRequest(
|
||||||
|
user,
|
||||||
|
state.value.tvSeries.successValue
|
||||||
|
?.mediaInfo
|
||||||
|
?.requests,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(canCancelRequest = canCancel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun request(
|
fun request(
|
||||||
|
|
@ -209,7 +203,7 @@ class DiscoverSeriesViewModel
|
||||||
is4k: Boolean,
|
is4k: Boolean,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
tvSeries.value?.let { tv ->
|
state.value.tvSeries.successValue?.let { tv ->
|
||||||
val currentRequest =
|
val currentRequest =
|
||||||
tv.mediaInfo?.requests?.firstOrNull {
|
tv.mediaInfo?.requests?.firstOrNull {
|
||||||
it.requestedBy?.id ==
|
it.requestedBy?.id ==
|
||||||
|
|
@ -238,21 +232,35 @@ class DiscoverSeriesViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAndSetItem().await()
|
fetchAndSetItem().await()?.let {
|
||||||
updateSeasonStatus()
|
updateSeasonStatus(it)
|
||||||
updateCanCancel()
|
updateCanCancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelRequest(id: Int) {
|
fun cancelRequest(id: Int) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let {
|
state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let {
|
||||||
// TODO handle multiple requests? Or just delete self's request?
|
// TODO handle multiple requests? Or just delete self's request?
|
||||||
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
seerrService.api.requestApi.requestRequestIdDelete(it.id.toString())
|
||||||
fetchAndSetItem().await()
|
fetchAndSetItem().await()?.let {
|
||||||
|
updateSeasonStatus(it)
|
||||||
updateCanCancel()
|
updateCanCancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DiscoverSeriesState(
|
||||||
|
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
|
||||||
|
val rating: DiscoverRating? = null,
|
||||||
|
val seasons: List<RequestSeason> = emptyList(),
|
||||||
|
val trailers: List<Trailer> = emptyList(),
|
||||||
|
val people: List<DiscoverItem> = emptyList(),
|
||||||
|
val similar: List<DiscoverItem> = emptyList(),
|
||||||
|
val recommended: List<DiscoverItem> = emptyList(),
|
||||||
|
val canCancelRequest: Boolean = false,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -43,11 +42,10 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
@ -66,27 +64,20 @@ fun EpisodeDetails(
|
||||||
),
|
),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
val item by viewModel.item.observeAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
|
||||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
val canDelete by viewModel.canDelete.collectAsState()
|
val canDelete by viewModel.canDelete.collectAsState()
|
||||||
|
|
||||||
val preferredSubtitleLanguage =
|
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
|
||||||
viewModel.serverRepository.currentUserDto
|
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
|
||||||
.observeAsState()
|
|
||||||
.value
|
|
||||||
?.configuration
|
|
||||||
?.subtitleLanguagePreference
|
|
||||||
|
|
||||||
val contextActions =
|
val contextActions =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -119,19 +110,19 @@ fun EpisodeDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.episode) {
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
item?.let { ep ->
|
val ep = st.data
|
||||||
LifecycleResumeEffect(ep) {
|
LifecycleResumeEffect(ep) {
|
||||||
ep.data.seriesId?.let { seriesId ->
|
ep.data.seriesId?.let { seriesId ->
|
||||||
viewModel.maybePlayThemeSong(seriesId)
|
viewModel.maybePlayThemeSong(seriesId)
|
||||||
|
|
@ -143,7 +134,7 @@ fun EpisodeDetails(
|
||||||
EpisodeDetailsContent(
|
EpisodeDetailsContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
ep = ep,
|
ep = ep,
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = state.chosenStreams,
|
||||||
playOnClick = {
|
playOnClick = {
|
||||||
viewModel.navigateTo(
|
viewModel.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
|
|
@ -161,7 +152,7 @@ fun EpisodeDetails(
|
||||||
ContextMenu.ForBaseItem(
|
ContextMenu.ForBaseItem(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
item = ep,
|
item = ep,
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = state.chosenStreams,
|
||||||
showGoTo = false,
|
showGoTo = false,
|
||||||
showStreamChoices = true,
|
showStreamChoices = true,
|
||||||
canDelete = canDelete,
|
canDelete = canDelete,
|
||||||
|
|
@ -182,12 +173,11 @@ fun EpisodeDetails(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
userDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.episode
|
package com.github.damontecres.wholphin.ui.detail.episode
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
|
|
@ -22,26 +20,26 @@ import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class)
|
@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class)
|
||||||
|
|
@ -67,53 +65,61 @@ class EpisodeViewModel
|
||||||
fun create(itemId: UUID): EpisodeViewModel
|
fun create(itemId: UUID): EpisodeViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _state = MutableStateFlow(EpisodeState())
|
||||||
val item = MutableLiveData<BaseItem?>(null)
|
val state: StateFlow<EpisodeState> = _state
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
|
||||||
|
|
||||||
val canDelete = MutableStateFlow(false)
|
val canDelete = MutableStateFlow(false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mediaManagementService.collectCanDelete(item.asFlow()) { canDelete ->
|
mediaManagementService.collectCanDelete(
|
||||||
|
state.map { (it.episode as? DataLoadingState.Success<BaseItem?>)?.data },
|
||||||
|
) { canDelete ->
|
||||||
this@EpisodeViewModel.canDelete.update { canDelete }
|
this@EpisodeViewModel.canDelete.update { canDelete }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAndSetItem(): Deferred<BaseItem> =
|
private fun fetchAndSetItem() {
|
||||||
viewModelScope.async(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
try {
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error fetching movie",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val item =
|
val item =
|
||||||
api.userLibraryApi.getItem(itemId).content.let {
|
api.userLibraryApi.getItem(itemId).content.let {
|
||||||
BaseItem.from(it, api)
|
BaseItem.from(it, api)
|
||||||
}
|
}
|
||||||
this@EpisodeViewModel.item.setValueOnMain(item)
|
_state.update { it.copy(episode = DataLoadingState.Success(item)) }
|
||||||
item
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error getting episode %s", itemId)
|
||||||
|
showToast(context, "Error updating episode")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(): Job =
|
fun init(): Job =
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
try {
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error fetching movie",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
val item = fetchAndSetItem().await()
|
val item =
|
||||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
api.userLibraryApi.getItem(itemId).content.let {
|
||||||
withContext(Dispatchers.Main) {
|
BaseItem(it)
|
||||||
this@EpisodeViewModel.item.value = item
|
}
|
||||||
chosenStreams.value = result
|
val chosenStreams =
|
||||||
loading.value = LoadingState.Success
|
itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
episode = DataLoadingState.Success(item),
|
||||||
|
chosenStreams = chosenStreams,
|
||||||
|
)
|
||||||
|
}
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error getting episode %s", itemId)
|
||||||
|
_state.update { it.copy(episode = DataLoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,9 +151,7 @@ class EpisodeViewModel
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
chosenStreams.value = chosen
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,9 +175,7 @@ class EpisodeViewModel
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
chosenStreams.value = chosen
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,14 +197,16 @@ class EpisodeViewModel
|
||||||
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
|
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
|
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
|
||||||
item.value?.let { item ->
|
state.value.episode.let { item ->
|
||||||
|
if (item is DataLoadingState.Success<BaseItem>) {
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.getSelectedTracks(
|
itemPlaybackRepository.getSelectedTracks(
|
||||||
itemId,
|
itemId,
|
||||||
item,
|
item.data,
|
||||||
userPreferencesService.getCurrent(),
|
userPreferencesService.getCurrent(),
|
||||||
)
|
)
|
||||||
this@EpisodeViewModel.chosenStreams.setValueOnMain(result)
|
_state.update { it.copy(chosenStreams = result) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,3 +217,8 @@ class EpisodeViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class EpisodeState(
|
||||||
|
val episode: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||||
|
val chosenStreams: ChosenStreams? = null,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.ListItem
|
import androidx.tv.material3.ListItem
|
||||||
|
|
@ -44,13 +43,14 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
@ -63,13 +63,16 @@ class DvrScheduleViewModel
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
private val _state = MutableStateFlow(DvrScheduleState())
|
||||||
val active = MutableLiveData<List<BaseItem>>()
|
val state: StateFlow<DvrScheduleState> = _state
|
||||||
val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>()
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
// loading.value = LoadingState.Loading
|
viewModelScope.launchIO {
|
||||||
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) {
|
try {
|
||||||
val active =
|
val active =
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
.getRecordings(
|
.getRecordings(
|
||||||
|
|
@ -84,15 +87,27 @@ class DvrScheduleViewModel
|
||||||
isActive = false,
|
isActive = false,
|
||||||
isScheduled = true,
|
isScheduled = true,
|
||||||
).content.items
|
).content.items
|
||||||
.map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings
|
.map {
|
||||||
|
BaseItem.from(
|
||||||
|
it.programInfo!!,
|
||||||
|
api,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} // TODO this probably breaks for time based recordings
|
||||||
.groupBy {
|
.groupBy {
|
||||||
it.data.startDate!!.toLocalDate()
|
it.data.startDate!!.toLocalDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@DvrScheduleViewModel.active.value = active
|
it.copy(
|
||||||
this@DvrScheduleViewModel.scheduled.value = scheduled
|
loading = LoadingState.Success,
|
||||||
loading.value = LoadingState.Success
|
active = active,
|
||||||
|
scheduled = scheduled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching DVR schedule")
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +127,12 @@ class DvrScheduleViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class DvrScheduleState(
|
||||||
|
val loading: LoadingState = LoadingState.Loading,
|
||||||
|
val active: List<BaseItem> = emptyList(),
|
||||||
|
val scheduled: Map<LocalDate, List<BaseItem>> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DvrSchedule(
|
fun DvrSchedule(
|
||||||
requestFocusAfterLoading: Boolean,
|
requestFocusAfterLoading: Boolean,
|
||||||
|
|
@ -122,12 +143,10 @@ fun DvrSchedule(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
}
|
}
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val state by viewModel.state.collectAsState()
|
||||||
val active by viewModel.active.observeAsState(listOf())
|
when (val st = state.loading) {
|
||||||
val recordings by viewModel.scheduled.observeAsState(mapOf())
|
|
||||||
when (val state = loading) {
|
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
|
|
@ -141,7 +160,7 @@ fun DvrSchedule(
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
if (requestFocusAfterLoading) {
|
if (requestFocusAfterLoading) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (active.isNotEmpty() || recordings.isNotEmpty()) {
|
if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
} else {
|
} else {
|
||||||
focusRequesterOnEmpty.tryRequestFocus()
|
focusRequesterOnEmpty.tryRequestFocus()
|
||||||
|
|
@ -149,8 +168,8 @@ fun DvrSchedule(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DvrScheduleContent(
|
DvrScheduleContent(
|
||||||
activeRecordings = active,
|
activeRecordings = state.active,
|
||||||
scheduledRecordings = recordings,
|
scheduledRecordings = state.scheduled,
|
||||||
onClickItem = {
|
onClickItem = {
|
||||||
showDialog = it
|
showDialog = it
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ class LiveTvViewModel
|
||||||
val channelData by api.liveTvApi.getLiveTvChannels(
|
val channelData by api.liveTvApi.getLiveTvChannels(
|
||||||
GetLiveTvChannelsRequest(
|
GetLiveTvChannelsRequest(
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
enableFavoriteSorting = favoriteChannelsAtBeginning,
|
enableFavoriteSorting = favoriteChannelsAtBeginning,
|
||||||
sortBy =
|
sortBy =
|
||||||
if (sortByRecentlyWatched) {
|
if (sortByRecentlyWatched) {
|
||||||
|
|
@ -229,7 +229,7 @@ class LiveTvViewModel
|
||||||
minEndDate = minEndDate,
|
minEndDate = minEndDate,
|
||||||
channelIds = channelsToFetch.map { it.id },
|
channelIds = channelsToFetch.map { it.id },
|
||||||
sortBy = listOf(ItemSortBy.START_DATE),
|
sortBy = listOf(ItemSortBy.START_DATE),
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
fields = listOf(ItemFields.OVERVIEW),
|
fields = listOf(ItemFields.OVERVIEW),
|
||||||
)
|
)
|
||||||
val fetchedPrograms =
|
val fetchedPrograms =
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -58,7 +57,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -97,14 +95,10 @@ fun MovieDetails(
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
|
|
||||||
val preferredSubtitleLanguage =
|
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
|
||||||
viewModel.serverRepository.currentUserDto
|
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
|
||||||
.observeAsState()
|
|
||||||
.value
|
|
||||||
?.configuration
|
|
||||||
?.subtitleLanguagePreference
|
|
||||||
|
|
||||||
val contextActions =
|
val contextActions =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -258,7 +252,7 @@ fun MovieDetails(
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
userDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ class MovieViewModel
|
||||||
api.libraryApi
|
api.libraryApi
|
||||||
.getSimilarItems(
|
.getSimilarItems(
|
||||||
GetSimilarItemsRequest(
|
GetSimilarItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -76,7 +75,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -188,7 +186,7 @@ class AlbumViewModel
|
||||||
api.libraryApi
|
api.libraryApi
|
||||||
.getSimilarItems(
|
.getSimilarItems(
|
||||||
GetSimilarItemsRequest(
|
GetSimilarItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
excludeArtistIds = album.data.albumArtists?.map { it.id },
|
excludeArtistIds = album.data.albumArtists?.map { it.id },
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
|
|
@ -202,7 +200,7 @@ class AlbumViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
albumIds = listOf(itemId),
|
albumIds = listOf(itemId),
|
||||||
parentId = null,
|
parentId = null,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
|
|
@ -328,7 +326,7 @@ fun AlbumDetailsPage(
|
||||||
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
val moreDialogActions =
|
val moreDialogActions =
|
||||||
remember {
|
remember {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -71,7 +70,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -210,7 +208,7 @@ class ArtistViewModel
|
||||||
api.libraryApi
|
api.libraryApi
|
||||||
.getSimilarItems(
|
.getSimilarItems(
|
||||||
GetSimilarItemsRequest(
|
GetSimilarItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
excludeArtistIds = listOf(itemId),
|
excludeArtistIds = listOf(itemId),
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
|
|
@ -224,7 +222,7 @@ class ArtistViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
artistIds = listOf(itemId),
|
artistIds = listOf(itemId),
|
||||||
parentId = null,
|
parentId = null,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
|
|
@ -319,7 +317,7 @@ fun ArtistDetailsPage(
|
||||||
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||||
|
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
val moreDialogActions =
|
val moreDialogActions =
|
||||||
remember {
|
remember {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class SearchForViewModel
|
||||||
try {
|
try {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
searchTerm = query,
|
searchTerm = query,
|
||||||
includeItemTypes = listOf(searchType),
|
includeItemTypes = listOf(searchType),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -34,7 +33,6 @@ import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -79,7 +77,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -89,7 +86,6 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
|
@ -111,20 +107,9 @@ fun SeriesDetails(
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
|
||||||
|
|
||||||
val item by viewModel.item.observeAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val canDelete by viewModel.canDeleteSeries.collectAsState()
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
|
||||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
|
||||||
val extras by viewModel.extras.observeAsState(listOf())
|
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
|
||||||
val discovered by viewModel.discovered.collectAsState()
|
|
||||||
val discoverSeries by viewModel.discoverSeries.collectAsState()
|
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
|
|
@ -180,37 +165,36 @@ fun SeriesDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.series) {
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
item?.let { item ->
|
|
||||||
LifecycleResumeEffect(destination.itemId) {
|
LifecycleResumeEffect(destination.itemId) {
|
||||||
viewModel.onResumePage()
|
viewModel.onResumePage()
|
||||||
|
|
||||||
onPauseOrDispose {}
|
onPauseOrDispose {}
|
||||||
}
|
}
|
||||||
|
val series = st.data
|
||||||
val played = item.data.userData?.played ?: false
|
val played = series.data.userData?.played ?: false
|
||||||
SeriesDetailsContent(
|
SeriesDetailsContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
series = item,
|
series = series,
|
||||||
seasons = seasons,
|
seasons = state.seasons,
|
||||||
trailers = trailers,
|
trailers = state.trailers,
|
||||||
extras = extras,
|
extras = state.extras,
|
||||||
people = people,
|
people = state.people,
|
||||||
similar = similar,
|
similar = state.similar,
|
||||||
played = played,
|
played = played,
|
||||||
favorite = item.data.userData?.isFavorite ?: false,
|
favorite = series.data.userData?.isFavorite ?: false,
|
||||||
canDelete = canDelete,
|
canDelete = state.canDeleteSeries,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClickItem = { index, item ->
|
onClickItem = { index, item ->
|
||||||
viewModel.navigateTo(item.destination())
|
viewModel.navigateTo(item.destination())
|
||||||
|
|
@ -238,13 +222,13 @@ fun SeriesDetails(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
overviewDialog = ItemDetailsDialogInfo(item)
|
overviewDialog = ItemDetailsDialogInfo(series)
|
||||||
},
|
},
|
||||||
playOnClick = { shuffle ->
|
playOnClick = { shuffle ->
|
||||||
if (shuffle) {
|
if (shuffle) {
|
||||||
viewModel.navigateTo(
|
viewModel.navigateTo(
|
||||||
Destination.PlaybackList(
|
Destination.PlaybackList(
|
||||||
itemId = item.id,
|
itemId = series.id,
|
||||||
shuffle = true,
|
shuffle = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -254,8 +238,8 @@ fun SeriesDetails(
|
||||||
},
|
},
|
||||||
watchOnClick = { showWatchConfirmation = true },
|
watchOnClick = { showWatchConfirmation = true },
|
||||||
favoriteOnClick = {
|
favoriteOnClick = {
|
||||||
val favorite = item.data.userData?.isFavorite ?: false
|
val favorite = series.data.userData?.isFavorite ?: false
|
||||||
viewModel.setFavorite(item.id, !favorite, null)
|
viewModel.setFavorite(series.id, !favorite, null)
|
||||||
},
|
},
|
||||||
trailerOnClick = {
|
trailerOnClick = {
|
||||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||||
|
|
@ -263,13 +247,13 @@ fun SeriesDetails(
|
||||||
onClickExtra = { _, extra ->
|
onClickExtra = { _, extra ->
|
||||||
viewModel.navigateTo(extra.destination)
|
viewModel.navigateTo(extra.destination)
|
||||||
},
|
},
|
||||||
discoverSeries = discoverSeries,
|
discoverSeries = state.discoverSeries,
|
||||||
onClickDiscoverSeries = {
|
onClickDiscoverSeries = {
|
||||||
discoverSeries?.let {
|
state.discoverSeries?.let {
|
||||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
discovered = discovered,
|
discovered = state.discovered,
|
||||||
onClickDiscover = { index, item ->
|
onClickDiscover = { index, item ->
|
||||||
viewModel.navigateTo(item.destination)
|
viewModel.navigateTo(item.destination)
|
||||||
},
|
},
|
||||||
|
|
@ -280,7 +264,7 @@ fun SeriesDetails(
|
||||||
)
|
)
|
||||||
if (showWatchConfirmation) {
|
if (showWatchConfirmation) {
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title = item.name ?: "",
|
title = series.name ?: "",
|
||||||
body =
|
body =
|
||||||
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
|
stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played),
|
||||||
onCancel = {
|
onCancel = {
|
||||||
|
|
@ -294,7 +278,6 @@ fun SeriesDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
showContextMenu?.let { contextMenu ->
|
showContextMenu?.let { contextMenu ->
|
||||||
ContextMenuDialog(
|
ContextMenuDialog(
|
||||||
onDismissRequest = { showContextMenu = null },
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,18 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.map
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
|
|
@ -32,10 +29,9 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
|
|
@ -82,28 +78,21 @@ fun SeriesOverview(
|
||||||
),
|
),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val firstItemFocusRequester = remember { FocusRequester() }
|
val firstItemFocusRequester = remember { FocusRequester() }
|
||||||
val episodeRowFocusRequester = remember { FocusRequester() }
|
val episodeRowFocusRequester = remember { FocusRequester() }
|
||||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||||
val guestStarRowFocusRequester = remember { FocusRequester() }
|
val guestStarRowFocusRequester = remember { FocusRequester() }
|
||||||
val extrasRowFocusRequester = remember { FocusRequester() }
|
val extrasRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val state by viewModel.state.collectAsState()
|
||||||
|
val episodeList =
|
||||||
val series by viewModel.item.observeAsState(null)
|
remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes }
|
||||||
val seasons by viewModel.seasons.observeAsState(emptyList())
|
|
||||||
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
|
|
||||||
val seasonExtras by viewModel.extras.observeAsState(emptyList())
|
|
||||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList())
|
|
||||||
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
|
||||||
|
|
||||||
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
||||||
val currentPosition by rememberUpdatedState(position)
|
val currentPosition by rememberUpdatedState(position)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (seasons.isNotEmpty()) {
|
if (state.seasons.isNotEmpty()) {
|
||||||
seasons.getOrNull(position.seasonTabIndex)?.let {
|
state.seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||||
viewModel.loadEpisodes(it.id)
|
viewModel.loadEpisodes(it.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +101,7 @@ fun SeriesOverview(
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
|
|
||||||
var rowFocused by rememberInt()
|
var rowFocused by rememberInt()
|
||||||
|
|
||||||
|
|
@ -149,7 +138,7 @@ fun SeriesOverview(
|
||||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||||
onClearChosenStreams = {
|
onClearChosenStreams = {
|
||||||
val focusedEpisode =
|
val focusedEpisode =
|
||||||
(episodes as? EpisodeList.Success)
|
(state.episodes as? EpisodeList.Success)
|
||||||
?.episodes
|
?.episodes
|
||||||
?.getOrNull(currentPosition.episodeRowIndex)
|
?.getOrNull(currentPosition.episodeRowIndex)
|
||||||
if (focusedEpisode != null) {
|
if (focusedEpisode != null) {
|
||||||
|
|
@ -159,9 +148,9 @@ fun SeriesOverview(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(position, episodes) {
|
LaunchedEffect(position, state.episodes) {
|
||||||
val focusedEpisode =
|
val focusedEpisode =
|
||||||
(episodes as? EpisodeList.Success)
|
(state.episodes as? EpisodeList.Success)
|
||||||
?.episodes
|
?.episodes
|
||||||
?.getOrNull(position.episodeRowIndex)
|
?.getOrNull(position.episodeRowIndex)
|
||||||
|
|
||||||
|
|
@ -170,29 +159,23 @@ fun SeriesOverview(
|
||||||
viewModel.lookupPeopleInEpisode(it)
|
viewModel.lookupPeopleInEpisode(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
val chosenStreams = state.chosenStreams
|
||||||
|
|
||||||
val preferredSubtitleLanguage =
|
val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null)
|
||||||
viewModel.serverRepository.currentUserDto
|
val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference
|
||||||
.observeAsState()
|
|
||||||
.value
|
|
||||||
?.configuration
|
|
||||||
?.subtitleLanguagePreference
|
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.series) {
|
||||||
is LoadingState.Error -> {
|
is DataLoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
DataLoadingState.Loading,
|
||||||
LoadingState.Pending,
|
DataLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
LoadingPage(modifier)
|
LoadingPage(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
is DataLoadingState.Success<BaseItem> -> {
|
||||||
series?.let { series ->
|
|
||||||
|
|
||||||
RequestOrRestoreFocus(
|
RequestOrRestoreFocus(
|
||||||
when (rowFocused) {
|
when (rowFocused) {
|
||||||
EPISODE_ROW -> episodeRowFocusRequester
|
EPISODE_ROW -> episodeRowFocusRequester
|
||||||
|
|
@ -213,12 +196,12 @@ fun SeriesOverview(
|
||||||
|
|
||||||
SeriesOverviewContent(
|
SeriesOverviewContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
series = series,
|
series = st.data,
|
||||||
seasons = seasons,
|
seasons = state.seasons,
|
||||||
episodes = episodes,
|
episodes = state.episodes,
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
peopleInEpisode = peopleInEpisode,
|
peopleInEpisode = state.peopleInEpisode.people,
|
||||||
seasonExtras = seasonExtras,
|
seasonExtras = state.extras,
|
||||||
position = position,
|
position = position,
|
||||||
firstItemFocusRequester = firstItemFocusRequester,
|
firstItemFocusRequester = firstItemFocusRequester,
|
||||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||||
|
|
@ -227,7 +210,7 @@ fun SeriesOverview(
|
||||||
extrasRowFocusRequester = extrasRowFocusRequester,
|
extrasRowFocusRequester = extrasRowFocusRequester,
|
||||||
onChangeSeason = { index ->
|
onChangeSeason = { index ->
|
||||||
if (index != position.seasonTabIndex) {
|
if (index != position.seasonTabIndex) {
|
||||||
seasons.getOrNull(index)?.let { season ->
|
state.seasons.getOrNull(index)?.let { season ->
|
||||||
viewModel.loadEpisodes(season.id)
|
viewModel.loadEpisodes(season.id)
|
||||||
viewModel.position.update {
|
viewModel.position.update {
|
||||||
SeriesOverviewPosition(index, 0)
|
SeriesOverviewPosition(index, 0)
|
||||||
|
|
@ -332,7 +315,6 @@ fun SeriesOverview(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
showContextMenu?.let { contextMenu ->
|
showContextMenu?.let { contextMenu ->
|
||||||
ContextMenuDialog(
|
ContextMenuDialog(
|
||||||
onDismissRequest = { showContextMenu = null },
|
onDismissRequest = { showContextMenu = null },
|
||||||
|
|
@ -345,7 +327,7 @@ fun SeriesOverview(
|
||||||
ItemDetailsDialog(
|
ItemDetailsDialog(
|
||||||
info = info,
|
info = info,
|
||||||
showFilePath =
|
showFilePath =
|
||||||
viewModel.serverRepository.currentUserDto.value
|
userDto
|
||||||
?.policy
|
?.policy
|
||||||
?.isAdministrator == true,
|
?.isAdministrator == true,
|
||||||
onDismissRequest = { overviewDialog = null },
|
onDismissRequest = { overviewDialog = null },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
|
|
@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.deleteItem
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.gt
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -36,14 +35,13 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.lt
|
import com.github.damontecres.wholphin.ui.lt
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.successValue
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
|
|
@ -57,6 +55,7 @@ import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
@ -84,7 +83,7 @@ import java.util.UUID
|
||||||
class SeriesViewModel
|
class SeriesViewModel
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
private val api: ApiClient,
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
|
|
@ -103,7 +102,7 @@ class SeriesViewModel
|
||||||
@Assisted val seriesId: UUID,
|
@Assisted val seriesId: UUID,
|
||||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
@Assisted val seriesPageType: SeriesPageType,
|
@Assisted val seriesPageType: SeriesPageType,
|
||||||
) : ItemViewModel(api) {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
|
|
@ -113,40 +112,28 @@ class SeriesViewModel
|
||||||
): SeriesViewModel
|
): SeriesViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
private val _state = MutableStateFlow(SeriesState())
|
||||||
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
|
val state: StateFlow<SeriesState> = _state
|
||||||
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
|
||||||
|
|
||||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
|
||||||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
|
||||||
val people = MutableLiveData<List<Person>>(listOf())
|
|
||||||
val similar = MutableLiveData<List<BaseItem>>()
|
|
||||||
val canDeleteSeries = MutableStateFlow(false)
|
|
||||||
|
|
||||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
|
||||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
|
||||||
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
|
|
||||||
|
|
||||||
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error loading series $seriesId",
|
|
||||||
) + Dispatchers.IO,
|
|
||||||
) {
|
|
||||||
Timber.v("Start")
|
Timber.v("Start")
|
||||||
addCloseable { themeSongPlayer.stop() }
|
addCloseable { themeSongPlayer.stop() }
|
||||||
val item = fetchItem(seriesId)
|
val series =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(seriesId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mediaManagementService.collectCanDelete(flowOf(item)) { canDelete ->
|
mediaManagementService.collectCanDelete(flowOf(series)) { canDelete ->
|
||||||
canDeleteSeries.update { canDelete }
|
_state.update { it.copy(canDeleteSeries = canDelete) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
backdropService.submit(item)
|
backdropService.submit(series)
|
||||||
|
|
||||||
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
val seasonsDeferred = getSeasons(series, seasonEpisodeIds?.seasonNumber)
|
||||||
|
|
||||||
val episodeListDeferred =
|
val episodeListDeferred =
|
||||||
if (seriesPageType == SeriesPageType.OVERVIEW) {
|
if (seriesPageType == SeriesPageType.OVERVIEW) {
|
||||||
|
|
@ -191,57 +178,58 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
|
val extras = extrasService.getExtras(seasonEpisodeIds.seasonId)
|
||||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
_state.update { it.copy(extras = extras) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val remoteTrailers = trailerService.getRemoteTrailers(item)
|
val remoteTrailers = trailerService.getRemoteTrailers(series)
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
this@SeriesViewModel.trailers.value = remoteTrailers
|
|
||||||
this@SeriesViewModel.position.update {
|
this@SeriesViewModel.position.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
episodeRowIndex =
|
episodeRowIndex =
|
||||||
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
|
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this@SeriesViewModel.seasons.value = seasons
|
_state.update {
|
||||||
this@SeriesViewModel.episodes.value = episodes
|
it.copy(
|
||||||
loading.value = LoadingState.Success
|
series = DataLoadingState.Success(series),
|
||||||
|
seasons = seasons,
|
||||||
|
episodes = episodes,
|
||||||
|
trailers = remoteTrailers,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seriesPageType == SeriesPageType.DETAILS) {
|
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
|
trailerService.getLocalTrailers(series).letNotEmpty { localTrailers ->
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(trailers = localTrailers + remoteTrailers) }
|
||||||
this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val people = peopleFavorites.getPeopleFor(item)
|
val people = peopleFavorites.getPeopleFor(series)
|
||||||
this@SeriesViewModel.people.setValueOnMain(people)
|
_state.update { it.copy(people = people) }
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val extras = extrasService.getExtras(item.id)
|
val extras = extrasService.getExtras(series.id)
|
||||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
_state.update { it.copy(extras = extras) }
|
||||||
}
|
}
|
||||||
if (!similar.isInitialized) {
|
if (state.value.similar.isEmpty()) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val similar =
|
val similar =
|
||||||
api.libraryApi
|
api.libraryApi
|
||||||
.getSimilarItems(
|
.getSimilarItems(
|
||||||
GetSimilarItemsRequest(
|
GetSimilarItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
itemId = seriesId,
|
itemId = seriesId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
),
|
),
|
||||||
).content.items
|
).content.items
|
||||||
.map { BaseItem.from(it, api, true) }
|
.map { BaseItem(it, true) }
|
||||||
this@SeriesViewModel.similar.setValueOnMain(similar)
|
_state.update { it.copy(similar = similar) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val results = seerrService.similar(item).orEmpty()
|
val results = seerrService.similar(series).orEmpty()
|
||||||
discovered.update { results }
|
_state.update { it.copy(discovered = results) }
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
seerrService.active.collectLatest { active ->
|
seerrService.active.collectLatest { active ->
|
||||||
|
|
@ -249,7 +237,7 @@ class SeriesViewModel
|
||||||
if (active) {
|
if (active) {
|
||||||
try {
|
try {
|
||||||
seerrService
|
seerrService
|
||||||
.getTvSeries(item)
|
.getTvSeries(series)
|
||||||
?.let { seerrService.createDiscoverItem(it) }
|
?.let { seerrService.createDiscoverItem(it) }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex)
|
Timber.e(ex)
|
||||||
|
|
@ -258,7 +246,7 @@ class SeriesViewModel
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
discoverSeries.update { tv }
|
_state.update { it.copy(discoverSeries = tv) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -270,8 +258,8 @@ class SeriesViewModel
|
||||||
deletedItem.item.id,
|
deletedItem.item.id,
|
||||||
seriesId,
|
seriesId,
|
||||||
)
|
)
|
||||||
val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await()
|
val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await()
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
_state.update { it.copy(seasons = seasons) }
|
||||||
}
|
}
|
||||||
}.catch { ex ->
|
}.catch { ex ->
|
||||||
Timber.e(ex, "Error refreshing after deleted item")
|
Timber.e(ex, "Error refreshing after deleted item")
|
||||||
|
|
@ -280,7 +268,7 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onResumePage() {
|
fun onResumePage() {
|
||||||
item.value?.let { item ->
|
state.value.series.successValue?.let { item ->
|
||||||
viewModelScope.launchDefault { backdropService.submit(item) }
|
viewModelScope.launchDefault { backdropService.submit(item) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
themeSongPlayer.playThemeFor(seriesId)
|
themeSongPlayer.playThemeFor(seriesId)
|
||||||
|
|
@ -289,11 +277,9 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
item.value?.let { item ->
|
state.value.series.successValue?.let { item ->
|
||||||
if (loading.value == LoadingState.Success) {
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
(seasons.value as? ApiRequestPager<*>)?.refresh()
|
(state.value.seasons as? ApiRequestPager<*>)?.refresh()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -389,11 +375,15 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadEpisodes(seasonId: UUID) {
|
fun loadEpisodes(seasonId: UUID) {
|
||||||
val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success)
|
val currentEpisodes = (state.value.episodes as? EpisodeList.Success)
|
||||||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||||
this@SeriesViewModel.peopleInEpisode.value = PeopleInItem()
|
_state.update {
|
||||||
this@SeriesViewModel.episodes.value = EpisodeList.Loading
|
it.copy(
|
||||||
this@SeriesViewModel.extras.value = emptyList()
|
peopleInEpisode = PeopleInItem(),
|
||||||
|
episodes = EpisodeList.Loading,
|
||||||
|
extras = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
viewModelScope.launchIO(ExceptionHandler(true)) {
|
||||||
val episodes =
|
val episodes =
|
||||||
|
|
@ -403,13 +393,11 @@ class SeriesViewModel
|
||||||
Timber.e(e, "Error loading episodes for $seriesId for season $seasonId")
|
Timber.e(e, "Error loading episodes for $seriesId for season $seasonId")
|
||||||
EpisodeList.Error(e)
|
EpisodeList.Error(e)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(episodes = episodes) }
|
||||||
this@SeriesViewModel.episodes.value = episodes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val extras = extrasService.getExtras(seasonId)
|
val extras = extrasService.getExtras(seasonId)
|
||||||
this@SeriesViewModel.extras.setValueOnMain(extras)
|
_state.update { it.copy(extras = extras) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,6 +412,30 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateSeries() {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
try {
|
||||||
|
val series =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(seriesId)
|
||||||
|
.content
|
||||||
|
.let(::BaseItem)
|
||||||
|
_state.update { it.copy(series = DataLoadingState.Success(series)) }
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val people = peopleFavorites.getPeopleFor(series)
|
||||||
|
_state.update { it.copy(people = people) }
|
||||||
|
}
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val seasons = getSeasons(series, null).await()
|
||||||
|
_state.update { it.copy(seasons = seasons) }
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error updating series")
|
||||||
|
showToast(context, "Error updating series")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setFavorite(
|
fun setFavorite(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
|
@ -433,11 +445,7 @@ class SeriesViewModel
|
||||||
if (listIndex != null) {
|
if (listIndex != null) {
|
||||||
refreshEpisode(itemId, listIndex)
|
refreshEpisode(itemId, listIndex)
|
||||||
} else {
|
} else {
|
||||||
val item = fetchItem(seriesId)
|
updateSeries()
|
||||||
viewModelScope.launchIO {
|
|
||||||
val people = peopleFavorites.getPeopleFor(item)
|
|
||||||
this@SeriesViewModel.people.setValueOnMain(people)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,32 +454,27 @@ class SeriesViewModel
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
setWatched(seasonId, played, null)
|
setWatched(seasonId, played, null)
|
||||||
val series = fetchItem(seriesId)
|
updateSeries()
|
||||||
val seasons = getSeasons(series, null).await()
|
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWatchedSeries(played: Boolean) =
|
fun setWatchedSeries(played: Boolean) =
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setWatched(seriesId, played)
|
favoriteWatchManager.setWatched(seriesId, played)
|
||||||
val series = fetchItem(seriesId)
|
updateSeries()
|
||||||
val seasons = getSeasons(series, null).await()
|
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshEpisode(
|
fun refreshEpisode(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
listIndex: Int,
|
listIndex: Int,
|
||||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
val eps = episodes.value
|
val eps = state.value.episodes
|
||||||
if (eps is EpisodeList.Success) {
|
if (eps is EpisodeList.Success) {
|
||||||
eps.episodes.refreshItem(listIndex, itemId)
|
eps.episodes.refreshItem(listIndex, itemId)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(episodes = eps) }
|
||||||
episodes.value = eps
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Kind of hack to ensure the backdrop is reloaded if needed
|
// Kind of hack to ensure the backdrop is reloaded if needed
|
||||||
item.value?.let { backdropService.submit(it) }
|
state.value.series.successValue
|
||||||
|
?.let { backdropService.submit(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -506,7 +509,6 @@ class SeriesViewModel
|
||||||
navigationManager.navigateTo(destination)
|
navigationManager.navigateTo(destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
|
||||||
private var chosenStreamsJob: Job? = null
|
private var chosenStreamsJob: Job? = null
|
||||||
|
|
||||||
fun lookUpChosenTracks(
|
fun lookUpChosenTracks(
|
||||||
|
|
@ -522,9 +524,7 @@ class SeriesViewModel
|
||||||
item,
|
item,
|
||||||
userPreferencesService.getCurrent(),
|
userPreferencesService.getCurrent(),
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(chosenStreams = result) }
|
||||||
chosenStreams.value = result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -540,9 +540,7 @@ class SeriesViewModel
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
chosenStreams.value = chosen
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -566,9 +564,7 @@ class SeriesViewModel
|
||||||
result?.let {
|
result?.let {
|
||||||
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(chosenStreams = chosen) }
|
||||||
chosenStreams.value = chosen
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,8 +577,8 @@ class SeriesViewModel
|
||||||
|
|
||||||
suspend fun lookupPeopleInEpisode(item: BaseItem) {
|
suspend fun lookupPeopleInEpisode(item: BaseItem) {
|
||||||
peopleInEpisodeJob?.cancel()
|
peopleInEpisodeJob?.cancel()
|
||||||
if (peopleInEpisode.value?.itemId != item.id) {
|
if (state.value.peopleInEpisode.itemId != item.id) {
|
||||||
peopleInEpisode.setValueOnMain(PeopleInItem())
|
_state.update { it.copy(peopleInEpisode = PeopleInItem()) }
|
||||||
val result =
|
val result =
|
||||||
peopleInEpisodeCache
|
peopleInEpisodeCache
|
||||||
.get(item.id) {
|
.get(item.id) {
|
||||||
|
|
@ -600,7 +596,8 @@ class SeriesViewModel
|
||||||
peopleInEpisodeJob =
|
peopleInEpisodeJob =
|
||||||
viewModelScope.launch(ExceptionHandler()) {
|
viewModelScope.launch(ExceptionHandler()) {
|
||||||
delay(250)
|
delay(250)
|
||||||
peopleInEpisode.setValueOnMain(result.await())
|
val peopleInEpisode = result.await()
|
||||||
|
_state.update { it.copy(peopleInEpisode = peopleInEpisode) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -621,17 +618,17 @@ class SeriesViewModel
|
||||||
if (item.type == BaseItemKind.SERIES) {
|
if (item.type == BaseItemKind.SERIES) {
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
} else if (seriesPageType == SeriesPageType.DETAILS) {
|
} else if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
this@SeriesViewModel.item.value?.let { series ->
|
state.value.series.successValue?.let { series ->
|
||||||
val seasons = getSeasons(series, null).await()
|
val seasons = getSeasons(series, null).await()
|
||||||
if (seasons.isEmpty()) {
|
if (seasons.isEmpty()) {
|
||||||
navigationManager.goBack()
|
navigationManager.goBack()
|
||||||
} else {
|
} else {
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
_state.update { it.copy(seasons = seasons) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
position.value.let { (_, episodeIndex) ->
|
position.value.let { (_, episodeIndex) ->
|
||||||
val eps = episodes.value as? EpisodeList.Success
|
val eps = state.value.episodes as? EpisodeList.Success
|
||||||
if (eps != null) {
|
if (eps != null) {
|
||||||
val pager = eps.episodes
|
val pager = eps.episodes
|
||||||
val lastIndex = pager.lastIndex
|
val lastIndex = pager.lastIndex
|
||||||
|
|
@ -641,16 +638,21 @@ class SeriesViewModel
|
||||||
} else {
|
} else {
|
||||||
if (episodeIndex == lastIndex) {
|
if (episodeIndex == lastIndex) {
|
||||||
// Deleted last episode, so need to move left
|
// Deleted last episode, so need to move left
|
||||||
episodes.setValueOnMain(
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
episodes =
|
||||||
EpisodeList.Success(
|
EpisodeList.Success(
|
||||||
eps.seasonId,
|
eps.seasonId,
|
||||||
pager,
|
pager,
|
||||||
episodeIndex - 1,
|
episodeIndex - 1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
|
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
|
||||||
} else {
|
} else {
|
||||||
episodes.setValueOnMain(
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
episodes =
|
||||||
EpisodeList.Success(
|
EpisodeList.Success(
|
||||||
eps.seasonId,
|
eps.seasonId,
|
||||||
pager,
|
pager,
|
||||||
|
|
@ -665,6 +667,7 @@ class SeriesViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
|
suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
|
||||||
|
|
||||||
|
|
@ -744,3 +747,18 @@ private suspend fun findIndexOf(
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SeriesState(
|
||||||
|
val series: DataLoadingState<BaseItem> = DataLoadingState.Pending,
|
||||||
|
val seasons: List<BaseItem?> = emptyList(),
|
||||||
|
val episodes: EpisodeList = EpisodeList.Loading,
|
||||||
|
val trailers: List<Trailer> = emptyList(),
|
||||||
|
val extras: List<ExtrasItem> = emptyList(),
|
||||||
|
val people: List<Person> = emptyList(),
|
||||||
|
val similar: List<BaseItem> = emptyList(),
|
||||||
|
val canDeleteSeries: Boolean = false,
|
||||||
|
val peopleInEpisode: PeopleInItem = PeopleInItem(),
|
||||||
|
val discovered: List<DiscoverItem> = emptyList(),
|
||||||
|
val discoverSeries: DiscoverItem? = null,
|
||||||
|
val chosenStreams: ChosenStreams? = null,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
|
@ -77,7 +76,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -128,7 +126,7 @@ fun HomePage(
|
||||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
|
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.collectAsState()
|
||||||
var position by rememberPosition()
|
var position by rememberPosition()
|
||||||
|
|
||||||
val onFocusPosition = remember { { it: RowColumn -> position = it } }
|
val onFocusPosition = remember { { it: RowColumn -> position = it } }
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class HomeViewModel
|
||||||
val preferences = userPreferencesService.getCurrent()
|
val preferences = userPreferencesService.getCurrent()
|
||||||
val prefs = preferences.appPreferences.homePagePreferences
|
val prefs = preferences.appPreferences.homePagePreferences
|
||||||
|
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto?.let { userDto ->
|
||||||
val libraries =
|
val libraries =
|
||||||
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||||
val settings =
|
val settings =
|
||||||
|
|
@ -256,7 +256,7 @@ class HomeViewModel
|
||||||
fun removeFromNextUp(item: BaseItem) {
|
fun removeFromNextUp(item: BaseItem) {
|
||||||
if (item.type == BaseItemKind.EPISODE) {
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
serverRepository.currentUser.value?.id?.let { userId ->
|
serverRepository.currentUser?.id?.let { userId ->
|
||||||
latestNextUpService.removeFromNextUp(userId, item)
|
latestNextUpService.removeFromNextUp(userId, item)
|
||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -58,7 +57,6 @@ import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -102,14 +100,17 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
import com.github.damontecres.wholphin.ui.preferences.SwitchColors
|
import com.github.damontecres.wholphin.ui.preferences.SwitchColors
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.SearchRelevance
|
import com.github.damontecres.wholphin.util.SearchRelevance
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -135,15 +136,8 @@ class SearchViewModel
|
||||||
val partialResult = voiceInputManager.partialResult
|
val partialResult = voiceInputManager.partialResult
|
||||||
val seerrActive = seerrService.active
|
val seerrActive = seerrService.active
|
||||||
|
|
||||||
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
private val _state = MutableStateFlow(SearchState())
|
||||||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val state: StateFlow<SearchState> = _state
|
||||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
val albums = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
val combinedResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
|
||||||
|
|
||||||
private var currentQuery: String? = null
|
private var currentQuery: String? = null
|
||||||
private var combinedMode = false
|
private var combinedMode = false
|
||||||
|
|
@ -159,38 +153,76 @@ class SearchViewModel
|
||||||
combinedMode = combined
|
combinedMode = combined
|
||||||
if (query.isNotNullOrBlank()) {
|
if (query.isNotNullOrBlank()) {
|
||||||
if (combined) {
|
if (combined) {
|
||||||
combinedResults.value = SearchResult.Searching
|
_state.update { it.copy(combinedResults = SearchResult.Searching) }
|
||||||
searchCombined(query)
|
searchCombined(query)
|
||||||
} else {
|
} else {
|
||||||
movies.value = SearchResult.Searching
|
_state.update {
|
||||||
series.value = SearchResult.Searching
|
it.copy(
|
||||||
episodes.value = SearchResult.Searching
|
movies = SearchResult.Searching,
|
||||||
collections.value = SearchResult.Searching
|
series = SearchResult.Searching,
|
||||||
searchInternal(query, BaseItemKind.MOVIE, movies)
|
episodes = SearchResult.Searching,
|
||||||
searchInternal(query, BaseItemKind.SERIES, series)
|
collections = SearchResult.Searching,
|
||||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
albums = SearchResult.Searching,
|
||||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
artists = SearchResult.Searching,
|
||||||
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
|
songs = SearchResult.Searching,
|
||||||
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
|
)
|
||||||
searchInternal(query, BaseItemKind.AUDIO, songs)
|
}
|
||||||
|
searchInternal(
|
||||||
|
query,
|
||||||
|
BaseItemKind.MOVIE,
|
||||||
|
) { result, state -> state.copy(movies = result) }
|
||||||
|
searchInternal(
|
||||||
|
query,
|
||||||
|
BaseItemKind.SERIES,
|
||||||
|
) { result, state -> state.copy(series = result) }
|
||||||
|
searchInternal(query, BaseItemKind.EPISODE) { result, state ->
|
||||||
|
state.copy(
|
||||||
|
episodes = result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searchInternal(query, BaseItemKind.BOX_SET) { result, state ->
|
||||||
|
state.copy(
|
||||||
|
collections = result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state ->
|
||||||
|
state.copy(
|
||||||
|
albums = result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state ->
|
||||||
|
state.copy(
|
||||||
|
artists = result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searchInternal(
|
||||||
|
query,
|
||||||
|
BaseItemKind.AUDIO,
|
||||||
|
) { result, state -> state.copy(songs = result) }
|
||||||
}
|
}
|
||||||
searchSeerr(query)
|
searchSeerr(query)
|
||||||
} else {
|
} else {
|
||||||
movies.value = SearchResult.NoQuery
|
_state.update {
|
||||||
series.value = SearchResult.NoQuery
|
it.copy(
|
||||||
episodes.value = SearchResult.NoQuery
|
combinedResults = SearchResult.NoQuery,
|
||||||
collections.value = SearchResult.NoQuery
|
movies = SearchResult.NoQuery,
|
||||||
seerrResults.value = SearchResult.NoQuery
|
series = SearchResult.NoQuery,
|
||||||
combinedResults.value = SearchResult.NoQuery
|
episodes = SearchResult.NoQuery,
|
||||||
|
collections = SearchResult.NoQuery,
|
||||||
|
albums = SearchResult.NoQuery,
|
||||||
|
artists = SearchResult.NoQuery,
|
||||||
|
songs = SearchResult.NoQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun searchInternal(
|
private fun searchInternal(
|
||||||
query: String,
|
query: String,
|
||||||
type: BaseItemKind,
|
type: BaseItemKind,
|
||||||
target: MutableLiveData<SearchResult>,
|
update: (SearchResult, SearchState) -> SearchState,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
|
@ -210,14 +242,12 @@ class SearchViewModel
|
||||||
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
||||||
.thenBy { it.name ?: "" },
|
.thenBy { it.name ?: "" },
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { update.invoke(SearchResult.Success(sorted), it) }
|
||||||
target.value = SearchResult.Success(sorted)
|
} catch (ex: CancellationException) {
|
||||||
}
|
throw ex
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception searching for $type")
|
Timber.e(ex, "Exception searching for $type")
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { update.invoke(SearchResult.Error(ex), it) }
|
||||||
target.value = SearchResult.Error(ex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -249,15 +279,10 @@ class SearchViewModel
|
||||||
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
||||||
.thenBy { it.name ?: "" },
|
.thenBy { it.name ?: "" },
|
||||||
)
|
)
|
||||||
|
_state.update { it.copy(combinedResults = SearchResult.Success(sorted)) }
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
combinedResults.value = SearchResult.Success(sorted)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception in combined search")
|
Timber.e(ex, "Exception in combined search")
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(combinedResults = SearchResult.Error(ex)) }
|
||||||
combinedResults.value = SearchResult.Error(ex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -285,13 +310,13 @@ class SearchViewModel
|
||||||
private fun searchSeerr(query: String) {
|
private fun searchSeerr(query: String) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
if (seerrService.active.first()) {
|
if (seerrService.active.first()) {
|
||||||
seerrResults.setValueOnMain(SearchResult.Searching)
|
_state.update { it.copy(seerrResults = SearchResult.Searching) }
|
||||||
val results =
|
val results =
|
||||||
seerrService
|
seerrService
|
||||||
.search(query)
|
.search(query)
|
||||||
.map { seerrService.createDiscoverItem(it) }
|
.map { seerrService.createDiscoverItem(it) }
|
||||||
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
|
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
|
||||||
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
|
_state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -324,6 +349,18 @@ sealed interface SearchResult {
|
||||||
) : SearchResult
|
) : SearchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SearchState(
|
||||||
|
val movies: SearchResult = SearchResult.NoQuery,
|
||||||
|
val series: SearchResult = SearchResult.NoQuery,
|
||||||
|
val episodes: SearchResult = SearchResult.NoQuery,
|
||||||
|
val collections: SearchResult = SearchResult.NoQuery,
|
||||||
|
val albums: SearchResult = SearchResult.NoQuery,
|
||||||
|
val artists: SearchResult = SearchResult.NoQuery,
|
||||||
|
val songs: SearchResult = SearchResult.NoQuery,
|
||||||
|
val seerrResults: SearchResult = SearchResult.NoQuery,
|
||||||
|
val combinedResults: SearchResult = SearchResult.NoQuery,
|
||||||
|
)
|
||||||
|
|
||||||
private const val SEARCH_ROW = 0
|
private const val SEARCH_ROW = 0
|
||||||
private const val TAB_ROW = SEARCH_ROW + 1
|
private const val TAB_ROW = SEARCH_ROW + 1
|
||||||
private const val MOVIE_ROW = TAB_ROW + 1
|
private const val MOVIE_ROW = TAB_ROW + 1
|
||||||
|
|
@ -348,15 +385,7 @@ fun SearchPage(
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
|
val state by viewModel.state.collectAsState()
|
||||||
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
|
|
||||||
val series by viewModel.series.observeAsState(SearchResult.NoQuery)
|
|
||||||
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
|
|
||||||
val albums by viewModel.albums.observeAsState(SearchResult.NoQuery)
|
|
||||||
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
|
|
||||||
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
|
|
||||||
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
|
|
||||||
val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery)
|
|
||||||
|
|
||||||
// Start with current preferences, but collect updates when view options change
|
// Start with current preferences, but collect updates when view options change
|
||||||
val prefs =
|
val prefs =
|
||||||
|
|
@ -441,12 +470,7 @@ fun SearchPage(
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
searchClicked,
|
searchClicked,
|
||||||
movies,
|
state,
|
||||||
collections,
|
|
||||||
series,
|
|
||||||
episodes,
|
|
||||||
seerrResults,
|
|
||||||
combinedResults,
|
|
||||||
combinedMode,
|
combinedMode,
|
||||||
selectedTab,
|
selectedTab,
|
||||||
seerrActive,
|
seerrActive,
|
||||||
|
|
@ -458,12 +482,20 @@ fun SearchPage(
|
||||||
val results =
|
val results =
|
||||||
if (isLibraryTab) {
|
if (isLibraryTab) {
|
||||||
if (combinedMode) {
|
if (combinedMode) {
|
||||||
listOf(combinedResults)
|
listOf(state.combinedResults)
|
||||||
} else {
|
} else {
|
||||||
listOf(movies, series, episodes, collections)
|
listOf(
|
||||||
|
state.movies,
|
||||||
|
state.series,
|
||||||
|
state.episodes,
|
||||||
|
state.collections,
|
||||||
|
state.albums,
|
||||||
|
state.artists,
|
||||||
|
state.songs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
listOf(seerrResults)
|
listOf(state.seerrResults)
|
||||||
}
|
}
|
||||||
val firstSuccess =
|
val firstSuccess =
|
||||||
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
|
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
|
||||||
|
|
@ -609,7 +641,7 @@ fun SearchPage(
|
||||||
when {
|
when {
|
||||||
isLibraryTab && combinedMode -> {
|
isLibraryTab && combinedMode -> {
|
||||||
SearchCombinedResults(
|
SearchCombinedResults(
|
||||||
result = combinedResults,
|
result = state.combinedResults,
|
||||||
focusRequester = focusRequesters[COMBINED_ROW],
|
focusRequester = focusRequesters[COMBINED_ROW],
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onPlayItem = onPlayItem,
|
onPlayItem = onPlayItem,
|
||||||
|
|
@ -622,7 +654,7 @@ fun SearchPage(
|
||||||
|
|
||||||
!isLibraryTab && combinedMode -> {
|
!isLibraryTab && combinedMode -> {
|
||||||
SearchCombinedResults(
|
SearchCombinedResults(
|
||||||
result = seerrResults,
|
result = state.seerrResults,
|
||||||
focusRequester = focusRequesters[SEERR_ROW],
|
focusRequester = focusRequesters[SEERR_ROW],
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onPlayItem = onPlayItem,
|
onPlayItem = onPlayItem,
|
||||||
|
|
@ -647,7 +679,7 @@ fun SearchPage(
|
||||||
) {
|
) {
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.movies,
|
title = R.string.movies,
|
||||||
result = movies,
|
result = state.movies,
|
||||||
rowIndex = MOVIE_ROW,
|
rowIndex = MOVIE_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[MOVIE_ROW],
|
focusRequester = focusRequesters[MOVIE_ROW],
|
||||||
|
|
@ -657,7 +689,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.tv_shows,
|
title = R.string.tv_shows,
|
||||||
result = series,
|
result = state.series,
|
||||||
rowIndex = SERIES_ROW,
|
rowIndex = SERIES_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[SERIES_ROW],
|
focusRequester = focusRequesters[SERIES_ROW],
|
||||||
|
|
@ -667,7 +699,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.episodes,
|
title = R.string.episodes,
|
||||||
result = episodes,
|
result = state.episodes,
|
||||||
rowIndex = EPISODE_ROW,
|
rowIndex = EPISODE_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[EPISODE_ROW],
|
focusRequester = focusRequesters[EPISODE_ROW],
|
||||||
|
|
@ -689,7 +721,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.collections,
|
title = R.string.collections,
|
||||||
result = collections,
|
result = state.collections,
|
||||||
rowIndex = COLLECTION_ROW,
|
rowIndex = COLLECTION_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||||
|
|
@ -699,7 +731,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.albums,
|
title = R.string.albums,
|
||||||
result = albums,
|
result = state.albums,
|
||||||
rowIndex = ALBUM_ROW,
|
rowIndex = ALBUM_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[ALBUM_ROW],
|
focusRequester = focusRequesters[ALBUM_ROW],
|
||||||
|
|
@ -723,7 +755,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.artists,
|
title = R.string.artists,
|
||||||
result = artists,
|
result = state.artists,
|
||||||
rowIndex = ARTIST_ROW,
|
rowIndex = ARTIST_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[ARTIST_ROW],
|
focusRequester = focusRequesters[ARTIST_ROW],
|
||||||
|
|
@ -747,7 +779,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.songs,
|
title = R.string.songs,
|
||||||
result = songs,
|
result = state.songs,
|
||||||
rowIndex = SONG_ROW,
|
rowIndex = SONG_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[SONG_ROW],
|
focusRequester = focusRequesters[SONG_ROW],
|
||||||
|
|
@ -771,7 +803,7 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.discover,
|
title = R.string.discover,
|
||||||
result = seerrResults,
|
result = state.seerrResults,
|
||||||
rowIndex = SEERR_ROW,
|
rowIndex = SEERR_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[SEERR_ROW],
|
focusRequester = focusRequesters[SEERR_ROW],
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ class HomeSettingsViewModel
|
||||||
init {
|
init {
|
||||||
addCloseable { saveToLocal() }
|
addCloseable { saveToLocal() }
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val userDto = serverRepository.currentUserDto.value ?: return@launchIO
|
val userDto = serverRepository.currentUserDto ?: return@launchIO
|
||||||
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
|
||||||
val currentSettings =
|
val currentSettings =
|
||||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||||
|
|
@ -122,7 +122,7 @@ class HomeSettingsViewModel
|
||||||
val limit = 8
|
val limit = 8
|
||||||
val semaphore = Semaphore(4)
|
val semaphore = Semaphore(4)
|
||||||
val rows =
|
val rows =
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto?.let { userDto ->
|
||||||
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
|
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
|
||||||
state.value
|
state.value
|
||||||
.let { state ->
|
.let { state ->
|
||||||
|
|
@ -508,7 +508,7 @@ class HomeSettingsViewModel
|
||||||
|
|
||||||
fun saveToRemote() {
|
fun saveToRemote() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
Timber.d("Saving home settings to remote")
|
Timber.d("Saving home settings to remote")
|
||||||
val rows = state.value.rows.map { it.config }
|
val rows = state.value.rows.map { it.config }
|
||||||
val settings =
|
val settings =
|
||||||
|
|
@ -527,7 +527,7 @@ class HomeSettingsViewModel
|
||||||
|
|
||||||
fun loadFromRemote() {
|
fun loadFromRemote() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
Timber.d("Loading home settings from remote")
|
Timber.d("Loading home settings from remote")
|
||||||
try {
|
try {
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
|
|
@ -561,7 +561,7 @@ class HomeSettingsViewModel
|
||||||
|
|
||||||
fun loadFromRemoteWeb() {
|
fun loadFromRemoteWeb() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
Timber.d("Loading home settings from web")
|
Timber.d("Loading home settings from web")
|
||||||
try {
|
try {
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
|
|
@ -588,7 +588,7 @@ class HomeSettingsViewModel
|
||||||
fun saveToLocal() {
|
fun saveToLocal() {
|
||||||
// This uses injected ioScope so that it will still run when the page is closing
|
// This uses injected ioScope so that it will still run when the page is closing
|
||||||
ioScope.launchIO {
|
ioScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val rows = state.value.rows.map { it.config }
|
val rows = state.value.rows.map { it.config }
|
||||||
val settings =
|
val settings =
|
||||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||||
|
|
@ -655,7 +655,7 @@ class HomeSettingsViewModel
|
||||||
|
|
||||||
fun resetToDefault() =
|
fun resetToDefault() =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val userId = serverRepository.currentUser.value?.id ?: return@launchIO
|
val userId = serverRepository.currentUser?.id ?: return@launchIO
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
val result = homeSettingsService.createDefault(userId)
|
val result = homeSettingsService.createDefault(userId)
|
||||||
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.ListItem
|
import androidx.tv.material3.ListItem
|
||||||
|
|
@ -75,7 +74,7 @@ class RemovedNextUpContentViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
serverRepository.currentUser.asFlow().collectLatest { user ->
|
serverRepository.currentUserFlow.collectLatest { user ->
|
||||||
_state.update { RemovedNextUpState() }
|
_state.update { RemovedNextUpState() }
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return@collectLatest
|
return@collectLatest
|
||||||
|
|
@ -105,7 +104,7 @@ class RemovedNextUpContentViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(item: RemovedItem) {
|
fun remove(item: RemovedItem) {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
_state.update { it.copy(removedEnabled = false) }
|
_state.update { it.copy(removedEnabled = false) }
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -56,7 +55,6 @@ import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -86,13 +84,15 @@ import com.github.damontecres.wholphin.ui.components.TimeDisplay
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
|
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
|
||||||
import com.github.damontecres.wholphin.ui.spacedByWithFooter
|
import com.github.damontecres.wholphin.ui.spacedByWithFooter
|
||||||
import com.github.damontecres.wholphin.ui.theme.LocalTheme
|
import com.github.damontecres.wholphin.ui.theme.LocalTheme
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -111,10 +111,10 @@ class NavDrawerViewModel
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
private val musicService: MusicService,
|
private val musicService: MusicService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val state = navDrawerService.state
|
val serviceState = navDrawerService.state
|
||||||
|
|
||||||
val selectedIndex = MutableLiveData(-1)
|
private val _state = MutableStateFlow(NavDrawerState())
|
||||||
val moreExpanded = MutableLiveData(false)
|
val state: StateFlow<NavDrawerState> = _state
|
||||||
|
|
||||||
fun onClickDrawerItem(
|
fun onClickDrawerItem(
|
||||||
index: Int,
|
index: Int,
|
||||||
|
|
@ -130,7 +130,7 @@ class NavDrawerViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
NavDrawerItem.More -> {
|
NavDrawerItem.More -> {
|
||||||
setShowMore(!moreExpanded.value!!)
|
setShowMore(!state.value.moreExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
NavDrawerItem.Discover -> {
|
NavDrawerItem.Discover -> {
|
||||||
|
|
@ -148,11 +148,11 @@ class NavDrawerViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setIndex(index: Int) {
|
fun setIndex(index: Int) {
|
||||||
selectedIndex.value = index
|
_state.update { it.copy(selectedIndex = index) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowMore(value: Boolean) {
|
fun setShowMore(value: Boolean) {
|
||||||
moreExpanded.value = value
|
_state.update { it.copy(moreExpanded = value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
||||||
|
|
@ -164,11 +164,11 @@ class NavDrawerViewModel
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val asDestinations =
|
val asDestinations =
|
||||||
(
|
(
|
||||||
state.value.items +
|
serviceState.value.items +
|
||||||
listOf(
|
listOf(
|
||||||
NavDrawerItem.More,
|
NavDrawerItem.More,
|
||||||
NavDrawerItem.Discover,
|
NavDrawerItem.Discover,
|
||||||
) + state.value.moreItems
|
) + serviceState.value.moreItems
|
||||||
).map {
|
).map {
|
||||||
if (it is ServerNavDrawerItem) {
|
if (it is ServerNavDrawerItem) {
|
||||||
it.destination
|
it.destination
|
||||||
|
|
@ -202,7 +202,7 @@ class NavDrawerViewModel
|
||||||
}
|
}
|
||||||
Timber.v("Found $index => $key")
|
Timber.v("Found $index => $key")
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
selectedIndex.setValueOnMain(index)
|
_state.update { it.copy(selectedIndex = index) }
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +218,11 @@ class NavDrawerViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NavDrawerState(
|
||||||
|
val selectedIndex: Int = -1,
|
||||||
|
val moreExpanded: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item that can be shown in the nav drawer
|
* An item that can be shown in the nav drawer
|
||||||
*
|
*
|
||||||
|
|
@ -303,10 +308,11 @@ fun NavDrawer(
|
||||||
drawerState.setValue(DrawerValue.Open)
|
drawerState.setValue(DrawerValue.Open)
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
val serviceState by viewModel.serviceState.collectAsState()
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
|
val moreExpanded = state.moreExpanded
|
||||||
// A negative index is a built-in page, >=0 is a library
|
// A negative index is a built-in page, >=0 is a library
|
||||||
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
|
val selectedIndex = state.selectedIndex
|
||||||
|
|
||||||
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
|
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
|
||||||
viewModel.setShowMore(false)
|
viewModel.setShowMore(false)
|
||||||
|
|
@ -361,14 +367,14 @@ fun NavDrawer(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.nowPlayingEnabled,
|
visible = serviceState.nowPlayingEnabled,
|
||||||
enter = expandVertically(expandFrom = Alignment.Top),
|
enter = expandVertically(expandFrom = Alignment.Top),
|
||||||
exit = shrinkVertically(shrinkTowards = Alignment.Top),
|
exit = shrinkVertically(shrinkTowards = Alignment.Top),
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
IconNavItem(
|
IconNavItem(
|
||||||
text = stringResource(R.string.now_playing),
|
text = stringResource(R.string.now_playing),
|
||||||
subtext = state.nowPlayingTitle,
|
subtext = serviceState.nowPlayingTitle,
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
selected = selectedIndex == NOW_PLAYING_INDEX,
|
selected = selectedIndex == NOW_PLAYING_INDEX,
|
||||||
drawerOpen = isOpen,
|
drawerOpen = isOpen,
|
||||||
|
|
@ -448,8 +454,8 @@ fun NavDrawer(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
itemsIndexed(state.items) { index, it ->
|
itemsIndexed(serviceState.items) { index, it ->
|
||||||
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
|
if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
NavItem(
|
NavItem(
|
||||||
library = it,
|
library = it,
|
||||||
|
|
@ -469,9 +475,9 @@ fun NavDrawer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.moreItems.isNotEmpty()) {
|
if (serviceState.moreItems.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
val index = state.items.size
|
val index = serviceState.items.size
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
NavItem(
|
NavItem(
|
||||||
library = NavDrawerItem.More,
|
library = NavDrawerItem.More,
|
||||||
|
|
@ -492,10 +498,10 @@ fun NavDrawer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (moreExpanded) {
|
if (moreExpanded) {
|
||||||
itemsIndexed(state.moreItems) { index, it ->
|
itemsIndexed(serviceState.moreItems) { index, it ->
|
||||||
val adjustedIndex =
|
val adjustedIndex =
|
||||||
remember(state) { (index + state.items.size + 1) }
|
remember(serviceState) { (index + serviceState.items.size + 1) }
|
||||||
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
|
if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
NavItem(
|
NavItem(
|
||||||
library = it,
|
library = it,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ fun DownloadSubtitlesContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
|
SubtitleSearchStatus.Inactive -> {}
|
||||||
|
|
||||||
SubtitleSearchStatus.Searching -> {
|
SubtitleSearchStatus.Searching -> {
|
||||||
Wrapper {
|
Wrapper {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ class PlayExternalViewModel
|
||||||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||||
}
|
}
|
||||||
val playbackConfig =
|
val playbackConfig =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
|
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
|
||||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||||
if (it.sourceId != null) {
|
if (it.sourceId != null) {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
|
@ -51,7 +50,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.intl.Locale
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
|
@ -69,8 +67,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
|
||||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
|
@ -101,7 +97,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -125,9 +120,8 @@ fun PlaybackPage(
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
when (val st = state.loading) {
|
||||||
when (val st = loading) {
|
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(st, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +135,7 @@ fun PlaybackPage(
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
val playerState by viewModel.currentPlayer.collectAsState()
|
val playerState by viewModel.currentPlayer.collectAsState()
|
||||||
PlaybackPageContent(
|
PlaybackPageContent(
|
||||||
playerState = playerState!!,
|
playerInstance = playerState!!,
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
destination = destination,
|
destination = destination,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
|
@ -154,41 +148,25 @@ fun PlaybackPage(
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackPageContent(
|
fun PlaybackPageContent(
|
||||||
playerState: PlayerState,
|
playerInstance: PlayerInstance,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlaybackViewModel,
|
viewModel: PlaybackViewModel,
|
||||||
) {
|
) {
|
||||||
val player = playerState.player
|
val state by viewModel.state.collectAsState()
|
||||||
val playerBackend = playerState.backend
|
val subtitleSearchState by viewModel.subtitleSearchState.collectAsState()
|
||||||
|
val player = playerInstance.player
|
||||||
|
val playerBackend = playerInstance.backend
|
||||||
|
|
||||||
val prefs = preferences.appPreferences.playbackPreferences
|
val prefs = preferences.appPreferences.playbackPreferences
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
val userDto by viewModel.currentUserDto.collectAsState()
|
||||||
val userDto by viewModel.currentUserDto.observeAsState()
|
|
||||||
|
|
||||||
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
|
||||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
|
||||||
ItemPlayback(
|
|
||||||
userId = -1,
|
|
||||||
itemId = UUID.randomUUID(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val currentSegment by viewModel.currentSegment.collectAsState()
|
|
||||||
val analyticsState by viewModel.analyticsState.collectAsState()
|
|
||||||
|
|
||||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
|
||||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
||||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
|
||||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
|
||||||
|
|
||||||
val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null)
|
|
||||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
|
||||||
|
|
||||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||||
LaunchedEffect(player) {
|
LaunchedEffect(player) {
|
||||||
if (playerBackend == PlayerBackend.MPV) {
|
if (playerBackend == PlayerBackend.MPV) {
|
||||||
|
|
@ -214,16 +192,16 @@ fun PlaybackPageContent(
|
||||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||||
|
|
||||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
LaunchedEffect(state.currentPlayback?.subtitleDelay) {
|
||||||
LaunchedEffect(subtitleDelay) {
|
(player as? MpvPlayer)?.subtitleDelay =
|
||||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
val presentationState = rememberPresentationState(player, false)
|
val presentationState = rememberPresentationState(player, false)
|
||||||
val playbackState by rememberPlaybackState(player)
|
val playbackState by rememberPlayerState(player)
|
||||||
var showBuffering by remember { mutableStateOf(false) }
|
var showBuffering by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(playbackState) {
|
LaunchedEffect(playbackState) {
|
||||||
if (playbackState == PlaybackState.BUFFERING) {
|
if (playbackState == PlayerState.BUFFERING) {
|
||||||
// Delay before showing the loading indicator
|
// Delay before showing the loading indicator
|
||||||
// So if buffering is quick, the UI won't flash
|
// So if buffering is quick, the UI won't flash
|
||||||
delay(250)
|
delay(250)
|
||||||
|
|
@ -259,7 +237,7 @@ fun PlaybackPageContent(
|
||||||
val keyHandler =
|
val keyHandler =
|
||||||
PlaybackKeyHandler(
|
PlaybackKeyHandler(
|
||||||
player = player,
|
player = player,
|
||||||
controlsEnabled = nextUp == null,
|
controlsEnabled = state.nextUp == null,
|
||||||
skipWithLeftRight = true,
|
skipWithLeftRight = true,
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
|
@ -318,7 +296,7 @@ fun PlaybackPageContent(
|
||||||
|
|
||||||
PlaybackAction.Previous -> {
|
PlaybackAction.Previous -> {
|
||||||
val pos = player.currentPosition
|
val pos = player.currentPosition
|
||||||
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
if (pos < player.maxSeekToPreviousPosition && state.playlist.hasPrevious()) {
|
||||||
viewModel.playPrevious()
|
viewModel.playPrevious()
|
||||||
} else {
|
} else {
|
||||||
player.seekToPrevious()
|
player.seekToPrevious()
|
||||||
|
|
@ -328,17 +306,17 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val showSegment =
|
val showSegment =
|
||||||
currentSegment?.interacted == false &&
|
state.currentSegment?.interacted == false &&
|
||||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||||
BackHandler(showSegment) {
|
BackHandler(showSegment) {
|
||||||
viewModel.updateSegment(currentSegment?.segment?.id, true)
|
viewModel.updateSegment(state.currentSegment?.segment?.id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
.background(if (state.nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
|
val playerSize by animateFloatAsState(if (state.nextUp == null) 1f else .6f)
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -427,12 +405,12 @@ fun PlaybackPageContent(
|
||||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Transparent),
|
.background(Color.Transparent),
|
||||||
item = currentPlayback?.item,
|
item = state.currentPlayback?.item,
|
||||||
player = player,
|
player = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
previousEnabled = true,
|
previousEnabled = true,
|
||||||
nextEnabled = playlist.hasNext(),
|
nextEnabled = state.playlist.hasNext(),
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
|
@ -441,23 +419,23 @@ fun PlaybackPageContent(
|
||||||
onClickPlaybackDialogType = { playbackDialog = it },
|
onClickPlaybackDialogType = { playbackDialog = it },
|
||||||
onSeekBarChange = seekBarState::onValueChange,
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
showDebugInfo = showDebugInfo,
|
showDebugInfo = showDebugInfo,
|
||||||
currentPlayback = currentPlayback,
|
currentPlayback = state.currentPlayback,
|
||||||
chapters = mediaInfo?.chapters ?: listOf(),
|
chapters = state.currentMediaInfo.chapters,
|
||||||
trickplayInfo = mediaInfo?.trickPlayInfo,
|
trickplayInfo = state.currentMediaInfo.trickPlayInfo,
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
playlist = playlist,
|
playlist = state.playlist,
|
||||||
onClickPlaylist = {
|
onClickPlaylist = {
|
||||||
viewModel.playItemInPlaylist(it)
|
viewModel.playItemInPlaylist(it)
|
||||||
},
|
},
|
||||||
currentSegment = currentSegment?.segment,
|
currentSegment = state.currentSegment?.segment,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
analyticsState = analyticsState,
|
analyticsState = state.analyticsState,
|
||||||
)
|
)
|
||||||
|
|
||||||
val subtitleSettings =
|
val subtitleSettings =
|
||||||
remember(mediaInfo) {
|
remember(state.currentMediaInfo) {
|
||||||
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
|
Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}")
|
||||||
if (mediaInfo?.videoStream?.hdr == true) {
|
if (state.currentMediaInfo.videoStream?.hdr == true) {
|
||||||
preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences
|
preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences
|
||||||
} else {
|
} else {
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||||
|
|
@ -468,10 +446,14 @@ fun PlaybackPageContent(
|
||||||
|
|
||||||
// Subtitles
|
// Subtitles
|
||||||
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||||
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
val isImageSubtitles =
|
||||||
|
remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null }
|
||||||
var cueCount by remember { mutableIntStateOf(0) }
|
var cueCount by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface
|
val subtitleVisible =
|
||||||
|
skipIndicatorDuration == 0L &&
|
||||||
|
state.currentItemPlayback?.subtitleIndexEnabled == true &&
|
||||||
|
!presentationState.coverSurface
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
|
@ -481,7 +463,7 @@ fun PlaybackPageContent(
|
||||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||||
}
|
}
|
||||||
playerState.assHandler?.let { assHandler ->
|
playerInstance.assHandler?.let { assHandler ->
|
||||||
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
|
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
|
||||||
Timber.v("Adding AssSubtitleView")
|
Timber.v("Adding AssSubtitleView")
|
||||||
addView(
|
addView(
|
||||||
|
|
@ -499,12 +481,12 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { subtitleView ->
|
update = { subtitleView ->
|
||||||
subtitleView.setCues(cues)
|
subtitleView.setCues(state.subtitleCues)
|
||||||
if (cues.size > cueCount) {
|
if (state.subtitleCues.size > cueCount) {
|
||||||
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
|
// The output creates a painter for each cue, so need to apply the changes when the number of cues increases
|
||||||
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
||||||
.apply(subtitleView)
|
.apply(subtitleView)
|
||||||
cueCount = cues.size
|
cueCount = state.subtitleCues.size
|
||||||
}
|
}
|
||||||
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
|
subtitleView.children.firstOrNull { it is AssSubtitleView }?.let {
|
||||||
(it as? AssSubtitleView)?.apply {
|
(it as? AssSubtitleView)?.apply {
|
||||||
|
|
@ -552,7 +534,7 @@ fun PlaybackPageContent(
|
||||||
.padding(40.dp)
|
.padding(40.dp)
|
||||||
.align(Alignment.BottomEnd),
|
.align(Alignment.BottomEnd),
|
||||||
) {
|
) {
|
||||||
currentSegment?.let { segment ->
|
state.currentSegment?.let { segment ->
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.tryRequestFocus()
|
focusRequester.tryRequestFocus()
|
||||||
|
|
@ -570,7 +552,7 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next up episode
|
// Next up episode
|
||||||
BackHandler(nextUp != null) {
|
BackHandler(state.nextUp != null) {
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
scope.launch(ExceptionHandler()) {
|
scope.launch(ExceptionHandler()) {
|
||||||
viewModel.cancelUpNextEpisode()
|
viewModel.cancelUpNextEpisode()
|
||||||
|
|
@ -580,12 +562,12 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
nextUp != null,
|
state.nextUp != null,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomCenter),
|
.align(Alignment.BottomCenter),
|
||||||
) {
|
) {
|
||||||
nextUp?.let {
|
state.nextUp?.let {
|
||||||
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
||||||
var timeLeft by remember {
|
var timeLeft by remember {
|
||||||
mutableLongStateOf(
|
mutableLongStateOf(
|
||||||
|
|
@ -642,7 +624,7 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitleSearch?.let { state ->
|
if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) {
|
||||||
val wasPlaying = remember { player.isPlaying }
|
val wasPlaying = remember { player.isPlaying }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
player.pause()
|
player.pause()
|
||||||
|
|
@ -661,8 +643,8 @@ fun PlaybackPageContent(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
DownloadSubtitlesContent(
|
DownloadSubtitlesContent(
|
||||||
state = state,
|
state = subtitleSearchState.status,
|
||||||
language = subtitleSearchLanguage,
|
language = subtitleSearchState.language,
|
||||||
onSearch = { lang ->
|
onSearch = { lang ->
|
||||||
viewModel.searchForSubtitles(lang)
|
viewModel.searchForSubtitles(lang)
|
||||||
},
|
},
|
||||||
|
|
@ -684,18 +666,18 @@ fun PlaybackPageContent(
|
||||||
settings =
|
settings =
|
||||||
PlaybackSettings(
|
PlaybackSettings(
|
||||||
showDebugInfo = showDebugInfo,
|
showDebugInfo = showDebugInfo,
|
||||||
audioIndex = currentItemPlayback?.audioIndex,
|
audioIndex = state.currentItemPlayback?.audioIndex,
|
||||||
audioStreams = mediaInfo?.audioStreams.orEmpty(),
|
audioStreams = state.currentMediaInfo.audioStreams,
|
||||||
subtitleIndex = currentItemPlayback?.subtitleIndex,
|
subtitleIndex = state.currentItemPlayback?.subtitleIndex,
|
||||||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
subtitleStreams = state.currentMediaInfo.subtitleStreams,
|
||||||
playbackSpeed = playbackSpeed,
|
playbackSpeed = playbackSpeed,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
subtitleDelay = subtitleDelay,
|
subtitleDelay = state.currentPlayback?.subtitleDelay ?: Duration.ZERO,
|
||||||
hasSubtitleDownloadPermission =
|
hasSubtitleDownloadPermission =
|
||||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||||
// TODO Passing through audio prevents changing playback speed
|
// TODO Passing through audio prevents changing playback speed
|
||||||
// See https://github.com/damontecres/Wholphin/issues/164
|
// See https://github.com/damontecres/Wholphin/issues/164
|
||||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
|
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
|
||||||
),
|
),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
playbackDialog =
|
playbackDialog =
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,47 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.listenTo
|
import androidx.media3.common.text.Cue
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.formatBitrate
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
|
|
||||||
/**
|
data class PlaybackState(
|
||||||
* Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering.
|
val loading: LoadingState = LoadingState.Loading,
|
||||||
*
|
val currentMediaInfo: CurrentMediaInfo = CurrentMediaInfo.EMPTY,
|
||||||
* @see Player.State
|
val currentPlayback: CurrentPlayback? = null,
|
||||||
* @see PlaybackState
|
val currentItemPlayback: ItemPlayback? = null,
|
||||||
*/
|
val currentSegment: MediaSegmentState? = null,
|
||||||
@Composable
|
val analyticsState: AnalyticsState = AnalyticsState(),
|
||||||
fun rememberPlaybackState(player: Player): State<PlaybackState> {
|
val subtitleCues: List<Cue> = emptyList(),
|
||||||
val state = remember(player) { mutableStateOf(getPlaybackState(player.playbackState)) }
|
val nextUp: BaseItem? = null,
|
||||||
LaunchedEffect(player) {
|
val playlist: Playlist = Playlist(listOf()),
|
||||||
player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) {
|
)
|
||||||
state.value = getPlaybackState(player.playbackState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPlaybackState(
|
data class PlayerInstance(
|
||||||
@Player.State value: Int,
|
val player: Player,
|
||||||
): PlaybackState = PlaybackState.entries.first { it.value == value }
|
val backend: PlayerBackend,
|
||||||
|
val assHandler: AssHandler?,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
data class MediaSegmentState(
|
||||||
* Represents [Player.State] integers as an Enum
|
val segment: MediaSegmentDto,
|
||||||
*/
|
val interacted: Boolean,
|
||||||
enum class PlaybackState(
|
)
|
||||||
@param:Player.State val value: Int,
|
|
||||||
) {
|
data class AnalyticsState(
|
||||||
IDLE(Player.STATE_IDLE),
|
val bitrate: String = formatBitrate(0),
|
||||||
BUFFERING(Player.STATE_BUFFERING),
|
val bitrateEstimate: String = formatBitrate(0),
|
||||||
READY(Player.STATE_READY),
|
val droppedFrames: Int = 0,
|
||||||
ENDED(Player.STATE_ENDED),
|
)
|
||||||
}
|
|
||||||
|
data class SubtitleSearchState(
|
||||||
|
val status: SubtitleSearchStatus = SubtitleSearchStatus.Inactive,
|
||||||
|
val language: String = Locale.current.language,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import android.media.MediaCodecList
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.ui.text.intl.Locale
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
|
|
@ -17,7 +15,6 @@ import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.text.Cue
|
|
||||||
import androidx.media3.common.text.CueGroup
|
import androidx.media3.common.text.CueGroup
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DecoderCounters
|
import androidx.media3.exoplayer.DecoderCounters
|
||||||
|
|
@ -62,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
import com.github.damontecres.wholphin.ui.seekBack
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
import com.github.damontecres.wholphin.ui.seekForward
|
import com.github.damontecres.wholphin.ui.seekForward
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -79,20 +75,20 @@ import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import io.github.peerless2012.ass.media.AssHandler
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
|
|
@ -105,7 +101,6 @@ import org.jellyfin.sdk.api.sockets.subscribe
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
|
@ -161,7 +156,7 @@ class PlaybackViewModel
|
||||||
fun create(destination: Destination): PlaybackViewModel
|
fun create(destination: Destination): PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentPlayer = MutableStateFlow<PlayerState?>(null)
|
val currentPlayer = MutableStateFlow<PlayerInstance?>(null)
|
||||||
|
|
||||||
internal lateinit var player: Player
|
internal lateinit var player: Player
|
||||||
|
|
||||||
|
|
@ -174,15 +169,8 @@ class PlaybackViewModel
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
private val _state = MutableStateFlow(PlaybackState())
|
||||||
|
val state: StateFlow<PlaybackState> = _state
|
||||||
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
|
||||||
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
|
|
||||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
|
||||||
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
|
|
||||||
val analyticsState = MutableStateFlow(AnalyticsState())
|
|
||||||
|
|
||||||
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
|
||||||
|
|
||||||
private lateinit var preferences: UserPreferences
|
private lateinit var preferences: UserPreferences
|
||||||
internal lateinit var itemId: UUID
|
internal lateinit var itemId: UUID
|
||||||
|
|
@ -191,14 +179,11 @@ class PlaybackViewModel
|
||||||
private var activityListener: TrackActivityPlaybackListener? = null
|
private var activityListener: TrackActivityPlaybackListener? = null
|
||||||
private val jobs = mutableListOf<Job>()
|
private val jobs = mutableListOf<Job>()
|
||||||
|
|
||||||
val nextUp = MutableLiveData<BaseItem?>()
|
|
||||||
private val isPlaylist = destination is Destination.PlaybackList
|
private val isPlaylist = destination is Destination.PlaybackList
|
||||||
|
|
||||||
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
val subtitleSearchState = MutableStateFlow(SubtitleSearchState())
|
||||||
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
|
|
||||||
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
|
||||||
|
|
||||||
val currentUserDto = serverRepository.currentUserDto
|
val currentUserDto = serverRepository.currentUserDtoFlow
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
|
@ -261,7 +246,7 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
this.player = playerCreation.player
|
this.player = playerCreation.player
|
||||||
currentPlayer.update {
|
currentPlayer.update {
|
||||||
PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
|
PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler)
|
||||||
}
|
}
|
||||||
configurePlayer()
|
configurePlayer()
|
||||||
}
|
}
|
||||||
|
|
@ -288,7 +273,7 @@ class PlaybackViewModel
|
||||||
*/
|
*/
|
||||||
private suspend fun init() {
|
private suspend fun init() {
|
||||||
musicService.stop()
|
musicService.stop()
|
||||||
nextUp.setValueOnMain(null)
|
_state.update { it.copy(nextUp = null) }
|
||||||
this.preferences = userPreferencesService.getCurrent()
|
this.preferences = userPreferencesService.getCurrent()
|
||||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||||
addCloseable { refreshRateService.resetRefreshRate() }
|
addCloseable { refreshRateService.resetRefreshRate() }
|
||||||
|
|
@ -335,7 +320,7 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
when (val r = playlistResult) {
|
when (val r = playlistResult) {
|
||||||
is PlaylistCreationResult.Error -> {
|
is PlaylistCreationResult.Error -> {
|
||||||
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
_state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,8 +331,8 @@ class PlaybackViewModel
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@PlaybackViewModel.playlist.value = r.playlist
|
it.copy(playlist = r.playlist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.playlist.items.first()
|
r.playlist.items.first()
|
||||||
|
|
@ -365,7 +350,7 @@ class PlaybackViewModel
|
||||||
api.userLibraryApi
|
api.userLibraryApi
|
||||||
.getIntros(
|
.getIntros(
|
||||||
itemId = playlistItem.id,
|
itemId = playlistItem.id,
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
).content.items
|
).content.items
|
||||||
.map {
|
.map {
|
||||||
PlaylistItem.Intro(BaseItem(it))
|
PlaylistItem.Intro(BaseItem(it))
|
||||||
|
|
@ -376,13 +361,9 @@ class PlaybackViewModel
|
||||||
val firstItem =
|
val firstItem =
|
||||||
if (intros.isNotEmpty()) {
|
if (intros.isNotEmpty()) {
|
||||||
Timber.v("Got %s intros", intros.size)
|
Timber.v("Got %s intros", intros.size)
|
||||||
val currentPlaylist =
|
_state.update {
|
||||||
this@PlaybackViewModel
|
it.copy(playlist = Playlist(intros + it.playlist.items))
|
||||||
.playlist.value
|
}
|
||||||
?.items
|
|
||||||
.orEmpty()
|
|
||||||
val newPlaylist = Playlist(intros + currentPlaylist)
|
|
||||||
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
|
||||||
intros.first()
|
intros.first()
|
||||||
} else {
|
} else {
|
||||||
playlistItem
|
playlistItem
|
||||||
|
|
@ -401,16 +382,20 @@ class PlaybackViewModel
|
||||||
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
|
||||||
val result = playlistCreator.createFrom(queriedItem)
|
val result = playlistCreator.createFrom(queriedItem)
|
||||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||||
val currentPlaylist =
|
_state.update {
|
||||||
this@PlaybackViewModel
|
it.copy(
|
||||||
.playlist.value
|
playlist = Playlist(it.playlist.items + result.playlist.items),
|
||||||
?.items
|
)
|
||||||
.orEmpty()
|
|
||||||
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
|
|
||||||
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(currentPlayback = block.invoke(it.currentPlayback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play an item
|
* Play an item
|
||||||
|
|
@ -436,7 +421,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
// New item, so we can clear the media segment tracker & subtitle cues
|
// New item, so we can clear the media segment tracker & subtitle cues
|
||||||
resetSegmentState()
|
resetSegmentState()
|
||||||
this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf())
|
_state.update { it.copy(subtitleCues = emptyList()) }
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
// Starting playback, so want to invalidate the last played timestamp for this item
|
// Starting playback, so want to invalidate the last played timestamp for this item
|
||||||
|
|
@ -459,7 +444,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
// Use the provided playback parameters or else check if the database has some
|
// Use the provided playback parameters or else check if the database has some
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||||
if (it.sourceId != null) {
|
if (it.sourceId != null) {
|
||||||
|
|
@ -496,7 +481,7 @@ class PlaybackViewModel
|
||||||
// Create the correct player for the media
|
// Create the correct player for the media
|
||||||
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
|
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
|
||||||
val subtitleLanguagePreference =
|
val subtitleLanguagePreference =
|
||||||
serverRepository.currentUserDto.value
|
serverRepository.currentUserDto
|
||||||
?.configuration
|
?.configuration
|
||||||
?.subtitleLanguagePreference
|
?.subtitleLanguagePreference
|
||||||
val subtitleStreams =
|
val subtitleStreams =
|
||||||
|
|
@ -572,8 +557,9 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val chapters = Chapter.fromDto(base, api)
|
val chapters = Chapter.fromDto(base, api)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
it.copy(currentItemPlayback = itemPlaybackToUse)
|
||||||
|
}
|
||||||
updateCurrentMedia {
|
updateCurrentMedia {
|
||||||
CurrentMediaInfo(
|
CurrentMediaInfo(
|
||||||
sourceId = mediaSource.id,
|
sourceId = mediaSource.id,
|
||||||
|
|
@ -584,7 +570,7 @@ class PlaybackViewModel
|
||||||
trickPlayInfo = trickPlayInfo,
|
trickPlayInfo = trickPlayInfo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
item,
|
||||||
itemPlaybackToUse,
|
itemPlaybackToUse,
|
||||||
|
|
@ -608,7 +594,7 @@ class PlaybackViewModel
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
internal suspend fun changeStreams(
|
internal suspend fun changeStreams(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
|
currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
positionMs: Long = 0,
|
positionMs: Long = 0,
|
||||||
|
|
@ -618,7 +604,7 @@ class PlaybackViewModel
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
val itemId = item.id
|
val itemId = item.id
|
||||||
|
|
||||||
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
|
val currentPlayback = state.value.currentPlayback
|
||||||
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
|
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
|
||||||
val wasSuccessful =
|
val wasSuccessful =
|
||||||
changeStreamsDirectPlay(
|
changeStreamsDirectPlay(
|
||||||
|
|
@ -649,7 +635,7 @@ class PlaybackViewModel
|
||||||
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
|
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
|
||||||
deviceProfileService.getOrCreateDeviceProfile(
|
deviceProfileService.getOrCreateDeviceProfile(
|
||||||
preferences.appPreferences.playbackPreferences,
|
preferences.appPreferences.playbackPreferences,
|
||||||
serverRepository.currentServer.value?.serverVersion,
|
serverRepository.currentServer?.serverVersion,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
mpvDeviceProfile
|
mpvDeviceProfile
|
||||||
|
|
@ -669,7 +655,7 @@ class PlaybackViewModel
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (response.errorCode != null) {
|
if (response.errorCode != null) {
|
||||||
loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName))
|
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val source = response.mediaSources.firstOrNull()
|
val source = response.mediaSources.firstOrNull()
|
||||||
|
|
@ -694,9 +680,14 @@ class PlaybackViewModel
|
||||||
source.transcodingUrl?.let(api::createUrl)
|
source.transcodingUrl?.let(api::createUrl)
|
||||||
}
|
}
|
||||||
if (mediaUrl.isNullOrBlank()) {
|
if (mediaUrl.isNullOrBlank()) {
|
||||||
loading.setValueOnMain(
|
_state.update {
|
||||||
LoadingState.Error("Unable to get media URL from the server. Do you have permission to view and/or transcode?"),
|
it.copy(
|
||||||
|
loading =
|
||||||
|
LoadingState.Error(
|
||||||
|
"Unable to get media URL from the server. Do you have permission to view and/or transcode?",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val transcodeType =
|
val transcodeType =
|
||||||
|
|
@ -794,8 +785,12 @@ class PlaybackViewModel
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
this@PlaybackViewModel.activityListener = activityListener
|
this@PlaybackViewModel.activityListener = activityListener
|
||||||
|
|
||||||
loading.value = LoadingState.Success
|
_state.update {
|
||||||
this@PlaybackViewModel.currentPlayback.update { playback }
|
it.copy(
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
currentPlayback = playback,
|
||||||
|
)
|
||||||
|
}
|
||||||
player.setMediaItem(
|
player.setMediaItem(
|
||||||
mediaItem,
|
mediaItem,
|
||||||
positionMs,
|
positionMs,
|
||||||
|
|
@ -884,20 +879,19 @@ class PlaybackViewModel
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
Timber.v("Saving user initiated item playback: %s", itemPlayback)
|
Timber.v("Saving user initiated item playback: %s", itemPlayback)
|
||||||
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
|
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(currentItemPlayback = updated) }
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = updated
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(currentItemPlayback = itemPlayback) }
|
||||||
}
|
}
|
||||||
}
|
_state.update {
|
||||||
withContext(Dispatchers.Main) {
|
it.copy(
|
||||||
this@PlaybackViewModel.currentPlayback.update {
|
currentPlayback =
|
||||||
(it ?: currentPlayback).copy(
|
(it.currentPlayback ?: currentPlayback).copy(
|
||||||
tracks = checkForSupport(player.currentTracks),
|
tracks = checkForSupport(player.currentTracks),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
|
||||||
}
|
|
||||||
loadSubtitleDelay()
|
loadSubtitleDelay()
|
||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
|
|
@ -913,14 +907,14 @@ class PlaybackViewModel
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = currentItem.item,
|
item = currentItem.item,
|
||||||
itemPlayback = currentItemPlayback.value!!,
|
itemPlayback = state.value.currentItemPlayback!!,
|
||||||
trackIndex = index,
|
trackIndex = index,
|
||||||
type = MediaStreamType.AUDIO,
|
type = MediaStreamType.AUDIO,
|
||||||
)
|
)
|
||||||
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
_state.update { it.copy(currentItemPlayback = itemPlayback) }
|
||||||
|
|
||||||
// Resolve ONLY_FORCED to actual track based on new audio language
|
// Resolve ONLY_FORCED to actual track based on new audio language
|
||||||
val source = currentPlayback.value?.mediaSourceInfo
|
val source = state.value.currentPlayback?.mediaSourceInfo
|
||||||
val resolvedSubtitleIndex =
|
val resolvedSubtitleIndex =
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
|
|
@ -951,14 +945,14 @@ class PlaybackViewModel
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
itemPlaybackRepository.saveTrackSelection(
|
itemPlaybackRepository.saveTrackSelection(
|
||||||
item = currentItem.item,
|
item = currentItem.item,
|
||||||
itemPlayback = currentItemPlayback.value!!,
|
itemPlayback = state.value.currentItemPlayback!!,
|
||||||
trackIndex = index,
|
trackIndex = index,
|
||||||
type = MediaStreamType.SUBTITLE,
|
type = MediaStreamType.SUBTITLE,
|
||||||
)
|
)
|
||||||
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
|
_state.update { it.copy(currentItemPlayback = itemPlayback) }
|
||||||
|
|
||||||
// Resolve ONLY_FORCED to actual track index for playback
|
// Resolve ONLY_FORCED to actual track index for playback
|
||||||
val source = currentPlayback.value?.mediaSourceInfo
|
val source = state.value.currentPlayback?.mediaSourceInfo
|
||||||
val resolvedIndex =
|
val resolvedIndex =
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
streamChoiceService.resolveSubtitleIndex(
|
streamChoiceService.resolveSubtitleIndex(
|
||||||
|
|
@ -1004,8 +998,8 @@ class PlaybackViewModel
|
||||||
|
|
||||||
fun getTrickplayUrl(
|
fun getTrickplayUrl(
|
||||||
index: Int,
|
index: Int,
|
||||||
trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo,
|
trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo,
|
||||||
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
|
mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId,
|
||||||
): String? =
|
): String? =
|
||||||
trickPlayInfo?.let {
|
trickPlayInfo?.let {
|
||||||
val itemId = currentItem.id
|
val itemId = currentItem.id
|
||||||
|
|
@ -1021,7 +1015,7 @@ class PlaybackViewModel
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
Timber.v("Playback state is STATE_ENDED")
|
Timber.v("Playback state is STATE_ENDED")
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
when (val nextItem = playlist.value?.peek()) {
|
when (val nextItem = state.value.playlist.peek()) {
|
||||||
is PlaylistItem.Intro -> {
|
is PlaylistItem.Intro -> {
|
||||||
Timber.v("Next item is intro, so playing immediately")
|
Timber.v("Next item is intro, so playing immediately")
|
||||||
playNextUp()
|
playNextUp()
|
||||||
|
|
@ -1033,9 +1027,7 @@ class PlaybackViewModel
|
||||||
playNextUp()
|
playNextUp()
|
||||||
} else {
|
} else {
|
||||||
Timber.v("Setting next up to ${nextItem.id}")
|
Timber.v("Setting next up to ${nextItem.id}")
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(nextUp = nextItem.item) }
|
||||||
nextUp.value = nextItem.item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1060,7 +1052,7 @@ class PlaybackViewModel
|
||||||
segmentJob?.cancel()
|
segmentJob?.cancel()
|
||||||
autoSkippedSegments.clear()
|
autoSkippedSegments.clear()
|
||||||
outroShownSegments.clear()
|
outroShownSegments.clear()
|
||||||
currentSegment.value = null
|
_state.update { it.copy(currentSegment = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1086,8 +1078,7 @@ class PlaybackViewModel
|
||||||
currentSegment.itemId == this@PlaybackViewModel.itemId
|
currentSegment.itemId == this@PlaybackViewModel.itemId
|
||||||
) {
|
) {
|
||||||
if (currentSegment.id !=
|
if (currentSegment.id !=
|
||||||
this@PlaybackViewModel
|
state.value.currentSegment
|
||||||
.currentSegment.value
|
|
||||||
?.segment
|
?.segment
|
||||||
?.id
|
?.id
|
||||||
) {
|
) {
|
||||||
|
|
@ -1098,19 +1089,17 @@ class PlaybackViewModel
|
||||||
currentSegment.type,
|
currentSegment.type,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val playlist = this@PlaybackViewModel.playlist.value
|
val playlist = state.value.playlist
|
||||||
|
|
||||||
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
if (currentSegment.type == MediaSegmentType.OUTRO &&
|
||||||
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
|
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
|
||||||
playlist != null && playlist.hasNext() &&
|
playlist.hasNext() &&
|
||||||
outroShownSegments.add(currentSegment.id)
|
outroShownSegments.add(currentSegment.id)
|
||||||
) {
|
) {
|
||||||
val nextItem = playlist.peek()
|
val nextItem = playlist.peek()
|
||||||
if (nextItem is PlaylistItem.Media) {
|
if (nextItem is PlaylistItem.Media) {
|
||||||
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
Timber.v("Setting next up during outro to ${nextItem?.id}")
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(nextUp = nextItem.item) }
|
||||||
nextUp.value = nextItem.item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val behavior =
|
val behavior =
|
||||||
|
|
@ -1123,35 +1112,31 @@ class PlaybackViewModel
|
||||||
MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE
|
MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
val newSegment =
|
||||||
when (behavior) {
|
when (behavior) {
|
||||||
SkipSegmentBehavior.AUTO_SKIP -> {
|
SkipSegmentBehavior.AUTO_SKIP -> {
|
||||||
if (autoSkippedSegments.add(currentSegment.id)) {
|
if (autoSkippedSegments.add(currentSegment.id)) {
|
||||||
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||||
}
|
}
|
||||||
this@PlaybackViewModel.currentSegment.update {
|
|
||||||
MediaSegmentState(currentSegment, true)
|
MediaSegmentState(currentSegment, true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
SkipSegmentBehavior.ASK_TO_SKIP -> {
|
||||||
this@PlaybackViewModel.currentSegment.update {
|
|
||||||
MediaSegmentState(
|
MediaSegmentState(
|
||||||
currentSegment,
|
currentSegment,
|
||||||
autoSkippedSegments.contains(currentSegment.id),
|
autoSkippedSegments.contains(currentSegment.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
this@PlaybackViewModel.currentSegment.value = null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_state.update { it.copy(currentSegment = newSegment) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (currentSegment == null) {
|
} else if (currentSegment == null) {
|
||||||
withContext(Dispatchers.Main) {
|
_state.update { it.copy(currentSegment = null) }
|
||||||
this@PlaybackViewModel.currentSegment.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1163,17 +1148,13 @@ class PlaybackViewModel
|
||||||
dismissed: Boolean,
|
dismissed: Boolean,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val segment = currentSegment.value?.segment
|
val segment = state.value.currentSegment?.segment
|
||||||
if (segment != null && segment.id == segmentId) {
|
if (segment != null && segment.id == segmentId) {
|
||||||
autoSkippedSegments.add(segment.id)
|
autoSkippedSegments.add(segment.id)
|
||||||
if (dismissed) {
|
if (dismissed) {
|
||||||
currentSegment.update {
|
_state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) }
|
||||||
it?.copy(interacted = true)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
currentSegment.update {
|
_state.update { it.copy(currentSegment = null) }
|
||||||
null
|
|
||||||
}
|
|
||||||
onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
|
onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1182,7 +1163,7 @@ class PlaybackViewModel
|
||||||
|
|
||||||
private fun listenForTranscodeReason(): Job =
|
private fun listenForTranscodeReason(): Job =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
currentPlayback.collectLatest {
|
state.map { it.currentPlayback }.collectLatest {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
try {
|
try {
|
||||||
var transcodeInfo = it.transcodeInfo
|
var transcodeInfo = it.transcodeInfo
|
||||||
|
|
@ -1197,13 +1178,13 @@ class PlaybackViewModel
|
||||||
if (transcodeInfo == null) delay(3.seconds)
|
if (transcodeInfo == null) delay(3.seconds)
|
||||||
}
|
}
|
||||||
Timber.v("transcodeInfo=$transcodeInfo")
|
Timber.v("transcodeInfo=$transcodeInfo")
|
||||||
currentPlayback.update { current ->
|
updateCurrentPlayback { current ->
|
||||||
current?.copy(transcodeInfo = transcodeInfo)
|
current?.copy(transcodeInfo = transcodeInfo)
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
if (ex !is CancellationException) {
|
if (ex !is CancellationException) {
|
||||||
Timber.w(ex, "Exception trying to get session info")
|
Timber.w(ex, "Exception trying to get session info")
|
||||||
currentPlayback.update { current ->
|
updateCurrentPlayback { current ->
|
||||||
current?.copy(transcodeInfo = null)
|
current?.copy(transcodeInfo = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1233,7 +1214,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playNextUp() {
|
fun playNextUp() {
|
||||||
playlist.value?.let {
|
state.value.playlist.let {
|
||||||
if (it.hasNext()) {
|
if (it.hasNext()) {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
cancelUpNextEpisode()
|
cancelUpNextEpisode()
|
||||||
|
|
@ -1248,7 +1229,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playPrevious() {
|
fun playPrevious() {
|
||||||
playlist.value?.let {
|
state.value.playlist.let {
|
||||||
if (it.hasPrevious()) {
|
if (it.hasPrevious()) {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
cancelUpNextEpisode()
|
cancelUpNextEpisode()
|
||||||
|
|
@ -1263,11 +1244,11 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cancelUpNextEpisode() {
|
suspend fun cancelUpNextEpisode() {
|
||||||
nextUp.setValueOnMain(null)
|
_state.update { it.copy(nextUp = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playItemInPlaylist(item: BaseItem) {
|
fun playItemInPlaylist(item: BaseItem) {
|
||||||
playlist.value?.let { playlist ->
|
state.value.playlist.let { playlist ->
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val toPlay = playlist.advanceTo(item.id)
|
val toPlay = playlist.advanceTo(item.id)
|
||||||
if (toPlay != null) {
|
if (toPlay != null) {
|
||||||
|
|
@ -1283,7 +1264,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
currentPlayback.update {
|
updateCurrentPlayback {
|
||||||
it?.copy(
|
it?.copy(
|
||||||
tracks = checkForSupport(tracks),
|
tracks = checkForSupport(tracks),
|
||||||
)
|
)
|
||||||
|
|
@ -1291,30 +1272,34 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCues(cueGroup: CueGroup) {
|
override fun onCues(cueGroup: CueGroup) {
|
||||||
subtitleCues.value = cueGroup.cues
|
_state.update { it.copy(subtitleCues = cueGroup.cues) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Timber.e(error, "Playback error")
|
Timber.e(error, "Playback error")
|
||||||
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
||||||
currentPlayback.value?.let {
|
state.value.currentPlayback?.let {
|
||||||
when (it.playMethod) {
|
when (it.playMethod) {
|
||||||
PlayMethod.TRANSCODE -> {
|
PlayMethod.TRANSCODE -> {
|
||||||
loading.setValueOnMain(
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loading =
|
||||||
LoadingState.Error(
|
LoadingState.Error(
|
||||||
"Error during playback",
|
"Error during playback",
|
||||||
error,
|
error,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
|
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
|
||||||
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
|
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
|
||||||
|
val currentItemPlayback = state.value.currentItemPlayback!!
|
||||||
changeStreams(
|
changeStreams(
|
||||||
currentItem.item,
|
currentItem.item,
|
||||||
currentItemPlayback.value!!,
|
currentItemPlayback,
|
||||||
currentItemPlayback.value?.audioIndex,
|
currentItemPlayback.audioIndex,
|
||||||
currentItemPlayback.value?.subtitleIndex,
|
currentItemPlayback.subtitleIndex,
|
||||||
player.currentPosition,
|
player.currentPosition,
|
||||||
false,
|
false,
|
||||||
enableDirectPlay = false,
|
enableDirectPlay = false,
|
||||||
|
|
@ -1397,9 +1382,8 @@ class PlaybackViewModel
|
||||||
*/
|
*/
|
||||||
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
|
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
mutex.withLock {
|
_state.update {
|
||||||
val newMediaInfo = block.invoke(currentMediaInfo.value!!)
|
it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo))
|
||||||
currentMediaInfo.setValueOnMain(newMediaInfo)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1422,7 +1406,7 @@ class PlaybackViewModel
|
||||||
} else {
|
} else {
|
||||||
decoderName
|
decoderName
|
||||||
}
|
}
|
||||||
currentPlayback.update {
|
updateCurrentPlayback {
|
||||||
when (type) {
|
when (type) {
|
||||||
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
|
MediaType.VIDEO -> it?.copy(videoDecoder = decoderString)
|
||||||
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
|
MediaType.AUDIO -> it?.copy(audioDecoder = decoderString)
|
||||||
|
|
@ -1447,7 +1431,7 @@ class PlaybackViewModel
|
||||||
decoderCounters: DecoderCounters,
|
decoderCounters: DecoderCounters,
|
||||||
) {
|
) {
|
||||||
Timber.d("onVideoDisabled")
|
Timber.d("onVideoDisabled")
|
||||||
currentPlayback.update { it?.copy(videoDecoder = null) }
|
updateCurrentPlayback { it?.copy(videoDecoder = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoInputFormatChanged(
|
override fun onVideoInputFormatChanged(
|
||||||
|
|
@ -1491,21 +1475,21 @@ class PlaybackViewModel
|
||||||
decoderCounters: DecoderCounters,
|
decoderCounters: DecoderCounters,
|
||||||
) {
|
) {
|
||||||
Timber.d("decoder: onAudioDisabled")
|
Timber.d("decoder: onAudioDisabled")
|
||||||
currentPlayback.update { it?.copy(audioDecoder = null) }
|
updateCurrentPlayback { it?.copy(audioDecoder = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var subtitleDelaySaveJob: Job? = null
|
private var subtitleDelaySaveJob: Job? = null
|
||||||
|
|
||||||
fun updateSubtitleDelay(delta: Duration) {
|
fun updateSubtitleDelay(delta: Duration) {
|
||||||
subtitleDelaySaveJob?.cancel()
|
subtitleDelaySaveJob?.cancel()
|
||||||
currentPlayback.update {
|
updateCurrentPlayback {
|
||||||
it?.let {
|
it?.let {
|
||||||
val newDelay = it.subtitleDelay + delta
|
val newDelay = it.subtitleDelay + delta
|
||||||
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
|
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
|
||||||
subtitleDelaySaveJob =
|
subtitleDelaySaveJob =
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
// Debounce & save
|
// Debounce & save
|
||||||
currentItemPlayback.value?.let { item ->
|
state.value.currentItemPlayback?.let { item ->
|
||||||
delay(1500)
|
delay(1500)
|
||||||
itemPlaybackRepository.saveTrackModifications(
|
itemPlaybackRepository.saveTrackModifications(
|
||||||
item.itemId,
|
item.itemId,
|
||||||
|
|
@ -1520,7 +1504,7 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadSubtitleDelay() {
|
suspend fun loadSubtitleDelay() {
|
||||||
currentItemPlayback.value?.let {
|
state.value.currentItemPlayback?.let {
|
||||||
if (it.subtitleIndexEnabled) {
|
if (it.subtitleIndexEnabled) {
|
||||||
val result =
|
val result =
|
||||||
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
|
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
|
||||||
|
|
@ -1531,7 +1515,7 @@ class PlaybackViewModel
|
||||||
it.subtitleIndex,
|
it.subtitleIndex,
|
||||||
it.itemId,
|
it.itemId,
|
||||||
)
|
)
|
||||||
currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1554,10 +1538,13 @@ class PlaybackViewModel
|
||||||
bitrateEstimate,
|
bitrateEstimate,
|
||||||
)
|
)
|
||||||
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
|
if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) {
|
||||||
analyticsState.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
analyticsState =
|
||||||
|
it.analyticsState.copy(
|
||||||
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()),
|
||||||
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
bitrateEstimate = formatBitrate(bitrateEstimate.toInt()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1568,24 +1555,13 @@ class PlaybackViewModel
|
||||||
droppedFrames: Int,
|
droppedFrames: Int,
|
||||||
elapsedMs: Long,
|
elapsedMs: Long,
|
||||||
) {
|
) {
|
||||||
// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames)
|
_state.update {
|
||||||
analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
it.copy(
|
||||||
|
analyticsState =
|
||||||
|
it.analyticsState.copy(
|
||||||
|
droppedFrames = it.analyticsState.droppedFrames + droppedFrames,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlayerState(
|
|
||||||
val player: Player,
|
|
||||||
val backend: PlayerBackend,
|
|
||||||
val assHandler: AssHandler?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MediaSegmentState(
|
|
||||||
val segment: MediaSegmentDto,
|
|
||||||
val interacted: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AnalyticsState(
|
|
||||||
val bitrate: String = formatBitrate(0),
|
|
||||||
val bitrateEstimate: String = formatBitrate(0),
|
|
||||||
val droppedFrames: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
|
@ -22,6 +22,8 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
sealed interface SubtitleSearchStatus {
|
sealed interface SubtitleSearchStatus {
|
||||||
|
data object Inactive : SubtitleSearchStatus
|
||||||
|
|
||||||
data object Searching : SubtitleSearchStatus
|
data object Searching : SubtitleSearchStatus
|
||||||
|
|
||||||
data object Downloading : SubtitleSearchStatus
|
data object Downloading : SubtitleSearchStatus
|
||||||
|
|
@ -40,11 +42,15 @@ sealed interface SubtitleSearchStatus {
|
||||||
* Trigger a search for subtitles in the given language for the currently playing media
|
* Trigger a search for subtitles in the given language for the currently playing media
|
||||||
*/
|
*/
|
||||||
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
|
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
|
||||||
subtitleSearchStatus.value = SubtitleSearchStatus.Searching
|
subtitleSearchState.update {
|
||||||
subtitleSearchLanguage.value = language
|
it.copy(
|
||||||
|
status = SubtitleSearchStatus.Searching,
|
||||||
|
language = language,
|
||||||
|
)
|
||||||
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
currentItemPlayback.value?.itemId?.let {
|
state.value.currentItemPlayback?.itemId?.let {
|
||||||
Timber.v("Searching for remote subtitles for %s", it)
|
Timber.v("Searching for remote subtitles for %s", it)
|
||||||
val results =
|
val results =
|
||||||
api.subtitleApi
|
api.subtitleApi
|
||||||
|
|
@ -56,11 +62,11 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu
|
||||||
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
|
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
|
||||||
.thenByDescending { it.downloadCount },
|
.thenByDescending { it.downloadCount },
|
||||||
)
|
)
|
||||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results))
|
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) }
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception while searching for subtitles")
|
Timber.e(ex, "Exception while searching for subtitles")
|
||||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,12 +79,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
wasPlaying: Boolean,
|
wasPlaying: Boolean,
|
||||||
) {
|
) {
|
||||||
if (subtitleId == null) {
|
if (subtitleId == null) {
|
||||||
subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null)
|
subtitleSearchState.update {
|
||||||
|
it.copy(
|
||||||
|
status =
|
||||||
|
SubtitleSearchStatus.Error(
|
||||||
|
"Subtitle has no ID",
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
subtitleSearchStatus.value = SubtitleSearchStatus.Downloading
|
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) }
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
currentItemPlayback.value?.let {
|
state.value.currentItemPlayback?.let {
|
||||||
Timber.v(
|
Timber.v(
|
||||||
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
|
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
|
||||||
it.itemId,
|
it.itemId,
|
||||||
|
|
@ -89,8 +103,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
itemId = it.sourceId ?: it.itemId,
|
itemId = it.sourceId ?: it.itemId,
|
||||||
subtitleId = subtitleId,
|
subtitleId = subtitleId,
|
||||||
)
|
)
|
||||||
val currentSource =
|
val currentSource = state.value.currentPlayback?.mediaSourceInfo
|
||||||
this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo
|
|
||||||
val currentSubtitleStreams =
|
val currentSubtitleStreams =
|
||||||
currentSource
|
currentSource
|
||||||
?.mediaStreams
|
?.mediaStreams
|
||||||
|
|
@ -144,7 +157,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
stream.isExternal && stream.path !in externalPaths
|
stream.isExternal && stream.path !in externalPaths
|
||||||
}
|
}
|
||||||
if (newStream != null) {
|
if (newStream != null) {
|
||||||
var audioIndex = currentItemPlayback.value?.audioIndex
|
var audioIndex = it?.audioIndex
|
||||||
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
|
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
|
||||||
// User has picked a specific audio track
|
// User has picked a specific audio track
|
||||||
// Since, now adding a new external subtitle track, need to adjust the audio index as well
|
// Since, now adding a new external subtitle track, need to adjust the audio index as well
|
||||||
|
|
@ -161,7 +174,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
}
|
}
|
||||||
this@downloadAndSwitchSubtitles.changeStreams(
|
this@downloadAndSwitchSubtitles.changeStreams(
|
||||||
currentItem.item,
|
currentItem.item,
|
||||||
currentItemPlayback.value!!,
|
it,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
newStream.index,
|
newStream.index,
|
||||||
onMain { player.currentPosition },
|
onMain { player.currentPosition },
|
||||||
|
|
@ -169,7 +182,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subtitleSearchStatus.setValueOnMain(null)
|
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (wasPlaying) {
|
if (wasPlaying) {
|
||||||
player.play()
|
player.play()
|
||||||
|
|
@ -178,12 +191,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
||||||
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
subtitleSearchState.update {
|
||||||
|
it.copy(
|
||||||
|
status =
|
||||||
|
SubtitleSearchStatus.Error(
|
||||||
|
null,
|
||||||
|
ex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun PlaybackViewModel.cancelSubtitleSearch() {
|
fun PlaybackViewModel.cancelSubtitleSearch() {
|
||||||
subtitleSearchStatus.value = null
|
subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class LocaleChoiceViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
serverRepository.currentUser.value?.let {
|
serverRepository.currentUser?.let {
|
||||||
val availableLocales = extractAvailableLocales()
|
val availableLocales = extractAvailableLocales()
|
||||||
Timber.v("availableLocales=%s", availableLocales)
|
Timber.v("availableLocales=%s", availableLocales)
|
||||||
_state.update {
|
_state.update {
|
||||||
|
|
@ -73,7 +73,7 @@ class LocaleChoiceViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
serverRepository.currentUser.asFlow().collectLatest { user ->
|
serverRepository.currentUserFlow.collectLatest { user ->
|
||||||
val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
|
val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
|
||||||
_state.update { it.copy(userLocale = userLocale) }
|
_state.update { it.copy(userLocale = userLocale) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ class NavDrawerPreferencesViewModel
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val state = navDrawerService.state.value
|
val state = navDrawerService.state.value
|
||||||
val user = serverRepository.currentUser.value
|
val user = serverRepository.currentUser
|
||||||
val seerr = seerrServerRepository.active.firstOrNull()
|
val seerr = seerrServerRepository.active.firstOrNull()
|
||||||
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
|
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
|
||||||
return@launchDefault
|
return@launchDefault
|
||||||
|
|
@ -337,8 +337,8 @@ class NavDrawerPreferencesViewModel
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
viewModelScope.launchIO(ExceptionHandler(true)) {
|
viewModelScope.launchIO(ExceptionHandler(true)) {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto?.let { userDto ->
|
||||||
if (user.id == userDto.id) {
|
if (user.id == userDto.id) {
|
||||||
val toSave =
|
val toSave =
|
||||||
state.value.mapIndexed { index, item ->
|
state.value.mapIndexed { index, item ->
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -100,7 +99,7 @@ fun PreferencesContent(
|
||||||
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
var preferences by remember { mutableStateOf(initialPreferences) }
|
var preferences by remember { mutableStateOf(initialPreferences) }
|
||||||
val currentUser by viewModel.currentUser.observeAsState()
|
val currentUser by viewModel.currentUser.collectAsState()
|
||||||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||||
var showPinFlow by remember { mutableStateOf(false) }
|
var showPinFlow by remember { mutableStateOf(false) }
|
||||||
var showVersionDialog by remember { mutableStateOf(false) }
|
var showVersionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -126,16 +125,20 @@ fun PreferencesContent(
|
||||||
updateCache = false
|
updateCache = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val release by updateVM.release.observeAsState(null)
|
val updateState by updateVM.state.collectAsState()
|
||||||
LaunchedEffect(Unit) {
|
val release = updateState.release
|
||||||
|
LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) {
|
||||||
if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) {
|
if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) {
|
||||||
updateVM.init(preferences.updateUrl)
|
updateVM.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val movementSounds = true
|
val movementSounds = true
|
||||||
val installedVersion = updateVM.currentVersion
|
val installedVersion = updateVM.currentVersion
|
||||||
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
|
val updateAvailable =
|
||||||
|
remember(updateState.release) {
|
||||||
|
updateState.release?.version?.isGreaterThan(installedVersion) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
val prefList =
|
val prefList =
|
||||||
when (preferenceScreenOption) {
|
when (preferenceScreenOption) {
|
||||||
|
|
@ -306,7 +309,7 @@ fun PreferencesContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateVM.init(preferences.updateUrl)
|
updateVM.init()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
|
|
@ -635,7 +638,7 @@ fun PreferencesContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
SeerrDialogMode.Add -> {
|
SeerrDialogMode.Add -> {
|
||||||
val currentUser by seerrVm.currentUser.observeAsState()
|
val currentUser by seerrVm.currentUser.collectAsState(null)
|
||||||
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
||||||
val serverAddedMessage = stringResource(R.string.seerr_server_added)
|
val serverAddedMessage = stringResource(R.string.seerr_server_added)
|
||||||
LaunchedEffect(status) {
|
LaunchedEffect(status) {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
|
|
@ -58,7 +60,13 @@ class PreferencesViewModel
|
||||||
private val updateChecker: UpdateChecker,
|
private val updateChecker: UpdateChecker,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
RememberTabManager by rememberTabManager {
|
RememberTabManager by rememberTabManager {
|
||||||
val currentUser get() = serverRepository.currentUser
|
val currentUser
|
||||||
|
get() =
|
||||||
|
serverRepository.currentUserFlow.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
val seerrConnection = seerrServerRepository.connection
|
val seerrConnection = seerrServerRepository.connection
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -39,7 +38,6 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -51,6 +49,8 @@ import com.github.damontecres.wholphin.services.DownloadCallback
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.Release
|
import com.github.damontecres.wholphin.services.Release
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -58,47 +58,56 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||||
import com.github.damontecres.wholphin.ui.dimAndBlur
|
import com.github.damontecres.wholphin.ui.dimAndBlur
|
||||||
import com.github.damontecres.wholphin.ui.formatBytes
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import com.mikepenz.markdown.m3.Markdown
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import com.mikepenz.markdown.m3.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class UpdateViewModel
|
class UpdateViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
val updater: UpdateChecker,
|
val updater: UpdateChecker,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
DownloadCallback {
|
DownloadCallback {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _state = MutableStateFlow(InstallUpdateState())
|
||||||
val release = MutableLiveData<Release?>(null)
|
val state: StateFlow<InstallUpdateState> = _state
|
||||||
|
|
||||||
val downloading = MutableLiveData<Boolean>(false)
|
private val _bytesDownloaded = MutableStateFlow(0L)
|
||||||
val contentLength = MutableLiveData<Long>(-1)
|
val bytesDownloaded: StateFlow<Long> = _bytesDownloaded
|
||||||
val bytesDownloaded = MutableLiveData<Long>(-1)
|
|
||||||
|
|
||||||
val currentVersion = updater.getInstalledVersion()
|
val currentVersion = updater.getInstalledVersion()
|
||||||
|
|
||||||
fun init(updateUrl: String) {
|
fun init() {
|
||||||
loading.value = LoadingState.Loading
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
|
viewModelScope.launchIO {
|
||||||
|
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||||
|
try {
|
||||||
val release = updater.getLatestRelease(updateUrl)
|
val release = updater.getLatestRelease(updateUrl)
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
contentLength.value = -1
|
it.copy(
|
||||||
bytesDownloaded.value = -1
|
loading = LoadingState.Success,
|
||||||
this@UpdateViewModel.release.value = release
|
release = release,
|
||||||
loading.value = LoadingState.Success
|
contentLength = -1L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching release from %s", updateUrl)
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,61 +116,59 @@ class UpdateViewModel
|
||||||
|
|
||||||
fun installRelease(release: Release) {
|
fun installRelease(release: Release) {
|
||||||
downloadJob =
|
downloadJob =
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
try {
|
||||||
LoadingExceptionHandler(
|
_bytesDownloaded.value = 0L
|
||||||
loading,
|
_state.update { it.copy(downloading = true) }
|
||||||
"Failed to install update",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
downloading.setValueOnMain(true)
|
|
||||||
updater.installRelease(release, this@UpdateViewModel)
|
updater.installRelease(release, this@UpdateViewModel)
|
||||||
downloading.setValueOnMain(false)
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error downloading")
|
||||||
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
|
} finally {
|
||||||
|
_state.update { it.copy(downloading = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelDownload() {
|
fun cancelDownload() {
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(
|
|
||||||
loading,
|
|
||||||
"Error",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
downloadJob?.cancel()
|
downloadJob?.cancel()
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
downloading.value = false
|
it.copy(
|
||||||
contentLength.value = -1
|
downloading = false,
|
||||||
bytesDownloaded.value = -1
|
contentLength = -1L,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun contentLength(contentLength: Long) {
|
override fun contentLength(contentLength: Long) {
|
||||||
this@UpdateViewModel.contentLength.value = contentLength
|
_state.update { it.copy(contentLength = contentLength) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bytesDownloaded(bytes: Long) {
|
override fun bytesDownloaded(bytes: Long) {
|
||||||
this@UpdateViewModel.bytesDownloaded.value = bytes
|
_bytesDownloaded.value = bytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class InstallUpdateState(
|
||||||
|
val loading: LoadingState = LoadingState.Pending,
|
||||||
|
val downloading: Boolean = false,
|
||||||
|
val release: Release? = null,
|
||||||
|
val contentLength: Long = -1L,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InstallUpdatePage(
|
fun InstallUpdatePage(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: UpdateViewModel = hiltViewModel(),
|
viewModel: UpdateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
OneTimeLaunchedEffect { viewModel.init() }
|
||||||
val release by viewModel.release.observeAsState(null)
|
|
||||||
|
|
||||||
val isDownloading by viewModel.downloading.observeAsState(false)
|
val state by viewModel.state.collectAsState()
|
||||||
val contentLength by viewModel.contentLength.observeAsState(-1L)
|
|
||||||
val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.init(preferences.appPreferences.updateUrl)
|
|
||||||
}
|
|
||||||
var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) }
|
var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) }
|
||||||
val launcher =
|
val launcher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
|
|
@ -173,9 +180,9 @@ fun InstallUpdatePage(
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (val state = loading) {
|
when (val st = state.loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
|
|
@ -185,26 +192,32 @@ fun InstallUpdatePage(
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
release?.let {
|
val release = state.release
|
||||||
|
if (release != null) {
|
||||||
InstallUpdatePageContent(
|
InstallUpdatePageContent(
|
||||||
currentVersion = viewModel.currentVersion,
|
currentVersion = viewModel.currentVersion,
|
||||||
release = it,
|
release = release,
|
||||||
onInstallRelease = {
|
onInstallRelease = {
|
||||||
if (!permissions) {
|
if (!permissions) {
|
||||||
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
} else {
|
} else {
|
||||||
viewModel.installRelease(it)
|
viewModel.installRelease(release)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = {
|
onCancel = {
|
||||||
viewModel.navigationManager.goBack()
|
viewModel.navigationManager.goBack()
|
||||||
},
|
},
|
||||||
modifier = modifier.dimAndBlur(isDownloading),
|
modifier = modifier.dimAndBlur(state.downloading),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "No release found! Check the update URL.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isDownloading) {
|
if (state.downloading) {
|
||||||
|
val bytesDownloaded by viewModel.bytesDownloaded.collectAsState(0L)
|
||||||
DownloadDialog(
|
DownloadDialog(
|
||||||
contentLength = contentLength,
|
contentLength = state.contentLength,
|
||||||
bytesDownloaded = bytesDownloaded,
|
bytesDownloaded = bytesDownloaded,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
viewModel.cancelDownload()
|
viewModel.cancelDownload()
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -70,7 +69,7 @@ fun SwitchUserContent(
|
||||||
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
val currentUser by viewModel.serverRepository.currentUser.observeAsState()
|
val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null)
|
||||||
var showAddUser by remember { mutableStateOf(false) }
|
var showAddUser by remember { mutableStateOf(false) }
|
||||||
var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) }
|
var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) }
|
||||||
var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") }
|
var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") }
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class SwitchSeerrViewModel
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val currentUser = serverRepository.currentUser
|
val currentUser = serverRepository.currentUserFlow
|
||||||
val currentSeerrServer = seerrServerRepository.currentServer
|
val currentSeerrServer = seerrServerRepository.currentServer
|
||||||
|
|
||||||
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -59,7 +58,6 @@ import coil3.request.ImageRequest
|
||||||
import coil3.request.transitionFactory
|
import coil3.request.transitionFactory
|
||||||
import coil3.size.Size
|
import coil3.size.Size
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -93,9 +91,9 @@ fun SlideshowPage(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val loading by viewModel.loading.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
when (val st = loading) {
|
when (val st = state.loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(st, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
@ -107,10 +105,10 @@ fun SlideshowPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Success -> {
|
LoadingState.Success -> {
|
||||||
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
val loadingState = state.imageLoading
|
||||||
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
val imageFilter by viewModel.imageFilter.collectAsState()
|
||||||
val position by viewModel.position.observeAsState(0)
|
val position = state.position
|
||||||
val pager by viewModel.pager.observeAsState()
|
val pager = state.items
|
||||||
// val imageState by viewModel.image.observeAsState()
|
// val imageState by viewModel.image.observeAsState()
|
||||||
|
|
||||||
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||||
|
|
@ -150,7 +148,7 @@ fun SlideshowPage(
|
||||||
label = "image_panY",
|
label = "image_panY",
|
||||||
)
|
)
|
||||||
|
|
||||||
val slideshowState by viewModel.slideshow.collectAsState()
|
val slideshowState by viewModel.state.collectAsState()
|
||||||
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@ package com.github.damontecres.wholphin.ui.slideshow
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.map
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
|
|
@ -25,9 +22,7 @@ import com.github.damontecres.wholphin.ui.PhotoItemFields
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
|
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
|
@ -87,28 +82,18 @@ class SlideshowViewModel
|
||||||
/**
|
/**
|
||||||
* Whether slideshow mode is on or off
|
* Whether slideshow mode is on or off
|
||||||
*/
|
*/
|
||||||
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false))
|
private val _state = MutableStateFlow(SlideshowState())
|
||||||
val slideshow: StateFlow<SlideshowState> = _slideshow
|
val state: StateFlow<SlideshowState> = _state
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
|
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
|
||||||
*/
|
*/
|
||||||
val slideshowActive = slideshow.map { it.enabled && !it.paused }
|
val slideshowActive = state.map { it.enabled && !it.paused }
|
||||||
|
|
||||||
var slideshowDelay by Delegates.notNull<Long>()
|
var slideshowDelay by Delegates.notNull<Long>()
|
||||||
|
|
||||||
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
|
private val _imageFilter = MutableStateFlow(VideoFilter())
|
||||||
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
|
val imageFilter: StateFlow<VideoFilter> = _imageFilter
|
||||||
val position = MutableLiveData(0)
|
|
||||||
|
|
||||||
private val _image = MutableLiveData<ImageState>()
|
|
||||||
val image: LiveData<ImageState> = _image
|
|
||||||
|
|
||||||
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
|
||||||
|
|
||||||
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
|
||||||
private val _imageFilter = MutableLiveData(VideoFilter())
|
|
||||||
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
|
||||||
|
|
||||||
private var albumImageFilter = VideoFilter()
|
private var albumImageFilter = VideoFilter()
|
||||||
|
|
||||||
|
|
@ -152,7 +137,7 @@ class SlideshowViewModel
|
||||||
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val filter =
|
val filter =
|
||||||
playbackEffectDao
|
playbackEffectDao
|
||||||
.getPlaybackEffect(
|
.getPlaybackEffect(
|
||||||
|
|
@ -168,21 +153,25 @@ class SlideshowViewModel
|
||||||
val pager =
|
val pager =
|
||||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||||
.init(slideshowSettings.index)
|
.init(slideshowSettings.index)
|
||||||
this@SlideshowViewModel._pager.setValueOnMain(pager)
|
_state.update {
|
||||||
loading.update { LoadingState.Success }
|
it.copy(
|
||||||
|
loading = LoadingState.Success,
|
||||||
|
items = pager,
|
||||||
|
)
|
||||||
|
}
|
||||||
updatePosition(slideshowSettings.index)?.join()
|
updatePosition(slideshowSettings.index)?.join()
|
||||||
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error")
|
Timber.e(ex, "Error")
|
||||||
loading.update { LoadingState.Error(ex) }
|
_state.update { it.copy(loading = LoadingState.Error(ex)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nextImage(): Boolean {
|
fun nextImage(): Boolean {
|
||||||
val size = pager.value?.size
|
val size = state.value.items.size
|
||||||
val newPosition = position.value!! + 1
|
val newPosition = state.value.position + 1
|
||||||
return if (size != null && newPosition < size) {
|
return if (newPosition < size) {
|
||||||
updatePosition(newPosition)
|
updatePosition(newPosition)
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -191,7 +180,7 @@ class SlideshowViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun previousImage(): Boolean {
|
fun previousImage(): Boolean {
|
||||||
val newPosition = position.value!! - 1
|
val newPosition = state.value.position - 1
|
||||||
return if (newPosition >= 0) {
|
return if (newPosition >= 0) {
|
||||||
updatePosition(newPosition)
|
updatePosition(newPosition)
|
||||||
true
|
true
|
||||||
|
|
@ -201,14 +190,14 @@ class SlideshowViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updatePosition(position: Int): Job? =
|
fun updatePosition(position: Int): Job? =
|
||||||
_pager.value?.let { pager ->
|
(state.value.items as? ApiRequestPager<*>)?.let { pager ->
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
val image =
|
val image =
|
||||||
if (position in pager.indices) pager.getBlocking(position) else null
|
if (position in pager.indices) pager.getBlocking(position) else null
|
||||||
Timber.v("Got image for $position: ${image != null}")
|
Timber.v("Got image for $position: ${image != null}")
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
this@SlideshowViewModel.position.setValueOnMain(position)
|
_state.update { it.copy(position = position) }
|
||||||
|
|
||||||
val url =
|
val url =
|
||||||
if (image.data.mediaType == MediaType.VIDEO) {
|
if (image.data.mediaType == MediaType.VIDEO) {
|
||||||
|
|
@ -252,7 +241,7 @@ class SlideshowViewModel
|
||||||
updateImageFilter(albumImageFilter)
|
updateImageFilter(albumImageFilter)
|
||||||
if (saveFilters) {
|
if (saveFilters) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val vf =
|
val vf =
|
||||||
playbackEffectDao
|
playbackEffectDao
|
||||||
.getPlaybackEffect(
|
.getPlaybackEffect(
|
||||||
|
|
@ -264,32 +253,38 @@ class SlideshowViewModel
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"Loaded VideoFilter for image ${image.id}",
|
"Loaded VideoFilter for image ${image.id}",
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
// Pause throttling so that the image loads with the filter applied immediately
|
|
||||||
imageFilter.stopThrottling(true)
|
|
||||||
updateImageFilter(vf.videoFilter)
|
updateImageFilter(vf.videoFilter)
|
||||||
imageFilter.startThrottling()
|
|
||||||
}
|
}
|
||||||
}
|
_state.update {
|
||||||
withContext(Dispatchers.Main) {
|
it.copy(
|
||||||
_image.value = imageState
|
image = imageState,
|
||||||
loadingState.value =
|
imageLoading = ImageLoadingState.Success(imageState),
|
||||||
ImageLoadingState.Success(imageState)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.Main) {
|
_state.update {
|
||||||
_image.value = imageState
|
it.copy(
|
||||||
loadingState.value = ImageLoadingState.Success(imageState)
|
image = imageState,
|
||||||
|
imageLoading = ImageLoadingState.Success(imageState),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
loadingState.setValueOnMain(ImageLoadingState.Error)
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
imageLoading = ImageLoadingState.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex)
|
Timber.e(ex)
|
||||||
loadingState.setValueOnMain(ImageLoadingState.Error)
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
imageLoading = ImageLoadingState.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,10 +293,10 @@ class SlideshowViewModel
|
||||||
|
|
||||||
fun startSlideshow() {
|
fun startSlideshow() {
|
||||||
screensaverService.keepScreenOn(true)
|
screensaverService.keepScreenOn(true)
|
||||||
_slideshow.update {
|
_state.update {
|
||||||
SlideshowState(enabled = true, paused = false)
|
it.copy(enabled = true, paused = false)
|
||||||
}
|
}
|
||||||
if (_image.value
|
if (state.value.image
|
||||||
?.image
|
?.image
|
||||||
?.data
|
?.data
|
||||||
?.mediaType != MediaType.VIDEO
|
?.mediaType != MediaType.VIDEO
|
||||||
|
|
@ -313,14 +308,14 @@ class SlideshowViewModel
|
||||||
fun stopSlideshow() {
|
fun stopSlideshow() {
|
||||||
screensaverService.keepScreenOn(false)
|
screensaverService.keepScreenOn(false)
|
||||||
slideshowJob?.cancel()
|
slideshowJob?.cancel()
|
||||||
_slideshow.update {
|
_state.update {
|
||||||
SlideshowState(enabled = false, paused = false)
|
it.copy(enabled = false, paused = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pauseSlideshow() {
|
fun pauseSlideshow() {
|
||||||
Timber.v("pauseSlideshow")
|
Timber.v("pauseSlideshow")
|
||||||
_slideshow.update {
|
_state.update {
|
||||||
if (it.enabled) {
|
if (it.enabled) {
|
||||||
slideshowJob?.cancel()
|
slideshowJob?.cancel()
|
||||||
it.copy(paused = true)
|
it.copy(paused = true)
|
||||||
|
|
@ -332,7 +327,7 @@ class SlideshowViewModel
|
||||||
|
|
||||||
fun unpauseSlideshow() {
|
fun unpauseSlideshow() {
|
||||||
Timber.v("unpauseSlideshow")
|
Timber.v("unpauseSlideshow")
|
||||||
_slideshow.update {
|
_state.update {
|
||||||
if (it.enabled) {
|
if (it.enabled) {
|
||||||
it.copy(paused = false)
|
it.copy(paused = false)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -362,16 +357,16 @@ class SlideshowViewModel
|
||||||
|
|
||||||
fun updateImageFilter(newFilter: VideoFilter) {
|
fun updateImageFilter(newFilter: VideoFilter) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
_imageFilter.setValueOnMain(newFilter)
|
_imageFilter.update { newFilter }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveImageFilter() {
|
fun saveImageFilter() {
|
||||||
image.value?.let {
|
state.value.image?.let {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val vf = _imageFilter.value
|
val vf = _imageFilter.value
|
||||||
if (vf != null) {
|
if (vf != null) {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
playbackEffectDao
|
playbackEffectDao
|
||||||
.insert(
|
.insert(
|
||||||
PlaybackEffect(
|
PlaybackEffect(
|
||||||
|
|
@ -400,7 +395,7 @@ class SlideshowViewModel
|
||||||
val vf = _imageFilter.value
|
val vf = _imageFilter.value
|
||||||
if (vf != null) {
|
if (vf != null) {
|
||||||
albumImageFilter = vf
|
albumImageFilter = vf
|
||||||
serverRepository.currentUser.value?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
playbackEffectDao
|
playbackEffectDao
|
||||||
.insert(
|
.insert(
|
||||||
PlaybackEffect(
|
PlaybackEffect(
|
||||||
|
|
@ -457,6 +452,11 @@ data class ImageState(
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SlideshowState(
|
data class SlideshowState(
|
||||||
val enabled: Boolean,
|
val items: List<BaseItem?> = emptyList(),
|
||||||
val paused: Boolean,
|
val position: Int = 0,
|
||||||
|
val image: ImageState? = null,
|
||||||
|
val loading: LoadingState = LoadingState.Pending,
|
||||||
|
val imageLoading: ImageLoadingState = ImageLoadingState.Loading,
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val paused: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(" - ")
|
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.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.LifecycleRegistry
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -14,6 +13,7 @@ import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
|
@ -28,7 +28,7 @@ import org.junit.Test
|
||||||
class LatestNextUpSchedulerServiceTest {
|
class LatestNextUpSchedulerServiceTest {
|
||||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
private val currentUser = MutableStateFlow<CurrentUser?>(null)
|
||||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||||
|
|
@ -38,7 +38,7 @@ class LatestNextUpSchedulerServiceTest {
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
every { mockActivity.lifecycle } returns lifecycleRegistry
|
every { mockActivity.lifecycle } returns lifecycleRegistry
|
||||||
every { mockServerRepository.current } returns currentLiveData
|
every { mockServerRepository.current } returns currentUser
|
||||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ class LatestNextUpSchedulerServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
createService()
|
createService()
|
||||||
|
|
||||||
currentLiveData.value = null
|
currentUser.value = null
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) }
|
verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
|
|
@ -15,6 +14,7 @@ import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
@ -98,8 +98,8 @@ class SuggestionServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
|
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
|
||||||
runTest {
|
runTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
val currentUser = MutableStateFlow<JellyfinUser?>(null)
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
|
|
||||||
val service = createService()
|
val service = createService()
|
||||||
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
|
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
|
||||||
|
|
@ -113,10 +113,10 @@ class SuggestionServiceTest {
|
||||||
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
|
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
mockEnqueueOnDemandWork()
|
mockEnqueueOnDemandWork()
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
|
||||||
|
|
@ -142,10 +142,10 @@ class SuggestionServiceTest {
|
||||||
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
|
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
mockEnqueueOnDemandWork()
|
mockEnqueueOnDemandWork()
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
|
||||||
|
|
@ -163,10 +163,10 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
mockEnqueueOnDemandWork()
|
mockEnqueueOnDemandWork()
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
|
||||||
|
|
@ -189,11 +189,11 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||||
val cachedId = UUID.randomUUID()
|
val cachedId = UUID.randomUUID()
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany
|
||||||
listOf(null, CachedSuggestions(listOf(cachedId)))
|
listOf(null, CachedSuggestions(listOf(cachedId)))
|
||||||
mockEnqueueOnDemandWork()
|
mockEnqueueOnDemandWork()
|
||||||
|
|
@ -220,9 +220,9 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList())
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList())
|
||||||
|
|
||||||
val service = createService()
|
val service = createService()
|
||||||
|
|
@ -244,10 +244,10 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
mockEnqueueOnDemandWork()
|
mockEnqueueOnDemandWork()
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns
|
||||||
|
|
@ -268,10 +268,10 @@ class SuggestionServiceTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val libraryId = UUID.randomUUID()
|
val libraryId = UUID.randomUUID()
|
||||||
val otherLibraryId = UUID.randomUUID()
|
val otherLibraryId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE)
|
val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE)
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
mockEnqueueOnDemandWork()
|
mockEnqueueOnDemandWork()
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
|
||||||
|
|
||||||
|
|
@ -304,9 +304,9 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
|
|
||||||
val cachedId = UUID.randomUUID()
|
val cachedId = UUID.randomUUID()
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|
@ -339,9 +339,9 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val userId = UUID.randomUUID()
|
val userId = UUID.randomUUID()
|
||||||
val parentId = UUID.randomUUID()
|
val parentId = UUID.randomUUID()
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUserFlow } returns currentUser
|
||||||
|
|
||||||
val cachedId = UUID.randomUUID()
|
val cachedId = UUID.randomUUID()
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.LifecycleRegistry
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.work.PeriodicWorkRequest
|
import androidx.work.PeriodicWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
|
@ -19,6 +18,7 @@ import io.mockk.slot
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
|
@ -35,7 +35,7 @@ import java.util.UUID
|
||||||
class SuggestionsSchedulerServiceTest {
|
class SuggestionsSchedulerServiceTest {
|
||||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
private val currentUser = MutableStateFlow<CurrentUser?>(null)
|
||||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||||
|
|
@ -45,7 +45,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
every { mockActivity.lifecycle } returns lifecycleRegistry
|
every { mockActivity.lifecycle } returns lifecycleRegistry
|
||||||
every { mockServerRepository.current } returns currentLiveData
|
every { mockServerRepository.current } returns currentUser
|
||||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,8 +57,8 @@ class SuggestionsSchedulerServiceTest {
|
||||||
context = mockActivity,
|
context = mockActivity,
|
||||||
serverRepository = mockServerRepository,
|
serverRepository = mockServerRepository,
|
||||||
workManager = mockWorkManager,
|
workManager = mockWorkManager,
|
||||||
|
dispatcher = testDispatcher,
|
||||||
).also {
|
).also {
|
||||||
it.dispatcher = testDispatcher
|
|
||||||
it.initialDelaySecondsProvider = { 60L }
|
it.initialDelaySecondsProvider = { 60L }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
mockWorkInfos(emptyList())
|
mockWorkInfos(emptyList())
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentUser.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
|
|
@ -88,13 +88,13 @@ class SuggestionsSchedulerServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
mockWorkInfos(emptyList())
|
mockWorkInfos(emptyList())
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentUser.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
)
|
)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
currentLiveData.value = null
|
currentUser.value = null
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
|
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +113,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
} returns mockk()
|
} returns mockk()
|
||||||
|
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentUser.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
|
|
@ -138,7 +138,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
} returns mockk()
|
} returns mockk()
|
||||||
|
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentUser.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
|
|
@ -159,7 +159,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
mockWorkInfos(listOf(workInfo))
|
mockWorkInfos(listOf(workInfo))
|
||||||
|
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentUser.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
|
@ -15,6 +14,7 @@ import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.unmockkObject
|
import io.mockk.unmockkObject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -112,7 +112,7 @@ class SuggestionsWorkerTest {
|
||||||
every { mockApi.accessToken } returns null
|
every { mockApi.accessToken } returns null
|
||||||
val mockUser = mockk<CurrentUser>()
|
val mockUser = mockk<CurrentUser>()
|
||||||
var restored = false
|
var restored = false
|
||||||
every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) }
|
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
|
||||||
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
||||||
restored = true
|
restored = true
|
||||||
mockUser
|
mockUser
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.test
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
|
@ -773,7 +772,6 @@ private fun serverRepo(
|
||||||
): ServerRepository {
|
): ServerRepository {
|
||||||
val mocked = mockk<ServerRepository>()
|
val mocked = mockk<ServerRepository>()
|
||||||
every { mocked.currentUserDto } returns
|
every { mocked.currentUserDto } returns
|
||||||
MutableLiveData(
|
|
||||||
UserDto(
|
UserDto(
|
||||||
id = UUID.randomUUID(),
|
id = UUID.randomUUID(),
|
||||||
hasPassword = true,
|
hasPassword = true,
|
||||||
|
|
@ -785,8 +783,8 @@ private fun serverRepo(
|
||||||
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
|
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
|
||||||
subtitleLanguagePreference = subtitleLang,
|
subtitleLanguagePreference = subtitleLang,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return mocked
|
return mocked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ object TestModule {
|
||||||
.addInterceptor {
|
.addInterceptor {
|
||||||
val request = it.request()
|
val request = it.request()
|
||||||
val newRequest =
|
val newRequest =
|
||||||
serverRepository.currentUser.value?.accessToken?.let { token ->
|
serverRepository.currentUser?.accessToken?.let { token ->
|
||||||
request
|
request
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addHeader(
|
.addHeader(
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,12 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
|
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
|
||||||
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
|
|
||||||
|
|
||||||
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
|
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
|
||||||
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
||||||
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
|
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue