mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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:theme="@style/Theme.Wholphin"
|
||||
android:name=".WholphinApplication"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
|
|
@ -122,12 +123,12 @@ class MainActivity : AppCompatActivity() {
|
|||
if (savedInstanceState == null) {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
refreshRateService.refreshRateMode.observe(this) { mode ->
|
||||
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
||||
// Listen for refresh rate changes
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != mode.modeId) {
|
||||
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate)
|
||||
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||
}
|
||||
}
|
||||
viewModel.appStart()
|
||||
|
|
@ -326,6 +327,11 @@ class MainActivity : AppCompatActivity() {
|
|||
Timber.d("onDestroy")
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
Timber.d("onConfigurationChanged")
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
Timber.v("onNewIntent")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
|
|
@ -17,6 +18,7 @@ import org.jellyfin.sdk.model.extensions.ticks
|
|||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
data class BaseItem(
|
||||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
|
|
|
|||
|
|
@ -696,7 +696,25 @@ sealed interface AppPreference<Pref, T> {
|
|||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.refreshRateSwitching },
|
||||
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,
|
||||
summaryOff = R.string.disabled,
|
||||
|
|
@ -942,6 +960,7 @@ val advancedPreferences =
|
|||
AppPreference.GlobalContentScale,
|
||||
AppPreference.MaxBitrate,
|
||||
AppPreference.RefreshRateSwitching,
|
||||
AppPreference.ResolutionSwitching,
|
||||
AppPreference.PlaybackDebugInfo,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class AppPreferencesSerializer
|
|||
playerBackend = AppPreference.PlayerBackendPref.defaultValue
|
||||
refreshRateSwitching =
|
||||
AppPreference.RefreshRateSwitching.defaultValue
|
||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||
|
||||
overrides =
|
||||
PlaybackOverrides
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class ImageUrlService
|
|||
itemType: BaseItemKind,
|
||||
seriesId: UUID?,
|
||||
useSeriesForPrimary: Boolean,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
|
|
@ -66,6 +67,13 @@ class ImageUrlService
|
|||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
|
|
@ -98,6 +106,7 @@ class ImageUrlService
|
|||
itemType = item.type,
|
||||
seriesId = item.data.seriesId,
|
||||
useSeriesForPrimary = item.useSeriesForPrimary,
|
||||
imageTags = item.data.imageTags.orEmpty(),
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
|
|
|
|||
|
|
@ -32,41 +32,61 @@ class RefreshRateService
|
|||
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
private val originalMode = display.mode
|
||||
|
||||
val displayModes get() = display.supportedModes
|
||||
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||
|
||||
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
|
||||
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
|
||||
private val displayModes: List<DisplayMode> by lazy {
|
||||
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
|
||||
*/
|
||||
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" }
|
||||
val width = stream.width
|
||||
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) {
|
||||
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||
return
|
||||
}
|
||||
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
|
||||
val targetMode =
|
||||
display.supportedModes
|
||||
.filterNot { it.physicalHeight < height || it.physicalWidth < width }
|
||||
.filter {
|
||||
(it.refreshRate * 100).roundToInt().let { modeRate ->
|
||||
frameRate % modeRate == 0 || // Exact multiple
|
||||
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||
}
|
||||
}.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
|
||||
if (targetMode != null && targetMode != display.mode) {
|
||||
findDisplayMode(
|
||||
displayModes = displayModes,
|
||||
streamWidth = width,
|
||||
streamHeight = height,
|
||||
targetFrameRate = frameRate,
|
||||
refreshRateSwitch = switchRefreshRate,
|
||||
resolutionSwitch = switchResolution,
|
||||
)
|
||||
Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode)
|
||||
if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) {
|
||||
val listener = Listener(display.displayId)
|
||||
displayManager.registerDisplayListener(
|
||||
listener,
|
||||
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
|
||||
)
|
||||
_refreshRateMode.setValueOnMain(targetMode)
|
||||
_refreshRateMode.setValueOnMain(targetMode.modeId)
|
||||
try {
|
||||
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
|
||||
Timber.w("Timed out waiting for display change")
|
||||
|
|
@ -98,7 +118,7 @@ class RefreshRateService
|
|||
* Reset the display mode to the original
|
||||
*/
|
||||
fun resetRefreshRate() {
|
||||
_refreshRateMode.value = originalMode
|
||||
_refreshRateMode.value = originalMode.modeId
|
||||
}
|
||||
|
||||
private class Listener(
|
||||
|
|
@ -119,4 +139,69 @@ class RefreshRateService
|
|||
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
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.core.DataStore
|
||||
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.Channels
|
||||
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
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.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
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.ImageType
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
|
|
@ -77,6 +88,21 @@ class TvProviderWorker
|
|||
val currentItems = getCurrentTvChannelNextUp()
|
||||
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 =
|
||||
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
|
||||
|
||||
|
|
@ -84,7 +110,7 @@ class TvProviderWorker
|
|||
val userRemovedIds = userRemoved.map { it.internalProviderId }
|
||||
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
|
||||
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 })
|
||||
|
||||
// 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)
|
||||
|
||||
addOtherChannels()
|
||||
|
||||
Timber.d("Completed successfully")
|
||||
} catch (_: ApiClientException) {
|
||||
return Result.retry()
|
||||
|
|
@ -226,6 +254,142 @@ class TvProviderWorker
|
|||
)
|
||||
}.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 {
|
||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
|
||||
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.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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.tv.material3.MaterialTheme
|
||||
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
|
||||
fun <T> ItemRow(
|
||||
|
|
@ -39,13 +32,10 @@ fun <T> ItemRow(
|
|||
onLongClick: () -> Unit,
|
||||
) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusPair: FocusPair? = null,
|
||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
|
|
@ -62,23 +52,14 @@ fun <T> ItemRow(
|
|||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val cardModifier =
|
||||
if (index == 0 && focusPair == null) {
|
||||
if (index == 0) {
|
||||
Modifier.focusRequester(firstFocus)
|
||||
} else {
|
||||
if (focusPair != null && focusPair.column == index) {
|
||||
Modifier.focusRequester(focusPair.focusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedIndex = index
|
||||
}
|
||||
cardOnFocus?.invoke(it.isFocused, index)
|
||||
Modifier
|
||||
}
|
||||
cardContent.invoke(
|
||||
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,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId.toServerString(),
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
|
|
@ -531,6 +532,7 @@ fun CollectionFolderGrid(
|
|||
onClickItem,
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
viewModelKey = viewModelKey,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
modifier = modifier,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
|
|
@ -551,6 +553,7 @@ fun CollectionFolderGrid(
|
|||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId,
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
|
|
@ -559,7 +562,7 @@ fun CollectionFolderGrid(
|
|||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
viewModel: CollectionFolderViewModel =
|
||||
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||
key = itemId,
|
||||
key = viewModelKey,
|
||||
) {
|
||||
it.create(
|
||||
itemId = itemId,
|
||||
|
|
|
|||
|
|
@ -136,11 +136,12 @@ class GenreViewModel
|
|||
// excludeItemIds.add(item.id)
|
||||
genreToUrl[genre.id] =
|
||||
imageUrlService.getItemImageUrl(
|
||||
item.id,
|
||||
item.type,
|
||||
null,
|
||||
false,
|
||||
ImageType.BACKDROP,
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = null,
|
||||
useSeriesForPrimary = false,
|
||||
imageType = ImageType.BACKDROP,
|
||||
imageTags = emptyMap(),
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.components
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -21,7 +22,7 @@ fun MovieQuickDetails(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val now = LocalClock.current.now
|
||||
val now by LocalClock.current.now
|
||||
val details =
|
||||
remember(dto, now) {
|
||||
buildList {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -59,7 +60,7 @@ fun EpisodeQuickDetails(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val now = LocalClock.current.now
|
||||
val now by LocalClock.current.now
|
||||
val details =
|
||||
remember(dto, now) {
|
||||
buildList {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components
|
|||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
|
|||
|
||||
@Composable
|
||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||
val timeString by LocalClock.current.timeString
|
||||
Text(
|
||||
text = LocalClock.current.timeString,
|
||||
text = timeString,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ fun CollectionFolderMovie(
|
|||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_library",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
|
|
@ -146,6 +147,7 @@ fun CollectionFolderMovie(
|
|||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
},
|
||||
itemId = destination.itemId,
|
||||
viewModelKey = "${destination.itemId}_collection",
|
||||
initialFilter =
|
||||
CollectionFolderFilter(
|
||||
filter =
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ fun DebugPage(
|
|||
"Manufacturer: ${Build.MANUFACTURER}",
|
||||
"Model: ${Build.MODEL}",
|
||||
"Display Modes:",
|
||||
*viewModel.refreshRateService.displayModes,
|
||||
*viewModel.refreshRateService.supportedDisplayModes,
|
||||
).forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ fun PlaylistItem(
|
|||
},
|
||||
trailingContent = {
|
||||
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
|
||||
val now = LocalClock.current.now
|
||||
val now by LocalClock.current.now
|
||||
val endTimeStr =
|
||||
remember(item, now) {
|
||||
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.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import eu.wewox.programguide.ProgramGuide
|
||||
import eu.wewox.programguide.ProgramGuideDimensions
|
||||
|
|
@ -289,22 +289,8 @@ fun TvGuideGridContent(
|
|||
|
||||
var focusedItem by rememberPosition(RowColumn(0, 0))
|
||||
val focusedChannelIndex = focusedItem.row
|
||||
val focusedProgramIndex =
|
||||
remember(programs.range, focusedItem) {
|
||||
focusedItem.let { focus ->
|
||||
(programs.range.first..<focus.row).sumOf {
|
||||
val channelId = channels[it].id
|
||||
channelProgramCount[channelId] ?: 0
|
||||
} + focus.column
|
||||
}
|
||||
}
|
||||
var focusedProgramIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(focusedProgramIndex) {
|
||||
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
|
||||
scope.launch(ExceptionHandler()) {
|
||||
state.animateToProgram(focusedProgramIndex, Alignment.Center)
|
||||
}
|
||||
}
|
||||
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
|
||||
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
|
|
@ -485,7 +471,7 @@ fun TvGuideGridContent(
|
|||
|
||||
if (newFocusedItem != null) {
|
||||
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
|
||||
val toFocus =
|
||||
newFocusedItem
|
||||
|
|
@ -494,10 +480,24 @@ fun TvGuideGridContent(
|
|||
column =
|
||||
newFocusedItem.column.coerceIn(
|
||||
0,
|
||||
(programs.size - 1).coerceAtLeast(0),
|
||||
(channelPrograms.size - 1).coerceAtLeast(0),
|
||||
),
|
||||
)
|
||||
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)
|
||||
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.PlaylistLoadingState
|
||||
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.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||
|
|
@ -226,14 +225,15 @@ fun HomePageContent(
|
|||
var position by rememberSaveable(stateSaver = RowColumnSaver) {
|
||||
mutableStateOf(RowColumn(firstRow, 0))
|
||||
}
|
||||
var focusedItem =
|
||||
position.let {
|
||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||
val focusedItem =
|
||||
remember(position) {
|
||||
position.let {
|
||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||
}
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val positionFocusRequester = remember { FocusRequester() }
|
||||
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(homeRows) {
|
||||
if (!focused) {
|
||||
|
|
@ -241,7 +241,7 @@ fun HomePageContent(
|
|||
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
?.let {
|
||||
positionFocusRequester.tryRequestFocus()
|
||||
rowFocusRequesters[it].tryRequestFocus()
|
||||
delay(50)
|
||||
listState.animateScrollToItem(position.row)
|
||||
focused = true
|
||||
|
|
@ -251,9 +251,6 @@ fun HomePageContent(
|
|||
LaunchedEffect(position) {
|
||||
listState.animateScrollToItem(position.row)
|
||||
}
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(onUpdateBackdrop)
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HomePageHeader(
|
||||
|
|
@ -275,16 +272,7 @@ fun HomePageContent(
|
|||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.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
|
||||
},
|
||||
.focusRestorer(),
|
||||
) {
|
||||
itemsIndexed(homeRows) { rowIndex, row ->
|
||||
when (val r = row) {
|
||||
|
|
@ -347,22 +335,17 @@ fun HomePageContent(
|
|||
onClickItem = { 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.invoke(RowColumn(rowIndex, index), item)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(rowFocusRequesters[rowIndex])
|
||||
.animateItem(),
|
||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||
val cornerText =
|
||||
remember {
|
||||
remember(item) {
|
||||
item?.data?.indexNumber?.let { "E$it" }
|
||||
?: item
|
||||
?.data
|
||||
|
|
@ -385,29 +368,32 @@ fun HomePageContent(
|
|||
onLongClick = onLongClick,
|
||||
modifier =
|
||||
cardModifier
|
||||
.ifElse(
|
||||
focusedItem == item,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
).ifElse(
|
||||
RowColumn(rowIndex, index) == position,
|
||||
Modifier.focusRequester(
|
||||
positionFocusRequester,
|
||||
),
|
||||
).onFocusChanged {
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(rowIndex, index)
|
||||
item?.let(onUpdateBackdrop)
|
||||
}
|
||||
if (it.isFocused && onFocusPosition != null) {
|
||||
val nonEmptyRowBefore =
|
||||
homeRows
|
||||
.subList(0, rowIndex)
|
||||
.count {
|
||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
||||
}
|
||||
onFocusPosition?.invoke(
|
||||
onFocusPosition.invoke(
|
||||
RowColumn(
|
||||
rowIndex - nonEmptyRowBefore,
|
||||
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,
|
||||
cardHeight = Cards.height2x3,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
|
|
@ -37,11 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
|
|
@ -360,10 +359,8 @@ fun NavDrawer(
|
|||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(drawerWidth)
|
||||
.background(drawerBackground)
|
||||
.onFocusChanged {
|
||||
if (!it.hasFocus) {
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(drawerBackground)
|
||||
},
|
||||
) {
|
||||
// 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
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
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.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PlaybackDebugOverlay(
|
||||
currentPlayback: CurrentPlayback?,
|
||||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
|
|
@ -38,10 +73,12 @@ fun PlaybackDebugOverlay(
|
|||
add("Video Decoder:" to currentPlayback.videoDecoder)
|
||||
add("Audio Decoder:" to currentPlayback.audioDecoder)
|
||||
}
|
||||
add("Display Mode: " to displayMode?.toString())
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
currentPlayback?.transcodeInfo?.let {
|
||||
TranscodeInfo(it, Modifier)
|
||||
TranscodeInfo(it, Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,27 +123,21 @@ fun SimpleTable(
|
|||
rows: List<Pair<String, String?>>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
rows.forEach {
|
||||
rows.forEach {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = it.first,
|
||||
modifier = Modifier.width(100.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
rows.forEach {
|
||||
Text(
|
||||
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.height
|
||||
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.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -457,9 +457,10 @@ fun PlaybackOverlay(
|
|||
AsyncImage(
|
||||
model = logoImageUrl,
|
||||
contentDescription = "Logo",
|
||||
alignment = Alignment.TopStart,
|
||||
modifier =
|
||||
Modifier
|
||||
.sizeIn(maxWidth = 180.dp, maxHeight = 100.dp)
|
||||
.size(width = 240.dp, height = 120.dp)
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -641,10 +642,16 @@ class PlaybackViewModel
|
|||
mediaSourceInfo = source,
|
||||
)
|
||||
|
||||
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
|
||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
|
||||
refreshRateService.changeRefreshRate(it)
|
||||
}
|
||||
preferences.appPreferences.playbackPreferences.let { prefs ->
|
||||
source.mediaStreams
|
||||
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||
?.let { stream ->
|
||||
refreshRateService.changeRefreshRate(
|
||||
stream = stream,
|
||||
switchRefreshRate = prefs.refreshRateSwitching,
|
||||
switchResolution = prefs.resolutionSwitching,
|
||||
)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
// TODO, don't need to release & recreate when switching streams
|
||||
|
|
@ -754,7 +761,7 @@ class PlaybackViewModel
|
|||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(url)
|
||||
.size(coil3.size.Size.ORIGINAL)
|
||||
.size(Size.ORIGINAL)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ package com.github.damontecres.wholphin.ui.util
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.github.damontecres.wholphin.ui.TimeFormatter
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
|
@ -22,20 +21,28 @@ data class Clock(
|
|||
/**
|
||||
* The current [LocalDateTime]
|
||||
*/
|
||||
val now: LocalDateTime,
|
||||
val now: MutableState<LocalDateTime>,
|
||||
/**
|
||||
* The current time formatted as a string with [TimeFormatter]
|
||||
*/
|
||||
val timeString: String,
|
||||
val timeString: MutableState<String>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
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) {
|
||||
while (isActive) {
|
||||
clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }
|
||||
delay(1_000)
|
||||
val now = LocalDateTime.now()
|
||||
val time = TimeFormatter.format(now)
|
||||
clock.now.value = now
|
||||
clock.timeString.value = time
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
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.graphics.Bitmap
|
||||
import android.view.Surface
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
// Wrapper for native library
|
||||
|
||||
|
|
@ -96,20 +97,16 @@ object MPVLib {
|
|||
format: Int,
|
||||
)
|
||||
|
||||
private val observers = mutableListOf<EventObserver>()
|
||||
private val observers = CopyOnWriteArrayList<EventObserver>()
|
||||
|
||||
@JvmStatic
|
||||
fun addObserver(o: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.add(o)
|
||||
}
|
||||
observers.add(o)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun removeObserver(o: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.remove(o)
|
||||
}
|
||||
observers.remove(o)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
@ -117,10 +114,8 @@ object MPVLib {
|
|||
property: String,
|
||||
value: Long,
|
||||
) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,10 +124,8 @@ object MPVLib {
|
|||
property: String,
|
||||
value: Boolean,
|
||||
) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +134,8 @@ object MPVLib {
|
|||
property: String,
|
||||
value: Double,
|
||||
) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,28 +144,22 @@ object MPVLib {
|
|||
property: String,
|
||||
value: String,
|
||||
) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun eventProperty(property: String) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.eventProperty(property)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.eventProperty(property)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun event(eventId: Int) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.event(eventId)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.event(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,27 +168,21 @@ object MPVLib {
|
|||
reason: Int,
|
||||
error: Int,
|
||||
) {
|
||||
synchronized(observers) {
|
||||
for (o in observers) {
|
||||
o.eventEndFile(reason, error)
|
||||
}
|
||||
for (o in observers) {
|
||||
o.eventEndFile(reason, error)
|
||||
}
|
||||
}
|
||||
|
||||
private val log_observers = mutableListOf<LogObserver>()
|
||||
private val log_observers = CopyOnWriteArrayList<LogObserver>()
|
||||
|
||||
@JvmStatic
|
||||
fun addLogObserver(o: LogObserver) {
|
||||
synchronized(log_observers) {
|
||||
log_observers.add(o)
|
||||
}
|
||||
log_observers.add(o)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun removeLogObserver(o: LogObserver) {
|
||||
synchronized(log_observers) {
|
||||
log_observers.remove(o)
|
||||
}
|
||||
log_observers.remove(o)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
@ -212,10 +191,8 @@ object MPVLib {
|
|||
level: Int,
|
||||
text: String,
|
||||
) {
|
||||
synchronized(log_observers) {
|
||||
for (o in log_observers) {
|
||||
o.logMessage(prefix, level, text)
|
||||
}
|
||||
for (o in log_observers) {
|
||||
o.logMessage(prefix, level, text)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ message PlaybackPreferences {
|
|||
PlayerBackend player_backend = 20;
|
||||
MpvOptions mpv_options = 21;
|
||||
bool refresh_rate_switching = 22;
|
||||
bool resolution_switching = 23;
|
||||
}
|
||||
|
||||
message HomePagePreferences{
|
||||
|
|
|
|||
|
|
@ -398,6 +398,7 @@
|
|||
<string name="color_code_programs">Color-code programs</string>
|
||||
<string name="subtitle_delay">Subtitle delay</string>
|
||||
<string name="backdrop_display">Backdrop style</string>
|
||||
<string name="resolution_switching">Resolution switching</string>
|
||||
|
||||
<string name="discover">Discover</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),
|
||||
),
|
||||
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