Merge branch 'main' into develop/jellyseerr

This commit is contained in:
Ray 2026-01-01 01:37:14 -05:00 committed by GitHub
commit ca0fe2ef55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 623 additions and 229 deletions

View file

@ -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" />

View file

@ -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")

View file

@ -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,

View file

@ -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,
), ),
), ),

View file

@ -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

View file

@ -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,

View file

@ -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,
)
}

View file

@ -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"

View file

@ -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,
)

View file

@ -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,

View file

@ -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,
) )
} }

View file

@ -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 {

View file

@ -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 {

View file

@ -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,

View file

@ -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 =

View file

@ -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(),

View file

@ -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)

View file

@ -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
} }

View file

@ -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,

View file

@ -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

View file

@ -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,
) )
} }
} }

View file

@ -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),
) )
} }

View file

@ -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(),
) )
} }

View file

@ -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)

View file

@ -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)
}
} }
} }

View file

@ -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{

View file

@ -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>

View 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>

View file

@ -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)
}
}

View file

@ -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),
),
),
) )
} }
} }