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:
parent
2b9af1e8bc
commit
f91bca18ff
21 changed files with 1968 additions and 1416 deletions
|
|
@ -243,7 +243,7 @@ data class HomeRowViewOptions(
|
|||
|
||||
val liveTvDefault =
|
||||
HomeRowViewOptions(
|
||||
heightDp = 96,
|
||||
heightDp = Cards.HEIGHT_LIVE_TV,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = { _, _ ->
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue