Improvements to "Add to playlist" dialog (#1706)

## Description
Fixes & improves the "Add to playlist" dialog:
- Added searching by name
- Playlists are sorted by recently updated
- No longer filters by media type
- Filters based on user's permission for the playlists

The behavior is now consistent with the web UI's list except for the
sort

### Related issues
Should fix #1124

### Testing
Emulator testing

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-16 11:35:48 -04:00 • committed by GitHub
parent 39c030c1d5
commit a350fa2017
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 211 additions and 76 deletions

View file

@ -15,13 +15,11 @@ import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
@ -344,26 +342,49 @@ class PlaylistCreator
* Get the playlists on the server for a given media type * Get the playlists on the server for a given media type
*/ */
suspend fun getServerPlaylists( suspend fun getServerPlaylists(
query: String,
mediaType: MediaType?, mediaType: MediaType?,
scope: CoroutineScope, ): List<PlaylistInfo> {
): List<PlaylistInfo?> { val userId =
serverRepository.currentUser?.id ?: throw IllegalStateException("No user found")
val request = val request =
GetItemsRequest( GetItemsRequest(
searchTerm = query.takeIf { it.isNotBlank() },
includeItemTypes = listOf(BaseItemKind.PLAYLIST), includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) }, mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true, recursive = true,
limit = 25,
sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
sortOrder = listOf(SortOrder.DESCENDING),
) )
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() val playlists = GetItemsRequestHandler.execute(api, request).content.items
return TransformList(pager) { return playlists
it?.let { .mapNotNull { playlist ->
PlaylistInfo( try {
id = it.id, val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content
name = it.name ?: context.getString(R.string.unknown), if (response.canEdit) {
count = it.data.childCount ?: 0, PlaylistInfo(
mediaType = it.data.mediaType, id = playlist.id,
) name = playlist.name ?: context.getString(R.string.unknown),
count = playlist.childCount ?: 0,
mediaType = playlist.mediaType,
)
} else {
null
}
} catch (ex: InvalidStatusException) {
if (ex.status == 404) {
null
} else {
throw ex
}
}
} }
} // .sortedWith(
// compareBy<PlaylistInfo> {
// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query)
// }.thenBy { it.name },
// )
} }
suspend fun createServerPlaylist( suspend fun createServerPlaylist(

View file

@ -18,7 +18,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
/** /**
@ -76,7 +75,7 @@ class ContextMenuUtils(
provider.setFavorite(position, itemId, favorite) provider.setFavorite(position, itemId, favorite)
}, },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = provider::sendReportFor, onSendMediaInfo = provider::sendReportFor,
@ -129,6 +128,7 @@ class ContextMenuUtils(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -62,7 +62,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -388,7 +387,7 @@ fun RecommendedContent(
viewModel.setFavorite(position, itemId, favorite) viewModel.setFavorite(position, itemId, favorite)
}, },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -485,12 +484,8 @@ fun RecommendedContent(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }
} }
data class RowColumnItem(
val position: RowColumn,
val item: BaseItem,
)

View file

@ -18,7 +18,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -38,13 +37,13 @@ class AddPlaylistViewModel
) : ViewModel() { ) : ViewModel() {
val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending) val playlistState = MutableStateFlow<PlaylistLoadingState>(PlaylistLoadingState.Pending)
fun loadPlaylists(mediaType: MediaType?) { fun loadPlaylists(query: String = "") {
viewModelScope.launchIO { viewModelScope.launchIO {
this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading
try { try {
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) val playlists = playlistCreator.getServerPlaylists(query, null)
this@AddPlaylistViewModel.playlistState.value = this@AddPlaylistViewModel.playlistState.value =
PlaylistLoadingState.Success(playlists) PlaylistLoadingState.Success(playlists, query)
} catch (ex: Exception) { } catch (ex: Exception) {
playlistState.value = PlaylistLoadingState.Error(ex) playlistState.value = PlaylistLoadingState.Error(ex)
} }

View file

@ -412,7 +412,7 @@ fun PlaylistDetails(
onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.AUDIO) addToPlaylistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onClickRemoveFromQueue = { _, _ -> }, onClickRemoveFromQueue = { _, _ -> },
@ -425,7 +425,7 @@ fun PlaylistDetails(
onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
addToPlaylistViewModel.loadPlaylists(MediaType.VIDEO) addToPlaylistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel::sendMediaReport, onSendMediaInfo = viewModel::sendMediaReport,
@ -511,6 +511,7 @@ fun PlaylistDetails(
addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = addToPlaylistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -1,11 +1,16 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import android.view.Gravity
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -25,9 +30,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@ -38,6 +47,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -49,12 +59,16 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.MediaType
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun PlaylistList( fun PlaylistList(
playlists: List<PlaylistInfo?>, playlists: List<PlaylistInfo>,
onClick: (PlaylistInfo) -> Unit, onClick: (PlaylistInfo) -> Unit,
createEnabled: Boolean, createEnabled: Boolean,
onCreatePlaylist: (String) -> Unit, onCreatePlaylist: (String) -> Unit,
@ -68,33 +82,37 @@ fun PlaylistList(
items(playlists) { playlist -> items(playlists) { playlist ->
ListItem( ListItem(
selected = false, selected = false,
enabled = playlist != null, enabled = true,
headlineContent = { headlineContent = {
Text( Text(
text = playlist?.name ?: stringResource(R.string.loading), text = playlist.name,
) )
}, },
supportingContent = { supportingContent = {
if (playlist != null) { val id =
Text( when (playlist.mediaType) {
text = playlist.mediaType.serialName, MediaType.UNKNOWN -> R.string.unknown
) MediaType.VIDEO -> R.string.video
} MediaType.AUDIO -> R.string.audio
MediaType.PHOTO -> R.string.photos
MediaType.BOOK -> R.string.unknown
}
Text(
text = stringResource(id),
)
}, },
trailingContent = { trailingContent = {
if (playlist != null) { Text(
Text( text =
text = pluralStringResource(
pluralStringResource( R.plurals.items,
R.plurals.items, playlist.count,
playlist.count, playlist.count,
playlist.count, ),
), )
)
}
}, },
onClick = { onClick = {
if (playlist != null) onClick.invoke(playlist) onClick.invoke(playlist)
}, },
modifier = Modifier, modifier = Modifier,
) )
@ -193,16 +211,38 @@ fun PlaylistDialog(
state: PlaylistLoadingState, state: PlaylistLoadingState,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onClick: (PlaylistInfo) -> Unit, onClick: (PlaylistInfo) -> Unit,
onSearch: (String) -> Unit,
createEnabled: Boolean, createEnabled: Boolean,
onCreatePlaylist: (String) -> Unit, onCreatePlaylist: (String) -> Unit,
elevation: Dp = 3.dp, elevation: Dp = 3.dp,
) { ) {
val elevatedContainerColor = val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
val placeholderFocusRequester = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val searchFocusRequester = remember { FocusRequester() }
var query by remember { mutableStateOf("") }
var searchClicked by remember { mutableStateOf(false) }
var searchHasFocus by remember { mutableStateOf(false) }
LaunchedEffect(query) {
val previousQuery = (state as? PlaylistLoadingState.Success)?.query ?: ""
if (previousQuery != query) {
delay(750.milliseconds)
if (searchClicked) {
searchClicked = false
return@LaunchedEffect
}
onSearch.invoke(query)
}
}
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
) { ) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.setGravity(Gravity.TOP)
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier =
@ -220,6 +260,74 @@ fun PlaylistDialog(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
SearchEditTextBox(
value = query,
onValueChange = { query = it },
onSearchClick = {
if (query.isNotBlank()) {
searchClicked = true
onSearch.invoke(query)
}
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(searchFocusRequester)
.onFocusChanged { searchHasFocus = it.hasFocus }
.focusProperties {
// Block focus to except for down/next to the results
// Don't want to focus on the placeholder
up = FocusRequester.Cancel
left = FocusRequester.Cancel
right = FocusRequester.Cancel
previous = FocusRequester.Cancel
start = FocusRequester.Cancel
end = FocusRequester.Cancel
down = focusRequester
next = focusRequester
},
)
LaunchedEffect(Unit) {
if (state !is PlaylistLoadingState.Success) {
placeholderFocusRequester.tryRequestFocus()
}
}
// Placeholder for initial focus
// After results load, focus will move to the first one
Box(
modifier =
Modifier
.align(Alignment.CenterHorizontally)
.width(0.dp)
.height(0.dp)
.focusRequester(placeholderFocusRequester)
.focusable(),
)
val resultsModifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.focusProperties {
// Only focus up to search bar and ignore the placeholder
left = FocusRequester.Cancel
right = FocusRequester.Cancel
start = FocusRequester.Cancel
end = FocusRequester.Cancel
down = FocusRequester.Cancel
next = FocusRequester.Cancel
up = searchFocusRequester
previous = searchFocusRequester
onExit = {
if (requestedFocusDirection == FocusDirection.Next ||
requestedFocusDirection == FocusDirection.Left ||
requestedFocusDirection == FocusDirection.Right
) {
cancelFocusChange()
} else {
searchFocusRequester.tryRequestFocus()
}
}
}
when (val s = state) { when (val s = state) {
PlaylistLoadingState.Pending, PlaylistLoadingState.Pending,
PlaylistLoadingState.Loading, PlaylistLoadingState.Loading,
@ -234,20 +342,24 @@ fun PlaylistDialog(
} }
is PlaylistLoadingState.Error -> { is PlaylistLoadingState.Error -> {
ErrorMessage(s.message, s.exception) ErrorMessage(
message = s.message,
exception = s.exception,
modifier = resultsModifier,
)
} }
is PlaylistLoadingState.Success -> { is PlaylistLoadingState.Success -> {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) {
// Once results load, focus unless the user is still focused on the search box
if (!searchHasFocus) focusRequester.tryRequestFocus()
}
PlaylistList( PlaylistList(
playlists = s.items, playlists = s.items,
onClick = onClick, onClick = onClick,
createEnabled = createEnabled, createEnabled = createEnabled,
onCreatePlaylist = onCreatePlaylist, onCreatePlaylist = onCreatePlaylist,
modifier = modifier = resultsModifier,
Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
) )
} }
} }
@ -261,7 +373,8 @@ sealed interface PlaylistLoadingState {
data object Loading : PlaylistLoadingState data object Loading : PlaylistLoadingState
data class Success( data class Success(
val items: List<PlaylistInfo?>, val items: List<PlaylistInfo>,
val query: String,
) : PlaylistLoadingState ) : PlaylistLoadingState
data class Error( data class Error(

View file

@ -56,7 +56,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -91,7 +90,7 @@ fun CollectionDetails(
viewModel.setFavorite(itemId, favorite, position) viewModel.setFavorite(itemId, favorite, position)
}, },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -273,6 +272,7 @@ fun CollectionDetails(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -49,7 +49,6 @@ import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
@ -88,7 +87,7 @@ fun EpisodeDetails(
onClickWatch = viewModel::setWatched, onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite, onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -208,6 +207,7 @@ fun EpisodeDetails(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -70,7 +70,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
@ -109,7 +108,7 @@ fun MovieDetails(
onClickWatch = viewModel::setWatched, onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite, onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -275,6 +274,7 @@ fun MovieDetails(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -104,7 +104,6 @@ import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import java.util.UUID import java.util.UUID
@ -337,7 +336,7 @@ fun AlbumDetailsPage(
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.AUDIO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onClickRemoveFromQueue = { _, _ -> }, onClickRemoveFromQueue = { _, _ -> },
@ -635,6 +634,7 @@ fun AlbumDetailsPage(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -99,7 +99,6 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
@ -328,7 +327,7 @@ fun ArtistDetailsPage(
onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) },
onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.AUDIO) playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onClickRemoveFromQueue = { _, _ -> }, onClickRemoveFromQueue = { _, _ -> },
@ -631,6 +630,7 @@ fun ArtistDetailsPage(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -88,7 +88,6 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -133,7 +132,7 @@ fun SeriesDetails(
viewModel.setFavorite(itemId, favorite, null) viewModel.setFavorite(itemId, favorite, null)
}, },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog = Optional.present(itemId) showPlaylistDialog = Optional.present(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -307,6 +306,7 @@ fun SeriesDetails(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog.makeAbsent() showPlaylistDialog.makeAbsent()
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
@ -116,7 +115,7 @@ fun SeriesOverview(
viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex) viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex)
}, },
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog = itemId showPlaylistDialog = itemId
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -348,6 +347,7 @@ fun SeriesOverview(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog = null showPlaylistDialog = null
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -91,7 +91,6 @@ import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -177,7 +176,7 @@ fun HomePage(
onClickWatch = viewModel::setWatched, onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite, onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId -> onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO) playlistViewModel.loadPlaylists()
showPlaylistDialog = itemId showPlaylistDialog = itemId
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
@ -258,6 +257,7 @@ fun HomePage(
playlistViewModel.createPlaylistAndAddItem(it, itemId) playlistViewModel.createPlaylistAndAddItem(it, itemId)
showPlaylistDialog = null showPlaylistDialog = null
}, },
onSearch = playlistViewModel::loadPlaylists,
elevation = 3.dp, elevation = 3.dp,
) )
} }

View file

@ -240,7 +240,7 @@ class SearchViewModel
val sorted = val sorted =
items.sortedWith( items.sortedWith(
compareBy<BaseItem> { SearchRelevance.score(it, query) } compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" }, .thenBy { it.sortName },
) )
_state.update { update.invoke(SearchResult.Success(sorted), it) } _state.update { update.invoke(SearchResult.Success(sorted), it) }
} catch (ex: CancellationException) { } catch (ex: CancellationException) {

View file

@ -10,14 +10,20 @@ object SearchRelevance {
fun score( fun score(
item: BaseItem, item: BaseItem,
query: String, query: String,
): Int = score(item.name, item.type, query)
fun score(
name: String?,
type: BaseItemKind,
query: String,
): Int { ): Int {
val name = item.name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE val name = name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE
val q = query.lowercase(Locale.getDefault()) val q = query.lowercase(Locale.getDefault())
REGEX_PATTERN.find(query)?.let { match -> REGEX_PATTERN.find(query)?.let { match ->
val pattern = match.groupValues[1] val pattern = match.groupValues[1]
val caseInsensitive = match.groupValues[2] == "i" val caseInsensitive = match.groupValues[2] == "i"
return regexScore(item.name ?: "", pattern, caseInsensitive) + typeBonus(item.type) return regexScore(name, pattern, caseInsensitive) + typeBonus(type)
} }
return when { return when {
@ -26,7 +32,7 @@ object SearchRelevance {
name.contains(" $q") -> 200 name.contains(" $q") -> 200
name.contains(q) -> 300 name.contains(q) -> 300
else -> fuzzyScore(name, q) else -> fuzzyScore(name, q)
} + typeBonus(item.type) } + typeBonus(type)
} }
private fun regexScore( private fun regexScore(