diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index eae1ff9c..3043d994 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,12 +1,17 @@
+
+
## Description
### Related issues
-
+
-### Screenshots
+### Testing
+
+
+## Screenshots
-### AI/LLM usage
-
+## AI or LLM usage
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9c8484fa..3eee4ee9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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.
diff --git a/README.md b/README.md
index 50f01c12..b4121cb9 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
index 0f8b7ee4..af88392e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -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
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
index 92dbecf3..fec0fb8d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt
@@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
DecadeFilter,
)
+val DefaultPlaylistItemsOptions =
+ listOf(
+ PlayedFilter,
+ FavoriteFilter,
+ CommunityRatingFilter,
+ OfficialRatingFilter,
+ VideoTypeFilter,
+ YearFilter,
+ DecadeFilter,
+ )
+
sealed interface ItemFilterBy {
@get:StringRes
val stringRes: Int
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
index 824342d5..4231d9b9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt
@@ -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,
),
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
index be6e461e..d961d4c5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt
@@ -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(originalMode.modeId)
- val refreshRateMode: LiveData = _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()
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)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt
index cf799c54..b1e64a2a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt
@@ -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(),
) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
index 83166fa7..314a7e1a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt
@@ -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 ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = state,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt
index 2895db83..140cc2fd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt
@@ -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),
) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index 3fa8ffa5..29991126 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -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 =
- 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()
- 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) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
index 5bf996a3..7e552525 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
@@ -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.Pending)
val items = MutableLiveData>(listOf())
+ val filterAndSort =
+ MutableStateFlow(
+ 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 =
+ 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(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,
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,
+ 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,
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,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
index 58e7762d..b4c4584d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt
@@ -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,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
index 60451804..4d5f72d7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt
@@ -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),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
index 305c6451..1db5bb61 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt
@@ -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(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
index d3d1a639..44269360 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt
@@ -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)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt
new file mode 100644
index 00000000..5358a14e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt
@@ -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 =
+ 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()
+ 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()
+ }
+ }
+}