Add more searchable types & choose what to search (#1857)

## Description
This PR adds several more data types to search. It also adds a setting
(per use profile) to choose which types to include/exclude when
searching. The combined results now uses the same types instead of a
separate list.

A user can only search for types that they have access to. For example,
they need Live TV access for TV channels/programs or need access to a
movies-type library to search movies.

Added types to search:
- Photos & Photo albums
- Playlists
- People
- TV channels
- Live TV programs

Clicking a TV channel will play it. Clicking a TV program shows the
program dialog allowing for recording or watching live if it's currently
airing.

On the main search page you can also toggle whether to search Seerr or
not. This does not apply to the discover search tab.

Dev note: Some minor refactoring to move setting up/canceling live tv
recordings into a new service so it can be shared between with the live
tv & search pages. Also search related files are moved into the same
package now.

### Related issues
Closes #409
Closes #646 
Closes #1668
Closes #1750

### Testing
Emulator using users with different permissions and library access

## Screenshots
<img width="475" height="584" alt="image"
src="https://github.com/user-attachments/assets/ebcff697-07ba-4ddd-8c26-57f147cfa02a"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-26 14:39:02 -04:00 • committed by GitHub
parent 2b9af1e8bc
commit f91bca18ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1968 additions and 1416 deletions

View file

@ -243,7 +243,7 @@ data class HomeRowViewOptions(
val liveTvDefault =
HomeRowViewOptions(
heightDp = 96,
heightDp = Cards.HEIGHT_LIVE_TV,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT,
)

View file

@ -0,0 +1,94 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.toServerString
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.TimerInfoDto
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Functions for recording live tv programs
*/
@Singleton
class LiveTvService
@Inject
constructor(
private val api: ApiClient,
) {
/**
* Fetch a tv program by ID
*/
suspend fun fetchProgramForDialog(programId: UUID): BaseItem {
val result =
api.liveTvApi
.getProgram(programId.toServerString())
.content
.let { BaseItem(it) }
return result
}
/**
* Cancel a recording by [timerId]
*
* @param series whether the timerId is a series recording or not
* @param timerId the recording ID
*/
suspend fun cancelRecording(
series: Boolean,
timerId: String?,
): Boolean =
if (timerId != null) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
} else {
api.liveTvApi.cancelTimer(timerId)
}
true
} else {
false
}
/**
* Record tv program
*
* @param programId the tv program ID
* @param series whether to set up series recording or just a single recording
*/
suspend fun record(
programId: UUID,
series: Boolean,
) {
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) {
api.liveTvApi.createSeriesTimer(d)
} else {
val payload =
TimerInfoDto(
id = d.id,
type = d.type,
serverId = d.serverId,
externalId = d.externalId,
channelId = d.channelId,
externalChannelId = d.externalChannelId,
channelName = d.channelName,
programId = d.programId,
externalProgramId = d.externalProgramId,
name = d.name,
overview = d.overview,
startDate = d.startDate,
endDate = d.endDate,
serviceName = d.serviceName,
priority = d.priority,
prePaddingSeconds = d.prePaddingSeconds,
postPaddingSeconds = d.postPaddingSeconds,
isPrePaddingRequired = d.isPrePaddingRequired,
isPostPaddingRequired = d.isPostPaddingRequired,
keepUntil = d.keepUntil,
)
api.liveTvApi.createTimer(payload)
}
}
}

View file

@ -220,6 +220,10 @@ fun listToDotString(
}
}
@get:StringRes
val BaseItemKind.titleStringRes: Int
get() = formatTypeName(this)
@StringRes
fun formatTypeName(type: BaseItemKind): Int =
when (type) {
@ -239,7 +243,7 @@ fun formatTypeName(type: BaseItemKind): Int =
BaseItemKind.MUSIC_GENRE -> R.string.genres
BaseItemKind.MUSIC_VIDEO -> R.string.music_videos
BaseItemKind.PHOTO -> R.string.photos
BaseItemKind.PHOTO_ALBUM -> R.string.photos
BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums
BaseItemKind.PROGRAM -> R.string.programs
BaseItemKind.RECORDING -> R.string.recordings
BaseItemKind.SEASON -> R.string.tv_seasons

View file

@ -94,6 +94,7 @@ object Cards {
const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp
const val HEIGHT_EPISODE = 128
const val HEIGHT_LIVE_TV = 96
val heightEpisode = HEIGHT_EPISODE.dp
val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f

View file

@ -51,37 +51,7 @@ fun SeasonCard(
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight.isSpecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth.isSpecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
val imageUrl = rememberImageUrl(item, imageHeight, imageWidth)
SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
@ -103,6 +73,44 @@ fun SeasonCard(
)
}
@Composable
fun rememberImageUrl(
item: BaseItem?,
imageHeight: Dp,
imageWidth: Dp = Dp.Unspecified,
): String? {
val density = LocalDensity.current
val imageUrlService = LocalImageUrlService.current
return remember(item, imageHeight, imageWidth, density) {
if (item != null) {
val fillHeight =
if (imageHeight.isSpecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth.isSpecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
}
private val spaceBetweenFocused = 4.dp
private val spaceBetweenUnfocused = 12.dp

View file

@ -206,7 +206,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
}
CollectionType.TVSHOWS -> {
listOf(BaseItemKind.SERIES)
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
}
CollectionType.HOMEVIDEOS -> {
@ -221,6 +221,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
)
}
CollectionType.MUSICVIDEOS -> {
listOf(BaseItemKind.MUSIC_VIDEO)
}
CollectionType.BOXSETS -> {
listOf(BaseItemKind.BOX_SET)
}
@ -229,6 +233,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf(BaseItemKind.PLAYLIST)
}
CollectionType.PHOTOS -> {
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
}
else -> {
listOf()
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -178,6 +179,7 @@ fun DvrSchedule(
.focusRequester(focusRequester),
)
showDialog?.let { item ->
val context = LocalContext.current
ProgramDialog(
state = DataLoadingState.Success(item),
canRecord = true,
@ -185,13 +187,18 @@ fun DvrSchedule(
showDialog = null
},
onWatch = { program ->
program.data.channelId?.let {
val channelId = program.data.channelId
if (channelId != null) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
itemId = channelId,
positionMs = 0L,
),
)
} else {
Toast
.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG)
.show()
}
},
onRecord = { _, _ ->

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LiveTvService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn
@ -25,7 +26,7 @@ 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.roundMinutes
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@ -51,7 +52,6 @@ import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
@ -81,6 +81,7 @@ class LiveTvViewModel
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val favoriteWatchManager: FavoriteWatchManager,
private val liveTvService: LiveTvService,
) : ViewModel() {
private lateinit var channelsIdToIndex: Map<UUID, Int>
private val mutex = Mutex()
@ -430,12 +431,10 @@ class LiveTvViewModel
_programDialogState.update { it.copy(loading = DataLoadingState.Loading) }
viewModelScope.launchDefault {
try {
val result =
api.liveTvApi
.getProgram(programId.toServerString())
.content
.let { BaseItem(it) }
val result = liveTvService.fetchProgramForDialog(programId)
_programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching program $programId")
_programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) }
@ -447,16 +446,19 @@ class LiveTvViewModel
series: Boolean,
timerId: String?,
) {
if (timerId != null) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
if (series) {
api.liveTvApi.cancelSeriesTimer(timerId)
} else {
api.liveTvApi.cancelTimer(timerId)
}
state.value.let {
refreshPrograms(it.channels, it.programs.range)
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
try {
val result = liveTvService.cancelRecording(series, timerId)
if (result) {
state.value.let {
refreshPrograms(it.channels, it.programs.range)
}
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
@ -466,37 +468,16 @@ class LiveTvViewModel
series: Boolean,
) {
viewModelScope.launchIO {
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
if (series) {
api.liveTvApi.createSeriesTimer(d)
} else {
val payload =
TimerInfoDto(
id = d.id,
type = d.type,
serverId = d.serverId,
externalId = d.externalId,
channelId = d.channelId,
externalChannelId = d.externalChannelId,
channelName = d.channelName,
programId = d.programId,
externalProgramId = d.externalProgramId,
name = d.name,
overview = d.overview,
startDate = d.startDate,
endDate = d.endDate,
serviceName = d.serviceName,
priority = d.priority,
prePaddingSeconds = d.prePaddingSeconds,
postPaddingSeconds = d.postPaddingSeconds,
isPrePaddingRequired = d.isPrePaddingRequired,
isPostPaddingRequired = d.isPostPaddingRequired,
keepUntil = d.keepUntil,
)
api.liveTvApi.createTimer(payload)
}
state.value.let {
refreshPrograms(it.channels, it.programs.range)
try {
liveTvService.record(programId, series)
state.value.let {
refreshPrograms(it.channels, it.programs.range)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error recording %s, series=%s", programId, series)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}

View file

@ -211,13 +211,18 @@ fun TvGuideGrid(
onDismissRequest = onDismissRequest,
onWatch = {
onDismissRequest.invoke()
it.data.channelId?.let {
val channelId = it.data.channelId
if (channelId != null) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it,
itemId = channelId,
positionMs = 0L,
),
)
} else {
Toast
.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG)
.show()
}
},
onRecord = { program, series ->

View file

@ -50,10 +50,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.SearchCombinedResults
import com.github.damontecres.wholphin.ui.main.SearchResult
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.search.SearchCombinedResults
import com.github.damontecres.wholphin.ui.search.SearchResult
import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException

View file

@ -36,12 +36,12 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.search.SearchForDialog
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import kotlinx.coroutines.Job

View file

@ -42,13 +42,13 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.playback.PlayExternalPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage
import com.github.damontecres.wholphin.ui.search.SearchPage
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage
import org.jellyfin.sdk.model.api.BaseItemKind

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.detail.search
package com.github.damontecres.wholphin.ui.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusGroup
@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.main.SearchResult
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.detail.search
package com.github.damontecres.wholphin.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -7,7 +7,6 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.SearchResult
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler

View file

@ -0,0 +1,770 @@
package com.github.damontecres.wholphin.ui.search
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
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.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.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.detail.livetv.ProgramDialog
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.titleStringRes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
internal const val SEARCH_ROW = 0
internal const val TAB_ROW = SEARCH_ROW + 1
internal const val SEERR_ROW = TAB_ROW + 1
internal const val COMBINED_ROW = SEERR_ROW
internal const val RESULTS_START = SEERR_ROW + 1
@Composable
fun SearchPage(
initialQuery: String,
userPreferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val state by viewModel.state.collectAsState()
val programDialogState by viewModel.programDialogState.collectAsState()
// Start with current preferences, but collect updates when view options change
val prefs =
viewModel.userPreferencesService.flow
.collectAsState(userPreferences)
.value.appPreferences.interfacePreferences.searchPreferences
val combinedMode = prefs.combinedSearchResults
val voiceSearchButtonVisible = prefs.showVoiceSearchButton
// val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf(initialQuery) }
val focusRequesters =
remember(state.includedSearchableTypes.size) { List(RESULTS_START + state.includedSearchableTypes.size) { FocusRequester() } }
val seerrActive by viewModel.seerrActive.collectAsState(initial = false)
var selectedTab by rememberSaveable(seerrActive, state.discoverEnabled) { mutableIntStateOf(0) }
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var showFilterTypeDialog by rememberSaveable { mutableStateOf(false) }
var searchClicked by rememberSaveable(query) { mutableStateOf(false) }
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
var showProgramDialog by remember { mutableStateOf(false) }
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()
}
}
fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery
searchClicked = true
viewModel.search(searchQuery, combinedMode)
}
LaunchedEffect(query, combinedMode) {
when {
immediateSearchQuery == query -> {
immediateSearchQuery = null
}
else -> {
delay(750.milliseconds)
viewModel.search(query, combinedMode)
}
}
}
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
}
val onClickItem = { _: Int, item: BaseItem ->
Timber.v("Clicked %s, type=%s", item.id, item.type)
if (item.type == BaseItemKind.TV_PROGRAM || item.type == BaseItemKind.PROGRAM || item.type == BaseItemKind.LIVE_TV_PROGRAM) {
viewModel.fetchProgramForDialog(item.id)
showProgramDialog = true
} else {
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))
}
val onClickDiscover = { _: Int, item: DiscoverItem ->
val dest =
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = item.type.baseItemKind,
)
} else {
Destination.DiscoveredItem(item)
}
viewModel.navigationManager.navigateTo(dest)
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val positionCallback = { columns: Int, index: Int ->
showHeader = index < columns
}
val showTabs =
seerrActive && state.discoverEnabled && query.isNotBlank() && showHeader && combinedMode
val isLibraryTab = selectedTab == 0
LaunchedEffect(seerrActive, query) {
if (!seerrActive || query.isBlank()) {
selectedTab = 0
}
}
LaunchedEffect(
searchClicked,
state,
combinedMode,
selectedTab,
seerrActive,
) {
if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect
withContext(WholphinDispatchers.IO) {
// Want to focus on the first successful row after all the ones before it are finished searching
val results =
if (isLibraryTab) {
if (combinedMode) {
listOf(state.combinedResults)
} else {
state.includedSearchableTypes.map { state.results[it] }
}
} else {
listOf(state.seerrResults)
}
val firstSuccess =
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
if (firstSuccess >= 0) {
val anyBeforeSearching =
results.subList(0, firstSuccess).any { it is SearchResult.Searching }
if (!anyBeforeSearching) {
val targetRow =
if (isLibraryTab) {
if (combinedMode) {
COMBINED_ROW
} else {
firstSuccess
}
} else {
SEERR_ROW
}
// setPosition(RowColumn(targetRow, 0))
onMain { focusRequesters.getOrNull(targetRow)?.tryRequestFocus() }
}
}
}
}
val tabs =
remember(seerrActive, state.discoverEnabled) {
buildList {
add(TabDetails(R.string.library))
if (seerrActive && state.discoverEnabled) {
add(TabDetails(R.string.discover))
}
}
}
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
) {
var isSearchActive by remember { mutableStateOf(false) }
var isTextFieldFocused by remember { mutableStateOf(false) }
val textFieldFocusRequester = remember { FocusRequester() }
BackHandler(isTextFieldFocused) {
when {
isSearchActive -> {
isSearchActive = false
keyboardController?.hide()
}
else -> {
focusManager.moveFocus(FocusDirection.Next)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.focusGroup()
.focusRestorer(textFieldFocusRequester)
.focusRequester(focusRequesters[SEARCH_ROW]),
) {
AnimatedVisibility(
visible = voiceSearchButtonVisible,
enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End),
exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End),
) {
VoiceSearchButton(
onSpeechResult = { spokenText ->
query = spokenText
triggerImmediateSearch(spokenText)
},
voiceInputManager = viewModel.voiceInputManager,
modifier = Modifier.padding(end = 12.dp),
)
}
SearchEditTextBox(
value = query,
onValueChange = {
isSearchActive = true
query = it
},
onSearchClick = { triggerImmediateSearch(query) },
readOnly = !isSearchActive,
modifier =
Modifier
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
isTextFieldFocused = state.isFocused
if (!state.isFocused) isSearchActive = false
}.onPreviewKeyEvent { event ->
val isActivationKey =
event.key in listOf(Key.DirectionCenter, Key.Enter)
if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
isSearchActive = true
keyboardController?.show()
true
} else {
false
}
},
)
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = { showViewOptions = true },
modifier = Modifier.padding(start = 12.dp),
)
}
}
AnimatedVisibility(
visible = showTabs,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTab,
tabs = tabs,
onClick = {
selectedTab = it
val row =
when (selectedTab) {
0 -> COMBINED_ROW
else -> SEERR_ROW
}
setPosition(RowColumn(row, 0))
},
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp)
.onFocusChanged {
if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0))
},
)
}
Box(
modifier = Modifier.fillMaxSize(),
) {
// SideEffect {
// Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode)
// }
when {
isLibraryTab && combinedMode -> {
SearchCombinedResults(
result = state.combinedResults,
focusRequester = focusRequesters[COMBINED_ROW],
onClickItem = onClickItem,
onLongClickItem = { index, item ->
onLongClickItem(COMBINED_ROW, index, item)
},
onPlayItem = onPlayItem,
onClickPosition = { setPosition(it) },
onClickDiscover = onClickDiscover,
positionCallback = positionCallback,
modifier = Modifier.fillMaxSize(),
)
}
!isLibraryTab && combinedMode -> {
SearchCombinedResults(
result = state.seerrResults,
focusRequester = focusRequesters[SEERR_ROW],
onClickItem = onClickItem,
onLongClickItem = { _, _ -> },
onPlayItem = onPlayItem,
onClickPosition = { setPosition(it) },
onClickDiscover = onClickDiscover,
positionCallback = positionCallback,
modifier = Modifier.fillMaxSize(),
)
}
isLibraryTab -> {
LazyColumn(
contentPadding =
PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 44.dp,
),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.focusGroup(),
) {
itemsIndexed(state.includedSearchableTypes) { index, type ->
val rowIndex = RESULTS_START + index
val result = state.results.getOrDefault(type, SearchResult.Searching)
SearchRowResult(
title = type.titleStringRes,
result = result,
rowIndex = rowIndex,
position = position,
focusRequester = focusRequesters[rowIndex],
onClickItem = onClickItem,
onLongClickItem = { index, item ->
onLongClickItem(rowIndex, index, item)
},
onClickPosition = { setPosition(it) },
modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, mod, onClick, onLongClick ->
SearchPageCard(
item = item,
type = type,
onClick = {
setPosition(RowColumn(rowIndex, index))
onClick.invoke()
},
onLongClick = onLongClick,
modifier = mod,
)
},
)
}
if (seerrActive && state.discoverEnabled) {
item {
SearchRowResult(
title = R.string.discover,
result = state.seerrResults,
rowIndex = SEERR_ROW,
position = position,
focusRequester = focusRequesters[SEERR_ROW],
onClickItem = onClickItem,
onLongClickItem = { _, _ -> },
onClickDiscover = onClickDiscover,
onClickPosition = { setPosition(it) },
cardContent = { _, _, _, _, _ -> },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
contextMenu.Compose()
}
if (showViewOptions) {
SearchViewOptionsDialog(
combinedResults = combinedMode,
onCombinedResultsChange = viewModel::setCombinedResults,
voiceSearchButtonVisible = voiceSearchButtonVisible,
onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible,
onClickFilterTypes = { showFilterTypeDialog = true },
onDismissRequest = { showViewOptions = false },
)
}
if (showFilterTypeDialog) {
SearchTypeOptionsDialog(
onDismissRequest = { showFilterTypeDialog = false },
searchableTypes = state.possibleSearchableTypes,
excludedSearchableTypes = state.excludedSearchableTypes,
discoverAvailable = seerrActive,
discoverEnabled = state.discoverEnabled,
onClick = viewModel::onClickExcludeSearchableType,
onClickDiscover = viewModel::onClickExcludeDiscover,
)
}
if (showProgramDialog) {
val context = LocalContext.current
val onDismissRequest = { showProgramDialog = false }
ProgramDialog(
state = programDialogState.loading,
canRecord = true,
onDismissRequest = onDismissRequest,
onWatch = {
onDismissRequest.invoke()
val channelId = it.data.channelId
if (channelId != null) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = channelId,
positionMs = 0L,
),
)
} else {
Toast.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG).show()
}
},
onRecord = { program, series ->
viewModel.record(
programId = program.id,
series = series,
)
onDismissRequest.invoke()
},
onCancelRecord = { program, series ->
viewModel.cancelRecording(
series = series,
timerId = if (series) program.data.seriesTimerId else program.data.timerId,
)
onDismissRequest.invoke()
},
)
}
}
@Composable
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 (result) {
SearchResult.NoQuery -> {}
SearchResult.Searching -> {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = stringResource(R.string.searching),
modifier = modifier.padding(16.dp),
)
}
is SearchResult.Error -> {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = result.ex.localizedMessage ?: "Error occurred during search",
messageColor = MaterialTheme.colorScheme.error,
modifier = modifier.padding(16.dp),
)
}
is SearchResult.Success -> {
if (result.items.isEmpty()) {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = stringResource(R.string.no_results),
modifier = modifier.padding(16.dp),
)
} else {
SearchGrid(
items = result.items,
focusRequester = focusRequester,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onPlayItem = onPlayItem,
onClickPosition = onClickPosition,
positionCallback = positionCallback,
cardContent = { details ->
GridCard(
item = details.item,
onClick = details.onClick,
onLongClick = details.onLongClick,
modifier = details.mod,
fillWidth = details.widthPx,
)
},
modifier = modifier,
)
}
}
is SearchResult.SuccessSeerr -> {
if (result.items.isEmpty()) {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = stringResource(R.string.no_results),
modifier = modifier.padding(16.dp),
)
} else {
SearchGrid(
items = result.items,
focusRequester = focusRequester,
onClickItem = onClickDiscover,
onLongClickItem = { _, _ -> },
onPlayItem = { _, _ -> },
onClickPosition = onClickPosition,
positionCallback = positionCallback,
cardContent = { details ->
DiscoverItemCard(
item = details.item,
onClick = details.onClick,
onLongClick = details.onLongClick,
modifier = details.mod,
)
},
modifier = modifier,
)
}
}
}
}
@Composable
private fun <T : CardGridItem> SearchGrid(
items: List<T?>,
focusRequester: FocusRequester,
onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, T) -> Unit,
onPlayItem: (Int, T) -> Unit,
onClickPosition: (RowColumn) -> Unit,
cardContent: @Composable (GridItemDetails<T>) -> Unit,
positionCallback: (columns: Int, position: Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
) {
ItemRowTitle(stringResource(R.string.results))
CardGrid(
pager = items,
onClickItem = { index, item ->
onClickPosition.invoke(RowColumn(COMBINED_ROW, index))
onClickItem.invoke(index, item)
},
onLongClickItem = onLongClickItem,
onClickPlay = { index, item ->
onClickPosition.invoke(RowColumn(COMBINED_ROW, index))
onPlayItem.invoke(index, item)
},
letterPosition = { -1 },
gridFocusRequester = focusRequester,
showJumpButtons = false,
showLetterButtons = false,
positionCallback = positionCallback,
modifier = Modifier.fillMaxSize(),
cardContent = cardContent,
)
}
}
@Composable
fun SearchRowResult(
@StringRes title: Int,
result: SearchResult,
rowIndex: Int,
position: RowColumn,
focusRequester: FocusRequester,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null,
cardContent: @Composable (
index: Int,
item: BaseItem?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
) -> Unit,
) {
when (val r = result) {
is SearchResult.Error -> {
SearchResultPlaceholder(
title = stringResource(title),
message = r.ex.localizedMessage ?: "Error occurred during search",
messageColor = MaterialTheme.colorScheme.error,
modifier = Modifier,
)
}
SearchResult.NoQuery -> {
// no-op
}
SearchResult.Searching -> {
SearchResultPlaceholder(
title = stringResource(title),
message = stringResource(R.string.searching),
modifier = modifier,
)
}
is SearchResult.Success -> {
if (r.items.isNotEmpty()) {
ItemRow(
title = stringResource(title),
items = r.items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
modifier = modifier.focusRequester(focusRequester),
cardContent = cardContent,
)
}
}
is SearchResult.SuccessSeerr -> {
if (r.items.isNotEmpty()) {
ItemRow(
title = stringResource(title),
items = r.items,
onClickItem = { index, item ->
onClickPosition.invoke(RowColumn(rowIndex, index))
onClickDiscover?.invoke(index, item)
},
onLongClickItem = { _, _ -> },
modifier = modifier.focusRequester(focusRequester),
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = true,
modifier = mod,
)
},
)
}
}
}
}
@Composable
fun SearchResultPlaceholder(
title: String,
message: String,
modifier: Modifier = Modifier,
messageColor: Color = MaterialTheme.colorScheme.onBackground,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.padding(bottom = 32.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = message,
style = MaterialTheme.typography.titleMedium,
color = messageColor,
)
}
}

View file

@ -0,0 +1,149 @@
package com.github.damontecres.wholphin.ui.search
import android.text.format.DateUtils
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.PersonCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.cards.personRowCardWidth
import com.github.damontecres.wholphin.ui.cards.rememberImageUrl
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import java.time.OffsetDateTime
@Composable
fun SearchPageCard(
item: BaseItem?,
type: BaseItemKind,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
when (type) {
BaseItemKind.EPISODE -> {
BannerCardWithTitle(
title = item?.title,
subtitle = item?.subtitle,
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier.padding(horizontal = 8.dp),
cardHeight = Cards.heightEpisode,
)
}
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.AUDIO,
-> {
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.heightEpisode,
aspectRatio = AspectRatios.SQUARE,
showImageOverlay = true,
modifier = modifier,
)
}
BaseItemKind.PERSON -> {
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember(item) {
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
)
}
PersonCard(
name = item?.name ?: "",
role = null,
imageUrl = imageUrl,
favorite = item?.favorite ?: false,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier.width(personRowCardWidth),
)
}
BaseItemKind.TV_CHANNEL -> {
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.HEIGHT_LIVE_TV.dp,
showImageOverlay = true,
aspectRatio = AspectRatios.WIDE,
modifier = modifier,
)
}
BaseItemKind.PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_PROGRAM,
-> {
val subtitle =
remember(item) {
val startDate = item?.data?.startDate
val endDate = item?.data?.endDate
if (startDate != null && endDate != null) {
DateUtils.formatDateRange(
context,
startDate
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
endDate
.toInstant(OffsetDateTime.now().offset)
.epochSecond * 1000,
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY,
)
} else {
null
}
}
SeasonCard(
title = item?.title,
subtitle = subtitle,
name = item?.name,
imageUrl = rememberImageUrl(item, Cards.HEIGHT_LIVE_TV.dp, Dp.Unspecified),
isFavorite = item?.data?.userData?.isFavorite ?: false,
isPlayed = item?.data?.userData?.played ?: false,
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
imageHeight = Cards.HEIGHT_LIVE_TV.dp,
imageWidth = Dp.Unspecified,
showImageOverlay = true,
aspectRatio = AspectRatios.WIDE,
)
}
else -> {
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
showImageOverlay = true,
modifier = modifier,
)
}
}
}

View file

@ -0,0 +1,100 @@
package com.github.damontecres.wholphin.ui.search
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItem
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.main.settings.TitleText
import com.github.damontecres.wholphin.ui.preferences.SwitchColors
import com.github.damontecres.wholphin.ui.titleStringRes
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun SearchTypeOptionsDialog(
onDismissRequest: () -> Unit,
searchableTypes: List<BaseItemKind>,
excludedSearchableTypes: List<BaseItemKind>,
discoverAvailable: Boolean,
discoverEnabled: Boolean,
onClick: (BaseItemKind) -> Unit,
onClickDiscover: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
elevation = 3.dp,
) {
SearchTypeOptionsDialogContent(
searchableTypes = searchableTypes,
excludedSearchableTypes = excludedSearchableTypes,
discoverAvailable = discoverAvailable,
discoverEnabled = discoverEnabled,
onClick = onClick,
onClickDiscover = onClickDiscover,
modifier = Modifier.padding(16.dp),
)
}
}
@Composable
fun SearchTypeOptionsDialogContent(
searchableTypes: List<BaseItemKind>,
excludedSearchableTypes: List<BaseItemKind>,
discoverAvailable: Boolean,
discoverEnabled: Boolean,
onClick: (BaseItemKind) -> Unit,
onClickDiscover: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
TitleText(stringResource(R.string.include_types))
LazyColumn {
items(searchableTypes) { searchableType ->
val checked = searchableType !in excludedSearchableTypes
ListItem(
enabled = true,
selected = false,
onClick = { onClick.invoke(searchableType) },
headlineContent = {
Text(stringResource(searchableType.titleStringRes))
},
trailingContent = {
Switch(
checked = checked,
onCheckedChange = {},
colors = SwitchColors(),
)
},
)
}
if (discoverAvailable) {
item {
ListItem(
enabled = true,
selected = false,
onClick = onClickDiscover,
headlineContent = {
Text(stringResource(R.string.discover))
},
trailingContent = {
Switch(
checked = discoverEnabled,
onCheckedChange = {},
colors = SwitchColors(),
)
},
)
}
}
}
}
}

View file

@ -0,0 +1,628 @@
package com.github.damontecres.wholphin.ui.search
import android.content.Context
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
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.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.KeyValueService
import com.github.damontecres.wholphin.services.LiveTvService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
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.services.tvAccess
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialogState
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.showToast
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.DataLoadingState
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.artistsApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.personsApi
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
@HiltViewModel
class SearchViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
val api: ApiClient,
val navigationManager: NavigationManager,
val appPreferences: DataStore<AppPreferences>,
private val seerrService: SeerrService,
val voiceInputManager: VoiceInputManager,
val userPreferencesService: UserPreferencesService,
private val serverRepository: ServerRepository,
private val favoriteWatchManager: FavoriteWatchManager,
private val mediaManagementService: MediaManagementService,
private val mediaReportService: MediaReportService,
private val liveTvService: LiveTvService,
private val keyValueService: KeyValueService,
private val navDrawerService: NavDrawerService,
) : ViewModel(),
ContextMenuProvider {
val seerrActive = seerrService.active
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state
val position = MutableStateFlow(RowColumn(0, 0))
private var currentQuery: String? = null
private var combinedMode = false
private val _programDialogState = MutableStateFlow(ProgramDialogState())
val programDialogState: StateFlow<ProgramDialogState> = _programDialogState
private var userLibraryTypes: Set<BaseItemKind> = emptySet()
init {
init()
}
private fun init() {
viewModelScope.launchDefault {
val tvAccess = serverRepository.currentUserDto?.tvAccess == true
userLibraryTypes =
serverRepository.currentUser
?.id
?.let { userId ->
navDrawerService
.getAllUserLibraries(userId, tvAccess)
.flatMap { it.collectionType.baseItemKinds }
.toSet()
}.orEmpty()
val excludedSearchableTypes =
serverRepository.currentUser?.id?.let { userId ->
try {
keyValueService
.get<List<BaseItemKind>>(
userId,
EXCLUDED_SEARCHABLE_TYPES_KEY,
emptyList(),
).firstOrNull()
} catch (ex: Exception) {
Timber.e(ex, "Error occurred fetching excluded search types")
emptyList()
}
} ?: emptyList()
val discoverEnabled =
serverRepository.currentUser?.id?.let { userId ->
try {
keyValueService
.get(
userId,
INCLUDE_DISCOVER_KEY,
true,
).firstOrNull()
} catch (ex: Exception) {
Timber.e(ex, "Error occurred fetching excluded search types")
true
}
} ?: true
val searchableTypes = determineSearchableTypes(excludedSearchableTypes)
val possibleSearchableTypes = determineSearchableTypes(emptyList())
_state.update {
it.copy(
discoverEnabled = discoverEnabled,
includedSearchableTypes = searchableTypes,
possibleSearchableTypes = possibleSearchableTypes,
excludedSearchableTypes = excludedSearchableTypes,
results =
SnapshotStateMap<BaseItemKind, SearchResult>().apply {
searchableTypes.forEach { put(it, SearchResult.NoQuery) }
},
)
}
}
}
private fun determineSearchableTypes(excludedSearchableTypes: List<BaseItemKind>): List<BaseItemKind> {
val tvAccess = serverRepository.currentUserDto?.tvAccess == true
val searchableTypes =
allSearchableTypes.filter {
val excluded = excludedSearchableTypes.contains(it)
// Only include if there's a relevant, accessible library
val hasLibrary = userLibraryTypes.contains(it)
when (it) {
// No library type for person
BaseItemKind.PERSON -> !excluded
// Remove live tv search if user doesn't have access
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.PROGRAM,
-> tvAccess && !excluded
else -> hasLibrary && !excluded
}
}
return searchableTypes
}
fun search(
query: String?,
combined: Boolean = false,
force: Boolean = false,
) {
if (currentQuery == query && combinedMode == combined && !force) {
return
}
currentQuery = query
combinedMode = combined
if (query.isNotNullOrBlank()) {
_state.update {
it.copy(
results =
SnapshotStateMap<BaseItemKind, SearchResult>().apply {
it.includedSearchableTypes.forEach {
put(it, SearchResult.Searching)
}
},
combinedResults = SearchResult.Searching,
)
}
if (combined) {
searchCombined(query)
} else {
state.value.includedSearchableTypes.forEach { type ->
searchType(query, type)
}
}
searchSeerr(query)
} else {
_state.update {
it.copy(
results =
SnapshotStateMap<BaseItemKind, SearchResult>().apply {
it.includedSearchableTypes.forEach {
put(it, SearchResult.NoQuery)
}
},
combinedResults = SearchResult.NoQuery,
)
}
}
}
private fun searchType(
query: String,
type: BaseItemKind,
) {
viewModelScope.launchIO {
try {
val items: List<BaseItem> =
when (type) {
BaseItemKind.LIVE_TV_PROGRAM -> {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(type),
fields = ProgramItemFields,
limit = SEARCH_LIMIT,
enableTotalRecordCount = false,
)
api.itemsApi.getItems(request).toBaseItems(api, false)
}
BaseItemKind.MUSIC_ARTIST -> {
api.artistsApi
.getArtists(
searchTerm = query,
fields = SlimItemFields,
limit = SEARCH_LIMIT,
enableTotalRecordCount = false,
).toBaseItems(api, false)
}
BaseItemKind.PERSON -> {
api.personsApi
.getPersons(
searchTerm = query,
fields = SlimItemFields,
limit = SEARCH_LIMIT,
).toBaseItems(api, false)
}
else -> {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(type),
fields = SlimItemFields,
limit = SEARCH_LIMIT,
enableTotalRecordCount = false,
)
api.itemsApi.getItems(request).toBaseItems(api, false)
}
}
val sorted =
items.sortedWith(
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.sortName },
)
Timber.v("Search finished for %s, %s results", type, sorted.size)
_state.value.results[type] = SearchResult.Success(sorted)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Exception searching for $type")
_state.value.results[type] = SearchResult.Error(ex)
}
}
}
private fun searchCombined(query: String) {
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
try {
Timber.v("Starting searchCombined")
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = state.value.includedSearchableTypes,
fields = SlimItemFields,
limit = SEARCH_LIMIT,
)
val result = api.itemsApi.getItems(request).content
val items =
result.items.map {
BaseItem(it, false)
}
val sorted =
items.sortedWith(
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" },
)
Timber.v("searchCombined complete %s results", sorted.size)
_state.update { it.copy(combinedResults = SearchResult.Success(sorted)) }
} catch (ex: Exception) {
Timber.e(ex, "Exception in combined search")
_state.update { it.copy(combinedResults = SearchResult.Error(ex)) }
}
}
}
fun setCombinedResults(enabled: Boolean) {
viewModelScope.launchIO {
appPreferences.updateData {
it.updateSearchPreferences {
combinedSearchResults = enabled
}
}
}
}
fun setVoiceSearchButtonVisible(visible: Boolean) {
viewModelScope.launchIO {
appPreferences.updateData {
it.updateSearchPreferences {
showVoiceSearchButton = visible
}
}
}
}
private fun searchSeerr(query: String) {
viewModelScope.launchIO {
if (seerrActive.first() && state.value.discoverEnabled) {
_state.update { it.copy(seerrResults = SearchResult.Searching) }
val results =
seerrService
.search(query)
.map { seerrService.createDiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
_state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) }
}
}
}
init {
addCloseable(voiceInputManager)
}
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 {
state.value.includedSearchableTypes.getOrNull(position.row)?.let {
state.value.results[it]
}
} ?: 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)
},
)
if (combinedMode) {
_state.update {
it.copy(
combinedResults = newList,
)
}
} else {
state.value.includedSearchableTypes.getOrNull(position.row)?.let { type ->
state.value.results[type] = newList
}
}
}
} 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)
fun fetchProgramForDialog(programId: UUID) {
_programDialogState.update { it.copy(loading = DataLoadingState.Loading) }
viewModelScope.launchDefault {
try {
val result = liveTvService.fetchProgramForDialog(programId)
_programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching program $programId")
_programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) }
}
}
}
fun cancelRecording(
series: Boolean,
timerId: String?,
) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
try {
val result = liveTvService.cancelRecording(series, timerId)
// TODO update program card?
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
fun record(
programId: UUID,
series: Boolean,
) {
viewModelScope.launchIO {
try {
liveTvService.record(programId, series)
// TODO update program card?
refreshItem(programId)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error recording %s, series=%s", programId, series)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
fun onClickExcludeSearchableType(type: BaseItemKind) {
viewModelScope.launchIO {
try {
val state = state.value
val updateSearch: Boolean
val newExcludes =
state.excludedSearchableTypes.toMutableList().apply {
if (type in this) {
updateSearch = true
remove(type)
} else {
// If combined, need to update, otherwise just hide the newly excluded results
updateSearch = combinedMode
add(type)
}
}
Timber.v("newExcludes=%s", newExcludes)
serverRepository.currentUser?.id?.let { userId ->
keyValueService.save(userId, EXCLUDED_SEARCHABLE_TYPES_KEY, newExcludes)
}
val searchableTypes = determineSearchableTypes(newExcludes)
_state.update {
it.copy(
includedSearchableTypes = searchableTypes,
excludedSearchableTypes = newExcludes,
)
}
if (updateSearch && currentQuery.isNotNullOrBlank()) {
Timber.d("Need to update search")
search(currentQuery, combinedMode, true)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Exception toggling %s", type)
showToast(context, "An error occurred: ${ex.localizedMessage}")
}
}
}
fun onClickExcludeDiscover() {
viewModelScope.launchIO {
try {
val newIncludeDiscover = state.value.discoverEnabled.not()
Timber.v("newIncludeDiscover=%s", newIncludeDiscover)
serverRepository.currentUser?.id?.let { userId ->
keyValueService.save(userId, INCLUDE_DISCOVER_KEY, newIncludeDiscover)
}
_state.update {
it.copy(
discoverEnabled = newIncludeDiscover,
)
}
if (newIncludeDiscover) {
currentQuery?.takeIf { it.isNotNullOrBlank() }?.let { query ->
searchSeerr(query)
}
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Exception discover")
showToast(context, "An error occurred: ${ex.localizedMessage}")
}
}
}
companion object {
private const val EXCLUDED_SEARCHABLE_TYPES_KEY = "excludedSearchableTypes"
private const val INCLUDE_DISCOVER_KEY = "searchIncludeDiscover"
}
}
sealed interface SearchResult {
data object NoQuery : SearchResult
data object Searching : SearchResult
data class Error(
val ex: Exception,
) : SearchResult
data class Success(
val items: List<BaseItem?>,
) : SearchResult
data class SuccessSeerr(
val items: List<DiscoverItem>,
) : SearchResult
}
data class SearchState(
val results: SnapshotStateMap<BaseItemKind, SearchResult> = SnapshotStateMap(),
val seerrResults: SearchResult = SearchResult.NoQuery,
val combinedResults: SearchResult = SearchResult.NoQuery,
val possibleSearchableTypes: List<BaseItemKind> = emptyList(),
val includedSearchableTypes: List<BaseItemKind> = emptyList(),
val excludedSearchableTypes: List<BaseItemKind> = emptyList(),
val discoverEnabled: Boolean = true,
)
private val allSearchableTypes =
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
BaseItemKind.EPISODE,
BaseItemKind.BOX_SET,
BaseItemKind.PERSON,
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_PROGRAM,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.PLAYLIST,
BaseItemKind.VIDEO,
BaseItemKind.PHOTO,
BaseItemKind.PHOTO_ALBUM,
)
private const val SEARCH_LIMIT = 50

View file

@ -0,0 +1,121 @@
package com.github.damontecres.wholphin.ui.search
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
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.ui.preferences.SwitchColors
@Composable
fun SearchViewOptionsDialog(
combinedResults: Boolean,
onCombinedResultsChange: (Boolean) -> Unit,
voiceSearchButtonVisible: Boolean,
onVoiceSearchButtonVisibleChange: (Boolean) -> Unit,
onClickFilterTypes: () -> Unit,
onDismissRequest: () -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.setGravity(Gravity.CENTER)
Box(
modifier =
Modifier
.width(400.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
RoundedCornerShape(28.dp),
).padding(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
ListItem(
selected = false,
headlineContent = {
Text(stringResource(R.string.combined_search_results))
},
supportingContent = {
Text(
if (combinedResults) {
stringResource(R.string.combined_search_results_on)
} else {
stringResource(R.string.combined_search_results_off)
},
)
},
trailingContent = {
Switch(
checked = combinedResults,
onCheckedChange = onCombinedResultsChange,
colors = SwitchColors(),
)
},
onClick = { onCombinedResultsChange(!combinedResults) },
modifier = Modifier.fillMaxWidth(),
)
ListItem(
selected = false,
headlineContent = {
Text(stringResource(R.string.show_voice_search_button))
},
supportingContent = {
Text(
if (voiceSearchButtonVisible) {
stringResource(R.string.visible_ui)
} else {
stringResource(R.string.hidden_ui)
},
)
},
trailingContent = {
Switch(
checked = voiceSearchButtonVisible,
onCheckedChange = onVoiceSearchButtonVisibleChange,
colors = SwitchColors(),
)
},
onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) },
modifier = Modifier.fillMaxWidth(),
)
ListItem(
selected = false,
headlineContent = {
Text(stringResource(R.string.include_types))
},
onClick = onClickFilterTypes,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

View file

@ -913,4 +913,5 @@
<string name="dim_after_delay">Dim screen after delay</string>
<string name="dim_percentage">Dim percentage</string>
<string name="dim_screen">Dim screen</string>
<string name="photo_albums">Photo albums</string>
</resources>