Merge branch 'main' into develop/mpv

This commit is contained in:
Damontecres 2025-11-13 13:11:02 -05:00
commit c8b6394a5d
No known key found for this signature in database
55 changed files with 1267 additions and 575 deletions

View file

@ -12,6 +12,7 @@ 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.key import androidx.compose.runtime.key
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
@ -99,7 +100,6 @@ class MainActivity : AppCompatActivity() {
) { ) {
var isRestoringSession by remember { mutableStateOf(true) } var isRestoringSession by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) {
try { try {
serverRepository.restoreSession( serverRepository.restoreSession(
appPreferences.currentServerId?.toUUIDOrNull(), appPreferences.currentServerId?.toUUIDOrNull(),
@ -108,13 +108,11 @@ class MainActivity : AppCompatActivity() {
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception restoring session") Timber.e(ex, "Exception restoring session")
} }
Timber.d("MainActivity session restored")
}
isRestoringSession = false isRestoringSession = false
} }
val server = serverRepository.currentServer val server by serverRepository.currentServer.observeAsState()
val user = serverRepository.currentUser val user by serverRepository.currentUser.observeAsState()
val userDto = serverRepository.currentUserDto val userDto by serverRepository.currentUserDto.observeAsState()
val preferences = val preferences =
UserPreferences( UserPreferences(
@ -142,8 +140,6 @@ class MainActivity : AppCompatActivity() {
val initialDestination = val initialDestination =
if (server != null && user != null) { if (server != null && user != null) {
Destination.Home() Destination.Home()
} else if (server != null) {
Destination.UserList
} else { } else {
Destination.ServerList Destination.ServerList
} }
@ -158,9 +154,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
LaunchedEffect(server, user) {
serverEventListener.init(server, user)
}
ApplicationContent( ApplicationContent(
user = user, user = user,
server = server, server = server,

View file

@ -25,7 +25,7 @@ class ItemPlaybackRepository
itemId: UUID, itemId: UUID,
item: BaseItem, item: BaseItem,
): ChosenStreams? = ): ChosenStreams? =
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) { if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId) Timber.v("Got itemPlayback for %s", itemId)
@ -72,7 +72,7 @@ class ItemPlaybackRepository
sourceId: UUID, sourceId: UUID,
): ItemPlayback? = ): ItemPlayback? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
val itemPlayback = val itemPlayback =
ItemPlayback( ItemPlayback(
userId = user.rowId, userId = user.rowId,
@ -89,7 +89,7 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
trackIndex: Int, trackIndex: Int,
type: MediaStreamType, type: MediaStreamType,
) = serverRepository.currentUser?.let { user -> ) = serverRepository.currentUser.value?.let { user ->
var toSave = var toSave =
itemPlayback ?: ItemPlayback( itemPlayback ?: ItemPlayback(
userId = user.rowId, userId = user.rowId,
@ -113,7 +113,9 @@ class ItemPlaybackRepository
val toSave = val toSave =
if (itemPlayback.userId < 0) { if (itemPlayback.userId < 0) {
val userRowId = val userRowId =
serverRepository.currentUser?.rowId?.takeIf { it >= 0 } serverRepository.currentUser.value
?.rowId
?.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")
itemPlayback.copy(userId = userRowId) itemPlayback.copy(userId = userRowId)
} else { } else {

View file

@ -27,7 +27,7 @@ class NavDrawerItemRepository
val user = serverRepository.currentUser val user = serverRepository.currentUser
val userViews = val userViews =
api.userViewsApi api.userViewsApi
.getUserViews(userId = user?.id) .getUserViews(userId = user.value?.id)
.content.items .content.items
val builtins = listOf(NavDrawerItem.Favorites) val builtins = listOf(NavDrawerItem.Favorites)
@ -46,7 +46,7 @@ class NavDrawerItemRepository
} }
suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> { suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> {
val user = serverRepository.currentUser val user = serverRepository.currentUser.value
val navDrawerPins = val navDrawerPins =
user user
?.let { ?.let {

View file

@ -2,14 +2,15 @@ package com.github.damontecres.wholphin.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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.MutableLiveData
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.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -41,12 +42,12 @@ class ServerRepository
) { ) {
private val sharedPreferences = getServerSharedPreferences(context) private val sharedPreferences = getServerSharedPreferences(context)
private var _currentServer by mutableStateOf<JellyfinServer?>(null) private var _current = MutableLiveData<CurrentUser?>(null)
val currentServer get() = _currentServer val current: LiveData<CurrentUser?> = _current
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
val currentUser get() = _currentUser val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
private var _currentUserDto by mutableStateOf<UserDto?>(null) val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
val currentUserDto get() = _currentUserDto val currentUserDto: LiveData<UserDto?> get() = _current.map { it?.userDto }
/** /**
* 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
@ -57,19 +58,8 @@ class ServerRepository
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
serverDao.addOrUpdateServer(server) serverDao.addOrUpdateServer(server)
} }
changeServer(server)
}
/**
* Updates the [ApiClient] to the server's URL
*
* The current user is removed
*/
fun changeServer(server: JellyfinServer) {
apiClient.update(baseUrl = server.url, accessToken = null) apiClient.update(baseUrl = server.url, accessToken = null)
_currentServer = server _current.setValueOnMain(null)
_currentUser = null
_currentUserDto = null
} }
/** /**
@ -109,9 +99,7 @@ class ServerRepository
}.build() }.build()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_currentUserDto = userDto _current.value = CurrentUser(updatedServer, updatedUser, userDto)
_currentServer = updatedServer
_currentUser = updatedUser
} }
sharedPreferences.edit(true) { sharedPreferences.edit(true) {
putString(SERVER_URL_KEY, updatedServer.url) putString(SERVER_URL_KEY, updatedServer.url)
@ -127,6 +115,7 @@ class ServerRepository
userId: UUID?, userId: UUID?,
): Boolean { ): Boolean {
if (serverId == null || userId == null) { if (serverId == null || userId == null) {
_current.setValueOnMain(null)
return false return false
} }
val serverAndUsers = val serverAndUsers =
@ -185,9 +174,10 @@ class ServerRepository
} }
suspend fun removeUser(user: JellyfinUser) { suspend fun removeUser(user: JellyfinUser) {
if (currentUser == user) { if (currentUser.value?.id == user.id) {
_currentUser = null withContext(Dispatchers.Main) {
_currentUserDto = null _current.value = null
}
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
it it
.toBuilder() .toBuilder()
@ -203,10 +193,10 @@ class ServerRepository
} }
suspend fun removeServer(server: JellyfinServer) { suspend fun removeServer(server: JellyfinServer) {
if (currentServer == server) { if (currentServer.value?.id == server.id) {
_currentServer = null withContext(Dispatchers.Main) {
_currentUser = null _current.value = null
_currentUserDto = null }
userPreferencesDataStore.updateData { userPreferencesDataStore.updateData {
it it
.toBuilder() .toBuilder()
@ -222,6 +212,18 @@ class ServerRepository
} }
} }
suspend fun switchServerOrUser() {
apiClient.update(baseUrl = null, accessToken = null)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = ""
currentUserId = ""
}.build()
}
}
companion object { companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences = fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences( context.getSharedPreferences(
@ -233,3 +235,9 @@ class ServerRepository
const val ACCESS_TOKEN_KEY = "current.accessToken" const val ACCESS_TOKEN_KEY = "current.accessToken"
} }
} }
data class CurrentUser(
val server: JellyfinServer,
val user: JellyfinUser,
val userDto: UserDto,
)

View file

@ -11,6 +11,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
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.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@Serializable @Serializable
data class BaseItem( data class BaseItem(
@ -18,29 +19,29 @@ data class BaseItem(
val imageUrl: String?, val imageUrl: String?,
val backdropImageUrl: String? = null, val backdropImageUrl: String? = null,
) { ) {
@Transient val id = data.id val id get() = data.id
@Transient val type = data.type val type get() = data.type
@Transient val name = data.name val name get() = data.name
@Transient val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
val title = if (type == BaseItemKind.EPISODE) data.seriesName else name
@Transient val subtitle
val subtitle = get() =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
val resumeMs =
data.userData
?.playbackPositionTicks
?.ticks
?.inWholeMilliseconds
@Transient @Transient
val indexNumber = data.indexNumber ?: dateAsIndex() val indexNumber = data.indexNumber ?: dateAsIndex()
val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val resumeMs get() = playbackPosition.inWholeMilliseconds
val played get() = data.userData?.played ?: false
val favorite get() = data.userData?.isFavorite ?: false
private fun dateAsIndex(): Int? = private fun dateAsIndex(): Int? =
data.premiereDate data.premiereDate
?.let { ?.let {

View file

@ -1,3 +1,5 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
@ -8,9 +10,13 @@ import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Relation import androidx.room.Relation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
@Entity(tableName = "servers") @Entity(tableName = "servers")
@Serializable
data class JellyfinServer( data class JellyfinServer(
@PrimaryKey val id: UUID, @PrimaryKey val id: UUID,
val name: String?, val name: String?,

View file

@ -145,7 +145,7 @@ class PlaylistCreator
name: String, name: String,
initialItems: List<UUID>, initialItems: List<UUID>,
): UUID? = ): UUID? =
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
api.playlistsApi api.playlistsApi
.createPlaylist( .createPlaylist(
CreatePlaylistDto( CreatePlaylistDto(

View file

@ -11,6 +11,8 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.FavoriteWatchManagerImpl
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -23,6 +25,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
@ -92,7 +95,7 @@ object AppModule {
.addInterceptor { .addInterceptor {
val request = it.request() val request = it.request()
val newRequest = val newRequest =
serverRepository.currentUser?.accessToken?.let { token -> serverRepository.currentUser.value?.accessToken?.let { token ->
request request
.newBuilder() .newBuilder()
.addHeader( .addHeader(
@ -102,7 +105,7 @@ object AppModule {
clientVersion = clientInfo.version, clientVersion = clientInfo.version,
deviceId = deviceInfo.id, deviceId = deviceInfo.id,
deviceName = deviceInfo.name, deviceName = deviceInfo.name,
accessToken = serverRepository.currentUser?.accessToken, accessToken = token,
), ),
).build() ).build()
} }
@ -146,7 +149,7 @@ object AppModule {
appPreference: DataStore<AppPreferences>, appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope, @IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager { ) = object : RememberTabManager {
fun key(itemId: String) = "${serverRepository.currentServer?.id}_${serverRepository.currentUser?.id}_$itemId" fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
override fun getRememberedTab( override fun getRememberedTab(
preferences: UserPreferences, preferences: UserPreferences,
@ -182,4 +185,8 @@ object AppModule {
@Singleton @Singleton
@IoCoroutineScope @IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api)
} }

View file

@ -38,6 +38,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
@ -380,3 +381,12 @@ fun equalsNotNull(
a: Any?, a: Any?,
b: Any?, b: Any?,
) = a != null && b != null && a == b ) = a != null && b != null && a == b
fun logTab(
name: String,
tabIndex: Int,
) {
val info = "$tabIndex, $name"
Timber.i("Current tab: $info")
ACRA.errorReporter.putCustomData("tabInfo", info)
}

View file

@ -21,16 +21,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
/** /**
@ -45,6 +50,7 @@ fun BannerCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cornerText: String? = null, cornerText: String? = null,
played: Boolean = false, played: Boolean = false,
favorite: Boolean = false,
playPercent: Double = 0.0, playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f, cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9, aspectRatio: Float = 16f / 9,
@ -118,6 +124,18 @@ fun BannerCard(
} }
} }
} }
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 16.sp,
fontFamily = FontAwesome,
)
}
if (playPercent > 0 && playPercent < 100) { if (playPercent > 0 && playPercent < 100) {
Box( Box(
modifier = modifier =

View file

@ -28,8 +28,8 @@ import com.github.damontecres.wholphin.util.FocusPair
fun ItemRow( fun ItemRow(
title: String, title: String,
items: List<BaseItem?>, items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
cardContent: @Composable ( cardContent: @Composable (
index: Int, index: Int,
item: BaseItem?, item: BaseItem?,
@ -83,8 +83,8 @@ fun ItemRow(
index, index,
item, item,
cardModifier, cardModifier,
{ if (item != null) onClickItem.invoke(item) }, { if (item != null) onClickItem.invoke(index, item) },
{ if (item != null) onLongClickItem.invoke(item) }, { if (item != null) onLongClickItem.invoke(index, item) },
) )
} }
} }
@ -95,8 +95,8 @@ fun ItemRow(
fun BannerItemRow( fun BannerItemRow(
title: String, title: String,
items: List<BaseItem?>, items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusPair: FocusPair? = null, focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,

View file

@ -28,7 +28,7 @@ fun PersonRow(
people: List<Person>, people: List<Person>,
onClick: (Person) -> Unit, onClick: (Person) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongClick: ((Person) -> Unit)? = null, onLongClick: ((Int, Person) -> Unit)? = null,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
Column( Column(
@ -54,7 +54,7 @@ fun PersonRow(
PersonCard( PersonCard(
item = item, item = item,
onClick = { onClick.invoke(item) }, onClick = { onClick.invoke(item) },
onLongClick = { onLongClick?.invoke(item) }, onLongClick = { onLongClick?.invoke(index, item) },
modifier = modifier =
Modifier Modifier
.width(120.dp) .width(120.dp)

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
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.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -37,12 +38,20 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.nav.NavigationManager
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 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.FavoriteWatchManager
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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -57,6 +66,7 @@ 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
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
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.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -71,6 +81,8 @@ class CollectionFolderViewModel
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
val navigationManager: NavigationManager,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf()) val pager = MutableLiveData<List<BaseItem?>>(listOf())
@ -96,7 +108,7 @@ class CollectionFolderViewModel
val sortAndDirection = val sortAndDirection =
if (initialSortAndDirection == null) { if (initialSortAndDirection == null) {
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} ?: SortAndDirection.DEFAULT } ?: SortAndDirection.DEFAULT
} else { } else {
@ -111,7 +123,7 @@ class CollectionFolderViewModel
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, filter: GetItemsFilter,
) { ) {
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo = val libraryDisplayInfo =
LibraryDisplayInfo( LibraryDisplayInfo(
@ -228,6 +240,24 @@ class CollectionFolderViewModel
result.totalRecordCount result.totalRecordCount
} }
} }
fun setWatched(
position: Int,
itemId: UUID,
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
fun setFavorite(
position: Int,
itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
(pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
} }
/** /**
@ -241,7 +271,7 @@ fun CollectionFolderGrid(
itemId: UUID, itemId: UUID,
initialFilter: GetItemsFilter, initialFilter: GetItemsFilter,
recursive: Boolean, recursive: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
@ -266,14 +296,16 @@ fun CollectionFolderGrid(
itemId: String, itemId: String,
initialFilter: GetItemsFilter, initialFilter: GetItemsFilter,
recursive: Boolean, recursive: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
val context = LocalContext.current
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(itemId, initialSortAndDirection, recursive, initialFilter) viewModel.init(itemId, initialSortAndDirection, recursive, initialFilter)
} }
@ -283,6 +315,10 @@ fun CollectionFolderGrid(
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val pager by viewModel.pager.observeAsState() val pager by viewModel.pager.observeAsState()
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading, LoadingState.Loading,
@ -297,6 +333,9 @@ fun CollectionFolderGrid(
sortAndDirection = sortAndDirection!!, sortAndDirection = sortAndDirection!!,
modifier = modifier, modifier = modifier,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item))
},
onSortChange = { onSortChange = {
viewModel.onSortChange(it, recursive, filter) viewModel.onSortChange(it, recursive, filter)
}, },
@ -308,6 +347,55 @@ fun CollectionFolderGrid(
} }
} }
} }
moreDialog.compose { (position, item) ->
DialogPopup(
showDialog = true,
title = item.title ?: "",
dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = null,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, watched ->
viewModel.setFavorite(position, itemId, watched)
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },
dismissOnClick = true,
waitToLoad = true,
)
}
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
state = playlistState,
onDismissRequest = { showPlaylistDialog.makeAbsent() },
onClick = {
playlistViewModel.addToPlaylist(it.id, itemId)
showPlaylistDialog.makeAbsent()
},
createEnabled = true,
onCreatePlaylist = {
playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent()
},
elevation = 3.dp,
)
}
} }
@Composable @Composable
@ -316,7 +404,8 @@ fun CollectionFolderGridContent(
item: BaseItem?, item: BaseItem?,
pager: List<BaseItem?>, pager: List<BaseItem?>,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onSortChange: (SortAndDirection) -> Unit, onSortChange: (SortAndDirection) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
@ -358,7 +447,7 @@ fun CollectionFolderGridContent(
CardGrid( CardGrid(
pager = pager, pager = pager,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = {}, onLongClickItem = onLongClickItem,
letterPosition = letterPosition, letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference showJumpButtons = false, // TODO add preference
@ -372,3 +461,8 @@ fun CollectionFolderGridContent(
) )
} }
} }
data class PositionItem(
val position: Int,
val item: BaseItem,
)

View file

@ -103,7 +103,7 @@ fun GenreCardGrid(
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
CardGrid( CardGrid(
pager = genres, pager = genres,
onClickItem = { genre -> onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.FilteredCollection( Destination.FilteredCollection(
itemId = itemId, itemId = itemId,
@ -112,7 +112,7 @@ fun GenreCardGrid(
), ),
) )
}, },
onLongClickItem = {}, onLongClickItem = { _, _ -> },
letterPosition = { viewModel.positionOfLetter(it) }, letterPosition = { viewModel.positionOfLetter(it) },
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
showJumpButtons = false, showJumpButtons = false,

View file

@ -29,7 +29,7 @@ fun LoadingRow(
rowIndex: Int, rowIndex: Int,
position: RowColumn, position: RowColumn,
focusRequester: FocusRequester, focusRequester: FocusRequester,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit, onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showIfEmpty: Boolean = true, showIfEmpty: Boolean = true,
@ -82,7 +82,7 @@ fun LoadingRow(
title = title, title = title,
items = r.items, items = r.items,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = {}, onLongClickItem = { _, _ -> },
modifier = modifier, modifier = modifier,
cardContent = cardContent, cardContent = cardContent,
horizontalPadding = horizontalPadding, horizontalPadding = horizontalPadding,

View file

@ -0,0 +1,31 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class Optional<T> private constructor(
initialValue: T?,
) {
private var value by mutableStateOf(initialValue)
@Composable
fun compose(run: @Composable (T) -> Unit) {
value?.let { run.invoke(it) }
}
fun makeAbsent() {
value = null
}
fun makePresent(value: T) {
this.value = value
}
companion object {
fun <T> absent() = Optional<T>(null)
fun <T> present(value: T) = Optional(value)
}
}

View file

@ -0,0 +1,178 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
abstract class RecommendedViewModel(
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
) : ViewModel() {
abstract fun init()
abstract val rows: MutableStateFlow<MutableList<HomeRowLoadingState>>
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
fun refreshItem(
position: RowColumn,
itemId: UUID,
) {
viewModelScope.launchIO {
val row = rows.value.getOrNull(position.row)
if (row is HomeRowLoadingState.Success) {
(row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId)
}
}
}
fun setWatched(
position: RowColumn,
itemId: UUID,
watched: Boolean,
) {
viewModelScope.launchIO {
favoriteWatchManager.setWatched(itemId, watched)
refreshItem(position, itemId)
}
}
fun setFavorite(
position: RowColumn,
itemId: UUID,
watched: Boolean,
) {
viewModelScope.launchIO {
favoriteWatchManager.setFavorite(itemId, watched)
refreshItem(position, itemId)
}
}
}
@Composable
fun RecommendedContent(
preferences: UserPreferences,
viewModel: RecommendedViewModel,
modifier: Modifier = Modifier,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
onFocusPosition: ((RowColumn) -> Unit)? = null,
) {
val context = LocalContext.current
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect {
viewModel.init()
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.collectAsState()
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success ->
HomePageContent(
homeRows = rows,
onClickItem = { _, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { position, item ->
moreDialog.makePresent(RowColumnItem(position, item))
},
onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock,
modifier = modifier,
)
}
moreDialog.compose { (position, item) ->
DialogPopup(
showDialog = true,
title = item.title ?: "",
dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = null,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, watched ->
viewModel.setFavorite(position, itemId, watched)
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },
dismissOnClick = true,
waitToLoad = true,
)
}
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
state = playlistState,
onDismissRequest = { showPlaylistDialog.makeAbsent() },
onClick = {
playlistViewModel.addToPlaylist(it.id, itemId)
showPlaylistDialog.makeAbsent()
},
createEnabled = true,
onCreatePlaylist = {
playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent()
},
elevation = 3.dp,
)
}
}
private data class RowColumnItem(
val position: RowColumn,
val item: BaseItem,
)

View file

@ -2,25 +2,19 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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 com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
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.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.NavigationManager
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.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
@ -54,15 +48,15 @@ class RecommendedMovieViewModel
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
) : ViewModel() { navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel fun create(parentId: UUID): RecommendedMovieViewModel
} }
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) override val rows =
val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>( MutableStateFlow<MutableList<HomeRowLoadingState>>(
rowTitles rowTitles
.map { .map {
@ -72,7 +66,7 @@ class RecommendedMovieViewModel
}.toMutableList(), }.toMutableList(),
) )
fun init() { override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
try { try {
val resumeItemsRequest = val resumeItemsRequest =
@ -139,7 +133,7 @@ class RecommendedMovieViewModel
val suggestionsRequest = val suggestionsRequest =
GetSuggestionsRequest( GetSuggestionsRequest(
userId = serverRepository.currentUser?.id, userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.MOVIE), type = listOf(BaseItemKind.MOVIE),
) )
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
@ -211,7 +205,6 @@ class RecommendedMovieViewModel
fun RecommendedMovie( fun RecommendedMovie(
preferences: UserPreferences, preferences: UserPreferences,
parentId: UUID, parentId: UUID,
onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit, onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RecommendedMovieViewModel = viewModel: RecommendedMovieViewModel =
@ -219,27 +212,10 @@ fun RecommendedMovie(
creationCallback = { it.create(parentId) }, creationCallback = { it.create(parentId) },
), ),
) { ) {
OneTimeLaunchedEffect { RecommendedContent(
viewModel.init() preferences = preferences,
} viewModel = viewModel,
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.collectAsState()
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success ->
HomePageContent(
homeRows = rows,
onClickItem = onClickItem,
onLongClickItem = {},
onFocusPosition = onFocusPosition, onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock,
modifier = modifier, modifier = modifier,
) )
} }
}

View file

@ -2,25 +2,19 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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 com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
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.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.NavigationManager
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.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
@ -56,15 +50,15 @@ class RecommendedTvShowViewModel
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
) : ViewModel() { navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel fun create(parentId: UUID): RecommendedTvShowViewModel
} }
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) override val rows =
val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>( MutableStateFlow<MutableList<HomeRowLoadingState>>(
rowTitles rowTitles
.map { .map {
@ -74,7 +68,7 @@ class RecommendedTvShowViewModel
}.toMutableList(), }.toMutableList(),
) )
fun init() { override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
try { try {
val resumeItemsRequest = val resumeItemsRequest =
@ -167,7 +161,7 @@ class RecommendedTvShowViewModel
val suggestionsRequest = val suggestionsRequest =
GetSuggestionsRequest( GetSuggestionsRequest(
userId = serverRepository.currentUser?.id, userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.SERIES), type = listOf(BaseItemKind.SERIES),
) )
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
@ -240,7 +234,6 @@ class RecommendedTvShowViewModel
fun RecommendedTvShow( fun RecommendedTvShow(
preferences: UserPreferences, preferences: UserPreferences,
parentId: UUID, parentId: UUID,
onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit, onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RecommendedTvShowViewModel = viewModel: RecommendedTvShowViewModel =
@ -248,28 +241,10 @@ fun RecommendedTvShow(
creationCallback = { it.create(parentId) }, creationCallback = { it.create(parentId) },
), ),
) { ) {
OneTimeLaunchedEffect { RecommendedContent(
viewModel.init() preferences = preferences,
} viewModel = viewModel,
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.collectAsState()
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success ->
HomePageContent(
homeRows = rows,
onClickItem = onClickItem,
onLongClickItem = {},
onFocusPosition = onFocusPosition, onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock,
modifier = modifier, modifier = modifier,
) )
} }
}

View file

@ -71,8 +71,8 @@ private const val DEBUG = false
@Composable @Composable
fun CardGrid( fun CardGrid(
pager: List<BaseItem?>, pager: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
showJumpButtons: Boolean, showJumpButtons: Boolean,
@ -270,10 +270,10 @@ fun CardGrid(
{ {
if (item != null) { if (item != null) {
focusedIndex = index focusedIndex = index
onClickItem.invoke(item) onClickItem.invoke(index, item)
} }
}, },
{ if (item != null) onLongClickItem.invoke(item) }, { if (item != null) onLongClickItem.invoke(index, item) },
mod mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus)) .ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState -> .onFocusChanged { focusState ->

View file

@ -30,7 +30,7 @@ fun CollectionFolderGeneric(
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
itemId = itemId, itemId = itemId,
initialFilter = filter, initialFilter = filter,
showTitle = showHeader, showTitle = showHeader,

View file

@ -38,6 +38,7 @@ import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid 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.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -65,7 +66,7 @@ class LiveTvCollectionViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val folders = val folders =
api.liveTvApi api.liveTvApi
.getRecordingFolders(userId = serverRepository.currentUser?.id) .getRecordingFolders(userId = serverRepository.currentUser.value?.id)
.content.items .content.items
.map { TabId(it.name ?: "Recordings", it.id) } .map { TabId(it.name ?: "Recordings", it.id) }
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
@ -102,9 +103,10 @@ fun CollectionFolderLiveTv(
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("livetv", selectedTabIndex)
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
} }
val onClickItem = { item: BaseItem -> val onClickItem = { position: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
} }

View file

@ -23,7 +23,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 com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -33,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.RecommendedMovie
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
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.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -62,15 +62,12 @@ fun CollectionFolderMovie(
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
} }
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
val onClickItem = { item: BaseItem ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
}
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
modifier = modifier, modifier = modifier,
@ -94,7 +91,6 @@ fun CollectionFolderMovie(
0 -> { 0 -> {
RecommendedMovie( RecommendedMovie(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem,
parentId = destination.itemId, parentId = destination.itemId,
onFocusPosition = { pos -> onFocusPosition = { pos ->
showHeader = pos.row < 1 showHeader = pos.row < 1
@ -109,7 +105,9 @@ fun CollectionFolderMovie(
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId, itemId = destination.itemId,
initialFilter = initialFilter =
GetItemsFilter( GetItemsFilter(
@ -131,7 +129,9 @@ fun CollectionFolderMovie(
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId, itemId = destination.itemId,
initialFilter = initialFilter =
GetItemsFilter( GetItemsFilter(

View file

@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
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.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -60,6 +61,7 @@ fun CollectionFolderTv(
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("tv", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
} }
@ -93,7 +95,6 @@ fun CollectionFolderTv(
RecommendedTvShow( RecommendedTvShow(
preferences = preferences, preferences = preferences,
parentId = destination.itemId, parentId = destination.itemId,
onClickItem = onClickItem,
onFocusPosition = { pos -> onFocusPosition = { pos ->
showHeader = pos.row < 1 showHeader = pos.row < 1
}, },
@ -123,7 +124,9 @@ fun CollectionFolderTv(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
onClickItem = onClickItem, onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
) )
} }
2 -> { 2 -> {

View file

@ -61,7 +61,7 @@ class DebugViewModel
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser?.rowId?.let { serverRepository.currentUser.value?.rowId?.let {
val results = itemPlaybackDao.getItems(it) val results = itemPlaybackDao.getItems(it)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
itemPlaybacks.value = results itemPlaybacks.value = results

View file

@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -20,6 +21,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
data class MoreDialogActions(
val navigateTo: (Destination) -> Unit,
var onClickWatch: (UUID, Boolean) -> Unit,
var onClickFavorite: (UUID, Boolean) -> Unit,
var onClickAddPlaylist: (UUID) -> Unit,
)
/** /**
* Build the [DialogItem]s when clicking "More" * Build the [DialogItem]s when clicking "More"
* *
@ -43,12 +51,9 @@ fun buildMoreDialogItems(
sourceId: UUID?, sourceId: UUID?,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
navigateTo: (Destination) -> Unit, actions: MoreDialogActions,
onClickWatch: (Boolean) -> Unit,
onClickFavorite: (Boolean) -> Unit,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
onClickAddPlaylist: () -> Unit,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
add( add(
@ -57,7 +62,7 @@ fun buildMoreDialogItems(
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
navigateTo( actions.navigateTo(
Destination.Playback( Destination.Playback(
item.id, item.id,
item.resumeMs ?: 0L, item.resumeMs ?: 0L,
@ -119,7 +124,7 @@ fun buildMoreDialogItems(
text = R.string.add_to_playlist, text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul, iconStringRes = R.string.fa_list_ul,
) { ) {
onClickAddPlaylist.invoke() actions.onClickAddPlaylist.invoke(item.id)
}, },
) )
add( add(
@ -127,7 +132,7 @@ fun buildMoreDialogItems(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
) { ) {
onClickWatch.invoke(!watched) actions.onClickWatch.invoke(item.id, !watched)
}, },
) )
add( add(
@ -136,7 +141,7 @@ fun buildMoreDialogItems(
iconStringRes = R.string.fa_heart, iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified, iconColor = if (favorite) Color.Red else Color.Unspecified,
) { ) {
onClickFavorite.invoke(!favorite) actions.onClickFavorite.invoke(item.id, !favorite)
}, },
) )
series?.let { series?.let {
@ -145,7 +150,7 @@ fun buildMoreDialogItems(
context.getString(R.string.go_to_series), context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
) { ) {
navigateTo( actions.navigateTo(
Destination.MediaItem( Destination.MediaItem(
series.id, series.id,
BaseItemKind.SERIES, BaseItemKind.SERIES,
@ -160,7 +165,7 @@ fun buildMoreDialogItems(
context.getString(R.string.play_with_transcoding), context.getString(R.string.play_with_transcoding),
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
) { ) {
navigateTo( actions.navigateTo(
Destination.Playback( Destination.Playback(
item.id, item.id,
item.resumeMs ?: 0L, item.resumeMs ?: 0L,
@ -179,10 +184,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration, playbackPosition: Duration,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
navigateTo: (Destination) -> Unit, actions: MoreDialogActions,
onClickWatch: (UUID, Boolean) -> Unit,
onClickFavorite: (UUID, Boolean) -> Unit,
onClickAddPlaylist: (UUID) -> Unit,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
val itemId = item.id val itemId = item.id
@ -191,7 +193,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to), context.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
navigateTo(item.destination()) actions.navigateTo(item.destination())
}, },
) )
if (item.type in supportedPlayableTypes) { if (item.type in supportedPlayableTypes) {
@ -202,7 +204,7 @@ fun buildMoreDialogItemsForHome(
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
navigateTo( actions.navigateTo(
Destination.Playback( Destination.Playback(
itemId, itemId,
playbackPosition.inWholeMilliseconds, playbackPosition.inWholeMilliseconds,
@ -216,7 +218,7 @@ fun buildMoreDialogItemsForHome(
Icons.Default.Refresh, Icons.Default.Refresh,
// iconColor = Color.Green.copy(alpha = .8f), // iconColor = Color.Green.copy(alpha = .8f),
) { ) {
navigateTo( actions.navigateTo(
Destination.Playback( Destination.Playback(
itemId, itemId,
0L, 0L,
@ -231,7 +233,7 @@ fun buildMoreDialogItemsForHome(
Icons.Default.PlayArrow, Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f), iconColor = Color.Green.copy(alpha = .8f),
) { ) {
navigateTo( actions.navigateTo(
Destination.Playback( Destination.Playback(
itemId, itemId,
0L, 0L,
@ -246,7 +248,7 @@ fun buildMoreDialogItemsForHome(
text = R.string.add_to_playlist, text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul, iconStringRes = R.string.fa_list_ul,
) { ) {
onClickAddPlaylist.invoke(itemId) actions.onClickAddPlaylist.invoke(itemId)
}, },
) )
add( add(
@ -254,7 +256,7 @@ fun buildMoreDialogItemsForHome(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
) { ) {
onClickWatch.invoke(itemId, !watched) actions.onClickWatch.invoke(itemId, !watched)
}, },
) )
add( add(
@ -263,7 +265,7 @@ fun buildMoreDialogItemsForHome(
iconStringRes = R.string.fa_heart, iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified, iconColor = if (favorite) Color.Red else Color.Unspecified,
) { ) {
onClickFavorite.invoke(itemId, !favorite) actions.onClickFavorite.invoke(itemId, !favorite)
}, },
) )
seriesId?.let { seriesId?.let {
@ -272,7 +274,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to_series), context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward, Icons.AutoMirrored.Filled.ArrowForward,
) { ) {
navigateTo( actions.navigateTo(
Destination.MediaItem( Destination.MediaItem(
it, it,
BaseItemKind.SERIES, BaseItemKind.SERIES,
@ -282,3 +284,31 @@ fun buildMoreDialogItemsForHome(
) )
} }
} }
fun buildMoreDialogItemsForPerson(
context: Context,
person: Person,
// favorite: Boolean,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
val itemId = person.id
add(
DialogItem(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
},
)
// TODO need way to get person's favorite status
// add(
// DialogItem(
// text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
// iconStringRes = R.string.fa_heart,
// iconColor = if (favorite) Color.Red else Color.Unspecified,
// ) {
// actions.onClickFavorite.invoke(itemId, !favorite)
// },
// )
}

View file

@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -63,6 +64,7 @@ fun FavoritesPage(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("favorites", selectedTabIndex)
preferencesViewModel.saveRememberedTab( preferencesViewModel.saveRememberedTab(
preferences, preferences,
NavDrawerItem.Favorites.id, NavDrawerItem.Favorites.id,
@ -97,7 +99,7 @@ fun FavoritesPage(
0 -> { 0 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_movies", itemId = "${NavDrawerItem.Favorites.id}_movies",
initialFilter = initialFilter =
GetItemsFilter( GetItemsFilter(
@ -120,7 +122,7 @@ fun FavoritesPage(
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_series", itemId = "${NavDrawerItem.Favorites.id}_series",
initialFilter = initialFilter =
GetItemsFilter( GetItemsFilter(
@ -143,7 +145,7 @@ fun FavoritesPage(
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_episodes", itemId = "${NavDrawerItem.Favorites.id}_episodes",
initialFilter = initialFilter =
GetItemsFilter( GetItemsFilter(

View file

@ -26,9 +26,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.res.stringResource 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
@ -186,8 +183,8 @@ fun PersonPage(
movies = movies, movies = movies,
series = series, series = series,
episodes = episodes, episodes = episodes,
onClickItem = { onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
overviewOnClick = { showOverviewDialog = true }, overviewOnClick = { showOverviewDialog = true },
modifier = modifier, modifier = modifier,
@ -226,7 +223,7 @@ fun PersonPageContent(
movies: RowLoadingState, movies: RowLoadingState,
series: RowLoadingState, series: RowLoadingState,
episodes: RowLoadingState, episodes: RowLoadingState,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {

View file

@ -64,14 +64,18 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.chooseStream import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.MoreDialogActions
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.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
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
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
@ -83,6 +87,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
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
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -112,9 +117,24 @@ fun MovieDetails(
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) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf(false) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading, LoadingState.Loading,
@ -139,8 +159,8 @@ fun MovieDetails(
chapters = chapters, chapters = chapters,
trailers = trailers, trailers = trailers,
similar = similar, similar = similar,
onClickItem = { onClickItem = { index, item ->
viewModel.navigateTo(it.destination()) viewModel.navigateTo(item.destination())
}, },
onClickPerson = { onClickPerson = {
viewModel.navigateTo( viewModel.navigateTo(
@ -180,9 +200,7 @@ fun MovieDetails(
favorite = movie.data.userData?.isFavorite ?: false, favorite = movie.data.userData?.isFavorite ?: false,
series = null, series = null,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.sourceId,
navigateTo = viewModel::navigateTo, actions = moreActions,
onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
chooseVersionParams( chooseVersionParams(
@ -218,18 +236,46 @@ fun MovieDetails(
) )
} }
}, },
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = true
},
), ),
) )
}, },
watchOnClick = { watchOnClick = {
viewModel.setWatched((movie.data.userData?.played ?: false).not()) viewModel.setWatched(movie.id, !movie.played)
}, },
favoriteOnClick = { favoriteOnClick = {
viewModel.setFavorite((movie.data.userData?.isFavorite ?: false).not()) viewModel.setFavorite(movie.id, !movie.favorite)
},
onLongClickPerson = { index, person ->
val items =
buildMoreDialogItemsForPerson(
context = context,
person = person,
actions = moreActions,
)
moreDialog =
DialogParams(
fromLongClick = true,
title = person.name ?: "",
items = items,
)
},
onLongClickSimilar = { index, similar ->
val items =
buildMoreDialogItemsForHome(
context = context,
item = similar,
seriesId = null,
playbackPosition = similar.playbackPosition,
watched = similar.played,
favorite = similar.favorite,
actions = moreActions,
)
moreDialog =
DialogParams(
fromLongClick = true,
title = similar.title ?: "",
items = items,
)
}, },
trailerOnClick = { trailer -> trailerOnClick = { trailer ->
when (trailer) { when (trailer) {
@ -257,7 +303,7 @@ fun MovieDetails(
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
showFilePath = showFilePath =
viewModel.serverRepository.currentUserDto viewModel.serverRepository.currentUserDto.value
?.policy ?.policy
?.isAdministrator == true, ?.isAdministrator == true,
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },
@ -283,19 +329,19 @@ fun MovieDetails(
waitToLoad = params.fromLongClick, waitToLoad = params.fromLongClick,
) )
} }
if (showPlaylistDialog) { showPlaylistDialog.compose { itemId ->
PlaylistDialog( PlaylistDialog(
title = stringResource(R.string.add_to_playlist), title = stringResource(R.string.add_to_playlist),
state = playlistState, state = playlistState,
onDismissRequest = { showPlaylistDialog = false }, onDismissRequest = { showPlaylistDialog.makeAbsent() },
onClick = { onClick = {
playlistViewModel.addToPlaylist(it.id, destination.itemId) playlistViewModel.addToPlaylist(it.id, itemId)
showPlaylistDialog = false showPlaylistDialog.makeAbsent()
}, },
createEnabled = true, createEnabled = true,
onCreatePlaylist = { onCreatePlaylist = {
playlistViewModel.createPlaylistAndAddItem(it, destination.itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog = false showPlaylistDialog.makeAbsent()
}, },
elevation = 3.dp, elevation = 3.dp,
) )
@ -323,8 +369,10 @@ fun MovieDetailsContent(
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit, onClickPerson: (Person) -> Unit,
onLongClickPerson: (Int, Person) -> Unit,
onLongClickSimilar: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -422,7 +470,10 @@ fun MovieDetailsContent(
position = PEOPLE_ROW position = PEOPLE_ROW
onClickPerson.invoke(it) onClickPerson.invoke(it)
}, },
onLongClick = {}, onLongClick = { index, person ->
position = PEOPLE_ROW
onLongClickPerson.invoke(index, person)
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -466,11 +517,14 @@ fun MovieDetailsContent(
ItemRow( ItemRow(
title = stringResource(R.string.more_like_this), title = stringResource(R.string.more_like_this),
items = similar, items = similar,
onClickItem = { onClickItem = { index, item ->
position = SIMILAR_ROW position = SIMILAR_ROW
onClickItem.invoke(it) onClickItem.invoke(index, item)
},
onLongClickItem = { index, similar ->
position = SIMILAR_ROW
onLongClickSimilar.invoke(index, similar)
}, },
onLongClickItem = {},
cardContent = { index, item, mod, onClick, onLongClick -> cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard( SeasonCard(
item = item, item = item,

View file

@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.LoadingExceptionHandler 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.ThemeSongPlayer import com.github.damontecres.wholphin.util.ThemeSongPlayer
@ -39,7 +40,6 @@ 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.libraryApi import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
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 org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
@ -55,6 +55,7 @@ class MovieViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -146,7 +147,7 @@ class MovieViewModel
api.libraryApi api.libraryApi
.getSimilarItems( .getSimilarItems(
GetSimilarItemsRequest( GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id, userId = serverRepository.currentUser.value?.id,
itemId = itemId, itemId = itemId,
fields = SlimItemFields, fields = SlimItemFields,
limit = 25, limit = 25,
@ -157,23 +158,19 @@ class MovieViewModel
} }
} }
fun setWatched(played: Boolean) = fun setWatched(
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { itemId: UUID,
if (played) { played: Boolean,
api.playStateApi.markPlayedItem(itemId) ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
} else { favoriteWatchManager.setWatched(itemId, played)
api.playStateApi.markUnplayedItem(itemId)
}
fetchAndSetItem() fetchAndSetItem()
} }
fun setFavorite(favorite: Boolean) = fun setFavorite(
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { itemId: UUID,
if (favorite) { favorite: Boolean,
api.userLibraryApi.markFavoriteItem(itemId) ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
} else { favoriteWatchManager.setFavorite(itemId, favorite)
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
fetchAndSetItem() fetchAndSetItem()
} }

View file

@ -60,10 +60,17 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SimpleStarRating import com.github.damontecres.wholphin.ui.components.SimpleStarRating
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.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -74,7 +81,9 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState 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.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -83,6 +92,7 @@ fun SeriesDetails(
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -98,6 +108,8 @@ fun SeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) } var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -126,7 +138,9 @@ fun SeriesDetails(
played = played, played = played,
favorite = item.data.userData?.isFavorite ?: false, favorite = item.data.userData?.isFavorite ?: false,
modifier = modifier, modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) }, onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
},
onClickPerson = { onClickPerson = {
viewModel.navigateTo( viewModel.navigateTo(
Destination.MediaItem( Destination.MediaItem(
@ -135,7 +149,7 @@ fun SeriesDetails(
), ),
) )
}, },
onLongClickItem = { season -> onLongClickItem = { index, season ->
seasonDialog = seasonDialog =
buildDialogForSeason( buildDialogForSeason(
context = context, context = context,
@ -160,6 +174,20 @@ fun SeriesDetails(
val favorite = item.data.userData?.isFavorite ?: false val favorite = item.data.userData?.isFavorite ?: false
viewModel.setFavorite(item.id, !favorite, null) viewModel.setFavorite(item.id, !favorite, null)
}, },
moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, played ->
viewModel.setWatched(itemId, played, null)
},
onClickFavorite = { itemId, played ->
viewModel.setFavorite(itemId, played, null)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
),
) )
if (showWatchConfirmation) { if (showWatchConfirmation) {
ConfirmDialog( ConfirmDialog(
@ -194,6 +222,23 @@ fun SeriesDetails(
onDismissRequest = { seasonDialog = null }, onDismissRequest = { seasonDialog = null },
) )
} }
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
state = playlistState,
onDismissRequest = { showPlaylistDialog.makeAbsent() },
onClick = {
playlistViewModel.addToPlaylist(it.id, itemId)
showPlaylistDialog.makeAbsent()
},
createEnabled = true,
onCreatePlaylist = {
playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent()
},
elevation = 3.dp,
)
}
} }
private const val HEADER_ROW = 0 private const val HEADER_ROW = 0
@ -210,15 +255,17 @@ fun SeriesDetailsContent(
people: List<Person>, people: List<Person>,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit, onClickPerson: (Person) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
playOnClick: () -> Unit, playOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreActions: MoreDialogActions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -227,6 +274,7 @@ fun SeriesDetailsContent(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
Box( Box(
modifier = modifier, modifier = modifier,
@ -349,11 +397,14 @@ fun SeriesDetailsContent(
ItemRow( ItemRow(
title = stringResource(R.string.tv_seasons), title = stringResource(R.string.tv_seasons),
items = seasons, items = seasons,
onClickItem = { onClickItem = { index, item ->
position = SEASONS_ROW position = SEASONS_ROW
onClickItem.invoke(it) onClickItem.invoke(index, item)
},
onLongClickItem = { index, item ->
position = SEASONS_ROW
onLongClickItem.invoke(index, item)
}, },
onLongClickItem = onLongClickItem,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -379,7 +430,21 @@ fun SeriesDetailsContent(
position = PEOPLE_ROW position = PEOPLE_ROW
onClickPerson.invoke(it) onClickPerson.invoke(it)
}, },
onLongClick = {}, onLongClick = { index, person ->
position = PEOPLE_ROW
val items =
buildMoreDialogItemsForPerson(
context = context,
person = person,
actions = moreActions,
)
moreDialog =
DialogParams(
fromLongClick = true,
title = person.name ?: "",
items = items,
)
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -392,11 +457,29 @@ fun SeriesDetailsContent(
ItemRow( ItemRow(
title = stringResource(R.string.more_like_this), title = stringResource(R.string.more_like_this),
items = similar, items = similar,
onClickItem = { onClickItem = { index, item ->
position = SIMILAR_ROW position = SIMILAR_ROW
onClickItem.invoke(it) onClickItem.invoke(index, item)
},
onLongClickItem = { index, item ->
position = SIMILAR_ROW
val items =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = null,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
actions = moreActions,
)
moreDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items = items,
)
}, },
onLongClickItem = {},
cardContent = { index, item, mod, onClick, onLongClick -> cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard( SeasonCard(
item = item, item = item,
@ -418,6 +501,16 @@ fun SeriesDetailsContent(
} }
} }
} }
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
} }
@Composable @Composable

View file

@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.MoreDialogActions
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.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
@ -196,29 +197,28 @@ fun SeriesOverview(
favorite = ep.data.userData?.isFavorite ?: false, favorite = ep.data.userData?.isFavorite ?: false,
series = series, series = series,
sourceId = chosenStreams?.sourceId, sourceId = chosenStreams?.sourceId,
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
onClickWatch = { played -> onClickWatch = { itemId, watched ->
episodeList
?.getOrNull(position.episodeRowIndex)
?.let {
viewModel.setWatched( viewModel.setWatched(
it.id, itemId,
played, watched,
position.episodeRowIndex, position.episodeRowIndex,
) )
}
}, },
onClickFavorite = { favorite -> onClickFavorite = { itemId, favorite ->
episodeList
?.getOrNull(position.episodeRowIndex)
?.let {
viewModel.setFavorite( viewModel.setFavorite(
it.id, itemId,
favorite, favorite,
position.episodeRowIndex, position.episodeRowIndex,
) )
}
}, },
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = it
},
),
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
chooseVersionParams( chooseVersionParams(
@ -254,10 +254,6 @@ fun SeriesOverview(
) )
} }
}, },
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = ep.id
},
), ),
) )
@ -350,7 +346,7 @@ fun SeriesOverview(
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
showFilePath = showFilePath =
viewModel.serverRepository.currentUserDto viewModel.serverRepository.currentUserDto.value
?.policy ?.policy
?.isAdministrator == true, ?.isAdministrator == true,
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -50,6 +51,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.formatDateTime import com.github.damontecres.wholphin.util.formatDateTime
import kotlin.time.Duration import kotlin.time.Duration
@ -77,6 +79,9 @@ fun SeriesOverviewContent(
) { ) {
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
LaunchedEffect(selectedTabIndex) {
logTab("series_overview", selectedTabIndex)
}
val tabRowFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = val focusedEpisode =

View file

@ -23,6 +23,7 @@ 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.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
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.LoadingExceptionHandler
@ -36,9 +37,7 @@ 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.libraryApi import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
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.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -61,6 +60,7 @@ class SeriesViewModel
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val itemPlaybackRepository: ItemPlaybackRepository, private val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
@ -121,7 +121,7 @@ class SeriesViewModel
api.libraryApi api.libraryApi
.getSimilarItems( .getSimilarItems(
GetSimilarItemsRequest( GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id, userId = serverRepository.currentUser.value?.id,
itemId = itemId, itemId = itemId,
fields = SlimItemFields, fields = SlimItemFields,
limit = 25, limit = 25,
@ -240,11 +240,7 @@ class SeriesViewModel
played: Boolean, played: Boolean,
listIndex: Int?, listIndex: Int?,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
if (played) { favoriteWatchManager.setWatched(itemId, played)
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
listIndex?.let { listIndex?.let {
refreshEpisode(itemId, listIndex) refreshEpisode(itemId, listIndex)
} }
@ -255,11 +251,7 @@ class SeriesViewModel
favorite: Boolean, favorite: Boolean,
listIndex: Int?, listIndex: Int?,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) { favoriteWatchManager.setFavorite(itemId, favorite)
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
if (listIndex != null) { if (listIndex != null) {
refreshEpisode(itemId, listIndex) refreshEpisode(itemId, listIndex)
} else { } else {
@ -279,11 +271,7 @@ class SeriesViewModel
fun setWatchedSeries(played: Boolean) = fun setWatchedSeries(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) { favoriteWatchManager.setWatched(seriesId, played)
api.playStateApi.markPlayedItem(seriesId)
} else {
api.playStateApi.markUnplayedItem(seriesId)
}
val series = fetchItem(seriesId) val series = fetchItem(seriesId)
val seasons = getSeasons(series) val seasons = getSeasons(series)
this@SeriesViewModel.seasons.setValueOnMain(seasons) this@SeriesViewModel.seasons.setValueOnMain(seasons)

View file

@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
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.data.RowColumnSaver import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
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.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
@ -75,7 +76,6 @@ import org.jellyfin.sdk.model.api.BaseItemKind
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
import java.util.UUID import java.util.UUID
import kotlin.time.Duration
@Composable @Composable
fun HomePage( fun HomePage(
@ -121,22 +121,20 @@ fun HomePage(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
HomePageContent( HomePageContent(
watchingRows + latestRows, watchingRows + latestRows,
onClickItem = { onClickItem = { position, item ->
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { onLongClickItem = { position, item ->
val dialogItems = val dialogItems =
buildMoreDialogItemsForHome( buildMoreDialogItemsForHome(
context = context, context = context,
item = it, item = item,
seriesId = it.data.seriesId, seriesId = item.data.seriesId,
playbackPosition = playbackPosition = item.playbackPosition,
it.data.userData watched = item.played,
?.playbackPositionTicks favorite = item.favorite,
?.ticks actions =
?: Duration.ZERO, MoreDialogActions(
watched = it.data.userData?.played ?: false,
favorite = it.data.userData?.isFavorite ?: false,
navigateTo = viewModel.navigationManager::navigateTo, navigateTo = viewModel.navigationManager::navigateTo,
onClickWatch = { itemId, played -> onClickWatch = { itemId, played ->
viewModel.setWatched(itemId, played) viewModel.setWatched(itemId, played)
@ -148,10 +146,11 @@ fun HomePage(
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = itemId showPlaylistDialog = itemId
}, },
),
) )
dialog = dialog =
DialogParams( DialogParams(
title = it.title ?: "", title = item.title ?: "",
fromLongClick = true, fromLongClick = true,
items = dialogItems, items = dialogItems,
) )
@ -190,8 +189,8 @@ fun HomePage(
@Composable @Composable
fun HomePageContent( fun HomePageContent(
homeRows: List<HomeRowLoadingState>, homeRows: List<HomeRowLoadingState>,
onClickItem: (BaseItem) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit,
showClock: Boolean, showClock: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
@ -317,14 +316,18 @@ fun HomePageContent(
ItemRow( ItemRow(
title = row.title, title = row.title,
items = row.items, items = row.items,
onClickItem = onClickItem, onClickItem = { index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item)
},
cardOnFocus = { isFocused, index -> cardOnFocus = { isFocused, index ->
if (isFocused) { if (isFocused) {
focusedItem = row.items.getOrNull(index) focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index) position = RowColumn(rowIndex, index)
} }
}, },
onLongClickItem = onLongClickItem, onLongClickItem = { index, item ->
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -339,6 +342,7 @@ fun HomePageContent(
item?.data?.indexNumber?.let { "E$it" } item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },
played = item?.data?.userData?.played ?: false, played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent = playPercent =
item?.data?.userData?.playedPercentage item?.data?.userData?.playedPercentage
?: 0.0, ?: 0.0,

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -27,7 +28,6 @@ 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.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -50,6 +50,7 @@ class HomeViewModel
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -76,7 +77,7 @@ class HomeViewModel
) { ) {
Timber.d("init HomeViewModel") Timber.d("init HomeViewModel")
serverRepository.currentUserDto?.let { userDto -> serverRepository.currentUserDto.value?.let { userDto ->
val includedIds = val includedIds =
navDrawerItemRepository navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
@ -258,11 +259,7 @@ class HomeViewModel
itemId: UUID, itemId: UUID,
played: Boolean, played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) { favoriteWatchManager.setWatched(itemId, played)
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init(preferences)
} }
@ -272,11 +269,7 @@ class HomeViewModel
itemId: UUID, itemId: UUID,
favorite: Boolean, favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) { favoriteWatchManager.setFavorite(itemId, favorite)
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init(preferences)
} }

View file

@ -174,6 +174,9 @@ fun SearchPage(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
val onClickItem = { index: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
}
LazyColumn( LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp),
@ -207,7 +210,7 @@ fun SearchPage(
rowIndex = MOVIE_ROW, rowIndex = MOVIE_ROW,
position = position, position = position,
focusRequester = focusRequester, focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = onClickItem,
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@ -217,7 +220,7 @@ fun SearchPage(
rowIndex = COLLECTION_ROW, rowIndex = COLLECTION_ROW,
position = position, position = position,
focusRequester = focusRequester, focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = onClickItem,
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@ -227,7 +230,7 @@ fun SearchPage(
rowIndex = SERIES_ROW, rowIndex = SERIES_ROW,
position = position, position = position,
focusRequester = focusRequester, focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = onClickItem,
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@ -237,7 +240,7 @@ fun SearchPage(
rowIndex = EPISODE_ROW, rowIndex = EPISODE_ROW,
position = position, position = position,
focusRequester = focusRequester, focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = onClickItem,
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
cardContent = @Composable { index, item, mod, onClick, onLongClick -> cardContent = @Composable { index, item, mod, onClick, onLongClick ->
@ -268,7 +271,7 @@ fun LazyListScope.searchResultRow(
rowIndex: Int, rowIndex: Int,
position: RowColumn, position: RowColumn,
focusRequester: FocusRequester, focusRequester: FocusRequester,
onClickItem: (BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit, onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardContent: @Composable ( cardContent: @Composable (
@ -328,7 +331,7 @@ fun LazyListScope.searchResultRow(
title = title, title = title,
items = r.items, items = r.items,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = {}, onLongClickItem = { _, _ -> },
modifier = modifier, modifier = modifier,
cardContent = cardContent, cardContent = cardContent,
) )

View file

@ -10,8 +10,8 @@ import androidx.navigation3.ui.NavDisplay
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.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
/** /**
* This is generally the root composable of the of the app * This is generally the root composable of the of the app
@ -38,7 +38,6 @@ fun ApplicationContent(
entryProvider = { key -> entryProvider = { key ->
key as Destination key as Destination
val contentKey = "${key}_${server?.id}_${user?.id}" val contentKey = "${key}_${server?.id}_${user?.id}"
Timber.d("Navigate: %s", key)
NavEntry(key, contentKey = contentKey) { NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) { if (key.fullScreen) {
DestinationContent( DestinationContent(
@ -47,7 +46,7 @@ fun ApplicationContent(
deviceProfile = deviceProfile, deviceProfile = deviceProfile,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) )
} else { } else if (user != null && server != null) {
NavDrawer( NavDrawer(
destination = key, destination = key,
preferences = preferences, preferences = preferences,
@ -56,6 +55,8 @@ fun ApplicationContent(
server = server, server = server,
modifier = modifier, modifier = modifier,
) )
} else {
ErrorMessage("Trying to go to $key without a user logged in", null)
} }
} }
}, },

View file

@ -6,6 +6,7 @@ import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -29,7 +30,9 @@ sealed class Destination(
data object ServerList : Destination(true) data object ServerList : Destination(true)
@Serializable @Serializable
data object UserList : Destination(true) data class UserList(
val server: JellyfinServer,
) : Destination(true)
@Serializable @Serializable
data class Home( data class Home(

View file

@ -54,7 +54,7 @@ fun DestinationContent(
) )
Destination.ServerList -> SwitchServerContent(modifier) Destination.ServerList -> SwitchServerContent(modifier)
Destination.UserList -> SwitchUserContent(modifier) is Destination.UserList -> SwitchUserContent(destination.server, modifier)
is Destination.Settings -> is Destination.Settings ->
PreferencesPage( PreferencesPage(

View file

@ -84,7 +84,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
import java.time.LocalTime import java.time.LocalTime
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -141,7 +140,7 @@ class NavDrawerViewModel
null null
} }
} }
Timber.v("Found $index => $key") // Timber.v("Found $index => $key")
if (index != null) { if (index != null) {
selectedIndex.setValueOnMain(index) selectedIndex.setValueOnMain(index)
break break
@ -198,8 +197,8 @@ data class ServerNavDrawerItem(
fun NavDrawer( fun NavDrawer(
destination: Destination, destination: Destination,
preferences: UserPreferences, preferences: UserPreferences,
user: JellyfinUser?, user: JellyfinUser,
server: JellyfinServer?, server: JellyfinServer,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
@ -319,7 +318,11 @@ fun NavDrawer(
selected = false, selected = false,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.navigationManager.navigateTo(Destination.UserList) viewModel.navigationManager.navigateToFromDrawer(
Destination.UserList(
server,
),
)
}, },
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
) )

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.nav package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import org.acra.ACRA
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -18,6 +20,7 @@ class NavigationManager
*/ */
fun navigateTo(destination: Destination) { fun navigateTo(destination: Destination) {
backStack.add(destination) backStack.add(destination)
log()
} }
/** /**
@ -25,7 +28,13 @@ class NavigationManager
*/ */
fun navigateToFromDrawer(destination: Destination) { fun navigateToFromDrawer(destination: Destination) {
goToHome() goToHome()
if (destination == Destination.ServerList || destination is Destination.UserList) {
backStack.add(destination) backStack.add(destination)
(0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) }
} else {
backStack.add(destination)
}
log()
} }
/** /**
@ -33,6 +42,7 @@ class NavigationManager
*/ */
fun goBack() { fun goBack() {
backStack.removeLastOrNull() backStack.removeLastOrNull()
log()
} }
/** /**
@ -45,6 +55,7 @@ class NavigationManager
if (backStack[0] !is Destination.Home) { if (backStack[0] !is Destination.Home) {
backStack[0] = Destination.Home() backStack[0] = Destination.Home()
} }
log()
} }
/** /**
@ -54,5 +65,12 @@ class NavigationManager
goToHome() goToHome()
val id = (backStack[0] as Destination.Home).id + 1 val id = (backStack[0] as Destination.Home).id + 1
backStack[0] = Destination.Home(id) backStack[0] = Destination.Home(id)
log()
}
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.i("Current Destination: %s", dest)
ACRA.errorReporter.putCustomData("destination", dest)
} }
} }

View file

@ -272,7 +272,7 @@ class PlaybackViewModel
if (itemPlayback != null) { if (itemPlayback != null) {
itemPlayback itemPlayback
} else { } else {
val user = serverRepository.currentUser!! serverRepository.currentUser.value?.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) {
@ -282,6 +282,7 @@ class PlaybackViewModel
} }
} }
} }
}
val mediaSource = chooseSource(base, playbackConfig) val mediaSource = chooseSource(base, playbackConfig)
if (mediaSource == null) { if (mediaSource == null) {

View file

@ -49,7 +49,7 @@ class PreferencesViewModel
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
@ -60,7 +60,7 @@ class PreferencesViewModel
fun updatePins(newSelectedItems: List<NavDrawerItem>) { fun updatePins(newSelectedItems: List<NavDrawerItem>) {
viewModelScope.launchIO(ExceptionHandler(true)) { viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser?.let { user -> serverRepository.currentUser.value?.let { user ->
val disabledItems = val disabledItems =
mutableListOf<NavDrawerItem>().apply { mutableListOf<NavDrawerItem>().apply {
addAll(allNavDrawerItems) addAll(allNavDrawerItems)

View file

@ -49,6 +49,7 @@ fun ServerList(
servers: List<JellyfinServer>, servers: List<JellyfinServer>,
connectionStatus: Map<UUID, ServerConnectionStatus>, connectionStatus: Map<UUID, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit, onSwitchServer: (JellyfinServer) -> Unit,
onTestServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit, onRemoveServer: (JellyfinServer) -> Unit,
allowAdd: Boolean, allowAdd: Boolean,
@ -61,7 +62,7 @@ fun ServerList(
items(servers) { server -> items(servers) { server ->
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
ListItem( ListItem(
enabled = status == ServerConnectionStatus.Success, enabled = true,
selected = false, selected = false,
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) }, headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) }, supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
@ -77,12 +78,18 @@ fun ServerList(
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Default.Warning,
contentDescription = status.message, contentDescription = status.message,
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.errorContainer,
) )
} }
} }
}, },
onClick = { onSwitchServer.invoke(server) }, onClick = {
when (status) {
ServerConnectionStatus.Success -> onSwitchServer.invoke(server)
ServerConnectionStatus.Pending -> {}
is ServerConnectionStatus.Error -> onTestServer.invoke(server)
}
},
onLongClick = { onLongClick = {
if (allowDelete) { if (allowDelete) {
showDeleteDialog = server showDeleteDialog = server

View file

@ -43,9 +43,8 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable @Composable
fun SwitchServerContent( fun SwitchServerContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(), viewModel: SwitchServerViewModel = hiltViewModel(),
) { ) {
val currentServer = viewModel.serverRepository.currentServer
val servers by viewModel.servers.observeAsState(listOf()) val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
@ -90,6 +89,9 @@ fun SwitchServerContent(
onSwitchServer = { onSwitchServer = {
viewModel.addServer(it.url) viewModel.addServer(it.url)
}, },
onTestServer = {
viewModel.testServer(it)
},
onAddServer = { onAddServer = {
showAddServer = true showAddServer = true
}, },
@ -142,6 +144,9 @@ fun SwitchServerContent(
onSwitchServer = { onSwitchServer = {
viewModel.addServer(it.url) viewModel.addServer(it.url)
}, },
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {}, onAddServer = {},
onRemoveServer = {}, onRemoveServer = {},
allowAdd = false, allowAdd = false,

View file

@ -0,0 +1,214 @@
package com.github.damontecres.wholphin.ui.setup
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class SwitchServerViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
val jellyfin: Jellyfin,
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
init {
init()
}
fun init() {
viewModelScope.launchIO {
withContext(Dispatchers.Main) {
serverStatus.value = mapOf()
serverQuickConnect.value = mapOf()
}
val allServers =
serverDao
.getServers()
.map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) {
servers.value = allServers
}
allServers.forEach { server ->
internalTestServer(server)
}
}
}
fun testServer(server: JellyfinServer) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Pending,
)
}
viewModelScope.launchIO {
delay(1000)
val result = internalTestServer(server)
if (result == ServerConnectionStatus.Success) {
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
} else if (result is ServerConnectionStatus.Error) {
showToast(context, result.message ?: "Error", Toast.LENGTH_SHORT)
}
}
}
private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus =
try {
jellyfin
.createApi(
server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
}
ServerConnectionStatus.Success
} catch (ex: Exception) {
val status = ServerConnectionStatus.Error(ex.localizedMessage)
Timber.w(ex, "Error checking server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
status,
)
}
}
status
}
fun addServer(serverUrl: String) {
addServerState.value = LoadingState.Loading
viewModelScope.launchIO {
try {
val serverInfo by jellyfin
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id?.toUUIDOrNull()
if (id != null && serverInfo.startupWizardCompleted == true) {
val server =
JellyfinServer(
id = id,
name = serverInfo.serverName,
url = serverUrl,
)
serverRepository.addAndChangeServer(server)
val quickConnect =
jellyfin
.createApi(serverUrl)
.quickConnectApi
.getQuickConnectEnabled()
.content
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(id, quickConnect)
}
}
withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList(server))
}
} else {
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl")
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error(exception = ex)
}
}
}
}
fun removeServer(server: JellyfinServer) {
viewModelScope.launchIO {
serverRepository.removeServer(server)
init()
}
}
fun discoverServers() {
viewModelScope.launchIO {
jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply {
add(
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
),
)
}
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}
}
}
}
}

View file

@ -39,6 +39,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -49,17 +50,20 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable @Composable
fun SwitchUserContent( fun SwitchUserContent(
currentServer: JellyfinServer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(), viewModel: SwitchUserViewModel =
hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>(
creationCallback = { it.create(currentServer) },
),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val currentServer = viewModel.serverRepository.currentServer // val currentServer by viewModel.serverRepository.currentServer.observeAsState()
val currentUser = viewModel.serverRepository.currentUser val currentUser by viewModel.serverRepository.currentUser.observeAsState()
val users by viewModel.users.observeAsState(listOf()) val users by viewModel.users.observeAsState(listOf())
val serverQuickConnect by viewModel.serverQuickConnect.observeAsState(mapOf()) val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false)
val quickConnectEnabled = currentServer?.let { serverQuickConnect[it.id] ?: false } ?: false
val quickConnect by viewModel.quickConnectState.observeAsState(null) val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) } var showAddUser by remember { mutableStateOf(false) }
@ -109,7 +113,7 @@ fun SwitchUserContent(
users = users, users = users,
currentUser = currentUser, currentUser = currentUser,
onSwitchUser = { user -> onSwitchUser = { user ->
viewModel.switchUser(server, user) viewModel.switchUser(user)
}, },
onAddUser = { showAddUser = true }, onAddUser = { showAddUser = true },
onRemoveUser = { user -> onRemoveUser = { user ->

View file

@ -8,13 +8,18 @@ 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.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.HttpClientOptions
@ -25,42 +30,41 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult import org.jellyfin.sdk.model.api.QuickConnectResult
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel(assistedFactory = SwitchUserViewModel.Factory::class)
class SwitchUserViewModel class SwitchUserViewModel
@Inject @AssistedInject
constructor( constructor(
val jellyfin: Jellyfin, val jellyfin: Jellyfin,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
@Assisted val server: JellyfinServer,
) : ViewModel() { ) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf()) @AssistedFactory
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) interface Factory {
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf()) fun create(server: JellyfinServer): SwitchUserViewModel
}
init {
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
serverRepository.switchServerOrUser()
}
}
val serverQuickConnect = MutableLiveData<Boolean>(false)
val users = MutableLiveData<List<JellyfinUser>>(listOf()) val users = MutableLiveData<List<JellyfinUser>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null) val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null private var quickConnectJob: Job? = null
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending) val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
val loginAttempts = MutableLiveData(0) val loginAttempts = MutableLiveData(0)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
fun clearSwitchUserState() { fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending switchUserState.value = LoadingState.Pending
} }
@ -74,23 +78,17 @@ class SwitchUserViewModel
} }
fun init() { fun init() {
viewModelScope.launchIO {
quickConnectJob?.cancel() quickConnectJob?.cancel()
viewModelScope.launchIO {
users.setValueOnMain(listOf())
val serverUsers =
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
users.value = listOf() users.setValueOnMain(serverUsers)
serverStatus.value = mapOf() }
serverQuickConnect.value = mapOf()
} }
val allServers = viewModelScope.launchIO {
serverDao
.getServers()
.map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) {
servers.value = allServers
}
allServers.forEach { server ->
try { try {
jellyfin jellyfin
.createApi( .createApi(
@ -109,56 +107,18 @@ class SwitchUserViewModel
.quickConnectApi .quickConnectApi
.getQuickConnectEnabled() .getQuickConnectEnabled()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
serverStatus.value = serverQuickConnect.value = quickConnect
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, quickConnect)
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}") Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
serverStatus.value = serverQuickConnect.value = false
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Error(ex.localizedMessage),
)
}
}
}
}
}
viewModelScope.launchIO {
serverRepository.currentServer?.let {
val quickConnect =
jellyfin
.createApi(it.url)
.quickConnectApi
.getQuickConnectEnabled()
.content
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(it.id, quickConnect)
}
users.value = serverUsers
} }
} }
} }
} }
fun switchUser( fun switchUser(user: JellyfinUser) {
server: JellyfinServer,
user: JellyfinUser,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
serverRepository.changeUser(server, user) serverRepository.changeUser(server, user)
@ -242,10 +202,7 @@ class SwitchUserViewModel
if (ex is InvalidStatusException && ex.status == 401) { if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
quickConnectState.value = null quickConnectState.value = null
serverQuickConnect.value = serverQuickConnect.value = false
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, false)
}
} }
} }
setError("Error with Quick Connect", ex) setError("Error with Quick Connect", ex)
@ -258,55 +215,6 @@ class SwitchUserViewModel
quickConnectState.value = null quickConnectState.value = null
} }
fun addServer(serverUrl: String) {
addServerState.value = LoadingState.Loading
viewModelScope.launchIO {
try {
val serverInfo by jellyfin
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id?.toUUIDOrNull()
if (id != null && serverInfo.startupWizardCompleted == true) {
serverRepository.addAndChangeServer(
JellyfinServer(
id = id,
name = serverInfo.serverName,
url = serverUrl,
),
)
val quickConnect =
jellyfin
.createApi(serverUrl)
.quickConnectApi
.getQuickConnectEnabled()
.content
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(id, quickConnect)
}
}
withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList)
}
} else {
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl")
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error(exception = ex)
}
}
}
}
fun removeUser(user: JellyfinUser) { fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.removeUser(user) serverRepository.removeUser(user)
@ -318,35 +226,6 @@ class SwitchUserViewModel
} }
} }
fun removeServer(server: JellyfinServer) {
viewModelScope.launchIO {
serverRepository.removeServer(server)
init()
}
}
fun discoverServers() {
viewModelScope.launchIO {
jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply {
add(
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
),
)
}
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}
}
}
}
private suspend fun setError( private suspend fun setError(
msg: String? = null, msg: String? = null,
ex: Exception? = null, ex: Exception? = null,

View file

@ -0,0 +1,43 @@
package com.github.damontecres.wholphin.util
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.util.UUID
interface FavoriteWatchManager {
suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto
suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto
}
class FavoriteWatchManagerImpl(
private val api: ApiClient,
) : FavoriteWatchManager {
override suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto =
if (played) {
api.playStateApi.markPlayedItem(itemId).content
} else {
api.playStateApi.markUnplayedItem(itemId).content
}
override suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto =
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId).content
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content
}
}

View file

@ -16,6 +16,7 @@ class PlaybackLifecycleObserver
constructor( constructor(
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
@ -36,11 +37,13 @@ class PlaybackLifecycleObserver
wasPlaying = it.isPlaying wasPlaying = it.isPlaying
it.pause() it.pause()
} }
themeSongPlayer.stop()
} }
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
if (navigationManager.backStack.lastOrNull() is Destination.Playback) { if (navigationManager.backStack.lastOrNull() is Destination.Playback) {
navigationManager.goBack() navigationManager.goBack()
} }
themeSongPlayer.stop()
} }
} }

View file

@ -2,13 +2,17 @@ package com.github.damontecres.wholphin.util
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.hilt.IoCoroutineScope
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.ApplicationContext import dagger.hilt.android.qualifiers.ActivityContext
import kotlinx.coroutines.CoroutineScope import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -20,24 +24,36 @@ import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton @ActivityScoped
class ServerEventListener class ServerEventListener
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ActivityContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
@param:IoCoroutineScope private val scope: CoroutineScope, private val serverRepository: ServerRepository,
) { ) : DefaultLifecycleObserver {
private val activity = (context as AppCompatActivity)
private var listenJob: Job? = null private var listenJob: Job? = null
init {
activity.lifecycle.addObserver(this)
serverRepository.current.observe(activity) {
Timber.d("New user/server: %s", it)
listenJob?.cancel()
if (it != null) {
init(it.server, it.user)
}
}
}
fun init( fun init(
server: JellyfinServer?, server: JellyfinServer?,
user: JellyfinUser?, user: JellyfinUser?,
) { ) {
if (server != null && user != null && api.baseUrl != null && api.accessToken != null) { if (server != null && user != null && api.baseUrl != null && api.accessToken != null) {
scope.launchIO { (context as AppCompatActivity).lifecycleScope.launchIO {
api.sessionApi.postCapabilities( api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO), playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands = supportedCommands =
@ -53,6 +69,7 @@ class ServerEventListener
} }
fun setupListeners() { fun setupListeners() {
serverRepository.currentUser
Timber.v("Subscribing to WebSocket") Timber.v("Subscribing to WebSocket")
listenJob?.cancel() listenJob?.cancel()
listenJob = listenJob =
@ -73,6 +90,20 @@ class ServerEventListener
.joinToString("\n") .joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG) showToast(context, toast, Toast.LENGTH_LONG)
} }
}.launchIn(scope) }.launchIn(activity.lifecycleScope)
}
override fun onResume(owner: LifecycleOwner) {
serverRepository.current.value?.let { init(it.server, it.user) }
}
override fun onPause(owner: LifecycleOwner) {
Timber.v("Cancelling WebSocket")
listenJob?.cancel()
}
override fun onStop(owner: LifecycleOwner) {
Timber.v("Cancelling WebSocket")
listenJob?.cancel()
} }
} }

View file

@ -78,7 +78,6 @@ class ThemeSongPlayer
volumeLevel: ThemeSongVolume, volumeLevel: ThemeSongVolume,
url: String, url: String,
) { ) {
stop()
val volumeLevel = val volumeLevel =
when (volumeLevel) { when (volumeLevel) {
ThemeSongVolume.UNRECOGNIZED, ThemeSongVolume.UNRECOGNIZED,
@ -92,6 +91,7 @@ class ThemeSongPlayer
ThemeSongVolume.HIGHEST -> 75f ThemeSongVolume.HIGHEST -> 75f
} }
player.apply { player.apply {
stop()
volume = volumeLevel volume = volumeLevel
setMediaItem(MediaItem.fromUri(url)) setMediaItem(MediaItem.fromUri(url))
prepare() prepare()

View file

@ -4,10 +4,10 @@ import androidx.annotation.OptIn
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
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.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
@ -49,7 +49,7 @@ class TrackActivityPlaybackListener(
private var initialized = false private var initialized = false
fun init() { fun init() {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { launch("reportPlaybackStart") {
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") Timber.v("reportPlaybackStart for ${itemPlayback.itemId}")
api.playStateApi.reportPlaybackStart( api.playStateApi.reportPlaybackStart(
PlaybackStartInfo( PlaybackStartInfo(
@ -77,7 +77,7 @@ class TrackActivityPlaybackListener(
task.cancel() task.cancel()
TIMER.purge() TIMER.purge()
val position = player.currentPosition.milliseconds val position = player.currentPosition.milliseconds
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { launch("reportPlaybackStopped") {
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position")
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
@ -107,7 +107,7 @@ class TrackActivityPlaybackListener(
} }
private fun saveActivity(position: Long) { private fun saveActivity(position: Long) {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { launch("saveActivity") {
val calcPosition = val calcPosition =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
(if (position >= 0) position else player.currentPosition) (if (position >= 0) position else player.currentPosition)
@ -135,6 +135,19 @@ class TrackActivityPlaybackListener(
} }
} }
private fun launch(
name: String,
block: suspend CoroutineScope.() -> Unit,
) {
coroutineScope.launchIO {
try {
block.invoke(this)
} catch (ex: Exception) {
Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId)
}
}
}
companion object { companion object {
private const val TAG = "TrackActivityPlaybackListener" private const val TAG = "TrackActivityPlaybackListener"

View file

@ -147,6 +147,7 @@
<string name="italic_font">Italicize font</string> <string name="italic_font">Italicize font</string>
<string name="font">Font</string> <string name="font">Font</string>
<string name="background">Background</string> <string name="background">Background</string>
<string name="success">Success</string>
<plurals name="downloads"> <plurals name="downloads">
<item quantity="zero">%s downloads</item> <item quantity="zero">%s downloads</item>