diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9edf3067..cb130157 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 92398cb5..70e8b7f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index e5cd53bc..599b7638 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index bc5564f6..f719d19c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -696,7 +696,25 @@ sealed interface AppPreference { 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( + 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, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index c39b9012..f84744c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -52,6 +52,7 @@ class AppPreferencesSerializer playerBackend = AppPreference.PlayerBackendPref.defaultValue refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue + resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index cab8609e..39967e24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -28,6 +28,7 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, + imageTags: Map, 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index e34d2fa1..67b541fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -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(originalMode) - val refreshRateMode: LiveData = _refreshRateMode + private val displayModes: List by lazy { + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + } + + private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId) + val refreshRateMode: LiveData = _refreshRateMode /** * Find the best display mode for the given stream and signal to change to it */ - 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, + 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 1d54f256..134e4903 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -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" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 618b9003..c03a373a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -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 ItemRow( @@ -39,13 +32,10 @@ fun 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 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 ItemRow( } } } - -@Composable -fun BannerItemRow( - title: String, - items: List, - 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, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index a2e99ffe..eef5c9ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -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( - key = itemId, + key = viewModelKey, ) { it.create( itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index c84045b3..ec8feca0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -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, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt index e25f50be..6d9984dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index de79bd5a..3939df91 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index 8fa6f60f..f5791e04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index f824a4b9..1292cd5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 60ad839e..f0ad2c0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 150228a5..4907d71e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 68c62063..037a5ae7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -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.. + (programs.range.first..= 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index c5d80661..696c87d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index 496166c3..ce31d865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -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(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>, 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, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 74821924..99313af2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -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), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 1d380e08..39cee993 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index 53cd4d9b..5c5d2a60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -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, /** * The current time formatted as a string with [TimeFormatter] */ - val timeString: String, + val timeString: MutableState, ) @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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index acb2f8d8..e5b902d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -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() + private val observers = CopyOnWriteArrayList() @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() + private val log_observers = CopyOnWriteArrayList() @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) } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f10b49c5..e4cd52d7 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -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{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42e5c911..b7fb034d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,6 +398,7 @@ Color-code programs Subtitle delay Backdrop style + Resolution switching Discover Request diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..115a8e89 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt new file mode 100644 index 00000000..7af1bfce --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index fa16c5ab..7d551049 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -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), + ), + ), ) } }