diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index fe4a198a..4179f1d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -239,6 +239,7 @@ fun DiscoverSearchPage( result = seerrResults, focusRequester = gridFocusRequester, onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, onPlayItem = { _, _ -> }, onClickPosition = { position = it.column }, onClickDiscover = onClickDiscover, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index b1705970..50c2cc6a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.main +import android.content.Context import android.view.Gravity import androidx.activity.compose.BackHandler import androidx.annotation.StringRes @@ -66,15 +67,20 @@ import androidx.tv.material3.Switch import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation 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.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateSearchPreferences +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.SeerrService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields @@ -84,27 +90,31 @@ import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRowTitle import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.components.rememberContextMenu import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.preferences.SwitchColors -import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.SearchRelevance import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -115,29 +125,36 @@ import kotlinx.coroutines.launch 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.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber +import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class SearchViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, private val appPreferences: DataStore, private val seerrService: SeerrService, val voiceInputManager: VoiceInputManager, val userPreferencesService: UserPreferencesService, - ) : ViewModel() { - val voiceState = voiceInputManager.state - val soundLevel = voiceInputManager.soundLevel - val partialResult = voiceInputManager.partialResult + private val serverRepository: ServerRepository, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + private val mediaReportService: MediaReportService, + ) : ViewModel(), + ContextMenuProvider { val seerrActive = seerrService.active private val _state = MutableStateFlow(SearchState()) val state: StateFlow = _state + val position = MutableStateFlow(RowColumn(0, 0)) private var currentQuery: String? = null private var combinedMode = false @@ -234,8 +251,8 @@ class SearchViewModel ) val result = api.itemsApi.getItems(request).content val items = - (result.items ?: emptyList()).map { - BaseItem.from(it, api, false) + result.items.map { + BaseItem(it, false) } val sorted = items.sortedWith( @@ -271,8 +288,8 @@ class SearchViewModel val result = api.itemsApi.getItems(request).content val items = - (result.items ?: emptyList()).map { - BaseItem.from(it, api, false) + result.items.map { + BaseItem(it, false) } val sorted = items.sortedWith( @@ -325,10 +342,113 @@ class SearchViewModel addCloseable(voiceInputManager) } - fun getHints(query: String) { - // TODO -// api.searchApi.getSearchHints() + override fun navigateTo(destination: 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 { + refreshItem(item.id) + } + } + } + + private suspend fun refreshItem(itemId: UUID) { + try { + val position = position.value + val searchResult = + if (combinedMode) { + state.value.combinedResults + } else { + when (position.row) { + MOVIE_ROW -> state.value.movies + SERIES_ROW -> state.value.series + EPISODE_ROW -> state.value.episodes + COLLECTION_ROW -> state.value.collections + ALBUM_ROW -> state.value.albums + ARTIST_ROW -> state.value.artists + SONG_ROW -> state.value.songs + SEERR_ROW -> null + else -> null + } + } ?: return + val items = (searchResult as? SearchResult.Success)?.items ?: return + + Timber.v("Item refresh: position=%s", position) + val item = items.getOrNull(position.column) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item != null && item.id == itemId) { + val newItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + val newList = + SearchResult.Success( + items.toMutableList().apply { + set(position.column, newItem) + }, + ) + _state.update { + if (combinedMode) { + it.copy( + combinedResults = newList, + ) + } else { + when (position.row) { + MOVIE_ROW -> it.copy(movies = newList) + SERIES_ROW -> it.copy(series = newList) + EPISODE_ROW -> it.copy(episodes = newList) + COLLECTION_ROW -> it.copy(collections = newList) + ALBUM_ROW -> it.copy(albums = newList) + ARTIST_ROW -> it.copy(artists = newList) + SONG_ROW -> it.copy(songs = newList) + SEERR_ROW -> it + else -> it + } + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing item %s", itemId) + showToast(context, "Error refreshing") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshItem(itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + refreshItem(itemId) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) } sealed interface SearchResult { @@ -374,9 +494,6 @@ private const val SEERR_ROW = SONG_ROW + 1 private const val COMBINED_ROW = TAB_ROW + 1 -/** Delay for focus to settle after voice search dialog dismisses. */ -private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L - @Composable fun SearchPage( userPreferences: UserPreferences, @@ -398,16 +515,22 @@ fun SearchPage( // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - val tabFocusRequesters = remember { List(2) { FocusRequester() } } val seerrActive by viewModel.seerrActive.collectAsState(initial = false) var selectedTab by rememberSaveable { mutableIntStateOf(0) } var showViewOptions by rememberSaveable { mutableStateOf(false) } - - var position by rememberPosition(0, 0) var searchClicked by rememberSaveable(query) { mutableStateOf(false) } var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + val position by viewModel.position.collectAsState() + + fun setPosition(pos: RowColumn) { + Timber.v("pos=%s", pos) + viewModel.position.value = pos + } + + val contextMenu = rememberContextMenu(userPreferences, viewModel) + LifecycleResumeEffect(Unit) { onPauseOrDispose { viewModel.voiceInputManager.stopListening() @@ -427,7 +550,7 @@ fun SearchPage( } else -> { - delay(750L) + delay(750.milliseconds) viewModel.search(query, combinedMode) } } @@ -435,9 +558,13 @@ fun SearchPage( LaunchedEffect(Unit) { focusRequesters.getOrNull(position.row)?.tryRequestFocus() } - val onClickItem = { index: Int, item: BaseItem -> + val onClickItem = { _: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } + val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> + setPosition(RowColumn(rowIndex, index)) + contextMenu.showContextMenu(index, item) + } val onPlayItem = { _: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) } @@ -475,10 +602,10 @@ fun SearchPage( selectedTab, seerrActive, ) { - if (!searchClicked) return@LaunchedEffect + if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect withContext(WholphinDispatchers.IO) { - // Want to focus on the first successful row after all of the ones before it are finished searching + // Want to focus on the first successful row after all the ones before it are finished searching val results = if (isLibraryTab) { if (combinedMode) { @@ -513,7 +640,7 @@ fun SearchPage( } else { SEERR_ROW } - position = RowColumn(targetRow, 0) +// setPosition(RowColumn(targetRow, 0)) onMain { focusRequesters[targetRow].tryRequestFocus() } } } @@ -628,12 +755,15 @@ fun SearchPage( 0 -> COMBINED_ROW else -> SEERR_ROW } - position = RowColumn(row, 0) + setPosition(RowColumn(row, 0)) }, modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp), + .padding(start = 16.dp, end = 16.dp) + .onFocusChanged { + if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) + }, ) } Box( @@ -648,8 +778,11 @@ fun SearchPage( result = state.combinedResults, focusRequester = focusRequesters[COMBINED_ROW], onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(COMBINED_ROW, index, item) + }, onPlayItem = onPlayItem, - onClickPosition = { position = it }, + onClickPosition = { setPosition(it) }, onClickDiscover = onClickDiscover, positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), @@ -661,8 +794,9 @@ fun SearchPage( result = state.seerrResults, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, onPlayItem = onPlayItem, - onClickPosition = { position = it }, + onClickPosition = { setPosition(it) }, onClickDiscover = onClickDiscover, positionCallback = positionCallback, modifier = Modifier.fillMaxSize(), @@ -688,7 +822,10 @@ fun SearchPage( position = position, focusRequester = focusRequesters[MOVIE_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(MOVIE_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) searchResultRow( @@ -698,7 +835,10 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SERIES_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(SERIES_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) searchResultRow( @@ -708,13 +848,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[EPISODE_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(EPISODE_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = @Composable { index, item, mod, onClick, onLongClick -> EpisodeCard( item = item, onClick = { - position = RowColumn(EPISODE_ROW, index) + setPosition(RowColumn(EPISODE_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -730,7 +873,10 @@ fun SearchPage( position = position, focusRequester = focusRequesters[COLLECTION_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(COLLECTION_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) searchResultRow( @@ -740,13 +886,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[ALBUM_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(ALBUM_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, onClick = { - position = RowColumn(ALBUM_ROW, index) + setPosition(RowColumn(ALBUM_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -764,13 +913,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[ARTIST_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(ARTIST_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, onClick = { - position = RowColumn(ARTIST_ROW, index) + setPosition(RowColumn(ARTIST_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -788,13 +940,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SONG_ROW], onClickItem = onClickItem, - onClickPosition = { position = it }, + onLongClickItem = { index, item -> + onLongClickItem(SONG_ROW, index, item) + }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, onClick = { - position = RowColumn(SONG_ROW, index) + setPosition(RowColumn(SONG_ROW, index)) onClick.invoke() }, onLongClick = onLongClick, @@ -812,14 +967,16 @@ fun SearchPage( position = position, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, onClickDiscover = onClickDiscover, - onClickPosition = { position = it }, + onClickPosition = { setPosition(it) }, modifier = Modifier.fillMaxWidth(), ) } } } } + contextMenu.Compose() } if (showViewOptions) { @@ -923,16 +1080,15 @@ fun SearchCombinedResults( result: SearchResult, focusRequester: FocusRequester, onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, onPlayItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, positionCallback: (columns: Int, position: Int) -> Unit, modifier: Modifier = Modifier, ) { - when (val r = result) { - SearchResult.NoQuery -> { - Unit - } + when (result) { + SearchResult.NoQuery -> {} SearchResult.Searching -> { SearchResultPlaceholder( @@ -945,14 +1101,14 @@ fun SearchCombinedResults( is SearchResult.Error -> { SearchResultPlaceholder( title = stringResource(R.string.results), - message = r.ex.localizedMessage ?: "Error occurred during search", + message = result.ex.localizedMessage ?: "Error occurred during search", messageColor = MaterialTheme.colorScheme.error, modifier = modifier.padding(16.dp), ) } is SearchResult.Success -> { - if (r.items.isEmpty()) { + if (result.items.isEmpty()) { SearchResultPlaceholder( title = stringResource(R.string.results), message = stringResource(R.string.no_results), @@ -960,9 +1116,10 @@ fun SearchCombinedResults( ) } else { SearchGrid( - items = r.items, + items = result.items, focusRequester = focusRequester, onClickItem = onClickItem, + onLongClickItem = onLongClickItem, onPlayItem = onPlayItem, onClickPosition = onClickPosition, positionCallback = positionCallback, @@ -981,7 +1138,7 @@ fun SearchCombinedResults( } is SearchResult.SuccessSeerr -> { - if (r.items.isEmpty()) { + if (result.items.isEmpty()) { SearchResultPlaceholder( title = stringResource(R.string.results), message = stringResource(R.string.no_results), @@ -989,9 +1146,10 @@ fun SearchCombinedResults( ) } else { SearchGrid( - items = r.items, + items = result.items, focusRequester = focusRequester, onClickItem = onClickDiscover, + onLongClickItem = { _, _ -> }, onPlayItem = { _, _ -> }, onClickPosition = onClickPosition, positionCallback = positionCallback, @@ -1015,6 +1173,7 @@ private fun SearchGrid( items: List, focusRequester: FocusRequester, onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, onPlayItem: (Int, T) -> Unit, onClickPosition: (RowColumn) -> Unit, cardContent: @Composable (GridItemDetails) -> Unit, @@ -1033,7 +1192,7 @@ private fun SearchGrid( onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) onClickItem.invoke(index, item) }, - onLongClickItem = { _, _ -> }, + onLongClickItem = onLongClickItem, onClickPlay = { index, item -> onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) onPlayItem.invoke(index, item) @@ -1056,6 +1215,7 @@ fun LazyListScope.searchResultRow( position: RowColumn, focusRequester: FocusRequester, onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, @@ -1108,7 +1268,7 @@ fun LazyListScope.searchResultRow( title = stringResource(title), items = r.items, onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, + onLongClickItem = onLongClickItem, modifier = modifier.focusRequester(focusRequester), cardContent = cardContent, )