mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/music
This commit is contained in:
commit
bd7fba14d1
56 changed files with 1528 additions and 227 deletions
|
|
@ -1,6 +1,4 @@
|
|||
name: "Feature Request"
|
||||
description: Request a new feature
|
||||
title: "[FEA] - <title>"
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"enhancement"
|
||||
]
|
||||
6
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
6
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: "Bug Report"
|
||||
description: Report a bug
|
||||
title: "[BUG] - <title>"
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"bug"
|
||||
]
|
||||
|
|
@ -28,7 +28,7 @@ body:
|
|||
attributes:
|
||||
label: "App Version"
|
||||
description: What version of the app?
|
||||
placeholder: "v0.3.0"
|
||||
placeholder: "v0.5.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
@ -36,7 +36,7 @@ body:
|
|||
attributes:
|
||||
label: "Server Version"
|
||||
description: What version of the server?
|
||||
placeholder: "10.11.3"
|
||||
placeholder: "10.11.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
|
|||
7
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
7
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: "Playback Problem"
|
||||
description: Report an issue with playback
|
||||
title: "[BUG] - <title>"
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"bug", "playback"
|
||||
]
|
||||
|
|
@ -17,6 +17,7 @@ body:
|
|||
attributes:
|
||||
label: "Media info"
|
||||
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
|
||||
placeholder: Use mediainfo or "Send media info to server" in the app to gather this
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
@ -35,7 +36,7 @@ body:
|
|||
attributes:
|
||||
label: "App Version"
|
||||
description: What version of the app?
|
||||
placeholder: "v0.3.0"
|
||||
placeholder: "v0.5.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
@ -43,7 +44,7 @@ body:
|
|||
attributes:
|
||||
label: "Server Version"
|
||||
description: What version of the server?
|
||||
placeholder: "10.11.3"
|
||||
placeholder: "10.11.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
|
|
|||
15
.github/ISSUE_TEMPLATE/03-question.yml
vendored
15
.github/ISSUE_TEMPLATE/03-question.yml
vendored
|
|
@ -1,15 +0,0 @@
|
|||
name: "Question"
|
||||
description: Ask a question
|
||||
title: "[QST] - <title>"
|
||||
labels: [
|
||||
"question"
|
||||
]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Question"
|
||||
description: Please enter your question
|
||||
placeholder: "How do I...?"
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: New feature / UI suggestion
|
||||
url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas
|
||||
about: Please request new features here
|
||||
- name: Questions
|
||||
url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a
|
||||
about: Please ask questions here
|
||||
15
README.md
15
README.md
|
|
@ -24,7 +24,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
|||
|
||||
</p>
|
||||
|
||||
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
|
|||
### Customized home page
|
||||

|
||||
|
||||
|
||||
### Movie library browsing
|
||||
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" />
|
||||

|
||||
|
||||
### Movie page
|
||||
<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" />
|
||||

|
||||
|
||||
### Series page
|
||||
<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" />
|
||||

|
||||
|
||||
### Genres in library
|
||||

|
||||
|
||||
|
||||
### Playlist
|
||||
<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" />
|
||||

|
||||
|
|
|
|||
|
|
@ -68,6 +68,17 @@
|
|||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<service
|
||||
android:name=".WholphinDreamService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ package com.github.damontecres.wholphin
|
|||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -49,6 +51,7 @@ import com.github.damontecres.wholphin.services.ImageUrlService
|
|||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.ServerEventListener
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
|
|
@ -59,8 +62,10 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
|||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -74,8 +79,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -134,6 +143,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
||||
|
||||
@Inject
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
private var signInAuto = true
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -157,6 +169,20 @@ class MainActivity : AppCompatActivity() {
|
|||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
screensaverService.keepScreenOn
|
||||
.onEach { keepScreenOn ->
|
||||
Timber.v("keepScreenOn: %s", keepScreenOn)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (keepScreenOn) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error with keepScreenOn")
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.appStart()
|
||||
setContent {
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||
|
|
@ -317,6 +343,15 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
},
|
||||
)
|
||||
val screenSaverState by screensaverService.state.collectAsState()
|
||||
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
|
||||
AnimatedVisibility(
|
||||
screenSaverState.show,
|
||||
Modifier.fillMaxSize(),
|
||||
) {
|
||||
AppScreensaver(appPreferences, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -324,10 +359,22 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (screensaverService.state.value.show) {
|
||||
screensaverService.stop(false)
|
||||
screensaverService.pulse()
|
||||
return true
|
||||
} else {
|
||||
screensaverService.pulse()
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.d("onResume")
|
||||
lifecycleScope.launchIO {
|
||||
lifecycleScope.launchDefault {
|
||||
screensaverService.pulse()
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
}
|
||||
|
|
@ -341,6 +388,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onStop() {
|
||||
super.onStop()
|
||||
Timber.d("onStop")
|
||||
screensaverService.stop(true)
|
||||
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
|
|||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composer
|
||||
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
||||
|
|
@ -27,16 +26,6 @@ class WholphinApplication :
|
|||
init {
|
||||
instance = this
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy
|
||||
.Builder()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
|
|
@ -64,6 +53,23 @@ class WholphinApplication :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy
|
||||
.Builder()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.penaltyDeathOnNetwork()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy
|
||||
.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
OkHttp.initialize(this)
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.service.dreams.DreamService
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WholphinDreamService :
|
||||
DreamService(),
|
||||
SavedStateRegistryOwner {
|
||||
@Inject
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
@Inject
|
||||
lateinit var userPreferencesService: UserPreferencesService
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
|
||||
private val savedStateRegistryController =
|
||||
SavedStateRegistryController.create(this).apply {
|
||||
performAttach()
|
||||
}
|
||||
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
savedStateRegistryController.performRestore(null)
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
|
||||
setContentView(
|
||||
ComposeView(this).apply {
|
||||
setViewTreeLifecycleOwner(this@WholphinDreamService)
|
||||
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
|
||||
setContent {
|
||||
var prefs by remember { mutableStateOf<UserPreferences?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
userPreferencesService.flow.collectLatest { prefs = it }
|
||||
}
|
||||
prefs?.let { prefs ->
|
||||
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
|
||||
val screensaverPrefs =
|
||||
prefs.appPreferences.interfacePreferences.screensaverPreference
|
||||
val currentItem by itemFlow.collectAsState(null)
|
||||
AppScreensaverContent(
|
||||
currentItem = currentItem,
|
||||
showClock = screensaverPrefs.showClock,
|
||||
duration = screensaverPrefs.duration.milliseconds,
|
||||
animate = screensaverPrefs.animate,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDreamingStarted() {
|
||||
super.onDreamingStarted()
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
}
|
||||
|
||||
override fun onDreamingStopped() {
|
||||
super.onDreamingStopped()
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
}
|
||||
}
|
||||
|
|
@ -42,8 +42,6 @@ class ServerRepository
|
|||
val apiClient: ApiClient,
|
||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||
) {
|
||||
private val sharedPreferences = getServerSharedPreferences(context)
|
||||
|
||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||
val current: LiveData<CurrentUser?> = _current
|
||||
|
||||
|
|
@ -107,7 +105,7 @@ class ServerRepository
|
|||
_current.value = CurrentUser(updatedServer, updatedUser)
|
||||
_currentUserDto.value = userDto
|
||||
}
|
||||
sharedPreferences.edit(true) {
|
||||
getServerSharedPreferences(context).edit(true) {
|
||||
putString(SERVER_URL_KEY, updatedServer.url)
|
||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ data class BaseItem(
|
|||
data.indexNumber?.let { "E$it" }
|
||||
?: data.premiereDate?.let(::formatDateTime),
|
||||
episodeUnplayedCornerText =
|
||||
if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || type == BaseItemKind.BOX_SET) {
|
||||
if (type == BaseItemKind.SERIES ||
|
||||
type == BaseItemKind.SEASON ||
|
||||
type == BaseItemKind.EPISODE ||
|
||||
type == BaseItemKind.BOX_SET
|
||||
) {
|
||||
data.indexNumber?.let { "E$it" }
|
||||
?: data.userData
|
||||
?.unplayedItemCount
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -19,19 +23,21 @@ data class Person(
|
|||
) {
|
||||
companion object {
|
||||
fun fromDto(
|
||||
context: Context,
|
||||
dto: BaseItemPerson,
|
||||
api: ApiClient,
|
||||
): Person =
|
||||
Person(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
role = dto.role,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
favorite = false,
|
||||
)
|
||||
|
||||
fun fromDto(
|
||||
context: Context,
|
||||
dto: BaseItemPerson,
|
||||
favorite: Boolean,
|
||||
api: ApiClient,
|
||||
|
|
@ -39,10 +45,51 @@ data class Person(
|
|||
Person(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
role = dto.role,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
favorite = favorite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun personRole(
|
||||
context: Context,
|
||||
role: String?,
|
||||
type: PersonKind,
|
||||
): String? =
|
||||
if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) {
|
||||
role
|
||||
} else if (role.equals(type.name, ignoreCase = true)) {
|
||||
type.stringRes?.let { context.getString(it) }
|
||||
} else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) {
|
||||
role
|
||||
} else {
|
||||
listOfNotNull(
|
||||
role?.takeIf { it.isNotNullOrBlank() },
|
||||
type.stringRes?.let { context.getString(it) },
|
||||
).takeIf { it.isNotEmpty() }?.joinToString(" - ")
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
private val PersonKind.stringRes: Int?
|
||||
get() =
|
||||
when (this) {
|
||||
PersonKind.UNKNOWN -> R.string.unknown
|
||||
PersonKind.ACTOR -> R.string.actor
|
||||
PersonKind.DIRECTOR -> R.string.director
|
||||
PersonKind.COMPOSER -> R.string.composer
|
||||
PersonKind.WRITER -> R.string.writer
|
||||
PersonKind.GUEST_STAR -> R.string.guest_star
|
||||
PersonKind.PRODUCER -> R.string.producer
|
||||
PersonKind.CONDUCTOR -> R.string.conductor
|
||||
PersonKind.LYRICIST -> R.string.lyricist
|
||||
PersonKind.ARRANGER -> R.string.arranger
|
||||
PersonKind.ENGINEER -> R.string.engineer
|
||||
PersonKind.MIXER -> R.string.mixer
|
||||
PersonKind.REMIXER -> R.string.mixer
|
||||
PersonKind.CREATOR -> R.string.creator
|
||||
PersonKind.ARTIST -> R.string.artist
|
||||
PersonKind.ALBUM_ARTIST -> R.string.artist
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -997,6 +997,12 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val ScreensaverSettings =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.screensaver_settings,
|
||||
destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1011,6 +1017,7 @@ val basicPreferences =
|
|||
AppPreference.RememberSelectedTab,
|
||||
AppPreference.SubtitleStyle,
|
||||
AppPreference.ThemeColors,
|
||||
AppPreference.ScreensaverSettings,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
|
|
@ -1201,6 +1208,24 @@ val liveTvPreferences =
|
|||
AppPreference.LiveTvColorCodePrograms,
|
||||
)
|
||||
|
||||
val screensaverPreferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.screensaver,
|
||||
preferences =
|
||||
listOf(
|
||||
ScreensaverPreference.Enabled,
|
||||
ScreensaverPreference.StartDelay,
|
||||
ScreensaverPreference.Duration,
|
||||
ScreensaverPreference.ShowClock,
|
||||
ScreensaverPreference.Animate,
|
||||
ScreensaverPreference.MaxAge,
|
||||
ScreensaverPreference.ItemTypes,
|
||||
ScreensaverPreference.Start,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
data class AppSwitchPreference<Pref>(
|
||||
@get:StringRes override val title: Int,
|
||||
override val defaultValue: Boolean,
|
||||
|
|
@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference<Pref, T>(
|
|||
override val getter: (prefs: Pref) -> List<T>,
|
||||
override val setter: (prefs: Pref, value: List<T>) -> Pref,
|
||||
@param:StringRes val summary: Int? = null,
|
||||
val toSharedPrefs: (T) -> String,
|
||||
val fromSharedPrefs: (String) -> T?,
|
||||
) : AppPreference<Pref, List<T>>
|
||||
|
||||
data class AppClickablePreference<Pref>(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
|
|||
import androidx.datastore.core.Serializer
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
|
|
@ -120,6 +121,20 @@ class AppPreferencesSerializer
|
|||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}.build()
|
||||
|
||||
screensaverPreference =
|
||||
ScreensaverPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
|
||||
duration = ScreensaverPreference.DEFAULT_DURATION
|
||||
animate = ScreensaverPreference.Animate.defaultValue
|
||||
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
|
||||
showClock = ScreensaverPreference.ShowClock.defaultValue
|
||||
clearItemTypes()
|
||||
addItemTypes(BaseItemKind.MOVIE.serialName)
|
||||
addItemTypes(BaseItemKind.SERIES.serialName)
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
advancedPreferences =
|
||||
|
|
@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder
|
|||
photoPreferences = photoPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences =
|
||||
updateInterfacePreferences {
|
||||
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
package com.github.damontecres.wholphin.preferences
|
||||
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object ScreensaverPreference {
|
||||
val Enabled =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.in_app_screensaver,
|
||||
defaultValue = false,
|
||||
getter = { it.interfacePreferences.screensaverPreference.enabled },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences { enabled = value }
|
||||
},
|
||||
summaryOn = R.string.yes,
|
||||
summaryOff = R.string.no,
|
||||
)
|
||||
|
||||
const val DEFAULT_START_DELAY = 15 * 60_000L
|
||||
private val startDelayValues =
|
||||
listOf(
|
||||
30_000L,
|
||||
60_000L,
|
||||
2 * 60_000L,
|
||||
5 * 60_000L,
|
||||
10 * 60_000L,
|
||||
15 * 60_000L,
|
||||
30 * 60_000L,
|
||||
60 * 60_000L,
|
||||
)
|
||||
val StartDelay =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.start_after,
|
||||
defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(),
|
||||
min = 0,
|
||||
max = startDelayValues.size - 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
startDelay = startDelayValues[value.toInt()]
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY
|
||||
v.milliseconds.toString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const val DEFAULT_DURATION = 30_000L
|
||||
private val durationValues =
|
||||
listOf(
|
||||
15_000L,
|
||||
30_000L,
|
||||
60_000L,
|
||||
2 * 60_000L,
|
||||
)
|
||||
val Duration =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.duration,
|
||||
defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(),
|
||||
min = 0,
|
||||
max = durationValues.size - 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
duration = durationValues[value.toInt()]
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
if (value != null) {
|
||||
val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION
|
||||
v.milliseconds.toString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val ShowClock =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_clock,
|
||||
defaultValue = AppPreference.ShowClock.defaultValue,
|
||||
getter = { it.interfacePreferences.screensaverPreference.showClock },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences { showClock = value }
|
||||
},
|
||||
summaryOn = R.string.yes,
|
||||
summaryOff = R.string.no,
|
||||
)
|
||||
|
||||
val Animate =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.animate,
|
||||
defaultValue = true,
|
||||
getter = { it.interfacePreferences.screensaverPreference.animate },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences { animate = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
const val DEFAULT_MAX_AGE = 16
|
||||
private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1)
|
||||
val MaxAge =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
title = R.string.max_age_rating,
|
||||
defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(),
|
||||
min = 0,
|
||||
max = maxAgeValues.size - 1L,
|
||||
interval = 1,
|
||||
getter = {
|
||||
it.interfacePreferences.screensaverPreference.maxAgeFilter
|
||||
.takeIf { it >= 0 }
|
||||
?.let { maxAgeValues.indexOf(it).toLong() }
|
||||
?: maxAgeValues.lastIndex.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
maxAgeFilter = maxAgeValues[value.toInt()]
|
||||
}
|
||||
},
|
||||
summarizer = { value ->
|
||||
when (value) {
|
||||
null -> {
|
||||
null
|
||||
}
|
||||
|
||||
maxAgeValues.lastIndex.toLong() -> {
|
||||
WholphinApplication.instance.getString(R.string.no_max)
|
||||
}
|
||||
|
||||
0L -> {
|
||||
WholphinApplication.instance.getString(R.string.for_all_ages)
|
||||
}
|
||||
|
||||
else -> {
|
||||
WholphinApplication.instance.getString(
|
||||
R.string.up_to_age,
|
||||
maxAgeValues[value.toInt()].toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val ItemTypes =
|
||||
AppMultiChoicePreference<AppPreferences, BaseItemKind>(
|
||||
title = R.string.include_types,
|
||||
summary = R.string.include_types_summary,
|
||||
defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO),
|
||||
displayValues = R.array.screensaver_item_types,
|
||||
getter = {
|
||||
it.interfacePreferences.screensaverPreference.itemTypesList.map { type ->
|
||||
BaseItemKind.fromName(type)
|
||||
}
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateScreensaverPreferences {
|
||||
clearItemTypes()
|
||||
addAllItemTypes(value.map { it.serialName })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val Start =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.start_screensaver,
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
|
|||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||
|
|
@ -16,11 +17,13 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
|||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
|
@ -246,4 +249,18 @@ suspend fun upgradeApp(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateScreensaverPreferences {
|
||||
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
|
||||
duration = ScreensaverPreference.DEFAULT_DURATION
|
||||
animate = ScreensaverPreference.Animate.defaultValue
|
||||
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
|
||||
clearItemTypes()
|
||||
addItemTypes(BaseItemKind.MOVIE.serialName)
|
||||
addItemTypes(BaseItemKind.SERIES.serialName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class BackdropService
|
|||
if (item.type == BaseItemKind.GENRE) {
|
||||
item.imageUrlOverride
|
||||
} else {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
}
|
||||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,18 +28,16 @@ class ImageUrlService
|
|||
itemType: BaseItemKind,
|
||||
seriesId: UUID?,
|
||||
useSeriesForPrimary: Boolean,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
imageType: ImageType,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
backdropTags: List<String>,
|
||||
parentThumbId: UUID? = null,
|
||||
parentBackdropId: UUID? = null,
|
||||
backdropTags: List<String> = emptyList(),
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): String? =
|
||||
when (imageType) {
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
-> {
|
||||
ImageType.LOGO -> {
|
||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
|
|
@ -57,6 +55,27 @@ class ImageUrlService
|
|||
}
|
||||
}
|
||||
|
||||
ImageType.BACKDROP,
|
||||
-> {
|
||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (backdropTags.isNotEmpty()) {
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
ImageType.THUMB -> {
|
||||
if (useSeriesForPrimary && parentThumbId != null &&
|
||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -64,7 +66,11 @@ class NavDrawerService
|
|||
if (user != null && userDto != null && user.id == userDto.id) {
|
||||
updateNavDrawer(user, userDto)
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error updating nav drawer")
|
||||
showToast(context, "Error fetching user's views")
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
seerrServerRepository.active
|
||||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import javax.inject.Inject
|
||||
|
|
@ -12,6 +14,7 @@ import javax.inject.Singleton
|
|||
class PeopleFavorites
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
suspend fun getPeopleFor(item: BaseItem): List<Person> =
|
||||
|
|
@ -27,7 +30,14 @@ class PeopleFavorites
|
|||
val people =
|
||||
item.data.people
|
||||
?.letNotEmpty { people ->
|
||||
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
|
||||
people.map {
|
||||
Person.fromDto(
|
||||
context,
|
||||
it,
|
||||
favorites[it.id] ?: false,
|
||||
api,
|
||||
)
|
||||
}
|
||||
}.orEmpty()
|
||||
people
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.components.CurrentItem
|
||||
import com.github.damontecres.wholphin.ui.formatDate
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Singleton
|
||||
class ScreensaverService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@param:DefaultCoroutineScope private val scope: CoroutineScope,
|
||||
private val api: ApiClient,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
val state: StateFlow<ScreensaverState> = _state
|
||||
|
||||
val keepScreenOn = MutableStateFlow(false)
|
||||
|
||||
private var waitJob: Job? = null
|
||||
|
||||
init {
|
||||
userPreferencesService.flow
|
||||
.onEach { prefs ->
|
||||
_state.update {
|
||||
val enabled =
|
||||
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
|
||||
keepScreenOnInternal(enabled)
|
||||
ScreensaverState(enabled, false, false, false)
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun pulse() {
|
||||
waitJob?.cancel()
|
||||
if (_state.value.enabled) {
|
||||
// Timber.v("pulse")
|
||||
_state.update {
|
||||
if (!it.active) {
|
||||
it.copy(active = false)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
if (!_state.value.paused) {
|
||||
waitJob =
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val startDelay =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
|
||||
delay(startDelay)
|
||||
_state.update {
|
||||
it.copy(active = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
enabledTemp = true,
|
||||
active = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(cancelJob: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
enabledTemp = false,
|
||||
active = false,
|
||||
)
|
||||
}
|
||||
if (cancelJob) waitJob?.cancel()
|
||||
}
|
||||
|
||||
fun keepScreenOn(keep: Boolean) {
|
||||
scope.launchDefault {
|
||||
val screensaverEnabled = _state.value.enabled
|
||||
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
|
||||
if (screensaverEnabled) {
|
||||
// Page is requesting to keep screen on, so we don't wait to show the screensaver
|
||||
_state.update {
|
||||
it.copy(active = false, paused = keep)
|
||||
}
|
||||
if (!keep) {
|
||||
pulse()
|
||||
}
|
||||
} else {
|
||||
keepScreenOnInternal(keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepScreenOnInternal(keep: Boolean) {
|
||||
keepScreenOn.update { keep }
|
||||
}
|
||||
|
||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||
flow {
|
||||
val prefs =
|
||||
userPreferencesService.flow
|
||||
.first()
|
||||
.appPreferences
|
||||
.interfacePreferences.screensaverPreference
|
||||
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
|
||||
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
recursive = true,
|
||||
includeItemTypes = itemTypes,
|
||||
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
maxOfficialRating = maxAge?.toString(),
|
||||
hasParentalRating = maxAge?.let { true },
|
||||
)
|
||||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||
Timber.v("Got %s items", pager.size)
|
||||
var index = 0
|
||||
if (pager.isEmpty()) {
|
||||
emit(null)
|
||||
} else {
|
||||
while (true) {
|
||||
val item = pager.getBlocking(index)
|
||||
Timber.v("Next index=%s, item=%s", index, item?.id)
|
||||
if (item != null) {
|
||||
val backdropUrl =
|
||||
if (item.type == BaseItemKind.PHOTO) {
|
||||
api.libraryApi.getDownloadUrl(item.id)
|
||||
} else {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
}
|
||||
val title =
|
||||
if (item.type == BaseItemKind.PHOTO) {
|
||||
item.data.premiereDate?.let {
|
||||
formatDate(it.toLocalDate())
|
||||
}
|
||||
} else {
|
||||
item.title
|
||||
}
|
||||
val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
|
||||
if (backdropUrl != null) {
|
||||
try {
|
||||
context.imageLoader
|
||||
.enqueue(
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(backdropUrl)
|
||||
.build(),
|
||||
).job
|
||||
.await()
|
||||
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
|
||||
} catch (_: CancellationException) {
|
||||
break
|
||||
}
|
||||
val duration =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences
|
||||
.interfacePreferences.screensaverPreference.duration.milliseconds
|
||||
delay(duration)
|
||||
}
|
||||
}
|
||||
index++
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).cancellable()
|
||||
}
|
||||
|
||||
data class ScreensaverState(
|
||||
val enabled: Boolean,
|
||||
val enabledTemp: Boolean,
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
) {
|
||||
val show get() = (enabled || enabledTemp) && active && !paused
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ class UserPreferencesService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) {
|
||||
val flow = preferencesDataStore.data
|
||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||
|
||||
suspend fun getCurrent(): UserPreferences =
|
||||
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.Module
|
||||
|
|
@ -111,12 +112,12 @@ object AppModule {
|
|||
@Singleton
|
||||
fun okHttpFactory(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = OkHttpFactory(okHttpClient)
|
||||
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun jellyfin(
|
||||
okHttpFactory: OkHttpFactory,
|
||||
okHttpFactory: CoroutineContextApiClientFactory,
|
||||
@ApplicationContext context: Context,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
|||
import android.content.ContextWrapper
|
||||
import android.media.AudioManager
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.MarqueeAnimationMode
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
|
|
@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient
|
|||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
|
@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) =
|
|||
}
|
||||
|
||||
/**
|
||||
* Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
|
||||
* Tries to find the [Activity] for the given [Context]
|
||||
*/
|
||||
fun Context.findActivity(): Activity? {
|
||||
if (this is Activity) {
|
||||
|
|
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the screen on for an [Activity]. Often used with [findActivity].
|
||||
*/
|
||||
fun Activity.keepScreenOn(keep: Boolean) {
|
||||
Timber.v("Keep screen on: $keep")
|
||||
if (keep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectively log errors from Coil image loading.
|
||||
*
|
||||
|
|
@ -442,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
useSeriesForPrimary: Boolean,
|
||||
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
|
||||
@Composable
|
||||
fun rememberBackDropImage(item: BaseItem): String? {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this, coalescing nulls to zero, is greater than that
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RadialGradientShader
|
||||
import androidx.compose.ui.graphics.Shader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.useExistingImageAsPlaceholder
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transitionFactory
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
|
||||
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@HiltViewModel
|
||||
class ScreensaverViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val screensaverService: ScreensaverService,
|
||||
val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) : ViewModel() {
|
||||
val currentItem = screensaverService.createItemFlow(viewModelScope)
|
||||
}
|
||||
|
||||
data class CurrentItem(
|
||||
val item: BaseItem,
|
||||
val backdropUrl: String,
|
||||
val logoUrl: String?,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppScreensaver(
|
||||
prefs: AppPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ScreensaverViewModel = hiltViewModel(),
|
||||
) {
|
||||
val currentItem by viewModel.currentItem.collectAsState(null)
|
||||
AppScreensaverContent(
|
||||
currentItem = currentItem,
|
||||
showClock = prefs.interfacePreferences.screensaverPreference.showClock,
|
||||
duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds,
|
||||
animate = prefs.interfacePreferences.screensaverPreference.animate,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun AppScreensaverContent(
|
||||
currentItem: CurrentItem?,
|
||||
showClock: Boolean,
|
||||
duration: Duration,
|
||||
animate: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.background(Color.Black),
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
1f,
|
||||
if (animate) 1.1f else 1f,
|
||||
infiniteRepeatable(
|
||||
tween(
|
||||
durationMillis = duration.inWholeMilliseconds.toInt(),
|
||||
delayMillis = 500,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
)
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(currentItem?.backdropUrl)
|
||||
.transitionFactory(CrossFadeFactory(2000.milliseconds))
|
||||
.useExistingImageAsPlaceholder(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
)
|
||||
|
||||
var logoError by remember(currentItem) { mutableStateOf(false) }
|
||||
val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random()
|
||||
if (!logoError) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(currentItem?.logoUrl)
|
||||
.transitionFactory(CrossFadeFactory(750.milliseconds))
|
||||
.build(),
|
||||
contentDescription = "Logo",
|
||||
onError = {
|
||||
logoError = true
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.align(alignment)
|
||||
.size(width = 240.dp, height = 120.dp)
|
||||
.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(alignment)
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(.5f)
|
||||
.fillMaxHeight(.3f),
|
||||
) {
|
||||
Text(
|
||||
text = currentItem?.title ?: "",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(alignment),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val largeRadialGradient =
|
||||
remember {
|
||||
object : ShaderBrush() {
|
||||
override fun createShader(size: Size): Shader {
|
||||
val biggerDimension = maxOf(size.height, size.width)
|
||||
return RadialGradientShader(
|
||||
colors = listOf(Color.Transparent, AppColors.TransparentBlack25),
|
||||
center = size.center,
|
||||
radius = biggerDimension / 1.5f,
|
||||
colorStops = listOf(0f, 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = largeRadialGradient,
|
||||
blendMode = BlendMode.Multiply,
|
||||
)
|
||||
if (showClock) {
|
||||
// Add scrim to make clock more readable
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colorStops =
|
||||
arrayOf(
|
||||
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
|
||||
TOP_SCRIM_END_FRACTION to Color.Transparent,
|
||||
),
|
||||
),
|
||||
blendMode = BlendMode.Multiply,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showClock) {
|
||||
TimeDisplay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.components
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
|
|
@ -802,8 +800,8 @@ fun CollectionFolderGridContent(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader || loadingState !is DataLoadingState.Success,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ suspend fun getGenreImageMap(
|
|||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
backdropTags = item.backdropImageTags.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -127,8 +125,8 @@ fun CollectionFolderLiveTv(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -76,8 +74,8 @@ fun CollectionFolderMovie(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -80,8 +78,8 @@ fun CollectionFolderTv(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -98,8 +96,8 @@ fun FavoritesPage(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
|||
import android.text.format.DateUtils
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -140,7 +142,11 @@ fun TvGuideGrid(
|
|||
.fillMaxHeight(.30f),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(focusedPosition.row < 1) {
|
||||
AnimatedVisibility(
|
||||
focusedPosition.row < 1,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
ExpandableFaButton(
|
||||
title = R.string.view_options,
|
||||
iconStringRes = R.string.fa_sliders,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.media3.common.util.UnstableApi
|
|||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
|
|
@ -91,7 +92,13 @@ fun NowPlayingPage(
|
|||
val current = queue.getOrNull(state.currentIndex)
|
||||
|
||||
val controllerViewState = viewModel.controllerViewState
|
||||
val preferences by viewModel.userPreferencesService.flow.collectAsState(AppPreferences.getDefaultInstance())
|
||||
val preferences =
|
||||
viewModel.userPreferencesService.flow
|
||||
.collectAsState(
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
),
|
||||
).value.appPreferences
|
||||
|
||||
val keyHandler =
|
||||
remember(preferences) {
|
||||
|
|
@ -111,6 +118,7 @@ fun NowPlayingPage(
|
|||
// viewModel.navigationManager.goBack()
|
||||
},
|
||||
onPlaybackDialogTypeClick = { },
|
||||
getDurationMs = { player.duration },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ class SeriesViewModel
|
|||
api.userLibraryApi
|
||||
.getItem(item.id)
|
||||
.content.people
|
||||
?.map { Person.fromDto(it, api) }
|
||||
?.map { Person.fromDto(context, it, api) }
|
||||
.orEmpty()
|
||||
|
||||
PeopleInItem(item.id, list)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.discover
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -60,8 +58,8 @@ fun DiscoverPage(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ import javax.inject.Inject
|
|||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// Top scrim configuration for text readability (clock, season tabs)
|
||||
private const val TOP_SCRIM_ALPHA = 0.55f
|
||||
private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
|
||||
const val TOP_SCRIM_ALPHA = 0.55f
|
||||
const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
|
||||
|
||||
@HiltViewModel
|
||||
class ApplicationContentViewModel
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.Listener
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.keepScreenOn
|
||||
|
||||
/**
|
||||
* Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback
|
||||
*
|
||||
* This will clean up the listener when disposed
|
||||
*/
|
||||
@Composable
|
||||
fun AmbientPlayerListener(player: Player) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
context.findActivity()?.keepScreenOn(isPlaying)
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
context.findActivity()?.keepScreenOn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ class PlaybackKeyHandler(
|
|||
private val skipWithLeftRight: Boolean,
|
||||
private val seekBack: Duration,
|
||||
private val seekForward: Duration,
|
||||
private val getDurationMs: () -> Long,
|
||||
private val controllerViewState: ControllerViewState,
|
||||
private val updateSkipIndicator: (Long) -> Unit,
|
||||
private val skipBackOnResume: Duration?,
|
||||
|
|
@ -28,15 +29,22 @@ class PlaybackKeyHandler(
|
|||
private val onStop: () -> Unit,
|
||||
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
|
||||
) {
|
||||
private var leftHandledByRepeat = false
|
||||
private var rightHandledByRepeat = false
|
||||
|
||||
fun onKeyEvent(it: KeyEvent): Boolean {
|
||||
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
|
||||
|
||||
var result = true
|
||||
if (!controlsEnabled) {
|
||||
result = false
|
||||
return false
|
||||
} else if (handleHoldSkip(it)) {
|
||||
return true
|
||||
} else if (it.type != KeyEventType.KeyUp) {
|
||||
result = false
|
||||
} else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
|
||||
return false
|
||||
}
|
||||
|
||||
var result = true
|
||||
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
|
||||
if (!controllerViewState.controlsVisible) {
|
||||
if (skipWithLeftRight && isSkipBack(it)) {
|
||||
updateSkipIndicator(-seekBack.inWholeMilliseconds)
|
||||
|
|
@ -111,4 +119,84 @@ class PlaybackKeyHandler(
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun handleHoldSkip(event: KeyEvent): Boolean {
|
||||
if (
|
||||
controllerViewState.controlsVisible ||
|
||||
!skipWithLeftRight ||
|
||||
(!isSkipBack(event) && !isSkipForward(event))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val isBack = isSkipBack(event)
|
||||
return when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||
if (repeatCount > 0) {
|
||||
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
|
||||
setHandledByRepeat(isBack = isBack, handled = false)
|
||||
return true
|
||||
}
|
||||
val multiplier =
|
||||
calculateSeekAccelerationMultiplier(
|
||||
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
|
||||
durationMs = normalizedDurationMs(),
|
||||
)
|
||||
setHandledByRepeat(isBack = isBack, handled = true)
|
||||
seekWithMultiplier(isBack = isBack, multiplier = multiplier)
|
||||
} else {
|
||||
setHandledByRepeat(isBack = isBack, handled = false)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (!handledByRepeat(isBack = isBack)) {
|
||||
seekWithMultiplier(isBack = isBack, multiplier = 1)
|
||||
}
|
||||
setHandledByRepeat(isBack = isBack, handled = false)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun seekWithMultiplier(
|
||||
isBack: Boolean,
|
||||
multiplier: Int,
|
||||
) {
|
||||
if (isBack) {
|
||||
val skipDuration = seekBack * multiplier
|
||||
player.seekBack(skipDuration)
|
||||
updateSkipIndicator(-skipDuration.inWholeMilliseconds)
|
||||
} else {
|
||||
val skipDuration = seekForward * multiplier
|
||||
player.seekForward(skipDuration)
|
||||
updateSkipIndicator(skipDuration.inWholeMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setHandledByRepeat(
|
||||
isBack: Boolean,
|
||||
handled: Boolean,
|
||||
) {
|
||||
if (isBack) {
|
||||
leftHandledByRepeat = handled
|
||||
} else {
|
||||
rightHandledByRepeat = handled
|
||||
}
|
||||
}
|
||||
|
||||
private fun handledByRepeat(isBack: Boolean): Boolean =
|
||||
if (isBack) {
|
||||
leftHandledByRepeat
|
||||
} else {
|
||||
rightHandledByRepeat
|
||||
}
|
||||
|
||||
private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -612,29 +612,28 @@ fun Controller(
|
|||
fontSize = subtitleTextSize,
|
||||
)
|
||||
}
|
||||
if (showClock) {
|
||||
var endTimeStr by remember { mutableStateOf("...") }
|
||||
LaunchedEffect(playerControls) {
|
||||
while (isActive) {
|
||||
val remaining =
|
||||
(playerControls.duration - playerControls.currentPosition)
|
||||
.div(playerControls.playbackParameters.speed)
|
||||
.toLong()
|
||||
.milliseconds
|
||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||
endTimeStr = TimeFormatter.format(endTime)
|
||||
delay(1.seconds)
|
||||
}
|
||||
|
||||
var endTimeStr by remember { mutableStateOf("...") }
|
||||
LaunchedEffect(playerControls) {
|
||||
while (isActive) {
|
||||
val remaining =
|
||||
(playerControls.duration - playerControls.currentPosition)
|
||||
.div(playerControls.playbackParameters.speed)
|
||||
.toLong()
|
||||
.milliseconds
|
||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||
endTimeStr = TimeFormatter.format(endTime)
|
||||
delay(1.seconds)
|
||||
}
|
||||
Text(
|
||||
text = "Ends $endTimeStr",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(end = 32.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Ends $endTimeStr",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(end = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// TODO need to move these up a level?
|
||||
|
|
|
|||
|
|
@ -186,7 +186,6 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
AmbientPlayerListener(player)
|
||||
var contentScale by remember(playerBackend) {
|
||||
mutableStateOf(
|
||||
if (playerBackend == PlayerBackend.MPV) {
|
||||
|
|
@ -236,6 +235,7 @@ fun PlaybackPageContent(
|
|||
skipWithLeftRight = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
getDurationMs = { player.duration.coerceAtLeast(0L) },
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = updateSkipIndicator,
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
|
|||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
|
@ -140,6 +141,7 @@ class PlaybackViewModel
|
|||
val streamChoiceService: StreamChoiceService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val screensaverService: ScreensaverService,
|
||||
@Assisted private val destination: Destination,
|
||||
) : ViewModel(),
|
||||
Player.Listener,
|
||||
|
|
@ -189,7 +191,10 @@ class PlaybackViewModel
|
|||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
addCloseable { disconnectPlayer() }
|
||||
addCloseable {
|
||||
screensaverService.keepScreenOn(false)
|
||||
disconnectPlayer()
|
||||
}
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
|
@ -1424,6 +1429,10 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
screensaverService.keepScreenOn(isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
data class PlayerState(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12
|
||||
|
||||
/**
|
||||
* Shared seek acceleration profile for hold-to-seek behavior.
|
||||
* Keep this in sync anywhere directional key repeat seeking is handled.
|
||||
*/
|
||||
fun calculateSeekAccelerationMultiplier(
|
||||
repeatCount: Int,
|
||||
durationMs: Long,
|
||||
): Int {
|
||||
if (repeatCount <= 0 || durationMs <= 0L) return 1
|
||||
|
||||
// Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds.
|
||||
val scaledRepeatCount = repeatCount / 3
|
||||
if (scaledRepeatCount <= 0) return 1
|
||||
|
||||
val durationMinutes = durationMs / 60_000L
|
||||
return when {
|
||||
durationMinutes < 30 -> {
|
||||
if (scaledRepeatCount < 30) 1 else 2
|
||||
}
|
||||
|
||||
durationMinutes < 90 -> {
|
||||
when {
|
||||
scaledRepeatCount < 13 -> 1
|
||||
scaledRepeatCount < 50 -> 2
|
||||
scaledRepeatCount < 75 -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
|
||||
durationMinutes < 150 -> {
|
||||
when {
|
||||
scaledRepeatCount < 20 -> 1
|
||||
scaledRepeatCount < 40 -> 2
|
||||
scaledRepeatCount < 60 -> 4
|
||||
else -> 6
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
when {
|
||||
scaledRepeatCount < 20 -> 1
|
||||
scaledRepeatCount < 40 -> 3
|
||||
scaledRepeatCount < 60 -> 6
|
||||
else -> 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -80,15 +79,16 @@ fun SteppedSeekBarImpl(
|
|||
enabled = enabled,
|
||||
progress = progressToUse,
|
||||
bufferedProgress = bufferedProgress,
|
||||
onLeft = {
|
||||
durationMs = durationMs,
|
||||
onLeft = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekProgress = (progressToUse - offset).coerceAtLeast(0f)
|
||||
seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f)
|
||||
hasSeeked = true
|
||||
seek(seekProgress)
|
||||
},
|
||||
onRight = {
|
||||
onRight = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekProgress = (progressToUse + offset).coerceAtMost(1f)
|
||||
seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f)
|
||||
hasSeeked = true
|
||||
seek(seekProgress)
|
||||
},
|
||||
|
|
@ -126,16 +126,19 @@ fun IntervalSeekBarImpl(
|
|||
enabled = enabled,
|
||||
progress = (progressToUse.toDouble() / durationMs).toFloat(),
|
||||
bufferedProgress = bufferedProgress,
|
||||
onLeft = {
|
||||
durationMs = durationMs,
|
||||
onLeft = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
|
||||
seekPositionMs =
|
||||
(progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L)
|
||||
hasSeeked = true
|
||||
onSeek(seekPositionMs)
|
||||
},
|
||||
onRight = {
|
||||
onRight = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekPositionMs =
|
||||
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
|
||||
(progressToUse + seekForward.inWholeMilliseconds * multiplier)
|
||||
.coerceAtMost(durationMs)
|
||||
hasSeeked = true
|
||||
onSeek(seekPositionMs)
|
||||
},
|
||||
|
|
@ -148,8 +151,9 @@ fun IntervalSeekBarImpl(
|
|||
fun SeekBarDisplay(
|
||||
progress: Float,
|
||||
bufferedProgress: Float,
|
||||
onLeft: () -> Unit,
|
||||
onRight: () -> Unit,
|
||||
durationMs: Long,
|
||||
onLeft: (Int) -> Unit,
|
||||
onRight: (Int) -> Unit,
|
||||
interactionSource: MutableInteractionSource,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
|
|
@ -158,14 +162,13 @@ fun SeekBarDisplay(
|
|||
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
var leftHandledByRepeat by remember { mutableStateOf(false) }
|
||||
var rightHandledByRepeat by remember { mutableStateOf(false) }
|
||||
val animatedIndicatorHeight by animateDpAsState(
|
||||
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Canvas(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -173,24 +176,79 @@ fun SeekBarDisplay(
|
|||
.height(animatedIndicatorHeight)
|
||||
.padding(horizontal = 4.dp)
|
||||
.onPreviewKeyEvent { event ->
|
||||
val trigger =
|
||||
event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
|
||||
when (event.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
||||
if (trigger) onLeft.invoke()
|
||||
when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||
if (repeatCount > 0) {
|
||||
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
|
||||
leftHandledByRepeat = false
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
leftHandledByRepeat = true
|
||||
onLeft.invoke(
|
||||
calculateSeekAccelerationMultiplier(
|
||||
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
|
||||
durationMs = durationMs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
leftHandledByRepeat = false
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (!leftHandledByRepeat) {
|
||||
onLeft.invoke(1)
|
||||
}
|
||||
leftHandledByRepeat = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
}
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
||||
if (trigger) onRight.invoke()
|
||||
when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||
if (repeatCount > 0) {
|
||||
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
|
||||
rightHandledByRepeat = false
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
rightHandledByRepeat = true
|
||||
onRight.invoke(
|
||||
calculateSeekAccelerationMultiplier(
|
||||
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
|
||||
durationMs = durationMs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
rightHandledByRepeat = false
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (!rightHandledByRepeat) {
|
||||
onRight.invoke(1)
|
||||
}
|
||||
rightHandledByRepeat = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
}
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
false
|
||||
}.handleDPadKeyEvents(
|
||||
onLeft = onLeft,
|
||||
onRight = onRight,
|
||||
).focusable(enabled = enabled, interactionSource = interactionSource),
|
||||
}.focusable(enabled = enabled, interactionSource = interactionSource),
|
||||
onDraw = {
|
||||
val yOffset = size.height.div(2)
|
||||
drawLine(
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.SortedSet
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Composable
|
||||
|
|
@ -226,7 +225,7 @@ fun <T> ComposablePreference(
|
|||
}
|
||||
|
||||
is AppMultiChoicePreference<*, *> -> {
|
||||
val values = stringArrayResource(preference.displayValues).toSortedSet()
|
||||
val values = stringArrayResource(preference.displayValues)
|
||||
val summary =
|
||||
preference.summary?.let { stringResource(it) }
|
||||
?: preference.summary(context, value)
|
||||
|
|
@ -237,12 +236,15 @@ fun <T> ComposablePreference(
|
|||
list
|
||||
}
|
||||
MultiChoicePreference(
|
||||
possibleValues = values as SortedSet<Any>,
|
||||
possibleValues = preference.allValues,
|
||||
selectedValues = selectedValues,
|
||||
title = title,
|
||||
summary = summary,
|
||||
onValueChange = {
|
||||
onValueChange.invoke(selectedValues.toList() as T)
|
||||
onValueChange.invoke(it.toList() as T)
|
||||
},
|
||||
valueDisplay = { index, _ ->
|
||||
Text(values[index])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
|
|||
fun <T> MultiChoicePreference(
|
||||
title: String,
|
||||
summary: String?,
|
||||
possibleValues: Set<T>,
|
||||
possibleValues: List<T>,
|
||||
selectedValues: Set<T>,
|
||||
onValueChange: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) },
|
||||
valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) },
|
||||
) {
|
||||
// val values = stringArrayResource(preference.displayValues).toList()
|
||||
// val summary =
|
||||
|
|
@ -59,7 +59,7 @@ fun <T> MultiChoicePreference(
|
|||
items =
|
||||
possibleValues.mapIndexed { index, item ->
|
||||
DialogItem(
|
||||
headlineContent = { valueDisplay.invoke(item) },
|
||||
headlineContent = { valueDisplay.invoke(index, item) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = selectedValues.contains(item),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ enum class PreferenceScreenOption {
|
|||
ADVANCED,
|
||||
EXO_PLAYER,
|
||||
MPV,
|
||||
SCREENSAVER,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,10 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
||||
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
import com.github.damontecres.wholphin.preferences.advancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
||||
import com.github.damontecres.wholphin.preferences.screensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
|
|
@ -128,6 +130,7 @@ fun PreferencesContent(
|
|||
PreferenceScreenOption.ADVANCED -> advancedPreferences
|
||||
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
||||
PreferenceScreenOption.MPV -> MpvPreferences
|
||||
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
||||
}
|
||||
val screenTitle =
|
||||
when (preferenceScreenOption) {
|
||||
|
|
@ -135,6 +138,7 @@ fun PreferencesContent(
|
|||
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
|
||||
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
||||
PreferenceScreenOption.MPV -> R.string.mpv_options
|
||||
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
||||
}
|
||||
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
|
@ -283,7 +287,9 @@ fun PreferencesContent(
|
|||
if (movementSounds) playOnClickSound(context)
|
||||
if (release != null && updateAvailable) {
|
||||
release?.let {
|
||||
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.UpdateApp,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
updateVM.init(preferences.updateUrl)
|
||||
|
|
@ -313,7 +319,8 @@ fun PreferencesContent(
|
|||
val summary =
|
||||
remember(cacheUsage) {
|
||||
cacheUsage.let {
|
||||
val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
|
||||
val diskMB =
|
||||
it.imageDiskUsed / AppPreference.MEGA_BIT
|
||||
val memoryUsedMB =
|
||||
it.imageMemoryUsed / AppPreference.MEGA_BIT
|
||||
val memoryMaxMB =
|
||||
|
|
@ -392,7 +399,10 @@ fun PreferencesContent(
|
|||
seerrDialogMode =
|
||||
when (val conn = seerrConnection) {
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
SeerrDialogMode.Error(conn.serverUrl, conn.ex)
|
||||
SeerrDialogMode.Error(
|
||||
conn.serverUrl,
|
||||
conn.ex,
|
||||
)
|
||||
}
|
||||
|
||||
SeerrConnectionStatus.NotConfigured -> {
|
||||
|
|
@ -409,9 +419,19 @@ fun PreferencesContent(
|
|||
modifier = Modifier,
|
||||
summary =
|
||||
when (seerrConnection) {
|
||||
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
|
||||
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
|
||||
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
stringResource(R.string.voice_error_server)
|
||||
}
|
||||
|
||||
SeerrConnectionStatus.NotConfigured -> {
|
||||
stringResource(
|
||||
R.string.add_server,
|
||||
)
|
||||
}
|
||||
|
||||
is SeerrConnectionStatus.Success -> {
|
||||
stringResource(R.string.enabled)
|
||||
}
|
||||
},
|
||||
onLongClick = {},
|
||||
interactionSource = interactionSource,
|
||||
|
|
@ -434,6 +454,19 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
|
||||
ScreensaverPreference.Start -> {
|
||||
ClickPreference(
|
||||
title = stringResource(pref.title),
|
||||
onClick = {
|
||||
viewModel.screensaverService.start()
|
||||
},
|
||||
modifier = Modifier,
|
||||
summary = pref.summary(context, null),
|
||||
onLongClick = {},
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val value = pref.getter.invoke(preferences)
|
||||
ComposablePreference(
|
||||
|
|
@ -597,6 +630,7 @@ fun PreferencesPage(
|
|||
PreferenceScreenOption.ADVANCED,
|
||||
PreferenceScreenOption.EXO_PLAYER,
|
||||
PreferenceScreenOption.MPV,
|
||||
PreferenceScreenOption.SCREENSAVER,
|
||||
-> {
|
||||
PreferencesContent(
|
||||
initialPreferences,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
|
|||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -35,6 +36,7 @@ class PreferencesViewModel
|
|||
val preferenceDataStore: DataStore<AppPreferences>,
|
||||
val navigationManager: NavigationManager,
|
||||
val backdropService: BackdropService,
|
||||
val screensaverService: ScreensaverService,
|
||||
private val rememberTabManager: RememberTabManager,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -63,8 +62,6 @@ import com.github.damontecres.wholphin.data.model.VideoFilter
|
|||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.keepScreenOn
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
|
||||
import com.github.damontecres.wholphin.ui.playback.isDpad
|
||||
|
|
@ -208,12 +205,6 @@ fun SlideshowPage(
|
|||
LaunchedEffect(slideshowActive) {
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
||||
context.findActivity()?.keepScreenOn(slideshowActive)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
context.findActivity()?.keepScreenOn(false)
|
||||
}
|
||||
}
|
||||
|
||||
var longPressing by remember { mutableStateOf(false) }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.model.VideoFilter
|
|||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -67,6 +68,7 @@ class SlideshowViewModel
|
|||
private val serverRepository: ServerRepository,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val screensaverService: ScreensaverService,
|
||||
@Assisted val slideshowSettings: Destination.Slideshow,
|
||||
) : ViewModel(),
|
||||
Player.Listener {
|
||||
|
|
@ -110,6 +112,7 @@ class SlideshowViewModel
|
|||
|
||||
init {
|
||||
addCloseable {
|
||||
screensaverService.keepScreenOn(false)
|
||||
player.removeListener(this@SlideshowViewModel)
|
||||
player.release()
|
||||
}
|
||||
|
|
@ -282,6 +285,7 @@ class SlideshowViewModel
|
|||
private var slideshowJob: Job? = null
|
||||
|
||||
fun startSlideshow() {
|
||||
screensaverService.keepScreenOn(true)
|
||||
_slideshow.update {
|
||||
SlideshowState(enabled = true, paused = false)
|
||||
}
|
||||
|
|
@ -295,6 +299,7 @@ class SlideshowViewModel
|
|||
}
|
||||
|
||||
fun stopSlideshow() {
|
||||
screensaverService.keepScreenOn(false)
|
||||
slideshowJob?.cancel()
|
||||
_slideshow.update {
|
||||
SlideshowState(enabled = false, paused = false)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.ApiClientFactory
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.client.HttpMethod
|
||||
import org.jellyfin.sdk.api.client.RawResponse
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.api.sockets.SocketApi
|
||||
import org.jellyfin.sdk.api.sockets.SocketConnection
|
||||
import org.jellyfin.sdk.api.sockets.SocketConnectionFactory
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* Wraps [ApiClient.request] with the given [CoroutineContext]
|
||||
*/
|
||||
class CoroutineContextApiClient(
|
||||
private val client: ApiClient,
|
||||
private val coroutineContext: CoroutineContext = Dispatchers.IO,
|
||||
) : ApiClient() {
|
||||
override val baseUrl: String?
|
||||
get() = client.baseUrl
|
||||
override val accessToken: String?
|
||||
get() = client.accessToken
|
||||
override val clientInfo: ClientInfo
|
||||
get() = client.clientInfo
|
||||
override val deviceInfo: DeviceInfo
|
||||
get() = client.deviceInfo
|
||||
override val httpClientOptions: HttpClientOptions
|
||||
get() = client.httpClientOptions
|
||||
override val webSocket: SocketApi
|
||||
get() = client.webSocket
|
||||
|
||||
override fun update(
|
||||
baseUrl: String?,
|
||||
accessToken: String?,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
) {
|
||||
client.update(baseUrl, accessToken, clientInfo, deviceInfo)
|
||||
}
|
||||
|
||||
override suspend fun request(
|
||||
method: HttpMethod,
|
||||
pathTemplate: String,
|
||||
pathParameters: Map<String, Any?>,
|
||||
queryParameters: Map<String, Any?>,
|
||||
requestBody: Any?,
|
||||
): RawResponse =
|
||||
withContext(coroutineContext) {
|
||||
client.request(
|
||||
method,
|
||||
pathTemplate,
|
||||
pathParameters,
|
||||
queryParameters,
|
||||
requestBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class CoroutineContextApiClientFactory(
|
||||
private val factory: OkHttpFactory,
|
||||
private val coroutineContext: CoroutineContext = Dispatchers.IO,
|
||||
) : ApiClientFactory,
|
||||
SocketConnectionFactory {
|
||||
override fun create(
|
||||
baseUrl: String?,
|
||||
accessToken: String?,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
httpClientOptions: HttpClientOptions,
|
||||
socketConnectionFactory: SocketConnectionFactory,
|
||||
): ApiClient =
|
||||
CoroutineContextApiClient(
|
||||
factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory),
|
||||
coroutineContext,
|
||||
)
|
||||
|
||||
override fun create(
|
||||
clientOptions: HttpClientOptions,
|
||||
scope: CoroutineScope,
|
||||
): SocketConnection = factory.create(clientOptions, scope)
|
||||
}
|
||||
|
|
@ -144,6 +144,16 @@ enum BackdropStyle{
|
|||
BACKDROP_NONE = 2;
|
||||
}
|
||||
|
||||
message ScreensaverPreferences{
|
||||
int64 start_delay = 1;
|
||||
int64 duration = 2;
|
||||
bool animate = 3;
|
||||
int32 max_age_filter = 4;
|
||||
bool enabled = 5;
|
||||
repeated string item_types = 6;
|
||||
bool show_clock = 7;
|
||||
}
|
||||
|
||||
message InterfacePreferences {
|
||||
ThemeSongVolume play_theme_songs = 1;
|
||||
bool remember_selected_tab = 2;
|
||||
|
|
@ -155,6 +165,7 @@ message InterfacePreferences {
|
|||
LiveTvPreferences live_tv_preferences = 8;
|
||||
BackdropStyle backdrop_style = 9;
|
||||
SubtitlePreferences hdr_subtitles_preferences = 10;
|
||||
ScreensaverPreferences screensaver_preference = 11;
|
||||
}
|
||||
|
||||
message AdvancedPreferences {
|
||||
|
|
|
|||
|
|
@ -276,6 +276,11 @@
|
|||
<item quantity="one">%d second</item>
|
||||
<item quantity="other">%d seconds</item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="zero">%s minutes</item>
|
||||
<item quantity="one">%s minute</item>
|
||||
<item quantity="other">%s minutes</item>
|
||||
</plurals>
|
||||
|
||||
<plurals name="movies">
|
||||
<item quantity="zero">Movies</item>
|
||||
|
|
@ -530,6 +535,19 @@
|
|||
<string name="display_presets">Display presets</string>
|
||||
<string name="display_presets_description">Built-in presets to quickly style all rows</string>
|
||||
<string name="customize_home_summary">Choose rows and images on the home page</string>
|
||||
<string name="start_after">Start after</string>
|
||||
<string name="animate">Animate</string>
|
||||
<string name="max_age_rating">Max age rating</string>
|
||||
<string name="no_max">No max</string>
|
||||
<string name="for_all_ages">For all ages</string>
|
||||
<string name="up_to_age">Up to age %s</string>
|
||||
<string name="screensaver">Screensaver</string>
|
||||
<string name="screensaver_settings">Screensaver settings</string>
|
||||
<string name="start_screensaver">Start screensaver</string>
|
||||
<string name="duration">Duration</string>
|
||||
<string name="photos">Photos</string>
|
||||
<string name="include_types">Include types</string>
|
||||
<string name="include_types_summary">What types of items to show images for</string>
|
||||
<string name="content_scale_fit">Fit</string>
|
||||
<string name="content_scale_crop">Crop</string>
|
||||
<string name="content_scale_fill">Fill</string>
|
||||
|
|
@ -681,10 +699,30 @@
|
|||
<string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string>
|
||||
<string name="no_subtitles_found">No remote subtitles were found</string>
|
||||
<string name="series_continueing">Present</string>
|
||||
<string name="in_app_screensaver">Use in-app screensaver</string>
|
||||
<string-array name="backdrop_style_options">
|
||||
<item>@string/backdrop_style_dynamic</item>
|
||||
<item>@string/backdrop_style_image</item>
|
||||
<item>@string/none</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="screensaver_item_types">
|
||||
<item>@string/movies</item>
|
||||
<item>@string/tv_shows</item>
|
||||
<item>@string/photos</item>
|
||||
</string-array>
|
||||
|
||||
<string name="actor">Actor</string>
|
||||
<string name="composer">Composer</string>
|
||||
<string name="writer">Writer</string>
|
||||
<string name="guest_star">Guest star</string>
|
||||
<string name="producer">Producer</string>
|
||||
<string name="conductor">Conductor</string>
|
||||
<string name="lyricist">Lyricist</string>
|
||||
<string name="arranger">Arranger</string>
|
||||
<string name="engineer">Engineer</string>
|
||||
<string name="mixer">Mixer</string>
|
||||
<string name="creator">Creator</string>
|
||||
<string name="artist">Artist</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SeekAccelerationTest {
|
||||
@Test
|
||||
fun returnsOneWhenNotRepeating() {
|
||||
assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownDurationDoesNotAccelerate() {
|
||||
assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L))
|
||||
assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortContentHasTwoTiers() {
|
||||
val shortDurationMs = 20L * 60_000L
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs),
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediumContentEscalatesAcrossAllTiers() {
|
||||
val mediumDurationMs = 60L * 60_000L
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs),
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs),
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs),
|
||||
)
|
||||
assertEquals(
|
||||
4,
|
||||
calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ tvMaterial = "1.0.1"
|
|||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.12.4"
|
||||
androidx-media3 = "1.9.2"
|
||||
coil = "3.3.0"
|
||||
coil = "3.4.0"
|
||||
jellyfin-sdk = "1.7.1"
|
||||
nav3Core = "1.0.1"
|
||||
lifecycleViewmodelNav3 = "2.10.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue