Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-04 13:55:58 -05:00
commit 034cf5d599
No known key found for this signature in database
17 changed files with 587 additions and 305 deletions

View file

@ -1,12 +1,17 @@
<!-- By submitting this pull request, you acknowledge that you have read the [contributing guide](https://github.com/damontecres/Wholphin/blob/main/CONTRIBUTING.md, including the AI/LLM policy, and [developer's guide](https://github.com/damontecres/Wholphin/blob/main/DEVELOPMENT.md) -->
## Description
<!-- Describe the changes in detail -->
### Related issues
<!-- If this is a new feature or a change, there must be a discussion in an issue first, then reference the issue here -->
<!-- If this is a new feature or a change, there must be a discussion in an issue first, reference the issue here -->
<!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce -->
### Screenshots
### Testing
<!-- Describe how this change was tested and on what device(s) -->
## Screenshots
<!-- Please include screenshots if the PR alters any UI elements -->
### AI/LLM usage
<!-- If you used any AI/LLM assistance, please describe where in the code and how you tested it -->
## AI or LLM usage
<!-- If you used any AI or LLM assistance, please list where in the code and how you tested it -->

View file

@ -23,3 +23,10 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any
Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features.
See the [developer's guide](./DEVELOPMENT.md) for more information.
#### Policy for AI or LLMs
Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words.
At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance.

View file

@ -4,7 +4,7 @@
Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin.
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.
<p align="center">
<a href="https://github.com/damontecres/Wholphin/releases">

View file

@ -66,9 +66,12 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -126,19 +129,12 @@ class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
}
refreshRateService.refreshRateMode.observe(this) { modeId ->
// Listen for refresh rate changes
val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
}
}
viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) {
window?.setFlags(
@ -384,6 +380,16 @@ class MainActivity : AppCompatActivity() {
}
}
fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) {
val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
}
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
@ -391,6 +397,9 @@ class MainActivity : AppCompatActivity() {
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
lateinit var instance: MainActivity
private set
}
}

View file

@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
DecadeFilter,
)
val DefaultPlaylistItemsOptions =
listOf(
PlayedFilter,
FavoriteFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
sealed interface ItemFilterBy<T> {
@get:StringRes
val stringRes: Int

View file

@ -17,7 +17,6 @@ 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.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
@ -34,7 +33,6 @@ import org.jellyfin.sdk.model.api.PlaylistUserPermissions
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject
@ -79,19 +77,22 @@ class PlaylistCreator
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
shuffled: Boolean,
sortAndDirection: SortAndDirection,
filter: GetItemsFilter,
): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
filter.applyTo(
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
parentId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction),
),
)
var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
if (shuffled) {
items = items.shuffled()
}
val items = GetItemsRequestHandler.execute(api, request).content.items
return Playlist(items.convertAndAddParts(), 0)
}
@ -206,9 +207,18 @@ class PlaylistCreator
BaseItemKind.PLAYLIST -> {
PlaylistCreationResult.Success(
createFromPlaylistId(
item.id,
startIndex,
shuffled,
playlistId = item.id,
startIndex = startIndex,
sortAndDirection =
if (shuffled) {
SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING)
} else {
sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
)
},
filter = filter,
),
)
}

View file

@ -6,17 +6,17 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Display
import androidx.lifecycle.LiveData
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
@ -30,7 +30,6 @@ class RefreshRateService
) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode
val supportedDisplayModes get() = display.supportedModes.orEmpty()
@ -44,9 +43,6 @@ class RefreshRateService
)
}
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
val refreshRateMode: LiveData<Int> = _refreshRateMode
/**
* Find the best display mode for the given stream and signal to change to it
*/
@ -54,10 +50,10 @@ class RefreshRateService
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
) {
) = withContext(Dispatchers.IO) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
return
return@withContext
}
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
@ -67,7 +63,7 @@ class RefreshRateService
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
return
return@withContext
}
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode =
@ -86,14 +82,20 @@ class RefreshRateService
listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
)
_refreshRateMode.setValueOnMain(targetMode.modeId)
try {
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
MainActivity.instance.changeDisplayMode(targetMode.modeId)
val result =
withTimeoutOrNull(5.seconds) {
listener.deferred.await()
}
if (result == null) {
Timber.w("Timed out waiting for display change")
showToast(context, "Refresh rate switch is taking a long time")
}
} catch (ex: InterruptedException) {
} catch (ex: Exception) {
Timber.w(ex, "Exception waiting for refresh rate switch")
} finally {
displayManager.unregisterDisplayListener(listener)
}
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
val isSeamless =
@ -110,7 +112,6 @@ class RefreshRateService
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
delay(2.seconds)
}
displayManager.unregisterDisplayListener(listener)
}
}
@ -118,13 +119,13 @@ class RefreshRateService
* Reset the display mode to the original
*/
fun resetRefreshRate() {
_refreshRateMode.value = originalMode.modeId
MainActivity.instance.changeDisplayMode(0)
}
private class Listener(
val displayId: Int,
) : DisplayManager.DisplayListener {
val latch = CountDownLatch(1)
val deferred = CompletableDeferred<Unit>()
override fun onDisplayAdded(displayId: Int) {
}
@ -132,7 +133,7 @@ class RefreshRateService
override fun onDisplayChanged(displayId: Int) {
if (displayId == this.displayId) {
Timber.v("Got display change for $displayId")
latch.countDown()
deferred.complete(Unit)
}
}

View file

@ -34,6 +34,7 @@ fun ChapterRow(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = rememberLazyListState(),
@ -41,7 +42,6 @@ fun ChapterRow(
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(),
) {

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -57,6 +58,7 @@ fun <T> ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = state,

View file

@ -46,6 +46,7 @@ fun PersonRow(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = rememberLazyListState(),
@ -53,7 +54,6 @@ fun PersonRow(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(firstFocus),
) {

View file

@ -47,18 +47,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi
import org.jellyfin.sdk.api.client.extensions.yearsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.TreeSet
import java.util.UUID
import kotlin.time.Duration
@ -391,79 +379,12 @@ class CollectionFolderViewModel
}
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
try {
when (filterOption) {
GenreFilter -> {
api.genresApi
.getGenres(
parentId = itemUuid,
userId = serverRepository.currentUser.value?.id,
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
FavoriteFilter,
PlayedFilter,
-> {
listOf(
FilterValueOption("True", null),
FilterValueOption("False", null),
)
}
OfficialRatingFilter -> {
api.localizationApi.getParentalRatings().content.map {
FilterValueOption(it.name ?: "", it.value)
}
}
VideoTypeFilter -> {
FilterVideoType.entries.map {
FilterValueOption(it.readable, it)
}
}
YearFilter -> {
api.yearsApi
.getYears(
parentId = itemUuid,
userId = serverRepository.currentUser.value?.id,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNull {
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
}
}
DecadeFilter -> {
val items = TreeSet<Int>()
api.yearsApi
.getYears(
parentId = itemUuid,
userId = serverRepository.currentUser.value?.id,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNullTo(items) {
it.name
?.toIntOrNull()
?.div(10)
?.times(10)
}
items.toList().sorted().map { FilterValueOption("$it's", it) }
}
CommunityRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception get filter value options for $filterOption")
listOf()
}
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
filterOption,
)
suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) {

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
@ -22,7 +23,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
@ -61,26 +71,39 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
@ -92,9 +115,22 @@ class PlaylistViewModel
api: ApiClient,
val navigationManager: NavigationManager,
private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf())
val filterAndSort =
MutableStateFlow<FilterAndSort>(
FilterAndSort(
filter = GetItemsFilter(),
sortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
),
)
fun init(playlistId: UUID) {
loading.value = LoadingState.Loading
@ -103,19 +139,93 @@ class PlaylistViewModel
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) {
val playlist = fetchItem(playlistId)
val request =
GetPlaylistItemsRequest(
playlistId = playlist.id,
fields = DefaultItemFields,
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
)
val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init()
withContext(Dispatchers.Main) {
items.value = pager
loading.value = LoadingState.Success
loadItems(filter, sortAndDirection)
}
}
fun loadItems(
filter: GetItemsFilter,
sortAndDirection: SortAndDirection,
) {
viewModelScope.launchIO {
backdropService.clearBackdrop()
loading.setValueOnMain(LoadingState.Loading)
this@PlaylistViewModel.filterAndSort.update {
FilterAndSort(filter, sortAndDirection)
}
serverRepository.currentUser.value?.let { user ->
val playlistId = item.value!!.id
viewModelScope.launchIO {
val libraryDisplayInfo =
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
filter = filter,
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
)
?: LibraryDisplayInfo(
userId = user.rowId,
itemId = itemId,
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
filter = filter,
viewOptions = null,
)
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
val request =
filter.applyTo(
GetItemsRequest(
parentId = playlistId,
userId = user.id,
fields = DefaultItemFields,
sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction),
),
)
try {
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
).init()
withContext(Dispatchers.Main) {
items.value = pager
loading.value = LoadingState.Success
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching playlist %s", itemId)
withContext(Dispatchers.Main) {
items.value = listOf()
loading.value = LoadingState.Error(ex)
}
}
}
}
}
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemUuid,
filterOption,
)
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
@ -123,6 +233,12 @@ class PlaylistViewModel
}
}
@Immutable
data class FilterAndSort(
val filter: GetItemsFilter,
val sortAndDirection: SortAndDirection,
)
@Composable
fun PlaylistDetails(
destination: Destination.MediaItem,
@ -136,78 +252,75 @@ fun PlaylistDetails(
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val playlist by viewModel.item.observeAsState(null)
val items by viewModel.items.observeAsState(listOf())
val filterAndSort by viewModel.filterAndSort.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val st = loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
val goToString = stringResource(R.string.go_to)
val playFromHereString = stringResource(R.string.play_from_here)
LoadingState.Pending, LoadingState.Loading -> {
LoadingPage(modifier)
}
LoadingState.Success -> {
playlist?.let {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
PlaylistDetailsContent(
playlist = it,
items = items,
focusRequester = focusRequester,
onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it.id,
startIndex = index,
shuffle = false,
),
)
},
onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it.id,
startIndex = 0,
shuffle = shuffle,
),
)
},
onLongClickIndex = { index, item ->
longClickDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
listOf(
DialogItem(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
viewModel.navigationManager.navigateTo(item.destination())
},
DialogItem(
context.getString(R.string.play_from_here),
Icons.Default.PlayArrow,
) {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it.id,
startIndex = index,
shuffle = false,
),
)
},
PlaylistDetailsContent(
loadingState = loading,
playlist = playlist,
items = items,
onChangeBackdrop = viewModel::updateBackdrop,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = index,
shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
},
onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = 0,
shuffle = shuffle,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
},
onLongClickIndex = { index, item ->
longClickDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
listOf(
DialogItem(
goToString,
Icons.Default.ArrowForward,
) {
viewModel.navigationManager.navigateTo(item.destination())
},
DialogItem(
playFromHereString,
Icons.Default.PlayArrow,
) {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = index,
shuffle = false,
filter = filterAndSort.filter,
sortAndDirection = filterAndSort.sortAndDirection,
),
)
},
modifier = modifier,
)
},
),
)
}
}
}
},
filterAndSort = filterAndSort,
onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier,
)
longClickDialog?.let { params ->
DialogPopup(
params = params,
@ -218,14 +331,17 @@ fun PlaylistDetails(
@Composable
fun PlaylistDetailsContent(
playlist: BaseItem,
playlist: BaseItem?,
items: List<BaseItem?>,
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
loadingState: LoadingState,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
@ -234,6 +350,12 @@ fun PlaylistDetailsContent(
LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop)
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(loadingState) {
if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) {
focusRequester.tryRequestFocus()
}
}
val playButtonFocusRequester = remember { FocusRequester() }
@ -248,83 +370,123 @@ fun PlaylistDetailsContent(
.fillMaxSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(32.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
.padding(horizontal = 16.dp)
.padding(horizontal = 8.dp)
.fillMaxWidth(),
) {
PlaylistDetailsHeader(
focusedItem = focusedItem,
onClickPlay = onClickPlay,
playButtonFocusRequester = playButtonFocusRequester,
focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() },
filterAndSort = filterAndSort,
onFilterAndSortChange = onFilterAndSortChange,
getPossibleFilterValues = getPossibleFilterValues,
modifier =
Modifier
.padding(start = 16.dp)
.padding(start = 16.dp, top = 80.dp)
.fillMaxWidth(.25f),
)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = playlist.name ?: stringResource(R.string.playlist),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(bottom = 32.dp)
.fillMaxHeight()
// .fillMaxWidth(.8f)
.weight(1f)
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(1.dp)
.copy(alpha = .75f),
shape = RoundedCornerShape(16.dp),
).focusRequester(focusRequester)
.focusGroup()
.focusRestorer(focus),
) {
itemsIndexed(items) { index, item ->
PlaylistItem(
item = item,
index = index,
onClick = {
savedIndex = index
item?.let {
onClickIndex.invoke(index, item)
}
},
onLongClick = {
savedIndex = index
item?.let {
onLongClickIndex.invoke(index, item)
}
},
modifier =
Modifier
.height(80.dp)
.ifElse(
index == savedIndex,
Modifier.focusRequester(focus),
).onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
}.focusProperties {
left = playButtonFocusRequester
previous = playButtonFocusRequester
},
when (loadingState) {
is LoadingState.Error -> {
ErrorMessage(loadingState, modifier)
}
LoadingState.Pending, LoadingState.Loading -> {
LoadingPage(modifier)
}
LoadingState.Success -> {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = playlist?.name ?: stringResource(R.string.playlist),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
if (items.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(bottom = 32.dp)
.fillMaxHeight()
// .fillMaxWidth(.8f)
.weight(1f)
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(1.dp)
.copy(alpha = .75f),
shape = RoundedCornerShape(16.dp),
).focusProperties {
onExit = {
playButtonFocusRequester.tryRequestFocus()
}
}.focusRequester(focusRequester)
.focusGroup()
.focusRestorer(focus),
) {
itemsIndexed(items) { index, item ->
PlaylistItem(
item = item,
index = index,
onClick = {
savedIndex = index
item?.let {
onClickIndex.invoke(index, item)
}
},
onLongClick = {
savedIndex = index
item?.let {
onLongClickIndex.invoke(index, item)
}
},
modifier =
Modifier
.height(80.dp)
.ifElse(
index == savedIndex,
Modifier.focusRequester(focus),
).onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
}.focusProperties {
left = playButtonFocusRequester
previous = playButtonFocusRequester
},
)
}
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.no_results),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier =
Modifier
.focusProperties {
onExit = {
playButtonFocusRequester.tryRequestFocus()
}
}.focusRequester(focusRequester)
.focusable(),
)
}
}
}
}
}
@ -338,6 +500,10 @@ fun PlaylistDetailsHeader(
focusedItem: BaseItem?,
onClickPlay: (shuffle: Boolean) -> Unit,
playButtonFocusRequester: FocusRequester,
focusRequester: FocusRequester,
filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
modifier: Modifier = Modifier,
) {
Column(
@ -346,13 +512,14 @@ fun PlaylistDetailsHeader(
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.focusRequester(playButtonFocusRequester),
modifier = Modifier,
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) },
modifier = Modifier.focusRequester(playButtonFocusRequester),
)
ExpandableFaButton(
title = R.string.shuffle,
@ -360,16 +527,45 @@ fun PlaylistDetailsHeader(
onClick = { onClickPlay.invoke(true) },
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier,
) {
FilterByButton(
filterOptions = DefaultPlaylistItemsOptions,
current = filterAndSort.filter,
onFilterChange = {
onFilterAndSortChange.invoke(
it,
filterAndSort.sortAndDirection,
)
},
getPossibleValues = getPossibleFilterValues,
modifier = Modifier.focusRequester(focusRequester),
)
SortByButton(
sortOptions = BoxSetSortOptions,
current = filterAndSort.sortAndDirection,
onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) },
)
}
Text(
text = focusedItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge,
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
style = MaterialTheme.typography.titleMedium,
)
if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) {
Text(
text = formatDateTime(focusedItem.data.premiereDate!!),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
)
}
OverviewText(
overview = focusedItem?.data?.overview ?: "",
maxLines = 10,

View file

@ -410,7 +410,7 @@ fun MovieDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@ -430,7 +430,7 @@ fun MovieDetailsContent(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp),
.padding(top = 40.dp, bottom = 16.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,

View file

@ -46,46 +46,47 @@ fun MovieDetailsHeader(
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
// Title
Text(
text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 4.dp
QuickDetails(
movie.ui.quickDetails,
movie.timeRemainingOrRuntime,
Modifier.padding(bottom = padding),
Modifier.padding(start = 8.dp),
)
dto.genres?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
GenreText(it, Modifier.padding(start = 8.dp))
}
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = movie.data.mediaSourceCount ?: 0,
modifier = Modifier.padding(bottom = padding),
modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp),
)
dto.taglines?.firstOrNull()?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
modifier = Modifier,
modifier = Modifier.padding(start = 8.dp),
)
}
@ -120,6 +121,7 @@ fun MovieDetailsHeader(
text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -332,12 +333,12 @@ fun SeriesDetailsContent(
Column(
modifier =
Modifier
.padding(16.dp)
.padding(horizontal = 24.dp, vertical = 16.dp)
.fillMaxSize(),
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier,
) {
item {
@ -354,7 +355,7 @@ fun SeriesDetailsContent(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.padding(start = 16.dp)
.padding(start = 8.dp)
.focusRequester(focusRequesters[HEADER_ROW])
.focusRestorer(playFocusRequester)
.focusGroup()
@ -599,23 +600,27 @@ fun SeriesDetailsHeader(
val scope = rememberCoroutineScope()
val dto = series.data
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = series.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(series.ui.quickDetails, null)
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
dto.genres?.letNotEmpty {
GenreText(it)
GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp))
}
dto.overview?.let { overview ->
OverviewText(

View file

@ -248,7 +248,7 @@ fun HomePageContent(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding =
PaddingValues(
start = 16.dp,
start = 24.dp,
end = 16.dp,
top = 0.dp,
bottom = Cards.height2x3,
@ -467,7 +467,7 @@ fun HomePageHeader(
subtitle: String?,
overview: String?,
overviewTwoLines: Boolean,
quickDetails: AnnotatedString,
quickDetails: AnnotatedString?,
timeRemaining: Duration?,
modifier: Modifier = Modifier,
) {
@ -495,7 +495,7 @@ fun HomePageHeader(
subtitle?.let {
EpisodeName(it)
}
QuickDetails(quickDetails, timeRemaining)
QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
val overviewModifier =
Modifier
.padding(0.dp)

View file

@ -0,0 +1,113 @@
package com.github.damontecres.wholphin.ui.util
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi
import org.jellyfin.sdk.api.client.extensions.yearsApi
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import timber.log.Timber
import java.util.TreeSet
import java.util.UUID
object FilterUtils {
/**
* Gets the possible values for a filter
*
* For example, the possible genres in the parent ID
*/
suspend fun getFilterOptionValues(
api: ApiClient,
userId: UUID?,
parentId: UUID?,
filterOption: ItemFilterBy<*>,
): List<FilterValueOption> =
withContext(Dispatchers.IO) {
try {
when (filterOption) {
GenreFilter -> {
api.genresApi
.getGenres(
parentId = parentId,
userId = userId,
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
FavoriteFilter,
PlayedFilter,
-> {
listOf(
FilterValueOption("True", null),
FilterValueOption("False", null),
)
}
OfficialRatingFilter -> {
api.localizationApi.getParentalRatings().content.map {
FilterValueOption(it.name ?: "", it.value)
}
}
VideoTypeFilter -> {
FilterVideoType.entries.map {
FilterValueOption(it.readable, it)
}
}
YearFilter -> {
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNull {
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
}
}
DecadeFilter -> {
val items = TreeSet<Int>()
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNullTo(items) {
it.name
?.toIntOrNull()
?.div(10)
?.times(10)
}
items.toList().sorted().map { FilterValueOption("$it's", it) }
}
CommunityRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception get filter value options for $filterOption")
listOf()
}
}
}