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 ## Description
<!-- Describe the changes in detail --> <!-- Describe the changes in detail -->
### Related issues ### 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 --> <!-- 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 --> <!-- Please include screenshots if the PR alters any UI elements -->
### AI/LLM usage ## AI or LLM usage
<!-- If you used any AI/LLM assistance, please describe where in the code and how you tested it --> <!-- 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. 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. 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. 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"> <p align="center">
<a href="https://github.com/damontecres/Wholphin/releases"> <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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -126,19 +129,12 @@ class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) { if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false) 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 -> viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) { if (user?.hasPin == true) {
window?.setFlags( 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 { companion object {
const val INTENT_ITEM_ID = "itemId" const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType" const val INTENT_ITEM_TYPE = "itemType"
@ -391,6 +397,9 @@ class MainActivity : AppCompatActivity() {
const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId" const val INTENT_SEASON_ID = "seaId"
lateinit var instance: MainActivity
private set
} }
} }

View file

@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
DecadeFilter, DecadeFilter,
) )
val DefaultPlaylistItemsOptions =
listOf(
PlayedFilter,
FavoriteFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
sealed interface ItemFilterBy<T> { sealed interface ItemFilterBy<T> {
@get:StringRes @get:StringRes
val stringRes: Int 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.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.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope 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.SortOrder
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -79,19 +77,22 @@ class PlaylistCreator
suspend fun createFromPlaylistId( suspend fun createFromPlaylistId(
playlistId: UUID, playlistId: UUID,
startIndex: Int?, startIndex: Int?,
shuffled: Boolean, sortAndDirection: SortAndDirection,
filter: GetItemsFilter,
): Playlist { ): Playlist {
val request = val request =
GetPlaylistItemsRequest( filter.applyTo(
playlistId = playlistId, GetItemsRequest(
fields = DefaultItemFields, userId = serverRepository.currentUser.value?.id,
startIndex = startIndex, parentId = playlistId,
limit = Playlist.MAX_SIZE, fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction),
),
) )
var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items val items = GetItemsRequestHandler.execute(api, request).content.items
if (shuffled) {
items = items.shuffled()
}
return Playlist(items.convertAndAddParts(), 0) return Playlist(items.convertAndAddParts(), 0)
} }
@ -206,9 +207,18 @@ class PlaylistCreator
BaseItemKind.PLAYLIST -> { BaseItemKind.PLAYLIST -> {
PlaylistCreationResult.Success( PlaylistCreationResult.Success(
createFromPlaylistId( createFromPlaylistId(
item.id, playlistId = item.id,
startIndex, startIndex = startIndex,
shuffled, 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.Handler
import android.os.Looper import android.os.Looper
import android.view.Display import android.view.Display
import androidx.lifecycle.LiveData import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay 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.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -30,7 +30,6 @@ class RefreshRateService
) { ) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode
val supportedDisplayModes get() = display.supportedModes.orEmpty() 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 * Find the best display mode for the given stream and signal to change to it
*/ */
@ -54,10 +50,10 @@ class RefreshRateService
stream: MediaStream, stream: MediaStream,
switchRefreshRate: Boolean, switchRefreshRate: Boolean,
switchResolution: Boolean, switchResolution: Boolean,
) { ) = withContext(Dispatchers.IO) {
if (!switchRefreshRate && !switchResolution) { if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution") Timber.v("Not switching either refresh rate nor resolution")
return return@withContext
} }
val currentDisplayMode = display.mode val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
@ -67,7 +63,7 @@ class RefreshRateService
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
if (width == null || height == null || frameRate == null) { if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) 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) Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode = val targetMode =
@ -86,14 +82,20 @@ class RefreshRateService
listener, listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()), Handler(Looper.myLooper() ?: Looper.getMainLooper()),
) )
_refreshRateMode.setValueOnMain(targetMode.modeId)
try { 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") Timber.w("Timed out waiting for display change")
showToast(context, "Refresh rate switch is taking a long time") 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") Timber.w(ex, "Exception waiting for refresh rate switch")
} finally {
displayManager.unregisterDisplayListener(listener)
} }
val targetRate = (targetMode.refreshRate * 1000).roundToInt() val targetRate = (targetMode.refreshRate * 1000).roundToInt()
val isSeamless = val isSeamless =
@ -110,7 +112,6 @@ class RefreshRateService
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate) // Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
delay(2.seconds) delay(2.seconds)
} }
displayManager.unregisterDisplayListener(listener)
} }
} }
@ -118,13 +119,13 @@ class RefreshRateService
* Reset the display mode to the original * Reset the display mode to the original
*/ */
fun resetRefreshRate() { fun resetRefreshRate() {
_refreshRateMode.value = originalMode.modeId MainActivity.instance.changeDisplayMode(0)
} }
private class Listener( private class Listener(
val displayId: Int, val displayId: Int,
) : DisplayManager.DisplayListener { ) : DisplayManager.DisplayListener {
val latch = CountDownLatch(1) val deferred = CompletableDeferred<Unit>()
override fun onDisplayAdded(displayId: Int) { override fun onDisplayAdded(displayId: Int) {
} }
@ -132,7 +133,7 @@ class RefreshRateService
override fun onDisplayChanged(displayId: Int) { override fun onDisplayChanged(displayId: Int) {
if (displayId == this.displayId) { if (displayId == this.displayId) {
Timber.v("Got display change for $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), text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
) )
LazyRow( LazyRow(
state = rememberLazyListState(), state = rememberLazyListState(),
@ -41,7 +42,6 @@ fun ChapterRow(
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxWidth() .fillMaxWidth()
.focusRestorer(), .focusRestorer(),
) { ) {

View file

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

View file

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

View file

@ -47,18 +47,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository 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.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption 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.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.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType 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.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.TreeSet
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -391,79 +379,12 @@ class CollectionFolderViewModel
} }
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
try { FilterUtils.getFilterOptionValues(
when (filterOption) { api,
GenreFilter -> { serverRepository.currentUser.value?.id,
api.genresApi itemUuid,
.getGenres( filterOption,
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()
}
suspend fun positionOfLetter(letter: Char): Int? = suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) { 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.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement 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.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R 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.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.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton 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.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText 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.enableMarquee
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes 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.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager 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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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 org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@ -92,9 +115,22 @@ class PlaylistViewModel
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf()) val items = MutableLiveData<List<BaseItem?>>(listOf())
val filterAndSort =
MutableStateFlow<FilterAndSort>(
FilterAndSort(
filter = GetItemsFilter(),
sortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
),
)
fun init(playlistId: UUID) { fun init(playlistId: UUID) {
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
@ -103,19 +139,93 @@ class PlaylistViewModel
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) { ) {
val playlist = fetchItem(playlistId) val playlist = fetchItem(playlistId)
val request = val libraryDisplayInfo =
GetPlaylistItemsRequest( serverRepository.currentUser.value?.let { user ->
playlistId = playlist.id, libraryDisplayInfoDao.getItem(user, itemId)
fields = DefaultItemFields, }
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
) )
val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() loadItems(filter, sortAndDirection)
withContext(Dispatchers.Main) { }
items.value = pager }
loading.value = LoadingState.Success
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) { fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO { viewModelScope.launchIO {
backdropService.submit(item) backdropService.submit(item)
@ -123,6 +233,12 @@ class PlaylistViewModel
} }
} }
@Immutable
data class FilterAndSort(
val filter: GetItemsFilter,
val sortAndDirection: SortAndDirection,
)
@Composable @Composable
fun PlaylistDetails( fun PlaylistDetails(
destination: Destination.MediaItem, destination: Destination.MediaItem,
@ -136,78 +252,75 @@ fun PlaylistDetails(
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val playlist by viewModel.item.observeAsState(null) val playlist by viewModel.item.observeAsState(null)
val items by viewModel.items.observeAsState(listOf()) val items by viewModel.items.observeAsState(listOf())
val filterAndSort by viewModel.filterAndSort.collectAsState()
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val st = loading) { val goToString = stringResource(R.string.go_to)
is LoadingState.Error -> { val playFromHereString = stringResource(R.string.play_from_here)
ErrorMessage(st, modifier)
}
LoadingState.Pending, LoadingState.Loading -> { PlaylistDetailsContent(
LoadingPage(modifier) loadingState = loading,
} playlist = playlist,
items = items,
LoadingState.Success -> { onChangeBackdrop = viewModel::updateBackdrop,
playlist?.let { onClickIndex = { index, _ ->
val focusRequester = remember { FocusRequester() } viewModel.navigationManager.navigateTo(
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Destination.PlaybackList(
PlaylistDetailsContent( itemId = destination.itemId,
playlist = it, startIndex = index,
items = items, shuffle = false,
focusRequester = focusRequester, filter = filterAndSort.filter,
onChangeBackdrop = viewModel::updateBackdrop, sortAndDirection = filterAndSort.sortAndDirection,
onClickIndex = { index, _ -> ),
viewModel.navigationManager.navigateTo( )
Destination.PlaybackList( },
itemId = it.id, onClickPlay = { shuffle ->
startIndex = index, viewModel.navigationManager.navigateTo(
shuffle = false, Destination.PlaybackList(
), itemId = destination.itemId,
) startIndex = 0,
}, shuffle = shuffle,
onClickPlay = { shuffle -> filter = filterAndSort.filter,
viewModel.navigationManager.navigateTo( sortAndDirection = filterAndSort.sortAndDirection,
Destination.PlaybackList( ),
itemId = it.id, )
startIndex = 0, },
shuffle = shuffle, onLongClickIndex = { index, item ->
), longClickDialog =
) DialogParams(
}, fromLongClick = true,
onLongClickIndex = { index, item -> title = item.name ?: "",
longClickDialog = items =
DialogParams( listOf(
fromLongClick = true, DialogItem(
title = item.name ?: "", goToString,
items = Icons.Default.ArrowForward,
listOf( ) {
DialogItem( viewModel.navigationManager.navigateTo(item.destination())
context.getString(R.string.go_to), },
Icons.Default.ArrowForward, DialogItem(
) { playFromHereString,
viewModel.navigationManager.navigateTo(item.destination()) Icons.Default.PlayArrow,
}, ) {
DialogItem( viewModel.navigationManager.navigateTo(
context.getString(R.string.play_from_here), Destination.PlaybackList(
Icons.Default.PlayArrow, itemId = destination.itemId,
) { startIndex = index,
viewModel.navigationManager.navigateTo( shuffle = false,
Destination.PlaybackList( filter = filterAndSort.filter,
itemId = it.id, sortAndDirection = filterAndSort.sortAndDirection,
startIndex = index,
shuffle = false,
),
)
},
), ),
) )
}, },
modifier = modifier, ),
) )
} },
} filterAndSort = filterAndSort,
} onFilterAndSortChange = viewModel::loadItems,
getPossibleFilterValues = viewModel::getFilterOptionValues,
modifier = modifier,
)
longClickDialog?.let { params -> longClickDialog?.let { params ->
DialogPopup( DialogPopup(
params = params, params = params,
@ -218,14 +331,17 @@ fun PlaylistDetails(
@Composable @Composable
fun PlaylistDetailsContent( fun PlaylistDetailsContent(
playlist: BaseItem, playlist: BaseItem?,
items: List<BaseItem?>, items: List<BaseItem?>,
onClickIndex: (Int, BaseItem) -> Unit, onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit,
filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
loadingState: LoadingState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) { ) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) } var savedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by remember { mutableIntStateOf(savedIndex) } var focusedIndex by remember { mutableIntStateOf(savedIndex) }
@ -234,6 +350,12 @@ fun PlaylistDetailsContent(
LaunchedEffect(focusedItem) { LaunchedEffect(focusedItem) {
focusedItem?.let(onChangeBackdrop) focusedItem?.let(onChangeBackdrop)
} }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(loadingState) {
if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) {
focusRequester.tryRequestFocus()
}
}
val playButtonFocusRequester = remember { FocusRequester() } val playButtonFocusRequester = remember { FocusRequester() }
@ -248,83 +370,123 @@ fun PlaylistDetailsContent(
.fillMaxSize(), .fillMaxSize(),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = modifier =
Modifier Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 8.dp)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
PlaylistDetailsHeader( PlaylistDetailsHeader(
focusedItem = focusedItem, focusedItem = focusedItem,
onClickPlay = onClickPlay, onClickPlay = onClickPlay,
playButtonFocusRequester = playButtonFocusRequester, playButtonFocusRequester = playButtonFocusRequester,
focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() },
filterAndSort = filterAndSort,
onFilterAndSortChange = onFilterAndSortChange,
getPossibleFilterValues = getPossibleFilterValues,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp, top = 80.dp)
.fillMaxWidth(.25f), .fillMaxWidth(.25f),
) )
Column( when (loadingState) {
modifier = is LoadingState.Error -> {
Modifier ErrorMessage(loadingState, modifier)
.fillMaxWidth() }
.padding(horizontal = 16.dp),
) { LoadingState.Pending, LoadingState.Loading -> {
Text( LoadingPage(modifier)
text = playlist.name ?: stringResource(R.string.playlist), }
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium, LoadingState.Success -> {
textAlign = TextAlign.Center, Column(
modifier = Modifier.fillMaxWidth(), modifier =
) Modifier
LazyColumn( .fillMaxWidth()
contentPadding = PaddingValues(8.dp), .padding(horizontal = 16.dp),
modifier = ) {
Modifier Text(
.padding(bottom = 32.dp) text = playlist?.name ?: stringResource(R.string.playlist),
.fillMaxHeight() color = MaterialTheme.colorScheme.onSurface,
// .fillMaxWidth(.8f) style = MaterialTheme.typography.displayMedium,
.weight(1f) textAlign = TextAlign.Center,
.background( modifier = Modifier.fillMaxWidth(),
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
},
) )
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?, focusedItem: BaseItem?,
onClickPlay: (shuffle: Boolean) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit,
playButtonFocusRequester: FocusRequester, playButtonFocusRequester: FocusRequester,
focusRequester: FocusRequester,
filterAndSort: FilterAndSort,
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -346,13 +512,14 @@ fun PlaylistDetailsHeader(
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.focusRequester(playButtonFocusRequester), modifier = Modifier,
) { ) {
ExpandablePlayButton( ExpandablePlayButton(
title = R.string.play, title = R.string.play,
resume = Duration.ZERO, resume = Duration.ZERO,
icon = Icons.Default.PlayArrow, icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) }, onClick = { onClickPlay.invoke(false) },
modifier = Modifier.focusRequester(playButtonFocusRequester),
) )
ExpandableFaButton( ExpandableFaButton(
title = R.string.shuffle, title = R.string.shuffle,
@ -360,16 +527,45 @@ fun PlaylistDetailsHeader(
onClick = { onClickPlay.invoke(true) }, 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(
text = focusedItem?.title ?: "", text = focusedItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineSmall,
) )
Text( Text(
text = focusedItem?.subtitle ?: "", text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface, 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( OverviewText(
overview = focusedItem?.data?.overview ?: "", overview = focusedItem?.data?.overview ?: "",
maxLines = 10, maxLines = 10,

View file

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

View file

@ -46,46 +46,47 @@ fun MovieDetailsHeader(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
// Title // Title
Text( Text(
text = movie.name ?: "", text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
fontWeight = FontWeight.SemiBold, maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f), modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
) )
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f), modifier = Modifier.fillMaxWidth(.60f),
) { ) {
val padding = 4.dp
QuickDetails( QuickDetails(
movie.ui.quickDetails, movie.ui.quickDetails,
movie.timeRemainingOrRuntime, movie.timeRemainingOrRuntime,
Modifier.padding(bottom = padding), Modifier.padding(start = 8.dp),
) )
dto.genres?.letNotEmpty { dto.genres?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding)) GenreText(it, Modifier.padding(start = 8.dp))
} }
VideoStreamDetails( VideoStreamDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
numberOfVersions = movie.data.mediaSourceCount ?: 0, 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 -> dto.taglines?.firstOrNull()?.let { tagline ->
Text( Text(
text = tagline, text = tagline,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier, modifier = Modifier.padding(start = 8.dp),
) )
} }
@ -120,6 +121,7 @@ fun MovieDetailsHeader(
text = stringResource(R.string.directed_by, it), text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -332,12 +333,12 @@ fun SeriesDetailsContent(
Column( Column(
modifier = modifier =
Modifier Modifier
.padding(16.dp) .padding(horizontal = 24.dp, vertical = 16.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp), contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier, modifier = Modifier,
) { ) {
item { item {
@ -354,7 +355,7 @@ fun SeriesDetailsContent(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 8.dp)
.focusRequester(focusRequesters[HEADER_ROW]) .focusRequester(focusRequesters[HEADER_ROW])
.focusRestorer(playFocusRequester) .focusRestorer(playFocusRequester)
.focusGroup() .focusGroup()
@ -599,23 +600,27 @@ fun SeriesDetailsHeader(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val dto = series.data val dto = series.data
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = series.name ?: stringResource(R.string.unknown), text = series.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onBackground,
maxLines = 2, style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f), modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
) )
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f), modifier = Modifier.fillMaxWidth(.60f),
) { ) {
QuickDetails(series.ui.quickDetails, null) QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
dto.genres?.letNotEmpty { dto.genres?.letNotEmpty {
GenreText(it) GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp))
} }
dto.overview?.let { overview -> dto.overview?.let { overview ->
OverviewText( OverviewText(

View file

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