Fix click/long click issues on some view more grid pages (#1486)

## Description
On a library recommended tab, if you clicked View More, clicking a card
on the grid would directly play the item instead of the regular
behavior.

This PR fixes that and simplifies the code required to support simple
context menus.

### Related issues
Fixes #1480

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-30 17:41:27 -04:00 • committed by GitHub
parent 7660c53e3a
commit 68836bb834
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 334 additions and 229 deletions

View file

@ -198,6 +198,10 @@ data class BaseItem(
*/ */
fun destination(index: Int? = null): Destination { fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride if (destinationOverride != null) return destinationOverride
if (data.extraType != null || type == BaseItemKind.TRAILER) {
// Extras including trailers should always play directly
return Destination.Playback(id, 0)
}
val result = val result =
// Redirect episodes & seasons to their series if possible // Redirect episodes & seasons to their series if possible
when (type) { when (type) {

View file

@ -53,11 +53,7 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -95,7 +91,6 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@ -128,7 +123,8 @@ class CollectionFolderViewModel
@Assisted private val collectionFilter: CollectionFolderFilter, @Assisted private val collectionFilter: CollectionFolderFilter,
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
@Assisted defaultViewOptions: ViewOptions, @Assisted defaultViewOptions: ViewOptions,
) : ViewModel() { ) : ViewModel(),
ContextMenuProvider {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
@ -461,25 +457,29 @@ class CollectionFolderViewModel
result.totalRecordCount result.totalRecordCount
} }
fun setWatched( override fun setWatched(
position: Int, position: Int,
itemId: UUID, itemId: UUID,
played: Boolean, played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) {
favoriteWatchManager.setWatched(itemId, played) viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
(state.value.items as? DataLoadingState.Success)?.let { favoriteWatchManager.setWatched(itemId, played)
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) (state.value.items as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
} }
} }
fun setFavorite( override fun setFavorite(
position: Int, position: Int,
itemId: UUID, itemId: UUID,
favorite: Boolean, favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) {
favoriteWatchManager.setFavorite(itemId, favorite) viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
(state.value.items as? DataLoadingState.Success)?.let { favoriteWatchManager.setFavorite(itemId, favorite)
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) (state.value.items as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
} }
} }
@ -489,7 +489,7 @@ class CollectionFolderViewModel
} }
} }
fun navigateTo(destination: Destination) { override fun navigateTo(destination: Destination) {
release() release()
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
@ -509,7 +509,7 @@ class CollectionFolderViewModel
} }
} }
fun deleteItem( override fun deleteItem(
index: Int, index: Int,
item: BaseItem, item: BaseItem,
) { ) {
@ -520,7 +520,7 @@ class CollectionFolderViewModel
} }
} }
fun canDelete( override fun canDelete(
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences) ): Boolean = mediaManagementService.canDelete(item, appPreferences)
@ -529,6 +529,10 @@ class CollectionFolderViewModel
item: BaseItem, item: BaseItem,
index: Int, index: Int,
) = addToQueue(api, musicService, item, index) ) = addToQueue(api, musicService, item, index)
override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId)
override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
} }
data class CollectionFolderState( data class CollectionFolderState(
@ -637,7 +641,6 @@ fun CollectionFolderView(
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null, focusRequesterOnEmpty: FocusRequester? = null,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel = viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
key = viewModelKey, key = viewModelKey,
@ -655,41 +658,9 @@ fun CollectionFolderView(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var position by rememberInt(viewModel.position) var position by rememberInt(viewModel.position)
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } val contextMenu = rememberContextMenu(preferences, viewModel)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.collectAsState()
var showViewOptions by rememberSaveable { mutableStateOf(false) } var showViewOptions by rememberSaveable { mutableStateOf(false) }
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(viewModel.position, itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(viewModel.position, itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = { viewModel.deleteItem(viewModel.position, it) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
)
}
val gridActions = val gridActions =
remember(actions) { remember(actions) {
GridClickActions( GridClickActions(
@ -702,18 +673,7 @@ fun CollectionFolderView(
if (actions.onLongClickItem != null) { if (actions.onLongClickItem != null) {
actions.onLongClickItem.invoke(index, item) actions.onLongClickItem.invoke(index, item)
} else { } else {
showContextMenu = contextMenu.showContextMenu(index, item)
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
} }
}, },
onClickPlayAll = onClickPlayAll =
@ -912,41 +872,7 @@ fun CollectionFolderView(
} }
} }
} }
overviewDialog?.let { info -> contextMenu.Compose()
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = 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,
)
}
AnimatedVisibility(showViewOptions) { AnimatedVisibility(showViewOptions) {
ViewOptionsDialog( ViewOptionsDialog(
viewOptions = state.viewOptions, viewOptions = state.viewOptions,

View file

@ -0,0 +1,163 @@
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
/**
* Simplifies showing a basic context menu
*/
@Composable
fun rememberContextMenu(
preferences: UserPreferences,
provider: ContextMenuProvider,
): ContextMenuUtils = remember(preferences, provider) { ContextMenuUtils(preferences, provider) }
class ContextMenuUtils(
private val preferences: UserPreferences,
private val provider: ContextMenuProvider,
) {
private var showContextMenu by mutableStateOf<Pair<Int, BaseItem>?>(null)
/**
* Show the context menu, typically from a long click
*/
fun showContextMenu(
position: Int,
item: BaseItem,
) {
showContextMenu = Pair(position, item)
}
/**
* Composes the context menu when needed
*/
@Composable
fun Compose(playlistViewModel: AddPlaylistViewModel = hiltViewModel()) {
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.collectAsState()
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath = provider.isAdministrator(),
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { (position, item) ->
val contextActions =
remember {
ContextMenuActions(
navigateTo = provider::navigateTo,
onClickWatch = { itemId, watched ->
provider.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, favorite ->
provider.setFavorite(position, itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = provider::sendReportFor,
onDeleteItem = { provider.deleteItem(position, it) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
)
}
val contextMenu =
remember {
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete = provider.canDelete(item, preferences.appPreferences),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions = contextActions,
)
}
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = 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,
)
}
}
}
interface ContextMenuProvider {
fun isAdministrator(): Boolean
fun navigateTo(destination: Destination)
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean
fun deleteItem(
index: Int,
item: BaseItem,
)
fun setWatched(
position: Int,
itemId: UUID,
played: Boolean,
)
fun setFavorite(
position: Int,
itemId: UUID,
favorite: Boolean,
)
fun sendReportFor(itemId: UUID)
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -7,49 +8,73 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.RequestHandler
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
class ItemGridViewModel class ItemGridViewModel
@AssistedInject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
private val mediaManagementService: MediaManagementService,
val serverRepository: ServerRepository,
val mediaReportService: MediaReportService,
@Assisted private val destination: Destination.ItemGrid<*>, @Assisted private val destination: Destination.ItemGrid<*>,
) : ViewModel() { ) : ViewModel(),
private val _state = MutableStateFlow(ItemGridState()) ContextMenuProvider {
val state: StateFlow<ItemGridState> = _state
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel
} }
private val _state = MutableStateFlow(ItemGridState())
val state: StateFlow<ItemGridState> = _state
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
@ -77,9 +102,78 @@ class ItemGridViewModel
} }
} }
fun navigateTo(destination: Destination) { override fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
override fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
override fun deleteItem(
index: Int,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
refreshAfterDelete(index, item)
}
}
}
private suspend fun refreshAfterDelete(
position: Int,
deletedItem: BaseItem,
) {
try {
val pager =
((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
position.let {
Timber.v("Item deleted: position=%s", it)
val item = pager?.get(it)
// Exact item deleted (eg a movie) or deleted item was within the series
if (item?.id == deletedItem.id ||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
) {
pager?.refreshPagesAfter(position)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error refreshing after deleted item %s", deletedItem.id)
showToast(context, "Error refreshing after item deleted")
}
}
override fun setWatched(
position: Int,
itemId: UUID,
played: Boolean,
) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
(state.value.items as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
}
}
override fun setFavorite(
position: Int,
itemId: UUID,
favorite: Boolean,
) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
(state.value.items as? DataLoadingState.Success)?.let {
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
}
}
override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId)
} }
data class ItemGridState( data class ItemGridState(
@ -91,13 +185,17 @@ data class ItemGridState(
*/ */
@Composable @Composable
fun ItemGrid( fun ItemGrid(
preferences: UserPreferences,
destination: Destination.ItemGrid<*>, destination: Destination.ItemGrid<*>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: ItemGridViewModel = viewModel: ItemGridViewModel =
hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>( hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>(
creationCallback = { it.create(destination) }, creationCallback = { it.create(destination) },
), ),
) { ) {
val contextMenu = rememberContextMenu(preferences, viewModel)
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
when (val st = state.items) { when (val st = state.items) {
is DataLoadingState.Error -> { is DataLoadingState.Error -> {
@ -111,20 +209,25 @@ fun ItemGrid(
} }
is DataLoadingState.Success<List<BaseItem?>> -> { is DataLoadingState.Success<List<BaseItem?>> -> {
var position by rememberInt(destination.initialPosition)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
Column(modifier = modifier) { Column(modifier = modifier) {
GridTitle(destination.title.getString()) GridTitle(destination.title.getString())
CardGrid( CardGrid(
pager = st.data, pager = st.data,
onClickItem = { index: Int, item: BaseItem -> onClickItem = { index: Int, item: BaseItem ->
// TODO handle more types position = index
viewModel.navigateTo(Destination.Playback(item.id, 0)) viewModel.navigateTo(item.destination(index))
},
onLongClickItem = { index: Int, item: BaseItem ->
position = index
contextMenu.showContextMenu(index, item)
}, },
onLongClickItem = { index: Int, item: BaseItem -> },
onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) }, onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) },
letterPosition = { c: Char -> 0 }, letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester, gridFocusRequester = focusRequester,
@ -148,4 +251,5 @@ fun ItemGrid(
} }
} }
} }
contextMenu.Compose()
} }

View file

@ -10,17 +10,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowConfig
@ -40,23 +36,17 @@ import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.GridTitle import com.github.damontecres.wholphin.ui.components.GridTitle
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.StudioCardGrid import com.github.damontecres.wholphin.ui.components.StudioCardGrid
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.components.rememberContextMenu
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider
@ -74,7 +64,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -97,7 +86,8 @@ class HomeRowGridViewModel
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
@Assisted private val title: StringProvider, @Assisted private val title: StringProvider,
@Assisted private val rowConfig: HomeRowConfig, @Assisted private val rowConfig: HomeRowConfig,
) : ViewModel() { ) : ViewModel(),
ContextMenuProvider {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
@ -171,44 +161,52 @@ class HomeRowGridViewModel
} }
} }
fun setWatched( override fun setWatched(
position: Int, position: Int,
itemId: UUID, itemId: UUID,
played: Boolean, played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) {
favoriteWatchManager.setWatched(itemId, played) viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
(state.value.loading as? HomeRowLoadingState.Success)?.let { favoriteWatchManager.setWatched(itemId, played)
(it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) (state.value.loading as? HomeRowLoadingState.Success)?.let {
(it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
} }
} }
fun setFavorite( override fun setFavorite(
position: Int, position: Int,
itemId: UUID, itemId: UUID,
favorite: Boolean, favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) {
favoriteWatchManager.setFavorite(itemId, favorite) viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
(state.value.loading as? HomeRowLoadingState.Success)?.let { favoriteWatchManager.setFavorite(itemId, favorite)
(it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) (state.value.loading as? HomeRowLoadingState.Success)?.let {
(it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId)
}
} }
} }
override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId)
fun updateBackdrop(item: BaseItem) { fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO { viewModelScope.launchIO {
backdropService.submit(item) backdropService.submit(item)
} }
} }
fun navigateTo(destination: Destination) { override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
override fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun canDelete( override fun canDelete(
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences) ): Boolean = mediaManagementService.canDelete(item, appPreferences)
fun deleteItem( override fun deleteItem(
index: Int, index: Int,
item: BaseItem, item: BaseItem,
) { ) {
@ -234,47 +232,12 @@ fun HomeRowGrid(
hiltViewModel<HomeRowGridViewModel, HomeRowGridViewModel.Factory>( hiltViewModel<HomeRowGridViewModel, HomeRowGridViewModel.Factory>(
creationCallback = { it.create(destination.title, destination.config) }, creationCallback = { it.create(destination.title, destination.config) },
), ),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var position by rememberInt(destination.initialPosition) val contextMenu = rememberContextMenu(preferences, viewModel)
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
val viewOptions = destination.config.viewOptions val viewOptions = destination.config.viewOptions
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.collectAsState()
val contextActions =
remember {
ContextMenuActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(position, itemId, favorite)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = { viewModel.deleteItem(position, it) },
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
onChooseVersion = { _, _ ->
// Not supported on this page
},
onChooseTracks = { result ->
// Not supported on this page
},
onClearChosenStreams = {
// Not supported on this page
},
)
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
@ -320,32 +283,11 @@ fun HomeRowGrid(
viewModel.navigateTo(item.destination(index)) viewModel.navigateTo(item.destination(index))
} }
} }
val onLongClickItem =
remember {
{ index: Int, item: BaseItem ->
showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
canRemoveContinueWatching = false,
canRemoveNextUp = false, // TODO
actions = contextActions,
)
}
}
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
CardGrid( CardGrid(
pager = st.items, pager = st.items,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = contextMenu::showContextMenu,
onClickPlay = { _, _ -> }, onClickPlay = { _, _ -> },
letterPosition = { -1 }, letterPosition = { -1 },
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
@ -354,7 +296,6 @@ fun HomeRowGrid(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
initialPosition = destination.initialPosition, initialPosition = destination.initialPosition,
positionCallback = { columns, newPosition -> positionCallback = { columns, newPosition ->
position = newPosition
}, },
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard( GridCard(
@ -378,39 +319,5 @@ fun HomeRowGrid(
} }
} }
} }
overviewDialog?.let { info -> contextMenu.Compose()
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
onDismissRequest = { showContextMenu = null },
getMediaSource = null,
contextMenu = contextMenu,
preferredSubtitleLanguage = 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,
)
}
} }

View file

@ -314,6 +314,7 @@ fun DestinationContent(
is Destination.ItemGrid<*> -> { is Destination.ItemGrid<*> -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() } LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ItemGrid( ItemGrid(
preferences,
destination, destination,
modifier, modifier,
) )