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
|
## Description
|
||||||
<!-- Describe the changes in detail -->
|
<!-- Describe the changes in detail -->
|
||||||
|
|
||||||
### Related issues
|
### Related issues
|
||||||
<!-- If this is a new feature or a change, there must be a discussion in an issue first, then reference the issue here -->
|
<!-- If this is a new feature or a change, there must be a discussion in an issue first, reference the issue here -->
|
||||||
<!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce -->
|
<!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce -->
|
||||||
|
|
||||||
### Screenshots
|
### Testing
|
||||||
|
<!-- Describe how this change was tested and on what device(s) -->
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
<!-- Please include screenshots if the PR alters any UI elements -->
|
<!-- Please include screenshots if the PR alters any UI elements -->
|
||||||
|
|
||||||
### AI/LLM usage
|
## AI or LLM usage
|
||||||
<!-- If you used any AI/LLM assistance, please describe where in the code and how you tested it -->
|
<!-- If you used any AI or LLM assistance, please list where in the code and how you tested it -->
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,10 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any
|
||||||
Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features.
|
Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features.
|
||||||
|
|
||||||
See the [developer's guide](./DEVELOPMENT.md) for more information.
|
See the [developer's guide](./DEVELOPMENT.md) for more information.
|
||||||
|
|
||||||
|
|
||||||
|
#### Policy for AI or LLMs
|
||||||
|
|
||||||
|
Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words.
|
||||||
|
|
||||||
|
At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin.
|
Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin.
|
||||||
|
|
||||||
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental).
|
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/damontecres/Wholphin/releases">
|
<a href="https://github.com/damontecres/Wholphin/releases">
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,12 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -126,19 +129,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
instance = this
|
||||||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||||
lifecycle.addObserver(playbackLifecycleObserver)
|
lifecycle.addObserver(playbackLifecycleObserver)
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
appUpgradeHandler.copySubfont(false)
|
appUpgradeHandler.copySubfont(false)
|
||||||
}
|
}
|
||||||
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
|
||||||
// Listen for refresh rate changes
|
|
||||||
val attrs = window.attributes
|
|
||||||
if (attrs.preferredDisplayModeId != modeId) {
|
|
||||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
|
||||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||||
if (user?.hasPin == true) {
|
if (user?.hasPin == true) {
|
||||||
window?.setFlags(
|
window?.setFlags(
|
||||||
|
|
@ -384,6 +380,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeDisplayMode(modeId: Int) {
|
||||||
|
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
||||||
|
val attrs = window.attributes
|
||||||
|
if (attrs.preferredDisplayModeId != modeId) {
|
||||||
|
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||||
|
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val INTENT_ITEM_ID = "itemId"
|
const val INTENT_ITEM_ID = "itemId"
|
||||||
const val INTENT_ITEM_TYPE = "itemType"
|
const val INTENT_ITEM_TYPE = "itemType"
|
||||||
|
|
@ -391,6 +397,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
const val INTENT_EPISODE_NUMBER = "epNum"
|
const val INTENT_EPISODE_NUMBER = "epNum"
|
||||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||||
const val INTENT_SEASON_ID = "seaId"
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
|
||||||
|
lateinit var instance: MainActivity
|
||||||
|
private set
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions =
|
||||||
DecadeFilter,
|
DecadeFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val DefaultPlaylistItemsOptions =
|
||||||
|
listOf(
|
||||||
|
PlayedFilter,
|
||||||
|
FavoriteFilter,
|
||||||
|
CommunityRatingFilter,
|
||||||
|
OfficialRatingFilter,
|
||||||
|
VideoTypeFilter,
|
||||||
|
YearFilter,
|
||||||
|
DecadeFilter,
|
||||||
|
)
|
||||||
|
|
||||||
sealed interface ItemFilterBy<T> {
|
sealed interface ItemFilterBy<T> {
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val stringRes: Int
|
val stringRes: Int
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
|
||||||
import com.github.damontecres.wholphin.util.TransformList
|
import com.github.damontecres.wholphin.util.TransformList
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -34,7 +33,6 @@ import org.jellyfin.sdk.model.api.PlaylistUserPermissions
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -79,19 +77,22 @@ class PlaylistCreator
|
||||||
suspend fun createFromPlaylistId(
|
suspend fun createFromPlaylistId(
|
||||||
playlistId: UUID,
|
playlistId: UUID,
|
||||||
startIndex: Int?,
|
startIndex: Int?,
|
||||||
shuffled: Boolean,
|
sortAndDirection: SortAndDirection,
|
||||||
|
filter: GetItemsFilter,
|
||||||
): Playlist {
|
): Playlist {
|
||||||
val request =
|
val request =
|
||||||
GetPlaylistItemsRequest(
|
filter.applyTo(
|
||||||
playlistId = playlistId,
|
GetItemsRequest(
|
||||||
fields = DefaultItemFields,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
startIndex = startIndex,
|
parentId = playlistId,
|
||||||
limit = Playlist.MAX_SIZE,
|
fields = DefaultItemFields,
|
||||||
|
startIndex = startIndex,
|
||||||
|
limit = Playlist.MAX_SIZE,
|
||||||
|
sortBy = listOf(sortAndDirection.sort),
|
||||||
|
sortOrder = listOf(sortAndDirection.direction),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
|
val items = GetItemsRequestHandler.execute(api, request).content.items
|
||||||
if (shuffled) {
|
|
||||||
items = items.shuffled()
|
|
||||||
}
|
|
||||||
return Playlist(items.convertAndAddParts(), 0)
|
return Playlist(items.convertAndAddParts(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,9 +207,18 @@ class PlaylistCreator
|
||||||
BaseItemKind.PLAYLIST -> {
|
BaseItemKind.PLAYLIST -> {
|
||||||
PlaylistCreationResult.Success(
|
PlaylistCreationResult.Success(
|
||||||
createFromPlaylistId(
|
createFromPlaylistId(
|
||||||
item.id,
|
playlistId = item.id,
|
||||||
startIndex,
|
startIndex = startIndex,
|
||||||
shuffled,
|
sortAndDirection =
|
||||||
|
if (shuffled) {
|
||||||
|
SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING)
|
||||||
|
} else {
|
||||||
|
sortAndDirection ?: SortAndDirection(
|
||||||
|
ItemSortBy.DEFAULT,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filter = filter,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@ import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
import androidx.lifecycle.LiveData
|
import com.github.damontecres.wholphin.MainActivity
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
@ -30,7 +30,6 @@ class RefreshRateService
|
||||||
) {
|
) {
|
||||||
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
private val originalMode = display.mode
|
|
||||||
|
|
||||||
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||||
|
|
||||||
|
|
@ -44,9 +43,6 @@ class RefreshRateService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
|
|
||||||
val refreshRateMode: LiveData<Int> = _refreshRateMode
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the best display mode for the given stream and signal to change to it
|
* Find the best display mode for the given stream and signal to change to it
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,10 +50,10 @@ class RefreshRateService
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
switchRefreshRate: Boolean,
|
switchRefreshRate: Boolean,
|
||||||
switchResolution: Boolean,
|
switchResolution: Boolean,
|
||||||
) {
|
) = withContext(Dispatchers.IO) {
|
||||||
if (!switchRefreshRate && !switchResolution) {
|
if (!switchRefreshRate && !switchResolution) {
|
||||||
Timber.v("Not switching either refresh rate nor resolution")
|
Timber.v("Not switching either refresh rate nor resolution")
|
||||||
return
|
return@withContext
|
||||||
}
|
}
|
||||||
val currentDisplayMode = display.mode
|
val currentDisplayMode = display.mode
|
||||||
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||||
|
|
@ -67,7 +63,7 @@ class RefreshRateService
|
||||||
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
|
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
|
||||||
if (width == null || height == null || frameRate == null) {
|
if (width == null || height == null || frameRate == null) {
|
||||||
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||||
return
|
return@withContext
|
||||||
}
|
}
|
||||||
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||||
val targetMode =
|
val targetMode =
|
||||||
|
|
@ -86,14 +82,20 @@ class RefreshRateService
|
||||||
listener,
|
listener,
|
||||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||||
)
|
)
|
||||||
_refreshRateMode.setValueOnMain(targetMode.modeId)
|
|
||||||
try {
|
try {
|
||||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
MainActivity.instance.changeDisplayMode(targetMode.modeId)
|
||||||
|
val result =
|
||||||
|
withTimeoutOrNull(5.seconds) {
|
||||||
|
listener.deferred.await()
|
||||||
|
}
|
||||||
|
if (result == null) {
|
||||||
Timber.w("Timed out waiting for display change")
|
Timber.w("Timed out waiting for display change")
|
||||||
showToast(context, "Refresh rate switch is taking a long time")
|
showToast(context, "Refresh rate switch is taking a long time")
|
||||||
}
|
}
|
||||||
} catch (ex: InterruptedException) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception waiting for refresh rate switch")
|
Timber.w(ex, "Exception waiting for refresh rate switch")
|
||||||
|
} finally {
|
||||||
|
displayManager.unregisterDisplayListener(listener)
|
||||||
}
|
}
|
||||||
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
|
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
|
||||||
val isSeamless =
|
val isSeamless =
|
||||||
|
|
@ -110,7 +112,6 @@ class RefreshRateService
|
||||||
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
|
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
|
||||||
delay(2.seconds)
|
delay(2.seconds)
|
||||||
}
|
}
|
||||||
displayManager.unregisterDisplayListener(listener)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,13 +119,13 @@ class RefreshRateService
|
||||||
* Reset the display mode to the original
|
* Reset the display mode to the original
|
||||||
*/
|
*/
|
||||||
fun resetRefreshRate() {
|
fun resetRefreshRate() {
|
||||||
_refreshRateMode.value = originalMode.modeId
|
MainActivity.instance.changeDisplayMode(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Listener(
|
private class Listener(
|
||||||
val displayId: Int,
|
val displayId: Int,
|
||||||
) : DisplayManager.DisplayListener {
|
) : DisplayManager.DisplayListener {
|
||||||
val latch = CountDownLatch(1)
|
val deferred = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
override fun onDisplayAdded(displayId: Int) {
|
override fun onDisplayAdded(displayId: Int) {
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +133,7 @@ class RefreshRateService
|
||||||
override fun onDisplayChanged(displayId: Int) {
|
override fun onDisplayChanged(displayId: Int) {
|
||||||
if (displayId == this.displayId) {
|
if (displayId == this.displayId) {
|
||||||
Timber.v("Got display change for $displayId")
|
Timber.v("Got display change for $displayId")
|
||||||
latch.countDown()
|
deferred.complete(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ fun ChapterRow(
|
||||||
text = stringResource(R.string.chapters),
|
text = stringResource(R.string.chapters),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rememberLazyListState(),
|
state = rememberLazyListState(),
|
||||||
|
|
@ -41,7 +42,6 @@ fun ChapterRow(
|
||||||
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
|
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRestorer(),
|
.focusRestorer(),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
|
@ -57,6 +58,7 @@ fun <T> ItemRow(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ fun PersonRow(
|
||||||
text = stringResource(title),
|
text = stringResource(title),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rememberLazyListState(),
|
state = rememberLazyListState(),
|
||||||
|
|
@ -53,7 +54,6 @@ fun PersonRow(
|
||||||
contentPadding = PaddingValues(8.dp),
|
contentPadding = PaddingValues(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRestorer(firstFocus),
|
.focusRestorer(firstFocus),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -47,18 +47,9 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.DecadeFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||||
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||||
import com.github.damontecres.wholphin.data.filter.FilterVideoType
|
|
||||||
import com.github.damontecres.wholphin.data.filter.GenreFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.PlayedFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
|
||||||
import com.github.damontecres.wholphin.data.filter.YearFilter
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
|
@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.genresApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.localizationApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.yearsApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.TreeSet
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -391,79 +379,12 @@ class CollectionFolderViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
try {
|
FilterUtils.getFilterOptionValues(
|
||||||
when (filterOption) {
|
api,
|
||||||
GenreFilter -> {
|
serverRepository.currentUser.value?.id,
|
||||||
api.genresApi
|
itemUuid,
|
||||||
.getGenres(
|
filterOption,
|
||||||
parentId = itemUuid,
|
)
|
||||||
userId = serverRepository.currentUser.value?.id,
|
|
||||||
).content.items
|
|
||||||
.map { FilterValueOption(it.name ?: "", it.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
FavoriteFilter,
|
|
||||||
PlayedFilter,
|
|
||||||
-> {
|
|
||||||
listOf(
|
|
||||||
FilterValueOption("True", null),
|
|
||||||
FilterValueOption("False", null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OfficialRatingFilter -> {
|
|
||||||
api.localizationApi.getParentalRatings().content.map {
|
|
||||||
FilterValueOption(it.name ?: "", it.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VideoTypeFilter -> {
|
|
||||||
FilterVideoType.entries.map {
|
|
||||||
FilterValueOption(it.readable, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
YearFilter -> {
|
|
||||||
api.yearsApi
|
|
||||||
.getYears(
|
|
||||||
parentId = itemUuid,
|
|
||||||
userId = serverRepository.currentUser.value?.id,
|
|
||||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
|
||||||
).content.items
|
|
||||||
.mapNotNull {
|
|
||||||
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DecadeFilter -> {
|
|
||||||
val items = TreeSet<Int>()
|
|
||||||
api.yearsApi
|
|
||||||
.getYears(
|
|
||||||
parentId = itemUuid,
|
|
||||||
userId = serverRepository.currentUser.value?.id,
|
|
||||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
|
||||||
).content.items
|
|
||||||
.mapNotNullTo(items) {
|
|
||||||
it.name
|
|
||||||
?.toIntOrNull()
|
|
||||||
?.div(10)
|
|
||||||
?.times(10)
|
|
||||||
}
|
|
||||||
items.toList().sorted().map { FilterValueOption("$it's", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
CommunityRatingFilter -> {
|
|
||||||
(1..10).map {
|
|
||||||
FilterValueOption("$it", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception get filter value options for $filterOption")
|
|
||||||
listOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun positionOfLetter(letter: Char): Int? =
|
suspend fun positionOfLetter(letter: Char): Int? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -22,7 +23,9 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||||
|
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||||
|
|
@ -61,26 +71,39 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
|
import com.github.damontecres.wholphin.ui.components.FilterByButton
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.SortByButton
|
||||||
|
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
|
||||||
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -92,9 +115,22 @@ class PlaylistViewModel
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
val filterAndSort =
|
||||||
|
MutableStateFlow<FilterAndSort>(
|
||||||
|
FilterAndSort(
|
||||||
|
filter = GetItemsFilter(),
|
||||||
|
sortAndDirection =
|
||||||
|
SortAndDirection(
|
||||||
|
ItemSortBy.DEFAULT,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
fun init(playlistId: UUID) {
|
fun init(playlistId: UUID) {
|
||||||
loading.value = LoadingState.Loading
|
loading.value = LoadingState.Loading
|
||||||
|
|
@ -103,19 +139,93 @@ class PlaylistViewModel
|
||||||
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
|
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
|
||||||
) {
|
) {
|
||||||
val playlist = fetchItem(playlistId)
|
val playlist = fetchItem(playlistId)
|
||||||
val request =
|
val libraryDisplayInfo =
|
||||||
GetPlaylistItemsRequest(
|
serverRepository.currentUser.value?.let { user ->
|
||||||
playlistId = playlist.id,
|
libraryDisplayInfoDao.getItem(user, itemId)
|
||||||
fields = DefaultItemFields,
|
}
|
||||||
|
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
|
||||||
|
val sortAndDirection =
|
||||||
|
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
|
||||||
|
ItemSortBy.DEFAULT,
|
||||||
|
SortOrder.ASCENDING,
|
||||||
)
|
)
|
||||||
val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init()
|
loadItems(filter, sortAndDirection)
|
||||||
withContext(Dispatchers.Main) {
|
}
|
||||||
items.value = pager
|
}
|
||||||
loading.value = LoadingState.Success
|
|
||||||
|
fun loadItems(
|
||||||
|
filter: GetItemsFilter,
|
||||||
|
sortAndDirection: SortAndDirection,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
loading.setValueOnMain(LoadingState.Loading)
|
||||||
|
this@PlaylistViewModel.filterAndSort.update {
|
||||||
|
FilterAndSort(filter, sortAndDirection)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverRepository.currentUser.value?.let { user ->
|
||||||
|
val playlistId = item.value!!.id
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
val libraryDisplayInfo =
|
||||||
|
libraryDisplayInfoDao.getItem(user, itemId)?.copy(
|
||||||
|
filter = filter,
|
||||||
|
sort = sortAndDirection.sort,
|
||||||
|
direction = sortAndDirection.direction,
|
||||||
|
)
|
||||||
|
?: LibraryDisplayInfo(
|
||||||
|
userId = user.rowId,
|
||||||
|
itemId = itemId,
|
||||||
|
sort = sortAndDirection.sort,
|
||||||
|
direction = sortAndDirection.direction,
|
||||||
|
filter = filter,
|
||||||
|
viewOptions = null,
|
||||||
|
)
|
||||||
|
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
val request =
|
||||||
|
filter.applyTo(
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = playlistId,
|
||||||
|
userId = user.id,
|
||||||
|
fields = DefaultItemFields,
|
||||||
|
sortBy = listOf(sortAndDirection.sort),
|
||||||
|
sortOrder = listOf(sortAndDirection.direction),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val pager =
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
).init()
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
items.value = pager
|
||||||
|
loading.value = LoadingState.Success
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching playlist %s", itemId)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
items.value = listOf()
|
||||||
|
loading.value = LoadingState.Error(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||||
|
FilterUtils.getFilterOptionValues(
|
||||||
|
api,
|
||||||
|
serverRepository.currentUser.value?.id,
|
||||||
|
itemUuid,
|
||||||
|
filterOption,
|
||||||
|
)
|
||||||
|
|
||||||
fun updateBackdrop(item: BaseItem) {
|
fun updateBackdrop(item: BaseItem) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
|
|
@ -123,6 +233,12 @@ class PlaylistViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class FilterAndSort(
|
||||||
|
val filter: GetItemsFilter,
|
||||||
|
val sortAndDirection: SortAndDirection,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetails(
|
fun PlaylistDetails(
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
|
|
@ -136,78 +252,75 @@ fun PlaylistDetails(
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||||
val playlist by viewModel.item.observeAsState(null)
|
val playlist by viewModel.item.observeAsState(null)
|
||||||
val items by viewModel.items.observeAsState(listOf())
|
val items by viewModel.items.observeAsState(listOf())
|
||||||
|
val filterAndSort by viewModel.filterAndSort.collectAsState()
|
||||||
|
|
||||||
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
when (val st = loading) {
|
val goToString = stringResource(R.string.go_to)
|
||||||
is LoadingState.Error -> {
|
val playFromHereString = stringResource(R.string.play_from_here)
|
||||||
ErrorMessage(st, modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
LoadingState.Pending, LoadingState.Loading -> {
|
PlaylistDetailsContent(
|
||||||
LoadingPage(modifier)
|
loadingState = loading,
|
||||||
}
|
playlist = playlist,
|
||||||
|
items = items,
|
||||||
LoadingState.Success -> {
|
onChangeBackdrop = viewModel::updateBackdrop,
|
||||||
playlist?.let {
|
onClickIndex = { index, _ ->
|
||||||
val focusRequester = remember { FocusRequester() }
|
viewModel.navigationManager.navigateTo(
|
||||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
Destination.PlaybackList(
|
||||||
PlaylistDetailsContent(
|
itemId = destination.itemId,
|
||||||
playlist = it,
|
startIndex = index,
|
||||||
items = items,
|
shuffle = false,
|
||||||
focusRequester = focusRequester,
|
filter = filterAndSort.filter,
|
||||||
onChangeBackdrop = viewModel::updateBackdrop,
|
sortAndDirection = filterAndSort.sortAndDirection,
|
||||||
onClickIndex = { index, _ ->
|
),
|
||||||
viewModel.navigationManager.navigateTo(
|
)
|
||||||
Destination.PlaybackList(
|
},
|
||||||
itemId = it.id,
|
onClickPlay = { shuffle ->
|
||||||
startIndex = index,
|
viewModel.navigationManager.navigateTo(
|
||||||
shuffle = false,
|
Destination.PlaybackList(
|
||||||
),
|
itemId = destination.itemId,
|
||||||
)
|
startIndex = 0,
|
||||||
},
|
shuffle = shuffle,
|
||||||
onClickPlay = { shuffle ->
|
filter = filterAndSort.filter,
|
||||||
viewModel.navigationManager.navigateTo(
|
sortAndDirection = filterAndSort.sortAndDirection,
|
||||||
Destination.PlaybackList(
|
),
|
||||||
itemId = it.id,
|
)
|
||||||
startIndex = 0,
|
},
|
||||||
shuffle = shuffle,
|
onLongClickIndex = { index, item ->
|
||||||
),
|
longClickDialog =
|
||||||
)
|
DialogParams(
|
||||||
},
|
fromLongClick = true,
|
||||||
onLongClickIndex = { index, item ->
|
title = item.name ?: "",
|
||||||
longClickDialog =
|
items =
|
||||||
DialogParams(
|
listOf(
|
||||||
fromLongClick = true,
|
DialogItem(
|
||||||
title = item.name ?: "",
|
goToString,
|
||||||
items =
|
Icons.Default.ArrowForward,
|
||||||
listOf(
|
) {
|
||||||
DialogItem(
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
context.getString(R.string.go_to),
|
},
|
||||||
Icons.Default.ArrowForward,
|
DialogItem(
|
||||||
) {
|
playFromHereString,
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
Icons.Default.PlayArrow,
|
||||||
},
|
) {
|
||||||
DialogItem(
|
viewModel.navigationManager.navigateTo(
|
||||||
context.getString(R.string.play_from_here),
|
Destination.PlaybackList(
|
||||||
Icons.Default.PlayArrow,
|
itemId = destination.itemId,
|
||||||
) {
|
startIndex = index,
|
||||||
viewModel.navigationManager.navigateTo(
|
shuffle = false,
|
||||||
Destination.PlaybackList(
|
filter = filterAndSort.filter,
|
||||||
itemId = it.id,
|
sortAndDirection = filterAndSort.sortAndDirection,
|
||||||
startIndex = index,
|
|
||||||
shuffle = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
filterAndSort = filterAndSort,
|
||||||
}
|
onFilterAndSortChange = viewModel::loadItems,
|
||||||
|
getPossibleFilterValues = viewModel::getFilterOptionValues,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
longClickDialog?.let { params ->
|
longClickDialog?.let { params ->
|
||||||
DialogPopup(
|
DialogPopup(
|
||||||
params = params,
|
params = params,
|
||||||
|
|
@ -218,14 +331,17 @@ fun PlaylistDetails(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetailsContent(
|
fun PlaylistDetailsContent(
|
||||||
playlist: BaseItem,
|
playlist: BaseItem?,
|
||||||
items: List<BaseItem?>,
|
items: List<BaseItem?>,
|
||||||
onClickIndex: (Int, BaseItem) -> Unit,
|
onClickIndex: (Int, BaseItem) -> Unit,
|
||||||
onLongClickIndex: (Int, BaseItem) -> Unit,
|
onLongClickIndex: (Int, BaseItem) -> Unit,
|
||||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
onChangeBackdrop: (BaseItem) -> Unit,
|
onChangeBackdrop: (BaseItem) -> Unit,
|
||||||
|
filterAndSort: FilterAndSort,
|
||||||
|
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
|
||||||
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
|
loadingState: LoadingState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
|
||||||
) {
|
) {
|
||||||
var savedIndex by rememberSaveable { mutableIntStateOf(0) }
|
var savedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
|
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
|
||||||
|
|
@ -234,6 +350,12 @@ fun PlaylistDetailsContent(
|
||||||
LaunchedEffect(focusedItem) {
|
LaunchedEffect(focusedItem) {
|
||||||
focusedItem?.let(onChangeBackdrop)
|
focusedItem?.let(onChangeBackdrop)
|
||||||
}
|
}
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(loadingState) {
|
||||||
|
if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val playButtonFocusRequester = remember { FocusRequester() }
|
val playButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
|
@ -248,83 +370,123 @@ fun PlaylistDetailsContent(
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 8.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
PlaylistDetailsHeader(
|
PlaylistDetailsHeader(
|
||||||
focusedItem = focusedItem,
|
focusedItem = focusedItem,
|
||||||
onClickPlay = onClickPlay,
|
onClickPlay = onClickPlay,
|
||||||
playButtonFocusRequester = playButtonFocusRequester,
|
playButtonFocusRequester = playButtonFocusRequester,
|
||||||
|
focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() },
|
||||||
|
filterAndSort = filterAndSort,
|
||||||
|
onFilterAndSortChange = onFilterAndSortChange,
|
||||||
|
getPossibleFilterValues = getPossibleFilterValues,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp, top = 80.dp)
|
||||||
.fillMaxWidth(.25f),
|
.fillMaxWidth(.25f),
|
||||||
)
|
)
|
||||||
Column(
|
when (loadingState) {
|
||||||
modifier =
|
is LoadingState.Error -> {
|
||||||
Modifier
|
ErrorMessage(loadingState, modifier)
|
||||||
.fillMaxWidth()
|
}
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
LoadingState.Pending, LoadingState.Loading -> {
|
||||||
Text(
|
LoadingPage(modifier)
|
||||||
text = playlist.name ?: stringResource(R.string.playlist),
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
LoadingState.Success -> {
|
||||||
textAlign = TextAlign.Center,
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
)
|
Modifier
|
||||||
LazyColumn(
|
.fillMaxWidth()
|
||||||
contentPadding = PaddingValues(8.dp),
|
.padding(horizontal = 16.dp),
|
||||||
modifier =
|
) {
|
||||||
Modifier
|
Text(
|
||||||
.padding(bottom = 32.dp)
|
text = playlist?.name ?: stringResource(R.string.playlist),
|
||||||
.fillMaxHeight()
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
// .fillMaxWidth(.8f)
|
style = MaterialTheme.typography.displayMedium,
|
||||||
.weight(1f)
|
textAlign = TextAlign.Center,
|
||||||
.background(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
MaterialTheme.colorScheme
|
|
||||||
.surfaceColorAtElevation(1.dp)
|
|
||||||
.copy(alpha = .75f),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
).focusRequester(focusRequester)
|
|
||||||
.focusGroup()
|
|
||||||
.focusRestorer(focus),
|
|
||||||
) {
|
|
||||||
itemsIndexed(items) { index, item ->
|
|
||||||
PlaylistItem(
|
|
||||||
item = item,
|
|
||||||
index = index,
|
|
||||||
onClick = {
|
|
||||||
savedIndex = index
|
|
||||||
item?.let {
|
|
||||||
onClickIndex.invoke(index, item)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
savedIndex = index
|
|
||||||
item?.let {
|
|
||||||
onLongClickIndex.invoke(index, item)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.height(80.dp)
|
|
||||||
.ifElse(
|
|
||||||
index == savedIndex,
|
|
||||||
Modifier.focusRequester(focus),
|
|
||||||
).onFocusChanged {
|
|
||||||
if (it.isFocused) {
|
|
||||||
focusedIndex = index
|
|
||||||
}
|
|
||||||
}.focusProperties {
|
|
||||||
left = playButtonFocusRequester
|
|
||||||
previous = playButtonFocusRequester
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
if (items.isNotEmpty()) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
// .fillMaxWidth(.8f)
|
||||||
|
.weight(1f)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme
|
||||||
|
.surfaceColorAtElevation(1.dp)
|
||||||
|
.copy(alpha = .75f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
).focusProperties {
|
||||||
|
onExit = {
|
||||||
|
playButtonFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}.focusRequester(focusRequester)
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer(focus),
|
||||||
|
) {
|
||||||
|
itemsIndexed(items) { index, item ->
|
||||||
|
PlaylistItem(
|
||||||
|
item = item,
|
||||||
|
index = index,
|
||||||
|
onClick = {
|
||||||
|
savedIndex = index
|
||||||
|
item?.let {
|
||||||
|
onClickIndex.invoke(index, item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
savedIndex = index
|
||||||
|
item?.let {
|
||||||
|
onLongClickIndex.invoke(index, item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.height(80.dp)
|
||||||
|
.ifElse(
|
||||||
|
index == savedIndex,
|
||||||
|
Modifier.focusRequester(focus),
|
||||||
|
).onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
focusedIndex = index
|
||||||
|
}
|
||||||
|
}.focusProperties {
|
||||||
|
left = playButtonFocusRequester
|
||||||
|
previous = playButtonFocusRequester
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_results),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusProperties {
|
||||||
|
onExit = {
|
||||||
|
playButtonFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}.focusRequester(focusRequester)
|
||||||
|
.focusable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +500,10 @@ fun PlaylistDetailsHeader(
|
||||||
focusedItem: BaseItem?,
|
focusedItem: BaseItem?,
|
||||||
onClickPlay: (shuffle: Boolean) -> Unit,
|
onClickPlay: (shuffle: Boolean) -> Unit,
|
||||||
playButtonFocusRequester: FocusRequester,
|
playButtonFocusRequester: FocusRequester,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
filterAndSort: FilterAndSort,
|
||||||
|
onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
|
||||||
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -346,13 +512,14 @@ fun PlaylistDetailsHeader(
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.focusRequester(playButtonFocusRequester),
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
title = R.string.play,
|
title = R.string.play,
|
||||||
resume = Duration.ZERO,
|
resume = Duration.ZERO,
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
onClick = { onClickPlay.invoke(false) },
|
onClick = { onClickPlay.invoke(false) },
|
||||||
|
modifier = Modifier.focusRequester(playButtonFocusRequester),
|
||||||
)
|
)
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = R.string.shuffle,
|
title = R.string.shuffle,
|
||||||
|
|
@ -360,16 +527,45 @@ fun PlaylistDetailsHeader(
|
||||||
onClick = { onClickPlay.invoke(true) },
|
onClick = { onClickPlay.invoke(true) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FilterByButton(
|
||||||
|
filterOptions = DefaultPlaylistItemsOptions,
|
||||||
|
current = filterAndSort.filter,
|
||||||
|
onFilterChange = {
|
||||||
|
onFilterAndSortChange.invoke(
|
||||||
|
it,
|
||||||
|
filterAndSort.sortAndDirection,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
getPossibleValues = getPossibleFilterValues,
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
SortByButton(
|
||||||
|
sortOptions = BoxSetSortOptions,
|
||||||
|
current = filterAndSort.sortAndDirection,
|
||||||
|
onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = focusedItem?.title ?: "",
|
text = focusedItem?.title ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = focusedItem?.subtitle ?: "",
|
text = focusedItem?.subtitle ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) {
|
||||||
|
Text(
|
||||||
|
text = formatDateTime(focusedItem.data.premiereDate!!),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
OverviewText(
|
OverviewText(
|
||||||
overview = focusedItem?.data?.overview ?: "",
|
overview = focusedItem?.data?.overview ?: "",
|
||||||
maxLines = 10,
|
maxLines = 10,
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ fun MovieDetailsContent(
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -430,7 +430,7 @@ fun MovieDetailsContent(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 32.dp, bottom = 16.dp),
|
.padding(top = 40.dp, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
|
|
|
||||||
|
|
@ -46,46 +46,47 @@ fun MovieDetailsHeader(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
text = movie.name ?: "",
|
text = movie.name ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
fontWeight = FontWeight.SemiBold,
|
maxLines = 1,
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
val padding = 4.dp
|
|
||||||
QuickDetails(
|
QuickDetails(
|
||||||
movie.ui.quickDetails,
|
movie.ui.quickDetails,
|
||||||
movie.timeRemainingOrRuntime,
|
movie.timeRemainingOrRuntime,
|
||||||
Modifier.padding(bottom = padding),
|
Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
dto.genres?.letNotEmpty {
|
dto.genres?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(bottom = padding))
|
GenreText(it, Modifier.padding(start = 8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
numberOfVersions = movie.data.mediaSourceCount ?: 0,
|
numberOfVersions = movie.data.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier.padding(bottom = padding),
|
modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
Text(
|
Text(
|
||||||
text = tagline,
|
text = tagline,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontStyle = FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
modifier = Modifier,
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +121,7 @@ fun MovieDetailsHeader(
|
||||||
text = stringResource(R.string.directed_by, it),
|
text = stringResource(R.string.directed_by, it),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -332,12 +333,12 @@ fun SeriesDetailsContent(
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(16.dp)
|
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(bottom = 80.dp),
|
contentPadding = PaddingValues(bottom = 80.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -354,7 +355,7 @@ fun SeriesDetailsContent(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 8.dp)
|
||||||
.focusRequester(focusRequesters[HEADER_ROW])
|
.focusRequester(focusRequesters[HEADER_ROW])
|
||||||
.focusRestorer(playFocusRequester)
|
.focusRestorer(playFocusRequester)
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
|
|
@ -599,23 +600,27 @@ fun SeriesDetailsHeader(
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val dto = series.data
|
val dto = series.data
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = series.name ?: stringResource(R.string.unknown),
|
text = series.name ?: stringResource(R.string.unknown),
|
||||||
style = MaterialTheme.typography.displaySmall,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
maxLines = 2,
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(.75f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
QuickDetails(series.ui.quickDetails, null)
|
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
|
||||||
dto.genres?.letNotEmpty {
|
dto.genres?.letNotEmpty {
|
||||||
GenreText(it)
|
GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp))
|
||||||
}
|
}
|
||||||
dto.overview?.let { overview ->
|
dto.overview?.let { overview ->
|
||||||
OverviewText(
|
OverviewText(
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ fun HomePageContent(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding =
|
contentPadding =
|
||||||
PaddingValues(
|
PaddingValues(
|
||||||
start = 16.dp,
|
start = 24.dp,
|
||||||
end = 16.dp,
|
end = 16.dp,
|
||||||
top = 0.dp,
|
top = 0.dp,
|
||||||
bottom = Cards.height2x3,
|
bottom = Cards.height2x3,
|
||||||
|
|
@ -467,7 +467,7 @@ fun HomePageHeader(
|
||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
overview: String?,
|
overview: String?,
|
||||||
overviewTwoLines: Boolean,
|
overviewTwoLines: Boolean,
|
||||||
quickDetails: AnnotatedString,
|
quickDetails: AnnotatedString?,
|
||||||
timeRemaining: Duration?,
|
timeRemaining: Duration?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -495,7 +495,7 @@ fun HomePageHeader(
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
EpisodeName(it)
|
EpisodeName(it)
|
||||||
}
|
}
|
||||||
QuickDetails(quickDetails, timeRemaining)
|
QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
|
||||||
val overviewModifier =
|
val overviewModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(0.dp)
|
.padding(0.dp)
|
||||||
|
|
|
||||||
|
|
@ -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