mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/customize-home
This commit is contained in:
commit
034cf5d599
17 changed files with 587 additions and 305 deletions
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue