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

View file

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

View file

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

View file

@ -2,14 +2,15 @@ package com.github.damontecres.wholphin.data
import android.content.Context
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.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.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -41,12 +42,12 @@ class ServerRepository
) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
val currentUser get() = _currentUser
private var _currentUserDto by mutableStateOf<UserDto?>(null)
val currentUserDto get() = _currentUserDto
private var _current = MutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
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
@ -57,19 +58,8 @@ class ServerRepository
withContext(Dispatchers.IO) {
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)
_currentServer = server
_currentUser = null
_currentUserDto = null
_current.setValueOnMain(null)
}
/**
@ -109,9 +99,7 @@ class ServerRepository
}.build()
}
withContext(Dispatchers.Main) {
_currentUserDto = userDto
_currentServer = updatedServer
_currentUser = updatedUser
_current.value = CurrentUser(updatedServer, updatedUser, userDto)
}
sharedPreferences.edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
@ -127,6 +115,7 @@ class ServerRepository
userId: UUID?,
): Boolean {
if (serverId == null || userId == null) {
_current.setValueOnMain(null)
return false
}
val serverAndUsers =
@ -185,9 +174,10 @@ class ServerRepository
}
suspend fun removeUser(user: JellyfinUser) {
if (currentUser == user) {
_currentUser = null
_currentUserDto = null
if (currentUser.value?.id == user.id) {
withContext(Dispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
it
.toBuilder()
@ -203,10 +193,10 @@ class ServerRepository
}
suspend fun removeServer(server: JellyfinServer) {
if (currentServer == server) {
_currentServer = null
_currentUser = null
_currentUserDto = null
if (currentServer.value?.id == server.id) {
withContext(Dispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
it
.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 {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
@ -233,3 +235,9 @@ class ServerRepository
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.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@Serializable
data class BaseItem(
@ -18,29 +19,29 @@ data class BaseItem(
val imageUrl: String?,
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 = if (type == BaseItemKind.EPISODE) data.seriesName else name
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
@Transient
val subtitle =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
val resumeMs =
data.userData
?.playbackPositionTicks
?.ticks
?.inWholeMilliseconds
val subtitle
get() =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
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? =
data.premiereDate
?.let {

View file

@ -1,3 +1,5 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
@ -8,9 +10,13 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
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
@Entity(tableName = "servers")
@Serializable
data class JellyfinServer(
@PrimaryKey val id: UUID,
val name: String?,

View file

@ -145,7 +145,7 @@ class PlaylistCreator
name: String,
initialItems: List<UUID>,
): UUID? =
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
api.playlistsApi
.createPlaylist(
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.updateInterfacePreferences
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 dagger.Module
import dagger.Provides
@ -23,6 +25,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
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.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
@ -92,7 +95,7 @@ object AppModule {
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser?.accessToken?.let { token ->
serverRepository.currentUser.value?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
@ -102,7 +105,7 @@ object AppModule {
clientVersion = clientInfo.version,
deviceId = deviceInfo.id,
deviceName = deviceInfo.name,
accessToken = serverRepository.currentUser?.accessToken,
accessToken = token,
),
).build()
}
@ -146,7 +149,7 @@ object AppModule {
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = 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(
preferences: UserPreferences,
@ -182,4 +185,8 @@ object AppModule {
@Singleton
@IoCoroutineScope
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.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
@ -380,3 +381,12 @@ fun equalsNotNull(
a: Any?,
b: Any?,
) = 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.graphics.RectangleShape
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
/**
@ -45,6 +50,7 @@ fun BannerCard(
modifier: Modifier = Modifier,
cornerText: String? = null,
played: Boolean = false,
favorite: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f,
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) {
Box(
modifier =

View file

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

View file

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

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.ui.OneTimeLaunchedEffect
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.detail.CardGrid
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.tryRequestFocus
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.LoadingExceptionHandler
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.ImageType
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.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -71,6 +81,8 @@ class CollectionFolderViewModel
@param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
val navigationManager: NavigationManager,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
@ -96,7 +108,7 @@ class CollectionFolderViewModel
val sortAndDirection =
if (initialSortAndDirection == null) {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} ?: SortAndDirection.DEFAULT
} else {
@ -111,7 +123,7 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
) {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo =
LibraryDisplayInfo(
@ -228,6 +240,24 @@ class CollectionFolderViewModel
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,
initialFilter: GetItemsFilter,
recursive: Boolean,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>,
modifier: Modifier = Modifier,
initialSortAndDirection: SortAndDirection? = null,
@ -266,14 +296,16 @@ fun CollectionFolderGrid(
itemId: String,
initialFilter: GetItemsFilter,
recursive: Boolean,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>,
modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
val context = LocalContext.current
OneTimeLaunchedEffect {
viewModel.init(itemId, initialSortAndDirection, recursive, initialFilter)
}
@ -283,6 +315,10 @@ fun CollectionFolderGrid(
val item by viewModel.item.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) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
@ -297,6 +333,9 @@ fun CollectionFolderGrid(
sortAndDirection = sortAndDirection!!,
modifier = modifier,
onClickItem = onClickItem,
onLongClickItem = { position, item ->
moreDialog.makePresent(PositionItem(position, item))
},
onSortChange = {
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
@ -316,7 +404,8 @@ fun CollectionFolderGridContent(
item: BaseItem?,
pager: List<BaseItem?>,
sortAndDirection: SortAndDirection,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onSortChange: (SortAndDirection) -> Unit,
letterPosition: suspend (Char) -> Int,
sortOptions: List<ItemSortBy>,
@ -358,7 +447,7 @@ fun CollectionFolderGridContent(
CardGrid(
pager = pager,
onClickItem = onClickItem,
onLongClickItem = {},
onLongClickItem = onLongClickItem,
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
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() }
CardGrid(
pager = genres,
onClickItem = { genre ->
onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo(
Destination.FilteredCollection(
itemId = itemId,
@ -112,7 +112,7 @@ fun GenreCardGrid(
),
)
},
onLongClickItem = {},
onLongClickItem = { _, _ -> },
letterPosition = { viewModel.positionOfLetter(it) },
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,

View file

@ -29,7 +29,7 @@ fun LoadingRow(
rowIndex: Int,
position: RowColumn,
focusRequester: FocusRequester,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
showIfEmpty: Boolean = true,
@ -82,7 +82,7 @@ fun LoadingRow(
title = title,
items = r.items,
onClickItem = onClickItem,
onLongClickItem = {},
onLongClickItem = { _, _ -> },
modifier = modifier,
cardContent = cardContent,
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 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.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.ServerRepository
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.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
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.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
@ -54,15 +48,15 @@ class RecommendedMovieViewModel
private val api: ApiClient,
private val serverRepository: ServerRepository,
@Assisted val parentId: UUID,
) : ViewModel() {
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
) : RecommendedViewModel(navigationManager, favoriteWatchManager) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val rows =
override val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>(
rowTitles
.map {
@ -72,7 +66,7 @@ class RecommendedMovieViewModel
}.toMutableList(),
)
fun init() {
override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
try {
val resumeItemsRequest =
@ -139,7 +133,7 @@ class RecommendedMovieViewModel
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.MOVIE),
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
@ -211,7 +205,6 @@ class RecommendedMovieViewModel
fun RecommendedMovie(
preferences: UserPreferences,
parentId: UUID,
onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedMovieViewModel =
@ -219,27 +212,10 @@ fun RecommendedMovie(
creationCallback = { it.create(parentId) },
),
) {
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 = onClickItem,
onLongClickItem = {},
onFocusPosition = onFocusPosition,
showClock = preferences.appPreferences.interfacePreferences.showClock,
modifier = modifier,
)
}
RecommendedContent(
preferences = preferences,
viewModel = viewModel,
onFocusPosition = onFocusPosition,
modifier = modifier,
)
}

View file

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

View file

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

View file

@ -30,7 +30,7 @@ fun CollectionFolderGeneric(
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
preferences = preferences,
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
itemId = itemId,
initialFilter = filter,
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.TvGuideGrid
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -65,7 +66,7 @@ class LiveTvCollectionViewModel
viewModelScope.launchIO {
val folders =
api.liveTvApi
.getRecordingFolders(userId = serverRepository.currentUser?.id)
.getRecordingFolders(userId = serverRepository.currentUser.value?.id)
.content.items
.map { TabId(it.name ?: "Recordings", it.id) }
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)
@ -102,9 +103,10 @@ fun CollectionFolderLiveTv(
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("livetv", selectedTabIndex)
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
}
val onClickItem = { item: BaseItem ->
val onClickItem = { position: Int, item: BaseItem ->
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.preferences.UserPreferences
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.data.MovieSortOptions
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.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -62,15 +62,12 @@ fun CollectionFolderMovie(
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val onClickItem = { item: BaseItem ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
}
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
@ -94,7 +91,6 @@ fun CollectionFolderMovie(
0 -> {
RecommendedMovie(
preferences = preferences,
onClickItem = onClickItem,
parentId = destination.itemId,
onFocusPosition = { pos ->
showHeader = pos.row < 1
@ -109,7 +105,9 @@ fun CollectionFolderMovie(
1 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
initialFilter =
GetItemsFilter(
@ -131,7 +129,9 @@ fun CollectionFolderMovie(
2 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
initialFilter =
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.TabRow
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.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -60,6 +61,7 @@ fun CollectionFolderTv(
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("tv", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
}
@ -93,7 +95,6 @@ fun CollectionFolderTv(
RecommendedTvShow(
preferences = preferences,
parentId = destination.itemId,
onClickItem = onClickItem,
onFocusPosition = { pos ->
showHeader = pos.row < 1
},
@ -123,7 +124,9 @@ fun CollectionFolderTv(
positionCallback = { columns, position ->
showHeader = position < columns
},
onClickItem = onClickItem,
onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
)
}
2 -> {

View file

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

View file

@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.R
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.letNotEmpty
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.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"
*
@ -43,12 +51,9 @@ fun buildMoreDialogItems(
sourceId: UUID?,
watched: Boolean,
favorite: Boolean,
navigateTo: (Destination) -> Unit,
onClickWatch: (Boolean) -> Unit,
onClickFavorite: (Boolean) -> Unit,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onClickAddPlaylist: () -> Unit,
): List<DialogItem> =
buildList {
add(
@ -57,7 +62,7 @@ fun buildMoreDialogItems(
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
navigateTo(
actions.navigateTo(
Destination.Playback(
item.id,
item.resumeMs ?: 0L,
@ -119,7 +124,7 @@ fun buildMoreDialogItems(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
onClickAddPlaylist.invoke()
actions.onClickAddPlaylist.invoke(item.id)
},
)
add(
@ -127,7 +132,7 @@ fun buildMoreDialogItems(
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,
) {
onClickWatch.invoke(!watched)
actions.onClickWatch.invoke(item.id, !watched)
},
)
add(
@ -136,7 +141,7 @@ fun buildMoreDialogItems(
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
) {
onClickFavorite.invoke(!favorite)
actions.onClickFavorite.invoke(item.id, !favorite)
},
)
series?.let {
@ -145,7 +150,7 @@ fun buildMoreDialogItems(
context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward,
) {
navigateTo(
actions.navigateTo(
Destination.MediaItem(
series.id,
BaseItemKind.SERIES,
@ -160,7 +165,7 @@ fun buildMoreDialogItems(
context.getString(R.string.play_with_transcoding),
Icons.Default.PlayArrow,
) {
navigateTo(
actions.navigateTo(
Destination.Playback(
item.id,
item.resumeMs ?: 0L,
@ -179,10 +184,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration,
watched: Boolean,
favorite: Boolean,
navigateTo: (Destination) -> Unit,
onClickWatch: (UUID, Boolean) -> Unit,
onClickFavorite: (UUID, Boolean) -> Unit,
onClickAddPlaylist: (UUID) -> Unit,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
val itemId = item.id
@ -191,7 +193,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
navigateTo(item.destination())
actions.navigateTo(item.destination())
},
)
if (item.type in supportedPlayableTypes) {
@ -202,7 +204,7 @@ fun buildMoreDialogItemsForHome(
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
navigateTo(
actions.navigateTo(
Destination.Playback(
itemId,
playbackPosition.inWholeMilliseconds,
@ -216,7 +218,7 @@ fun buildMoreDialogItemsForHome(
Icons.Default.Refresh,
// iconColor = Color.Green.copy(alpha = .8f),
) {
navigateTo(
actions.navigateTo(
Destination.Playback(
itemId,
0L,
@ -231,7 +233,7 @@ fun buildMoreDialogItemsForHome(
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
navigateTo(
actions.navigateTo(
Destination.Playback(
itemId,
0L,
@ -246,7 +248,7 @@ fun buildMoreDialogItemsForHome(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
onClickAddPlaylist.invoke(itemId)
actions.onClickAddPlaylist.invoke(itemId)
},
)
add(
@ -254,7 +256,7 @@ fun buildMoreDialogItemsForHome(
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,
) {
onClickWatch.invoke(itemId, !watched)
actions.onClickWatch.invoke(itemId, !watched)
},
)
add(
@ -263,7 +265,7 @@ fun buildMoreDialogItemsForHome(
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
) {
onClickFavorite.invoke(itemId, !favorite)
actions.onClickFavorite.invoke(itemId, !favorite)
},
)
seriesId?.let {
@ -272,7 +274,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to_series),
Icons.AutoMirrored.Filled.ArrowForward,
) {
navigateTo(
actions.navigateTo(
Destination.MediaItem(
it,
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.MovieSortOptions
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.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -63,6 +64,7 @@ fun FavoritesPage(
val focusRequester = remember { FocusRequester() }
LaunchedEffect(selectedTabIndex) {
logTab("favorites", selectedTabIndex)
preferencesViewModel.saveRememberedTab(
preferences,
NavDrawerItem.Favorites.id,
@ -97,7 +99,7 @@ fun FavoritesPage(
0 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_movies",
initialFilter =
GetItemsFilter(
@ -120,7 +122,7 @@ fun FavoritesPage(
1 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_series",
initialFilter =
GetItemsFilter(
@ -143,7 +145,7 @@ fun FavoritesPage(
2 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_episodes",
initialFilter =
GetItemsFilter(

View file

@ -26,9 +26,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -186,8 +183,8 @@ fun PersonPage(
movies = movies,
series = series,
episodes = episodes,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
onClickItem = { index, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
overviewOnClick = { showOverviewDialog = true },
modifier = modifier,
@ -226,7 +223,7 @@ fun PersonPageContent(
movies: RowLoadingState,
series: RowLoadingState,
episodes: RowLoadingState,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit,
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.ExpandablePlayButtons
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.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
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.nav.Destination
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.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID
import kotlin.time.Duration
@Composable
@ -112,9 +117,24 @@ fun MovieDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog 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 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) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
@ -139,8 +159,8 @@ fun MovieDetails(
chapters = chapters,
trailers = trailers,
similar = similar,
onClickItem = {
viewModel.navigateTo(it.destination())
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
},
onClickPerson = {
viewModel.navigateTo(
@ -180,9 +200,7 @@ fun MovieDetails(
favorite = movie.data.userData?.isFavorite ?: false,
series = null,
sourceId = chosenStreams?.sourceId,
navigateTo = viewModel::navigateTo,
onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite,
actions = moreActions,
onChooseVersion = {
chooseVersion =
chooseVersionParams(
@ -218,18 +236,46 @@ fun MovieDetails(
)
}
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = true
},
),
)
},
watchOnClick = {
viewModel.setWatched((movie.data.userData?.played ?: false).not())
viewModel.setWatched(movie.id, !movie.played)
},
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 ->
when (trailer) {
@ -257,7 +303,7 @@ fun MovieDetails(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
@ -283,19 +329,19 @@ fun MovieDetails(
waitToLoad = params.fromLongClick,
)
}
if (showPlaylistDialog) {
showPlaylistDialog.compose { itemId ->
PlaylistDialog(
title = stringResource(R.string.add_to_playlist),
state = playlistState,
onDismissRequest = { showPlaylistDialog = false },
onDismissRequest = { showPlaylistDialog.makeAbsent() },
onClick = {
playlistViewModel.addToPlaylist(it.id, destination.itemId)
showPlaylistDialog = false
playlistViewModel.addToPlaylist(it.id, itemId)
showPlaylistDialog.makeAbsent()
},
createEnabled = true,
onCreatePlaylist = {
playlistViewModel.createPlaylistAndAddItem(it, destination.itemId)
showPlaylistDialog = false
playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent()
},
elevation = 3.dp,
)
@ -323,8 +369,10 @@ fun MovieDetailsContent(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
onLongClickPerson: (Int, Person) -> Unit,
onLongClickSimilar: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@ -422,7 +470,10 @@ fun MovieDetailsContent(
position = PEOPLE_ROW
onClickPerson.invoke(it)
},
onLongClick = {},
onLongClick = { index, person ->
position = PEOPLE_ROW
onLongClickPerson.invoke(index, person)
},
modifier =
Modifier
.fillMaxWidth()
@ -466,11 +517,14 @@ fun MovieDetailsContent(
ItemRow(
title = stringResource(R.string.more_like_this),
items = similar,
onClickItem = {
onClickItem = { index, item ->
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 ->
SeasonCard(
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.setValueOnMain
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.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer
@ -39,7 +40,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
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.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
@ -55,6 +55,7 @@ class MovieViewModel
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -146,7 +147,7 @@ class MovieViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
@ -157,25 +158,21 @@ class MovieViewModel
}
}
fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
fetchAndSetItem()
}
fun setWatched(
itemId: UUID,
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
fetchAndSetItem()
}
fun setFavorite(favorite: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
fetchAndSetItem()
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
fetchAndSetItem()
}
fun savePlayVersion(
item: BaseItem,

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.ExpandablePlayButton
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.SimpleStarRating
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.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.letNotEmpty
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 kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
@Composable
@ -83,6 +92,7 @@ fun SeriesDetails(
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
@ -98,6 +108,8 @@ fun SeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
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) {
is LoadingState.Error -> ErrorMessage(state)
@ -126,7 +138,9 @@ fun SeriesDetails(
played = played,
favorite = item.data.userData?.isFavorite ?: false,
modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) },
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
},
onClickPerson = {
viewModel.navigateTo(
Destination.MediaItem(
@ -135,7 +149,7 @@ fun SeriesDetails(
),
)
},
onLongClickItem = { season ->
onLongClickItem = { index, season ->
seasonDialog =
buildDialogForSeason(
context = context,
@ -160,6 +174,20 @@ fun SeriesDetails(
val favorite = item.data.userData?.isFavorite ?: false
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) {
ConfirmDialog(
@ -194,6 +222,23 @@ fun SeriesDetails(
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
@ -210,15 +255,17 @@ fun SeriesDetailsContent(
people: List<Person>,
played: Boolean,
favorite: Boolean,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreActions: MoreDialogActions,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -227,6 +274,7 @@ fun SeriesDetailsContent(
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
Box(
modifier = modifier,
@ -349,11 +397,14 @@ fun SeriesDetailsContent(
ItemRow(
title = stringResource(R.string.tv_seasons),
items = seasons,
onClickItem = {
onClickItem = { index, item ->
position = SEASONS_ROW
onClickItem.invoke(it)
onClickItem.invoke(index, item)
},
onLongClickItem = { index, item ->
position = SEASONS_ROW
onLongClickItem.invoke(index, item)
},
onLongClickItem = onLongClickItem,
modifier =
Modifier
.fillMaxWidth()
@ -379,7 +430,21 @@ fun SeriesDetailsContent(
position = PEOPLE_ROW
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
.fillMaxWidth()
@ -392,11 +457,29 @@ fun SeriesDetailsContent(
ItemRow(
title = stringResource(R.string.more_like_this),
items = similar,
onClickItem = {
onClickItem = { index, item ->
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 ->
SeasonCard(
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

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.ItemDetailsDialog
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.buildMoreDialogItems
@ -196,29 +197,28 @@ fun SeriesOverview(
favorite = ep.data.userData?.isFavorite ?: false,
series = series,
sourceId = chosenStreams?.sourceId,
navigateTo = viewModel::navigateTo,
onClickWatch = { played ->
episodeList
?.getOrNull(position.episodeRowIndex)
?.let {
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(
it.id,
played,
itemId,
watched,
position.episodeRowIndex,
)
}
},
onClickFavorite = { favorite ->
episodeList
?.getOrNull(position.episodeRowIndex)
?.let {
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(
it.id,
itemId,
favorite,
position.episodeRowIndex,
)
}
},
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = it
},
),
onChooseVersion = {
chooseVersion =
chooseVersionParams(
@ -254,10 +254,6 @@ fun SeriesOverview(
)
}
},
onClickAddPlaylist = {
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = ep.id
},
),
)
@ -350,7 +346,7 @@ fun SeriesOverview(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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.ifElse
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.util.formatDateTime
import kotlin.time.Duration
@ -77,6 +79,9 @@ fun SeriesOverviewContent(
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
LaunchedEffect(selectedTabIndex) {
logTab("series_overview", selectedTabIndex)
}
val tabRowFocusRequester = remember { FocusRequester() }
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.util.ApiRequestPager
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.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
@ -36,9 +37,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
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.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
@ -61,6 +60,7 @@ class SeriesViewModel
private val navigationManager: NavigationManager,
private val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
) : ItemViewModel(api) {
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
@ -121,7 +121,7 @@ class SeriesViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
@ -240,11 +240,7 @@ class SeriesViewModel
played: Boolean,
listIndex: Int?,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
favoriteWatchManager.setWatched(itemId, played)
listIndex?.let {
refreshEpisode(itemId, listIndex)
}
@ -255,11 +251,7 @@ class SeriesViewModel
favorite: Boolean,
listIndex: Int?,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
favoriteWatchManager.setFavorite(itemId, favorite)
if (listIndex != null) {
refreshEpisode(itemId, listIndex)
} else {
@ -279,11 +271,7 @@ class SeriesViewModel
fun setWatchedSeries(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(seriesId)
} else {
api.playStateApi.markUnplayedItem(seriesId)
}
favoriteWatchManager.setWatched(seriesId, played)
val series = fetchItem(seriesId)
val seasons = getSeasons(series)
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.RowColumn
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.PlaylistLoadingState
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.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
@Composable
fun HomePage(
@ -121,37 +121,36 @@ fun HomePage(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
HomePageContent(
watchingRows + latestRows,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
onClickItem = { position, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = {
onLongClickItem = { position, item ->
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = it,
seriesId = it.data.seriesId,
playbackPosition =
it.data.userData
?.playbackPositionTicks
?.ticks
?: Duration.ZERO,
watched = it.data.userData?.played ?: false,
favorite = it.data.userData?.isFavorite ?: false,
navigateTo = viewModel.navigationManager::navigateTo,
onClickWatch = { itemId, played ->
viewModel.setWatched(itemId, played)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = itemId
},
item = item,
seriesId = item.data.seriesId,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
actions =
MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo,
onClickWatch = { itemId, played ->
viewModel.setWatched(itemId, played)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog = itemId
},
),
)
dialog =
DialogParams(
title = it.title ?: "",
title = item.title ?: "",
fromLongClick = true,
items = dialogItems,
)
@ -190,8 +189,8 @@ fun HomePage(
@Composable
fun HomePageContent(
homeRows: List<HomeRowLoadingState>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
showClock: Boolean,
modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null,
@ -317,14 +316,18 @@ fun HomePageContent(
ItemRow(
title = row.title,
items = row.items,
onClickItem = onClickItem,
onClickItem = { index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item)
},
cardOnFocus = { isFocused, index ->
if (isFocused) {
focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index)
}
},
onLongClickItem = onLongClickItem,
onLongClickItem = { index, item ->
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
},
modifier =
Modifier
.fillMaxWidth()
@ -339,6 +342,7 @@ fun HomePageContent(
item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 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.setValueOnMain
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.LoadingExceptionHandler
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.extensions.itemsApi
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.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -50,6 +50,7 @@ class HomeViewModel
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager,
) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -76,7 +77,7 @@ class HomeViewModel
) {
Timber.d("init HomeViewModel")
serverRepository.currentUserDto?.let { userDto ->
serverRepository.currentUserDto.value?.let { userDto ->
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
@ -258,11 +259,7 @@ class HomeViewModel
itemId: UUID,
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
favoriteWatchManager.setWatched(itemId, played)
withContext(Dispatchers.Main) {
init(preferences)
}
@ -272,11 +269,7 @@ class HomeViewModel
itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
favoriteWatchManager.setFavorite(itemId, favorite)
withContext(Dispatchers.Main) {
init(preferences)
}

View file

@ -174,6 +174,9 @@ fun SearchPage(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val onClickItem = { index: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
}
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp),
@ -207,7 +210,7 @@ fun SearchPage(
rowIndex = MOVIE_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
@ -217,7 +220,7 @@ fun SearchPage(
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
@ -227,7 +230,7 @@ fun SearchPage(
rowIndex = SERIES_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
@ -237,7 +240,7 @@ fun SearchPage(
rowIndex = EPISODE_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
@ -268,7 +271,7 @@ fun LazyListScope.searchResultRow(
rowIndex: Int,
position: RowColumn,
focusRequester: FocusRequester,
onClickItem: (BaseItem) -> Unit,
onClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
cardContent: @Composable (
@ -328,7 +331,7 @@ fun LazyListScope.searchResultRow(
title = title,
items = r.items,
onClickItem = onClickItem,
onLongClickItem = {},
onLongClickItem = { _, _ -> },
modifier = modifier,
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.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
/**
* This is generally the root composable of the of the app
@ -38,7 +38,6 @@ fun ApplicationContent(
entryProvider = { key ->
key as Destination
val contentKey = "${key}_${server?.id}_${user?.id}"
Timber.d("Navigate: %s", key)
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(
@ -47,7 +46,7 @@ fun ApplicationContent(
deviceProfile = deviceProfile,
modifier = modifier.fillMaxSize(),
)
} else {
} else if (user != null && server != null) {
NavDrawer(
destination = key,
preferences = preferences,
@ -56,6 +55,8 @@ fun ApplicationContent(
server = server,
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.GetItemsFilter
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.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -29,7 +30,9 @@ sealed class Destination(
data object ServerList : Destination(true)
@Serializable
data object UserList : Destination(true)
data class UserList(
val server: JellyfinServer,
) : Destination(true)
@Serializable
data class Home(

View file

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

View file

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

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey
import org.acra.ACRA
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -18,6 +20,7 @@ class NavigationManager
*/
fun navigateTo(destination: Destination) {
backStack.add(destination)
log()
}
/**
@ -25,7 +28,13 @@ class NavigationManager
*/
fun navigateToFromDrawer(destination: Destination) {
goToHome()
backStack.add(destination)
if (destination == Destination.ServerList || destination is Destination.UserList) {
backStack.add(destination)
(0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) }
} else {
backStack.add(destination)
}
log()
}
/**
@ -33,6 +42,7 @@ class NavigationManager
*/
fun goBack() {
backStack.removeLastOrNull()
log()
}
/**
@ -45,6 +55,7 @@ class NavigationManager
if (backStack[0] !is Destination.Home) {
backStack[0] = Destination.Home()
}
log()
}
/**
@ -54,5 +65,12 @@ class NavigationManager
goToHome()
val id = (backStack[0] as Destination.Home).id + 1
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,13 +272,14 @@ class PlaybackViewModel
if (itemPlayback != null) {
itemPlayback
} else {
val user = serverRepository.currentUser!!
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
} else {
null
serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
} else {
null
}
}
}
}

View file

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

View file

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

View file

@ -43,9 +43,8 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun SwitchServerContent(
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
viewModel: SwitchServerViewModel = hiltViewModel(),
) {
val currentServer = viewModel.serverRepository.currentServer
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
@ -90,6 +89,9 @@ fun SwitchServerContent(
onSwitchServer = {
viewModel.addServer(it.url)
},
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {
showAddServer = true
},
@ -142,6 +144,9 @@ fun SwitchServerContent(
onSwitchServer = {
viewModel.addServer(it.url)
},
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {},
onRemoveServer = {},
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.surfaceColorAtElevation
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.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -49,17 +50,20 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun SwitchUserContent(
currentServer: JellyfinServer,
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
viewModel: SwitchUserViewModel =
hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>(
creationCallback = { it.create(currentServer) },
),
) {
val context = LocalContext.current
val currentServer = viewModel.serverRepository.currentServer
val currentUser = viewModel.serverRepository.currentUser
// val currentServer by viewModel.serverRepository.currentServer.observeAsState()
val currentUser by viewModel.serverRepository.currentUser.observeAsState()
val users by viewModel.users.observeAsState(listOf())
val serverQuickConnect by viewModel.serverQuickConnect.observeAsState(mapOf())
val quickConnectEnabled = currentServer?.let { serverQuickConnect[it.id] ?: false } ?: false
val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false)
val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) }
@ -109,7 +113,7 @@ fun SwitchUserContent(
users = users,
currentUser = currentUser,
onSwitchUser = { user ->
viewModel.switchUser(server, user)
viewModel.switchUser(user)
},
onAddUser = { showAddUser = true },
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.JellyfinUser
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.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
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.model.api.QuickConnectDto
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 java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
@HiltViewModel(assistedFactory = SwitchUserViewModel.Factory::class)
class SwitchUserViewModel
@Inject
@AssistedInject
constructor(
val jellyfin: Jellyfin,
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager,
@Assisted val server: JellyfinServer,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
@AssistedFactory
interface Factory {
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 quickConnectState = MutableLiveData<QuickConnectResult?>(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 loginAttempts = MutableLiveData(0)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending
}
@ -74,91 +78,47 @@ class SwitchUserViewModel
}
fun init() {
quickConnectJob?.cancel()
viewModelScope.launchIO {
quickConnectJob?.cancel()
users.setValueOnMain(listOf())
val serverUsers =
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) {
users.value = listOf()
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 ->
try {
jellyfin
.createApi(
server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(server.url)
.quickConnectApi
.getQuickConnectEnabled()
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, quickConnect)
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Error(ex.localizedMessage),
)
}
}
}
users.setValueOnMain(serverUsers)
}
}
viewModelScope.launchIO {
serverRepository.currentServer?.let {
val quickConnect =
try {
jellyfin
.createApi(
server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(it.url)
.createApi(server.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
serverQuickConnect.value = quickConnect
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverQuickConnect.value = false
}
}
}
}
fun switchUser(
server: JellyfinServer,
user: JellyfinUser,
) {
fun switchUser(user: JellyfinUser) {
viewModelScope.launchIO {
try {
serverRepository.changeUser(server, user)
@ -242,10 +202,7 @@ class SwitchUserViewModel
if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) {
quickConnectState.value = null
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, false)
}
serverQuickConnect.value = false
}
}
setError("Error with Quick Connect", ex)
@ -258,55 +215,6 @@ class SwitchUserViewModel
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) {
viewModelScope.launchIO {
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(
msg: String? = 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(
private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer,
) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null
@ -36,11 +37,13 @@ class PlaybackLifecycleObserver
wasPlaying = it.isPlaying
it.pause()
}
themeSongPlayer.stop()
}
override fun onStop(owner: LifecycleOwner) {
if (navigationManager.backStack.lastOrNull() is Destination.Playback) {
navigationManager.goBack()
}
themeSongPlayer.stop()
}
}

View file

@ -2,13 +2,17 @@ package com.github.damontecres.wholphin.util
import android.content.Context
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.JellyfinUser
import com.github.damontecres.wholphin.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -20,24 +24,36 @@ import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@ActivityScoped
class ServerEventListener
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:ActivityContext private val context: Context,
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
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(
server: JellyfinServer?,
user: JellyfinUser?,
) {
if (server != null && user != null && api.baseUrl != null && api.accessToken != null) {
scope.launchIO {
(context as AppCompatActivity).lifecycleScope.launchIO {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands =
@ -53,6 +69,7 @@ class ServerEventListener
}
fun setupListeners() {
serverRepository.currentUser
Timber.v("Subscribing to WebSocket")
listenJob?.cancel()
listenJob =
@ -73,6 +90,20 @@ class ServerEventListener
.joinToString("\n")
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,
url: String,
) {
stop()
val volumeLevel =
when (volumeLevel) {
ThemeSongVolume.UNRECOGNIZED,
@ -92,6 +91,7 @@ class ThemeSongPlayer
ThemeSongVolume.HIGHEST -> 75f
}
player.apply {
stop()
volume = volumeLevel
setMediaItem(MediaItem.fromUri(url))
prepare()

View file

@ -4,10 +4,10 @@ import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
@ -49,7 +49,7 @@ class TrackActivityPlaybackListener(
private var initialized = false
fun init() {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
launch("reportPlaybackStart") {
Timber.v("reportPlaybackStart for ${itemPlayback.itemId}")
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
@ -77,7 +77,7 @@ class TrackActivityPlaybackListener(
task.cancel()
TIMER.purge()
val position = player.currentPosition.milliseconds
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
launch("reportPlaybackStopped") {
Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position")
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
@ -107,7 +107,7 @@ class TrackActivityPlaybackListener(
}
private fun saveActivity(position: Long) {
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
launch("saveActivity") {
val calcPosition =
withContext(Dispatchers.Main) {
(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 {
private const val TAG = "TrackActivityPlaybackListener"

View file

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