mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/jellyseerr
This commit is contained in:
commit
ca0fe2ef55
30 changed files with 623 additions and 229 deletions
|
|
@ -32,12 +32,13 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Wholphin"
|
android:theme="@style/Theme.Wholphin"
|
||||||
android:name=".WholphinApplication"
|
android:name=".WholphinApplication"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
|
@ -122,12 +123,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
appUpgradeHandler.copySubfont(false)
|
appUpgradeHandler.copySubfont(false)
|
||||||
}
|
}
|
||||||
refreshRateService.refreshRateMode.observe(this) { mode ->
|
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
||||||
// Listen for refresh rate changes
|
// Listen for refresh rate changes
|
||||||
val attrs = window.attributes
|
val attrs = window.attributes
|
||||||
if (attrs.preferredDisplayModeId != mode.modeId) {
|
if (attrs.preferredDisplayModeId != modeId) {
|
||||||
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate)
|
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||||
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
|
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
|
|
@ -326,6 +327,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.d("onDestroy")
|
Timber.d("onDestroy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
Timber.d("onConfigurationChanged")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
Timber.v("onNewIntent")
|
Timber.v("onNewIntent")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
|
|
@ -17,6 +18,7 @@ import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@Stable
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -696,7 +696,25 @@ sealed interface AppPreference<Pref, T> {
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
getter = { it.playbackPreferences.refreshRateSwitching },
|
getter = { it.playbackPreferences.refreshRateSwitching },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updatePlaybackPreferences { refreshRateSwitching = value }
|
prefs.updatePlaybackPreferences {
|
||||||
|
if (!value) resolutionSwitching = false
|
||||||
|
refreshRateSwitching = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
summaryOn = R.string.automatic,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ResolutionSwitching =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.resolution_switching,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.playbackPreferences.resolutionSwitching },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePlaybackPreferences {
|
||||||
|
if (value) refreshRateSwitching = true
|
||||||
|
resolutionSwitching = value
|
||||||
|
}
|
||||||
},
|
},
|
||||||
summaryOn = R.string.automatic,
|
summaryOn = R.string.automatic,
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
|
|
@ -942,6 +960,7 @@ val advancedPreferences =
|
||||||
AppPreference.GlobalContentScale,
|
AppPreference.GlobalContentScale,
|
||||||
AppPreference.MaxBitrate,
|
AppPreference.MaxBitrate,
|
||||||
AppPreference.RefreshRateSwitching,
|
AppPreference.RefreshRateSwitching,
|
||||||
|
AppPreference.ResolutionSwitching,
|
||||||
AppPreference.PlaybackDebugInfo,
|
AppPreference.PlaybackDebugInfo,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ class AppPreferencesSerializer
|
||||||
playerBackend = AppPreference.PlayerBackendPref.defaultValue
|
playerBackend = AppPreference.PlayerBackendPref.defaultValue
|
||||||
refreshRateSwitching =
|
refreshRateSwitching =
|
||||||
AppPreference.RefreshRateSwitching.defaultValue
|
AppPreference.RefreshRateSwitching.defaultValue
|
||||||
|
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||||
|
|
||||||
overrides =
|
overrides =
|
||||||
PlaybackOverrides
|
PlaybackOverrides
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class ImageUrlService
|
||||||
itemType: BaseItemKind,
|
itemType: BaseItemKind,
|
||||||
seriesId: UUID?,
|
seriesId: UUID?,
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
|
imageTags: Map<ImageType, String?>,
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
|
|
@ -66,6 +67,13 @@ class ImageUrlService
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
)
|
)
|
||||||
|
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||||
|
getItemImageUrl(
|
||||||
|
itemId = seriesId,
|
||||||
|
imageType = imageType,
|
||||||
|
fillWidth = fillWidth,
|
||||||
|
fillHeight = fillHeight,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
|
|
@ -98,6 +106,7 @@ class ImageUrlService
|
||||||
itemType = item.type,
|
itemType = item.type,
|
||||||
seriesId = item.data.seriesId,
|
seriesId = item.data.seriesId,
|
||||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
useSeriesForPrimary = item.useSeriesForPrimary,
|
||||||
|
imageTags = item.data.imageTags.orEmpty(),
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
|
|
|
||||||
|
|
@ -32,41 +32,61 @@ class RefreshRateService
|
||||||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
private val originalMode = display.mode
|
private val originalMode = display.mode
|
||||||
|
|
||||||
val displayModes get() = display.supportedModes
|
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||||
|
|
||||||
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
private val displayModes: List<DisplayMode> by lazy {
|
||||||
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
display.supportedModes
|
||||||
|
.orEmpty()
|
||||||
|
.map { DisplayMode(it) }
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
|
||||||
|
.thenBy { it.refreshRateRounded },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
suspend fun changeRefreshRate(stream: MediaStream) {
|
suspend fun changeRefreshRate(
|
||||||
|
stream: MediaStream,
|
||||||
|
switchRefreshRate: Boolean,
|
||||||
|
switchResolution: Boolean,
|
||||||
|
) {
|
||||||
|
if (!switchRefreshRate && !switchResolution) {
|
||||||
|
Timber.v("Not switching either refresh rate nor resolution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentDisplayMode = display.mode
|
||||||
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
|
||||||
val width = stream.width
|
val width = stream.width
|
||||||
val height = stream.height
|
val height = stream.height
|
||||||
val frameRate = stream.realFrameRate?.times(100)?.roundToInt()
|
val frameRate =
|
||||||
|
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
|
||||||
}
|
}
|
||||||
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 =
|
||||||
display.supportedModes
|
findDisplayMode(
|
||||||
.filterNot { it.physicalHeight < height || it.physicalWidth < width }
|
displayModes = displayModes,
|
||||||
.filter {
|
streamWidth = width,
|
||||||
(it.refreshRate * 100).roundToInt().let { modeRate ->
|
streamHeight = height,
|
||||||
frameRate % modeRate == 0 || // Exact multiple
|
targetFrameRate = frameRate,
|
||||||
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps
|
refreshRateSwitch = switchRefreshRate,
|
||||||
}
|
resolutionSwitch = switchResolution,
|
||||||
}.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
)
|
||||||
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode)
|
||||||
if (targetMode != null && targetMode != display.mode) {
|
if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) {
|
||||||
val listener = Listener(display.displayId)
|
val listener = Listener(display.displayId)
|
||||||
displayManager.registerDisplayListener(
|
displayManager.registerDisplayListener(
|
||||||
listener,
|
listener,
|
||||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||||
)
|
)
|
||||||
_refreshRateMode.setValueOnMain(targetMode)
|
_refreshRateMode.setValueOnMain(targetMode.modeId)
|
||||||
try {
|
try {
|
||||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||||
Timber.w("Timed out waiting for display change")
|
Timber.w("Timed out waiting for display change")
|
||||||
|
|
@ -98,7 +118,7 @@ class RefreshRateService
|
||||||
* Reset the display mode to the original
|
* Reset the display mode to the original
|
||||||
*/
|
*/
|
||||||
fun resetRefreshRate() {
|
fun resetRefreshRate() {
|
||||||
_refreshRateMode.value = originalMode
|
_refreshRateMode.value = originalMode.modeId
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Listener(
|
private class Listener(
|
||||||
|
|
@ -119,4 +139,69 @@ class RefreshRateService
|
||||||
override fun onDisplayRemoved(displayId: Int) {
|
override fun onDisplayRemoved(displayId: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Find the best display mode for the given stream & preferences
|
||||||
|
*
|
||||||
|
* @param displayModes candidates that are sorted by resolution and frame rate descending
|
||||||
|
*/
|
||||||
|
fun findDisplayMode(
|
||||||
|
displayModes: List<DisplayMode>,
|
||||||
|
streamWidth: Int,
|
||||||
|
streamHeight: Int,
|
||||||
|
targetFrameRate: Float,
|
||||||
|
refreshRateSwitch: Boolean,
|
||||||
|
resolutionSwitch: Boolean,
|
||||||
|
): DisplayMode? {
|
||||||
|
val streamRate = targetFrameRate.times(1000).roundToInt()
|
||||||
|
// Timber.v("display modes: %s", displayModes.joinToString("\n"))
|
||||||
|
val candidates =
|
||||||
|
if (refreshRateSwitch) {
|
||||||
|
displayModes
|
||||||
|
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
|
||||||
|
.filter {
|
||||||
|
it.refreshRateRounded % streamRate == 0 || // Exact multiple
|
||||||
|
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
displayModes
|
||||||
|
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
|
||||||
|
}
|
||||||
|
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
|
||||||
|
return if (!resolutionSwitch) {
|
||||||
|
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull {
|
||||||
|
it.physicalWidth == streamWidth &&
|
||||||
|
it.physicalHeight == streamHeight &&
|
||||||
|
it.refreshRateRounded == streamRate
|
||||||
|
}
|
||||||
|
?: candidates.firstOrNull {
|
||||||
|
it.physicalWidth == streamWidth &&
|
||||||
|
it.physicalHeight >= streamHeight &&
|
||||||
|
it.refreshRateRounded == streamRate
|
||||||
|
}
|
||||||
|
?: candidates
|
||||||
|
.filter { it.refreshRateRounded == streamRate }
|
||||||
|
.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class DisplayMode(
|
||||||
|
val modeId: Int,
|
||||||
|
val physicalWidth: Int,
|
||||||
|
val physicalHeight: Int,
|
||||||
|
val refreshRate: Float,
|
||||||
|
) {
|
||||||
|
val refreshRateRounded: Int = (refreshRate * 1000).roundToInt()
|
||||||
|
|
||||||
|
constructor(mode: Display.Mode) : this(
|
||||||
|
mode.modeId,
|
||||||
|
mode.physicalWidth,
|
||||||
|
mode.physicalHeight,
|
||||||
|
mode.refreshRate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,40 @@
|
||||||
package com.github.damontecres.wholphin.services.tvprovider
|
package com.github.damontecres.wholphin.services.tvprovider
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.tvprovider.media.tv.Channel
|
||||||
|
import androidx.tvprovider.media.tv.PreviewProgram
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat.Channels
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
||||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.github.damontecres.wholphin.MainActivity
|
import com.github.damontecres.wholphin.MainActivity
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -77,6 +88,21 @@ class TvProviderWorker
|
||||||
val currentItems = getCurrentTvChannelNextUp()
|
val currentItems = getCurrentTvChannelNextUp()
|
||||||
val currentItemIds = currentItems.map { it.internalProviderId }
|
val currentItemIds = currentItems.map { it.internalProviderId }
|
||||||
|
|
||||||
|
// TODO Remove after v0.3.10 release
|
||||||
|
// This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372
|
||||||
|
currentItems.groupBy { it.internalProviderId }.forEach { (id, items) ->
|
||||||
|
if (items.size > 1) {
|
||||||
|
Timber.v("Duplicate ID %s", id)
|
||||||
|
items
|
||||||
|
.subList(1, items.size)
|
||||||
|
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
|
||||||
|
.forEach {
|
||||||
|
context.contentResolver.delete(it, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// End temporary clean up
|
||||||
|
|
||||||
val toRemove =
|
val toRemove =
|
||||||
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
|
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
|
||||||
|
|
||||||
|
|
@ -84,7 +110,7 @@ class TvProviderWorker
|
||||||
val userRemovedIds = userRemoved.map { it.internalProviderId }
|
val userRemovedIds = userRemoved.map { it.internalProviderId }
|
||||||
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
|
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
|
||||||
val toAdd =
|
val toAdd =
|
||||||
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds }
|
potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds }
|
||||||
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
|
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
|
||||||
|
|
||||||
// Remove existing items if they are no longer in the next up from server
|
// Remove existing items if they are no longer in the next up from server
|
||||||
|
|
@ -104,6 +130,8 @@ class TvProviderWorker
|
||||||
)
|
)
|
||||||
Timber.v("Added %s", addedCount)
|
Timber.v("Added %s", addedCount)
|
||||||
|
|
||||||
|
addOtherChannels()
|
||||||
|
|
||||||
Timber.d("Completed successfully")
|
Timber.d("Completed successfully")
|
||||||
} catch (_: ApiClientException) {
|
} catch (_: ApiClientException) {
|
||||||
return Result.retry()
|
return Result.retry()
|
||||||
|
|
@ -226,6 +254,142 @@ class TvProviderWorker
|
||||||
)
|
)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
private suspend fun addOtherChannels() {
|
||||||
|
val preferences = preferences.data.firstOrNull()
|
||||||
|
val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val latest =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getLatestMedia(
|
||||||
|
GetLatestMediaRequest(
|
||||||
|
fields = SlimItemFields,
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
parentId = null,
|
||||||
|
groupItems = true,
|
||||||
|
limit =
|
||||||
|
preferences?.homePagePreferences?.maxItemsPerRow
|
||||||
|
?: AppPreference.HomePageItems.defaultValue.toInt(),
|
||||||
|
isPlayed = null, // Server will handle user's preference
|
||||||
|
),
|
||||||
|
).content
|
||||||
|
.map { BaseItem(it, true) }
|
||||||
|
|
||||||
|
var channelId = channelsPrefs.getString("latest", null)?.toUri()
|
||||||
|
if (channelId == null) {
|
||||||
|
Timber.d("channelId for latest is null")
|
||||||
|
val channel =
|
||||||
|
Channel
|
||||||
|
.Builder()
|
||||||
|
.apply {
|
||||||
|
setDisplayName(context.getString(R.string.recently_added))
|
||||||
|
setType(Channels.TYPE_PREVIEW)
|
||||||
|
setAppLinkIntent(Intent(context, MainActivity::class.java))
|
||||||
|
}.build()
|
||||||
|
channelId =
|
||||||
|
context.contentResolver.insert(
|
||||||
|
Channels.CONTENT_URI,
|
||||||
|
channel.toContentValues(),
|
||||||
|
)
|
||||||
|
if (channelId != null) {
|
||||||
|
channelsPrefs.edit(true) {
|
||||||
|
putString("latest", channelId.toString())
|
||||||
|
}
|
||||||
|
TvContractCompat.requestChannelBrowsable(
|
||||||
|
context,
|
||||||
|
ContentUris.parseId(channelId),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.w("channelId was null")
|
||||||
|
throw IllegalStateException("channelId was null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray()
|
||||||
|
|
||||||
|
// Delete & replace
|
||||||
|
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
|
||||||
|
val count =
|
||||||
|
context.contentResolver.bulkInsert(
|
||||||
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
|
programs,
|
||||||
|
)
|
||||||
|
Timber.v("Inserted $count records")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convert(
|
||||||
|
channelId: Uri,
|
||||||
|
item: BaseItem,
|
||||||
|
): PreviewProgram =
|
||||||
|
PreviewProgram
|
||||||
|
.Builder()
|
||||||
|
.apply {
|
||||||
|
setChannelId(ContentUris.parseId(channelId))
|
||||||
|
|
||||||
|
val dto = item.data
|
||||||
|
setInternalProviderId(item.id.toString())
|
||||||
|
|
||||||
|
val type =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
|
||||||
|
BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON
|
||||||
|
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||||
|
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||||
|
else -> WatchNextPrograms.TYPE_CLIP
|
||||||
|
}
|
||||||
|
setType(type)
|
||||||
|
|
||||||
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
|
||||||
|
if (resumePosition != null && resumePosition >= 2.minutes) {
|
||||||
|
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
|
||||||
|
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
|
||||||
|
}
|
||||||
|
dto.runTimeTicks
|
||||||
|
?.ticks
|
||||||
|
?.inWholeMilliseconds
|
||||||
|
?.toInt()
|
||||||
|
?.let(::setDurationMillis)
|
||||||
|
|
||||||
|
setTitle(item.title)
|
||||||
|
setDescription(dto.overview)
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
setEpisodeTitle(item.name)
|
||||||
|
dto.indexNumber?.let(::setEpisodeNumber)
|
||||||
|
dto.parentIndexNumber?.let(::setSeasonNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
|
||||||
|
setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri())
|
||||||
|
|
||||||
|
setIntent(
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||||
|
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||||
|
.apply {
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SERIES_ID,
|
||||||
|
dto.seriesId?.toString(),
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SEASON_ID,
|
||||||
|
dto.seasonId?.toString(),
|
||||||
|
)
|
||||||
|
dto.parentIndexNumber?.let {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SEASON_NUMBER,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dto.indexNumber?.let {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_EPISODE_NUMBER,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
|
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
|
||||||
const val PARAM_USER_ID = "userId"
|
const val PARAM_USER_ID = "userId"
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,15 @@ 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
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
|
||||||
import com.github.damontecres.wholphin.util.FocusPair
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> ItemRow(
|
fun <T> ItemRow(
|
||||||
|
|
@ -39,13 +32,10 @@ fun <T> ItemRow(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) -> Unit,
|
) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
focusPair: FocusPair? = null,
|
|
||||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
|
||||||
horizontalPadding: Dp = 16.dp,
|
horizontalPadding: Dp = 16.dp,
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -62,23 +52,14 @@ fun <T> ItemRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
|
.focusRestorer(firstFocus),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items) { index, item ->
|
itemsIndexed(items) { index, item ->
|
||||||
val cardModifier =
|
val cardModifier =
|
||||||
if (index == 0 && focusPair == null) {
|
if (index == 0) {
|
||||||
Modifier.focusRequester(firstFocus)
|
Modifier.focusRequester(firstFocus)
|
||||||
} else {
|
} else {
|
||||||
if (focusPair != null && focusPair.column == index) {
|
Modifier
|
||||||
Modifier.focusRequester(focusPair.focusRequester)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
}.onFocusChanged {
|
|
||||||
if (it.isFocused) {
|
|
||||||
focusedIndex = index
|
|
||||||
}
|
|
||||||
cardOnFocus?.invoke(it.isFocused, index)
|
|
||||||
}
|
}
|
||||||
cardContent.invoke(
|
cardContent.invoke(
|
||||||
index,
|
index,
|
||||||
|
|
@ -91,38 +72,3 @@ fun <T> ItemRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BannerItemRow(
|
|
||||||
title: String,
|
|
||||||
items: List<BaseItem?>,
|
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
focusPair: FocusPair? = null,
|
|
||||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
|
||||||
aspectRatioOverride: Float? = null,
|
|
||||||
) = ItemRow(
|
|
||||||
title = title,
|
|
||||||
items = items,
|
|
||||||
onClickItem = onClickItem,
|
|
||||||
onLongClickItem = onLongClickItem,
|
|
||||||
modifier = modifier,
|
|
||||||
cardContent = { index, item, modifier, onClick, onLongClick ->
|
|
||||||
BannerCard(
|
|
||||||
name = title,
|
|
||||||
item = item,
|
|
||||||
aspectRatio =
|
|
||||||
aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
|
|
||||||
cornerText = item?.data?.indexNumber?.let { "E$it" },
|
|
||||||
played = item?.data?.userData?.played ?: false,
|
|
||||||
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
modifier = modifier,
|
|
||||||
interactionSource = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
focusPair = focusPair,
|
|
||||||
cardOnFocus = cardOnFocus,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,7 @@ fun CollectionFolderGrid(
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
viewModelKey: String? = itemId.toServerString(),
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
|
|
@ -531,6 +532,7 @@ fun CollectionFolderGrid(
|
||||||
onClickItem,
|
onClickItem,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
playEnabled,
|
playEnabled,
|
||||||
|
viewModelKey = viewModelKey,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
initialSortAndDirection = initialSortAndDirection,
|
initialSortAndDirection = initialSortAndDirection,
|
||||||
|
|
@ -551,6 +553,7 @@ fun CollectionFolderGrid(
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
viewModelKey: String? = itemId,
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
|
|
@ -559,7 +562,7 @@ fun CollectionFolderGrid(
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
viewModel: CollectionFolderViewModel =
|
viewModel: CollectionFolderViewModel =
|
||||||
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||||
key = itemId,
|
key = viewModelKey,
|
||||||
) {
|
) {
|
||||||
it.create(
|
it.create(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
|
|
|
||||||
|
|
@ -136,11 +136,12 @@ class GenreViewModel
|
||||||
// excludeItemIds.add(item.id)
|
// excludeItemIds.add(item.id)
|
||||||
genreToUrl[genre.id] =
|
genreToUrl[genre.id] =
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
item.id,
|
itemId = item.id,
|
||||||
item.type,
|
itemType = item.type,
|
||||||
null,
|
seriesId = null,
|
||||||
false,
|
useSeriesForPrimary = false,
|
||||||
ImageType.BACKDROP,
|
imageType = ImageType.BACKDROP,
|
||||||
|
imageTags = emptyMap(),
|
||||||
fillWidth = cardWidthPx,
|
fillWidth = cardWidthPx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -21,7 +22,7 @@ fun MovieQuickDetails(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val now = LocalClock.current.now
|
val now by LocalClock.current.now
|
||||||
val details =
|
val details =
|
||||||
remember(dto, now) {
|
remember(dto, now) {
|
||||||
buildList {
|
buildList {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -59,7 +60,7 @@ fun EpisodeQuickDetails(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val now = LocalClock.current.now
|
val now by LocalClock.current.now
|
||||||
val details =
|
val details =
|
||||||
remember(dto, now) {
|
remember(dto, now) {
|
||||||
buildList {
|
buildList {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||||
|
val timeString by LocalClock.current.timeString
|
||||||
Text(
|
Text(
|
||||||
text = LocalClock.current.timeString,
|
text = timeString,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ fun CollectionFolderMovie(
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_library",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
filter =
|
filter =
|
||||||
|
|
@ -146,6 +147,7 @@ fun CollectionFolderMovie(
|
||||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||||
},
|
},
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
|
viewModelKey = "${destination.itemId}_collection",
|
||||||
initialFilter =
|
initialFilter =
|
||||||
CollectionFolderFilter(
|
CollectionFolderFilter(
|
||||||
filter =
|
filter =
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ fun DebugPage(
|
||||||
"Manufacturer: ${Build.MANUFACTURER}",
|
"Manufacturer: ${Build.MANUFACTURER}",
|
||||||
"Model: ${Build.MODEL}",
|
"Model: ${Build.MODEL}",
|
||||||
"Display Modes:",
|
"Display Modes:",
|
||||||
*viewModel.refreshRateService.displayModes,
|
*viewModel.refreshRateService.supportedDisplayModes,
|
||||||
).forEach {
|
).forEach {
|
||||||
Text(
|
Text(
|
||||||
text = it.toString(),
|
text = it.toString(),
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ fun PlaylistItem(
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
||||||
val now = LocalClock.current.now
|
val now by LocalClock.current.now
|
||||||
val endTimeStr =
|
val endTimeStr =
|
||||||
remember(item, now) {
|
remember(item, now) {
|
||||||
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -56,7 +57,6 @@ 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.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import eu.wewox.programguide.ProgramGuide
|
import eu.wewox.programguide.ProgramGuide
|
||||||
import eu.wewox.programguide.ProgramGuideDimensions
|
import eu.wewox.programguide.ProgramGuideDimensions
|
||||||
|
|
@ -289,22 +289,8 @@ fun TvGuideGridContent(
|
||||||
|
|
||||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||||
val focusedChannelIndex = focusedItem.row
|
val focusedChannelIndex = focusedItem.row
|
||||||
val focusedProgramIndex =
|
var focusedProgramIndex by remember { mutableIntStateOf(0) }
|
||||||
remember(programs.range, focusedItem) {
|
|
||||||
focusedItem.let { focus ->
|
|
||||||
(programs.range.first..<focus.row).sumOf {
|
|
||||||
val channelId = channels[it].id
|
|
||||||
channelProgramCount[channelId] ?: 0
|
|
||||||
} + focus.column
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(focusedProgramIndex) {
|
|
||||||
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
|
|
||||||
scope.launch(ExceptionHandler()) {
|
|
||||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
@ -485,7 +471,7 @@ fun TvGuideGridContent(
|
||||||
|
|
||||||
if (newFocusedItem != null) {
|
if (newFocusedItem != null) {
|
||||||
val channel = channels[newFocusedItem.row]
|
val channel = channels[newFocusedItem.row]
|
||||||
val programs = programs.programsByChannel[channel.id].orEmpty()
|
val channelPrograms = programs.programsByChannel[channel.id].orEmpty()
|
||||||
// Ensure it isn't going out of range
|
// Ensure it isn't going out of range
|
||||||
val toFocus =
|
val toFocus =
|
||||||
newFocusedItem
|
newFocusedItem
|
||||||
|
|
@ -494,10 +480,24 @@ fun TvGuideGridContent(
|
||||||
column =
|
column =
|
||||||
newFocusedItem.column.coerceIn(
|
newFocusedItem.column.coerceIn(
|
||||||
0,
|
0,
|
||||||
(programs.size - 1).coerceAtLeast(0),
|
(channelPrograms.size - 1).coerceAtLeast(0),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
focusedItem = toFocus
|
focusedItem = toFocus
|
||||||
|
focusedProgramIndex =
|
||||||
|
toFocus.let { focus ->
|
||||||
|
(programs.range.first..<focus.row).sumOf {
|
||||||
|
val channelId = channels[it].id
|
||||||
|
channelProgramCount[channelId] ?: 0
|
||||||
|
} + focus.column
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Couldn't scroll to $focusedProgramIndex")
|
||||||
|
}
|
||||||
|
}
|
||||||
onFocus(toFocus)
|
onFocus(toFocus)
|
||||||
return@onPreviewKeyEvent true
|
return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||||
|
|
@ -226,14 +225,15 @@ fun HomePageContent(
|
||||||
var position by rememberSaveable(stateSaver = RowColumnSaver) {
|
var position by rememberSaveable(stateSaver = RowColumnSaver) {
|
||||||
mutableStateOf(RowColumn(firstRow, 0))
|
mutableStateOf(RowColumn(firstRow, 0))
|
||||||
}
|
}
|
||||||
var focusedItem =
|
val focusedItem =
|
||||||
position.let {
|
remember(position) {
|
||||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
position.let {
|
||||||
|
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
|
||||||
val positionFocusRequester = remember { FocusRequester() }
|
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(homeRows) {
|
LaunchedEffect(homeRows) {
|
||||||
if (!focused) {
|
if (!focused) {
|
||||||
|
|
@ -241,7 +241,7 @@ fun HomePageContent(
|
||||||
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||||
.takeIf { it >= 0 }
|
.takeIf { it >= 0 }
|
||||||
?.let {
|
?.let {
|
||||||
positionFocusRequester.tryRequestFocus()
|
rowFocusRequesters[it].tryRequestFocus()
|
||||||
delay(50)
|
delay(50)
|
||||||
listState.animateScrollToItem(position.row)
|
listState.animateScrollToItem(position.row)
|
||||||
focused = true
|
focused = true
|
||||||
|
|
@ -251,9 +251,6 @@ fun HomePageContent(
|
||||||
LaunchedEffect(position) {
|
LaunchedEffect(position) {
|
||||||
listState.animateScrollToItem(position.row)
|
listState.animateScrollToItem(position.row)
|
||||||
}
|
}
|
||||||
LaunchedEffect(focusedItem) {
|
|
||||||
focusedItem?.let(onUpdateBackdrop)
|
|
||||||
}
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
|
|
@ -275,16 +272,7 @@ fun HomePageContent(
|
||||||
),
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.focusRestorer()
|
.focusRestorer(),
|
||||||
.onKeyEvent {
|
|
||||||
val item = focusedItem
|
|
||||||
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
|
||||||
Timber.v("Clicked play on ${item.id}")
|
|
||||||
onClickPlay.invoke(position, item)
|
|
||||||
return@onKeyEvent true
|
|
||||||
}
|
|
||||||
return@onKeyEvent false
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(homeRows) { rowIndex, row ->
|
itemsIndexed(homeRows) { rowIndex, row ->
|
||||||
when (val r = row) {
|
when (val r = row) {
|
||||||
|
|
@ -347,22 +335,17 @@ fun HomePageContent(
|
||||||
onClickItem = { index, item ->
|
onClickItem = { index, item ->
|
||||||
onClickItem.invoke(RowColumn(rowIndex, index), item)
|
onClickItem.invoke(RowColumn(rowIndex, index), item)
|
||||||
},
|
},
|
||||||
cardOnFocus = { isFocused, index ->
|
|
||||||
if (isFocused) {
|
|
||||||
focusedItem = row.items.getOrNull(index)
|
|
||||||
position = RowColumn(rowIndex, index)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClickItem = { index, item ->
|
onLongClickItem = { index, item ->
|
||||||
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
|
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.focusRequester(rowFocusRequesters[rowIndex])
|
||||||
.animateItem(),
|
.animateItem(),
|
||||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||||
val cornerText =
|
val cornerText =
|
||||||
remember {
|
remember(item) {
|
||||||
item?.data?.indexNumber?.let { "E$it" }
|
item?.data?.indexNumber?.let { "E$it" }
|
||||||
?: item
|
?: item
|
||||||
?.data
|
?.data
|
||||||
|
|
@ -385,29 +368,32 @@ fun HomePageContent(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier =
|
modifier =
|
||||||
cardModifier
|
cardModifier
|
||||||
.ifElse(
|
.onFocusChanged {
|
||||||
focusedItem == item,
|
|
||||||
Modifier.focusRequester(focusRequester),
|
|
||||||
).ifElse(
|
|
||||||
RowColumn(rowIndex, index) == position,
|
|
||||||
Modifier.focusRequester(
|
|
||||||
positionFocusRequester,
|
|
||||||
),
|
|
||||||
).onFocusChanged {
|
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
|
position = RowColumn(rowIndex, index)
|
||||||
|
item?.let(onUpdateBackdrop)
|
||||||
|
}
|
||||||
|
if (it.isFocused && onFocusPosition != null) {
|
||||||
val nonEmptyRowBefore =
|
val nonEmptyRowBefore =
|
||||||
homeRows
|
homeRows
|
||||||
.subList(0, rowIndex)
|
.subList(0, rowIndex)
|
||||||
.count {
|
.count {
|
||||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
||||||
}
|
}
|
||||||
onFocusPosition?.invoke(
|
onFocusPosition.invoke(
|
||||||
RowColumn(
|
RowColumn(
|
||||||
rowIndex - nonEmptyRowBefore,
|
rowIndex - nonEmptyRowBefore,
|
||||||
index,
|
index,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}.onKeyEvent {
|
||||||
|
if (isPlayKeyUp(it) && item?.type?.playable == true) {
|
||||||
|
Timber.v("Clicked play on ${item.id}")
|
||||||
|
onClickPlay.invoke(position, item)
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
return@onKeyEvent false
|
||||||
},
|
},
|
||||||
interactionSource = null,
|
interactionSource = null,
|
||||||
cardHeight = Cards.height2x3,
|
cardHeight = Cards.height2x3,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
|
@ -37,11 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
|
@ -360,10 +359,8 @@ fun NavDrawer(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(drawerWidth)
|
.width(drawerWidth)
|
||||||
.background(drawerBackground)
|
.drawBehind {
|
||||||
.onFocusChanged {
|
drawRect(drawerBackground)
|
||||||
if (!it.hasFocus) {
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Even though some must be clicked, focusing on it should clear other focused items
|
// Even though some must be clicked, focusing on it should clear other focused items
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.ProvideTextStyle
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
|
|
@ -14,13 +22,40 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
||||||
import com.github.damontecres.wholphin.ui.formatBytes
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackDebugOverlay(
|
fun PlaybackDebugOverlay(
|
||||||
currentPlayback: CurrentPlayback?,
|
currentPlayback: CurrentPlayback?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val display =
|
||||||
|
remember(context) {
|
||||||
|
try {
|
||||||
|
val displayManager =
|
||||||
|
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
|
||||||
|
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val displayMode by produceState<String?>(null) {
|
||||||
|
while (isActive) {
|
||||||
|
value =
|
||||||
|
display?.mode?.let {
|
||||||
|
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
|
||||||
|
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
|
||||||
|
}
|
||||||
|
delay(10.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -38,10 +73,12 @@ fun PlaybackDebugOverlay(
|
||||||
add("Video Decoder:" to currentPlayback.videoDecoder)
|
add("Video Decoder:" to currentPlayback.videoDecoder)
|
||||||
add("Audio Decoder:" to currentPlayback.audioDecoder)
|
add("Audio Decoder:" to currentPlayback.audioDecoder)
|
||||||
}
|
}
|
||||||
|
add("Display Mode: " to displayMode?.toString())
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
currentPlayback?.transcodeInfo?.let {
|
currentPlayback?.transcodeInfo?.let {
|
||||||
TranscodeInfo(it, Modifier)
|
TranscodeInfo(it, Modifier.weight(2f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,27 +123,21 @@ fun SimpleTable(
|
||||||
rows: List<Pair<String, String?>>,
|
rows: List<Pair<String, String?>>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
rows.forEach {
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
Row(
|
||||||
modifier = Modifier,
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
rows.forEach {
|
|
||||||
Text(
|
Text(
|
||||||
text = it.first,
|
text = it.first,
|
||||||
|
modifier = Modifier.width(100.dp),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier,
|
|
||||||
) {
|
|
||||||
rows.forEach {
|
|
||||||
Text(
|
Text(
|
||||||
text = it.second.toString(),
|
text = it.second.toString(),
|
||||||
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.size
|
||||||
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.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -457,9 +457,10 @@ fun PlaybackOverlay(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = logoImageUrl,
|
model = logoImageUrl,
|
||||||
contentDescription = "Logo",
|
contentDescription = "Logo",
|
||||||
|
alignment = Alignment.TopStart,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.sizeIn(maxWidth = 180.dp, maxHeight = 100.dp)
|
.size(width = 240.dp, height = 120.dp)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.size.Size
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -641,10 +642,16 @@ class PlaybackViewModel
|
||||||
mediaSourceInfo = source,
|
mediaSourceInfo = source,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
preferences.appPreferences.playbackPreferences.let { prefs ->
|
||||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
|
source.mediaStreams
|
||||||
refreshRateService.changeRefreshRate(it)
|
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||||
}
|
?.let { stream ->
|
||||||
|
refreshRateService.changeRefreshRate(
|
||||||
|
stream = stream,
|
||||||
|
switchRefreshRate = prefs.refreshRateSwitching,
|
||||||
|
switchResolution = prefs.resolutionSwitching,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
// TODO, don't need to release & recreate when switching streams
|
// TODO, don't need to release & recreate when switching streams
|
||||||
|
|
@ -754,7 +761,7 @@ class PlaybackViewModel
|
||||||
ImageRequest
|
ImageRequest
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.data(url)
|
.data(url)
|
||||||
.size(coil3.size.Size.ORIGINAL)
|
.size(Size.ORIGINAL)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ package com.github.damontecres.wholphin.ui.util
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
@ -22,20 +21,28 @@ data class Clock(
|
||||||
/**
|
/**
|
||||||
* The current [LocalDateTime]
|
* The current [LocalDateTime]
|
||||||
*/
|
*/
|
||||||
val now: LocalDateTime,
|
val now: MutableState<LocalDateTime>,
|
||||||
/**
|
/**
|
||||||
* The current time formatted as a string with [TimeFormatter]
|
* The current time formatted as a string with [TimeFormatter]
|
||||||
*/
|
*/
|
||||||
val timeString: String,
|
val timeString: MutableState<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProvideLocalClock(content: @Composable () -> Unit) {
|
fun ProvideLocalClock(content: @Composable () -> Unit) {
|
||||||
var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) }
|
val clock =
|
||||||
|
remember {
|
||||||
|
LocalDateTime
|
||||||
|
.now()
|
||||||
|
.let { Clock(mutableStateOf(it), mutableStateOf(TimeFormatter.format(it))) }
|
||||||
|
}
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }
|
val now = LocalDateTime.now()
|
||||||
delay(1_000)
|
val time = TimeFormatter.format(now)
|
||||||
|
clock.now.value = now
|
||||||
|
clock.timeString.value = time
|
||||||
|
delay(2_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CompositionLocalProvider(LocalClock provides clock, content)
|
CompositionLocalProvider(LocalClock provides clock, content)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
// Wrapper for native library
|
// Wrapper for native library
|
||||||
|
|
||||||
|
|
@ -96,20 +97,16 @@ object MPVLib {
|
||||||
format: Int,
|
format: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val observers = mutableListOf<EventObserver>()
|
private val observers = CopyOnWriteArrayList<EventObserver>()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun addObserver(o: EventObserver) {
|
fun addObserver(o: EventObserver) {
|
||||||
synchronized(observers) {
|
observers.add(o)
|
||||||
observers.add(o)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun removeObserver(o: EventObserver) {
|
fun removeObserver(o: EventObserver) {
|
||||||
synchronized(observers) {
|
observers.remove(o)
|
||||||
observers.remove(o)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
|
@ -117,10 +114,8 @@ object MPVLib {
|
||||||
property: String,
|
property: String,
|
||||||
value: Long,
|
value: Long,
|
||||||
) {
|
) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.eventProperty(property, value)
|
||||||
o.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,10 +124,8 @@ object MPVLib {
|
||||||
property: String,
|
property: String,
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
) {
|
) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.eventProperty(property, value)
|
||||||
o.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,10 +134,8 @@ object MPVLib {
|
||||||
property: String,
|
property: String,
|
||||||
value: Double,
|
value: Double,
|
||||||
) {
|
) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.eventProperty(property, value)
|
||||||
o.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,28 +144,22 @@ object MPVLib {
|
||||||
property: String,
|
property: String,
|
||||||
value: String,
|
value: String,
|
||||||
) {
|
) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.eventProperty(property, value)
|
||||||
o.eventProperty(property, value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun eventProperty(property: String) {
|
fun eventProperty(property: String) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.eventProperty(property)
|
||||||
o.eventProperty(property)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun event(eventId: Int) {
|
fun event(eventId: Int) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.event(eventId)
|
||||||
o.event(eventId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,27 +168,21 @@ object MPVLib {
|
||||||
reason: Int,
|
reason: Int,
|
||||||
error: Int,
|
error: Int,
|
||||||
) {
|
) {
|
||||||
synchronized(observers) {
|
for (o in observers) {
|
||||||
for (o in observers) {
|
o.eventEndFile(reason, error)
|
||||||
o.eventEndFile(reason, error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val log_observers = mutableListOf<LogObserver>()
|
private val log_observers = CopyOnWriteArrayList<LogObserver>()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun addLogObserver(o: LogObserver) {
|
fun addLogObserver(o: LogObserver) {
|
||||||
synchronized(log_observers) {
|
log_observers.add(o)
|
||||||
log_observers.add(o)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun removeLogObserver(o: LogObserver) {
|
fun removeLogObserver(o: LogObserver) {
|
||||||
synchronized(log_observers) {
|
log_observers.remove(o)
|
||||||
log_observers.remove(o)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
|
@ -212,10 +191,8 @@ object MPVLib {
|
||||||
level: Int,
|
level: Int,
|
||||||
text: String,
|
text: String,
|
||||||
) {
|
) {
|
||||||
synchronized(log_observers) {
|
for (o in log_observers) {
|
||||||
for (o in log_observers) {
|
o.logMessage(prefix, level, text)
|
||||||
o.logMessage(prefix, level, text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ message PlaybackPreferences {
|
||||||
PlayerBackend player_backend = 20;
|
PlayerBackend player_backend = 20;
|
||||||
MpvOptions mpv_options = 21;
|
MpvOptions mpv_options = 21;
|
||||||
bool refresh_rate_switching = 22;
|
bool refresh_rate_switching = 22;
|
||||||
|
bool resolution_switching = 23;
|
||||||
}
|
}
|
||||||
|
|
||||||
message HomePagePreferences{
|
message HomePagePreferences{
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@
|
||||||
<string name="color_code_programs">Color-code programs</string>
|
<string name="color_code_programs">Color-code programs</string>
|
||||||
<string name="subtitle_delay">Subtitle delay</string>
|
<string name="subtitle_delay">Subtitle delay</string>
|
||||||
<string name="backdrop_display">Backdrop style</string>
|
<string name="backdrop_display">Backdrop style</string>
|
||||||
|
<string name="resolution_switching">Resolution switching</string>
|
||||||
|
|
||||||
<string name="discover">Discover</string>
|
<string name="discover">Discover</string>
|
||||||
<string name="request">Request</string>
|
<string name="request">Request</string>
|
||||||
|
|
|
||||||
9
app/src/main/res/xml/network_security_config.xml
Normal file
9
app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system"/>
|
||||||
|
<certificates src="user"/>
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.services.DisplayMode
|
||||||
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TestDisplayModeChoice {
|
||||||
|
companion object {
|
||||||
|
val HD_60 = DisplayMode(0, 1920, 1080, 60f)
|
||||||
|
val HD_30 = DisplayMode(1, 1920, 1080, 30f)
|
||||||
|
val HD_24 = DisplayMode(2, 1920, 1080, 24f)
|
||||||
|
|
||||||
|
val UHD_60 = DisplayMode(3, 3840, 2160, 60f)
|
||||||
|
val UHD_30 = DisplayMode(4, 3840, 2160, 30f)
|
||||||
|
val UHD_24 = DisplayMode(5, 3840, 2160, 24f)
|
||||||
|
|
||||||
|
val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test1() {
|
||||||
|
val streamWidth = 1920
|
||||||
|
val streamHeight = 1080
|
||||||
|
val streamRealFrameRate = 60f
|
||||||
|
val result =
|
||||||
|
RefreshRateService.findDisplayMode(
|
||||||
|
displayModes = ALL_MODES,
|
||||||
|
streamWidth = streamWidth,
|
||||||
|
streamHeight = streamHeight,
|
||||||
|
targetFrameRate = streamRealFrameRate,
|
||||||
|
refreshRateSwitch = true,
|
||||||
|
resolutionSwitch = false,
|
||||||
|
)
|
||||||
|
Assert.assertEquals(3, result?.modeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test2() {
|
||||||
|
val streamWidth = 1920
|
||||||
|
val streamHeight = 1080
|
||||||
|
val streamRealFrameRate = 60f
|
||||||
|
val result =
|
||||||
|
RefreshRateService.findDisplayMode(
|
||||||
|
displayModes = ALL_MODES,
|
||||||
|
streamWidth = streamWidth,
|
||||||
|
streamHeight = streamHeight,
|
||||||
|
targetFrameRate = streamRealFrameRate,
|
||||||
|
refreshRateSwitch = true,
|
||||||
|
resolutionSwitch = true,
|
||||||
|
)
|
||||||
|
Assert.assertEquals(0, result?.modeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test3() {
|
||||||
|
val streamWidth = 1920
|
||||||
|
val streamHeight = 1080
|
||||||
|
val streamRealFrameRate = 30f
|
||||||
|
val result =
|
||||||
|
RefreshRateService.findDisplayMode(
|
||||||
|
displayModes = ALL_MODES,
|
||||||
|
streamWidth = streamWidth,
|
||||||
|
streamHeight = streamHeight,
|
||||||
|
targetFrameRate = streamRealFrameRate,
|
||||||
|
refreshRateSwitch = true,
|
||||||
|
resolutionSwitch = false,
|
||||||
|
)
|
||||||
|
Assert.assertEquals(4, result?.modeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test4() {
|
||||||
|
val streamWidth = 1920
|
||||||
|
val streamHeight = 804
|
||||||
|
val streamRealFrameRate = 30f
|
||||||
|
val result =
|
||||||
|
RefreshRateService.findDisplayMode(
|
||||||
|
displayModes = ALL_MODES,
|
||||||
|
streamWidth = streamWidth,
|
||||||
|
streamHeight = streamHeight,
|
||||||
|
targetFrameRate = streamRealFrameRate,
|
||||||
|
refreshRateSwitch = false,
|
||||||
|
resolutionSwitch = true,
|
||||||
|
)
|
||||||
|
Assert.assertEquals(1, result?.modeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFraction() {
|
||||||
|
val streamWidth = 1920
|
||||||
|
val streamHeight = 1080
|
||||||
|
val streamRealFrameRate = 30f
|
||||||
|
|
||||||
|
val displayModes =
|
||||||
|
listOf(
|
||||||
|
DisplayMode(0, 1920, 1080, 59.940f),
|
||||||
|
DisplayMode(1, 1920, 1080, 60f),
|
||||||
|
// DisplayMode(2, 1920, 1080, 29.970f),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
RefreshRateService.findDisplayMode(
|
||||||
|
displayModes = displayModes,
|
||||||
|
streamWidth = streamWidth,
|
||||||
|
streamHeight = streamHeight,
|
||||||
|
targetFrameRate = 29.970f,
|
||||||
|
refreshRateSwitch = true,
|
||||||
|
resolutionSwitch = false,
|
||||||
|
)
|
||||||
|
Assert.assertEquals(0, result?.modeId)
|
||||||
|
|
||||||
|
val result2 =
|
||||||
|
RefreshRateService.findDisplayMode(
|
||||||
|
displayModes = displayModes,
|
||||||
|
streamWidth = streamWidth,
|
||||||
|
streamHeight = streamHeight,
|
||||||
|
targetFrameRate = 24f,
|
||||||
|
refreshRateSwitch = true,
|
||||||
|
resolutionSwitch = false,
|
||||||
|
)
|
||||||
|
Assert.assertEquals(1, result2?.modeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,6 +104,16 @@ class TestStreamChoiceServiceBasic(
|
||||||
),
|
),
|
||||||
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
|
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
|
||||||
),
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.ALWAYS,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = true, default = true),
|
||||||
|
subtitle(1, "eng", false),
|
||||||
|
subtitle(2, "eng", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue