mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +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"
|
title: "<title>"
|
||||||
description: Request a new feature
|
|
||||||
title: "[FEA] - <title>"
|
|
||||||
labels: [
|
labels: [
|
||||||
"enhancement"
|
"enhancement"
|
||||||
]
|
]
|
||||||
6
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
6
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
name: "Bug Report"
|
name: "Bug Report"
|
||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: "[BUG] - <title>"
|
title: "<title>"
|
||||||
labels: [
|
labels: [
|
||||||
"bug"
|
"bug"
|
||||||
]
|
]
|
||||||
|
|
@ -28,7 +28,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: "App Version"
|
label: "App Version"
|
||||||
description: What version of the app?
|
description: What version of the app?
|
||||||
placeholder: "v0.3.0"
|
placeholder: "v0.5.1"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
|
|
@ -36,7 +36,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: "Server Version"
|
label: "Server Version"
|
||||||
description: What version of the server?
|
description: What version of the server?
|
||||||
placeholder: "10.11.3"
|
placeholder: "10.11.6"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- 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"
|
name: "Playback Problem"
|
||||||
description: Report an issue with playback
|
description: Report an issue with playback
|
||||||
title: "[BUG] - <title>"
|
title: "<title>"
|
||||||
labels: [
|
labels: [
|
||||||
"bug", "playback"
|
"bug", "playback"
|
||||||
]
|
]
|
||||||
|
|
@ -17,6 +17,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: "Media info"
|
label: "Media info"
|
||||||
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
|
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|
@ -35,7 +36,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: "App Version"
|
label: "App Version"
|
||||||
description: What version of the app?
|
description: What version of the app?
|
||||||
placeholder: "v0.3.0"
|
placeholder: "v0.5.1"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
|
|
@ -43,7 +44,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: "Server Version"
|
label: "Server Version"
|
||||||
description: What version of the server?
|
description: What version of the server?
|
||||||
placeholder: "10.11.3"
|
placeholder: "10.11.6"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- 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>
|
</p>
|
||||||
|
|
||||||
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" />
|

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

|

|
||||||
|
|
||||||
|
|
||||||
### Movie library browsing
|
### 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
|
### 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
|
### 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
|
### 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:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.androidx-startup"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
tools:node="remove" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ package com.github.damontecres.wholphin
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.KeyEvent
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
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.ServerEventListener
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
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.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
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.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
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.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -74,8 +79,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -134,6 +143,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var screensaverService: ScreensaverService
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -157,6 +169,20 @@ class MainActivity : AppCompatActivity() {
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
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()
|
viewModel.appStart()
|
||||||
setContent {
|
setContent {
|
||||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Timber.d("onResume")
|
Timber.d("onResume")
|
||||||
lifecycleScope.launchIO {
|
lifecycleScope.launchDefault {
|
||||||
|
screensaverService.pulse()
|
||||||
appUpgradeHandler.run()
|
appUpgradeHandler.run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +388,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
Timber.d("onStop")
|
Timber.d("onStop")
|
||||||
|
screensaverService.stop(true)
|
||||||
tvProviderSchedulerService.launchOneTimeRefresh()
|
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.os.StrictMode.ThreadPolicy
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.Composer
|
import androidx.compose.runtime.Composer
|
||||||
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
||||||
|
|
@ -27,16 +26,6 @@ class WholphinApplication :
|
||||||
init {
|
init {
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
StrictMode.setThreadPolicy(
|
|
||||||
ThreadPolicy
|
|
||||||
.Builder()
|
|
||||||
.detectNetwork()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Timber.plant(Timber.DebugTree())
|
Timber.plant(Timber.DebugTree())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -64,6 +53,23 @@ class WholphinApplication :
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.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)
|
OkHttp.initialize(this)
|
||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
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 apiClient: ApiClient,
|
||||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||||
) {
|
) {
|
||||||
private val sharedPreferences = getServerSharedPreferences(context)
|
|
||||||
|
|
||||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||||
val current: LiveData<CurrentUser?> = _current
|
val current: LiveData<CurrentUser?> = _current
|
||||||
|
|
||||||
|
|
@ -107,7 +105,7 @@ class ServerRepository
|
||||||
_current.value = CurrentUser(updatedServer, updatedUser)
|
_current.value = CurrentUser(updatedServer, updatedUser)
|
||||||
_currentUserDto.value = userDto
|
_currentUserDto.value = userDto
|
||||||
}
|
}
|
||||||
sharedPreferences.edit(true) {
|
getServerSharedPreferences(context).edit(true) {
|
||||||
putString(SERVER_URL_KEY, updatedServer.url)
|
putString(SERVER_URL_KEY, updatedServer.url)
|
||||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,11 @@ data class BaseItem(
|
||||||
data.indexNumber?.let { "E$it" }
|
data.indexNumber?.let { "E$it" }
|
||||||
?: data.premiereDate?.let(::formatDateTime),
|
?: data.premiereDate?.let(::formatDateTime),
|
||||||
episodeUnplayedCornerText =
|
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.indexNumber?.let { "E$it" }
|
||||||
?: data.userData
|
?: data.userData
|
||||||
?.unplayedItemCount
|
?.unplayedItemCount
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.Stable
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
@ -19,19 +23,21 @@ data class Person(
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDto(
|
fun fromDto(
|
||||||
|
context: Context,
|
||||||
dto: BaseItemPerson,
|
dto: BaseItemPerson,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
): Person =
|
): Person =
|
||||||
Person(
|
Person(
|
||||||
id = dto.id,
|
id = dto.id,
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
role = dto.role,
|
role = personRole(context, dto.role, dto.type),
|
||||||
type = dto.type,
|
type = dto.type,
|
||||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||||
favorite = false,
|
favorite = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun fromDto(
|
fun fromDto(
|
||||||
|
context: Context,
|
||||||
dto: BaseItemPerson,
|
dto: BaseItemPerson,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
|
@ -39,10 +45,51 @@ data class Person(
|
||||||
Person(
|
Person(
|
||||||
id = dto.id,
|
id = dto.id,
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
role = dto.role,
|
role = personRole(context, dto.role, dto.type),
|
||||||
type = dto.type,
|
type = dto.type,
|
||||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||||
favorite = favorite,
|
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,
|
summaryOn = R.string.enabled,
|
||||||
summaryOff = R.string.disabled,
|
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.RememberSelectedTab,
|
||||||
AppPreference.SubtitleStyle,
|
AppPreference.SubtitleStyle,
|
||||||
AppPreference.ThemeColors,
|
AppPreference.ThemeColors,
|
||||||
|
AppPreference.ScreensaverSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PreferenceGroup(
|
PreferenceGroup(
|
||||||
|
|
@ -1201,6 +1208,24 @@ val liveTvPreferences =
|
||||||
AppPreference.LiveTvColorCodePrograms,
|
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>(
|
data class AppSwitchPreference<Pref>(
|
||||||
@get:StringRes override val title: Int,
|
@get:StringRes override val title: Int,
|
||||||
override val defaultValue: Boolean,
|
override val defaultValue: Boolean,
|
||||||
|
|
@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference<Pref, T>(
|
||||||
override val getter: (prefs: Pref) -> List<T>,
|
override val getter: (prefs: Pref) -> List<T>,
|
||||||
override val setter: (prefs: Pref, value: List<T>) -> Pref,
|
override val setter: (prefs: Pref, value: List<T>) -> Pref,
|
||||||
@param:StringRes val summary: Int? = null,
|
@param:StringRes val summary: Int? = null,
|
||||||
val toSharedPrefs: (T) -> String,
|
|
||||||
val fromSharedPrefs: (String) -> T?,
|
|
||||||
) : AppPreference<Pref, List<T>>
|
) : AppPreference<Pref, List<T>>
|
||||||
|
|
||||||
data class AppClickablePreference<Pref>(
|
data class AppClickablePreference<Pref>(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
|
||||||
import androidx.datastore.core.Serializer
|
import androidx.datastore.core.Serializer
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||||
import com.google.protobuf.InvalidProtocolBufferException
|
import com.google.protobuf.InvalidProtocolBufferException
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -120,6 +121,20 @@ class AppPreferencesSerializer
|
||||||
colorCodePrograms =
|
colorCodePrograms =
|
||||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||||
}.build()
|
}.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()
|
}.build()
|
||||||
|
|
||||||
advancedPreferences =
|
advancedPreferences =
|
||||||
|
|
@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder
|
||||||
photoPreferences = photoPreferences.toBuilder().apply(block).build()
|
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() {
|
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
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.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||||
import com.github.damontecres.wholphin.preferences.update
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
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.updateMpvOptions
|
||||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
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.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
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) {
|
if (item.type == BaseItemKind.GENRE) {
|
||||||
item.imageUrlOverride
|
item.imageUrlOverride
|
||||||
} else {
|
} else {
|
||||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||||
}
|
}
|
||||||
submit(item.id.toString(), imageUrl)
|
submit(item.id.toString(), imageUrl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,16 @@ class ImageUrlService
|
||||||
itemType: BaseItemKind,
|
itemType: BaseItemKind,
|
||||||
seriesId: UUID?,
|
seriesId: UUID?,
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
imageTags: Map<ImageType, String?>,
|
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
|
imageTags: Map<ImageType, String?>,
|
||||||
|
backdropTags: List<String>,
|
||||||
parentThumbId: UUID? = null,
|
parentThumbId: UUID? = null,
|
||||||
parentBackdropId: UUID? = null,
|
parentBackdropId: UUID? = null,
|
||||||
backdropTags: List<String> = emptyList(),
|
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
): String? =
|
): String? =
|
||||||
when (imageType) {
|
when (imageType) {
|
||||||
ImageType.BACKDROP,
|
ImageType.LOGO -> {
|
||||||
ImageType.LOGO,
|
|
||||||
-> {
|
|
||||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||||
getItemImageUrl(
|
getItemImageUrl(
|
||||||
itemId = seriesId,
|
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 -> {
|
ImageType.THUMB -> {
|
||||||
if (useSeriesForPrimary && parentThumbId != null &&
|
if (useSeriesForPrimary && parentThumbId != null &&
|
||||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
(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.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -64,7 +66,11 @@ class NavDrawerService
|
||||||
if (user != null && userDto != null && user.id == userDto.id) {
|
if (user != null && userDto != null && user.id == userDto.id) {
|
||||||
updateNavDrawer(user, userDto)
|
updateNavDrawer(user, userDto)
|
||||||
}
|
}
|
||||||
|
}.catch { ex ->
|
||||||
|
Timber.e(ex, "Error updating nav drawer")
|
||||||
|
showToast(context, "Error fetching user's views")
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
|
|
||||||
seerrServerRepository.active
|
seerrServerRepository.active
|
||||||
.onEach { discoverActive ->
|
.onEach { discoverActive ->
|
||||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.services
|
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.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -12,6 +14,7 @@ import javax.inject.Singleton
|
||||||
class PeopleFavorites
|
class PeopleFavorites
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
suspend fun getPeopleFor(item: BaseItem): List<Person> =
|
suspend fun getPeopleFor(item: BaseItem): List<Person> =
|
||||||
|
|
@ -27,7 +30,14 @@ class PeopleFavorites
|
||||||
val people =
|
val people =
|
||||||
item.data.people
|
item.data.people
|
||||||
?.letNotEmpty { 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()
|
}.orEmpty()
|
||||||
people
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ class UserPreferencesService
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
) {
|
) {
|
||||||
val flow = preferencesDataStore.data
|
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||||
|
|
||||||
suspend fun getCurrent(): UserPreferences =
|
suspend fun getCurrent(): UserPreferences =
|
||||||
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
|
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.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
import com.github.damontecres.wholphin.services.SeerrApi
|
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.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -111,12 +112,12 @@ object AppModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpFactory(
|
fun okHttpFactory(
|
||||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
) = OkHttpFactory(okHttpClient)
|
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun jellyfin(
|
fun jellyfin(
|
||||||
okHttpFactory: OkHttpFactory,
|
okHttpFactory: CoroutineContextApiClientFactory,
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
clientInfo: ClientInfo,
|
clientInfo: ClientInfo,
|
||||||
deviceInfo: DeviceInfo,
|
deviceInfo: DeviceInfo,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.WindowManager
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.MarqueeAnimationMode
|
import androidx.compose.foundation.MarqueeAnimationMode
|
||||||
import androidx.compose.foundation.basicMarquee
|
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.api.client.Response
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
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? {
|
fun Context.findActivity(): Activity? {
|
||||||
if (this is Activity) {
|
if (this is Activity) {
|
||||||
|
|
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
|
||||||
return null
|
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.
|
* Selectively log errors from Coil image loading.
|
||||||
*
|
*
|
||||||
|
|
@ -442,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
) = 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
|
* 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 android.content.Context
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
|
@ -802,8 +800,8 @@ fun CollectionFolderGridContent(
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader || loadingState !is DataLoadingState.Success,
|
showHeader || loadingState !is DataLoadingState.Success,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = expandVertically(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ suspend fun getGenreImageMap(
|
||||||
imageType = ImageType.BACKDROP,
|
imageType = ImageType.BACKDROP,
|
||||||
imageTags = item.imageTags.orEmpty(),
|
imageTags = item.imageTags.orEmpty(),
|
||||||
fillWidth = cardWidthPx,
|
fillWidth = cardWidthPx,
|
||||||
|
backdropTags = item.backdropImageTags.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -127,8 +125,8 @@ fun CollectionFolderLiveTv(
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = expandVertically(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -76,8 +74,8 @@ fun CollectionFolderMovie(
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = expandVertically(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -80,8 +78,8 @@ fun CollectionFolderTv(
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = expandVertically(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -98,8 +96,8 @@ fun FavoritesPage(
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = expandVertically(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -140,7 +142,11 @@ fun TvGuideGrid(
|
||||||
.fillMaxHeight(.30f),
|
.fillMaxHeight(.30f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(focusedPosition.row < 1) {
|
AnimatedVisibility(
|
||||||
|
focusedPosition.row < 1,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = R.string.view_options,
|
title = R.string.view_options,
|
||||||
iconStringRes = R.string.fa_sliders,
|
iconStringRes = R.string.fa_sliders,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
|
@ -91,7 +92,13 @@ fun NowPlayingPage(
|
||||||
val current = queue.getOrNull(state.currentIndex)
|
val current = queue.getOrNull(state.currentIndex)
|
||||||
|
|
||||||
val controllerViewState = viewModel.controllerViewState
|
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 =
|
val keyHandler =
|
||||||
remember(preferences) {
|
remember(preferences) {
|
||||||
|
|
@ -111,6 +118,7 @@ fun NowPlayingPage(
|
||||||
// viewModel.navigationManager.goBack()
|
// viewModel.navigationManager.goBack()
|
||||||
},
|
},
|
||||||
onPlaybackDialogTypeClick = { },
|
onPlaybackDialogTypeClick = { },
|
||||||
|
getDurationMs = { player.duration },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -528,7 +528,7 @@ class SeriesViewModel
|
||||||
api.userLibraryApi
|
api.userLibraryApi
|
||||||
.getItem(item.id)
|
.getItem(item.id)
|
||||||
.content.people
|
.content.people
|
||||||
?.map { Person.fromDto(it, api) }
|
?.map { Person.fromDto(context, it, api) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
PeopleInItem(item.id, list)
|
PeopleInItem(item.id, list)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui.discover
|
package com.github.damontecres.wholphin.ui.discover
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -60,8 +58,8 @@ fun DiscoverPage(
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader,
|
||||||
enter = slideInVertically() + fadeIn(),
|
enter = expandVertically(),
|
||||||
exit = slideOutVertically() + fadeOut(),
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
// Top scrim configuration for text readability (clock, season tabs)
|
// Top scrim configuration for text readability (clock, season tabs)
|
||||||
private const val TOP_SCRIM_ALPHA = 0.55f
|
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_END_FRACTION = 0.25f // Fraction of backdrop image height
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ApplicationContentViewModel
|
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 skipWithLeftRight: Boolean,
|
||||||
private val seekBack: Duration,
|
private val seekBack: Duration,
|
||||||
private val seekForward: Duration,
|
private val seekForward: Duration,
|
||||||
|
private val getDurationMs: () -> Long,
|
||||||
private val controllerViewState: ControllerViewState,
|
private val controllerViewState: ControllerViewState,
|
||||||
private val updateSkipIndicator: (Long) -> Unit,
|
private val updateSkipIndicator: (Long) -> Unit,
|
||||||
private val skipBackOnResume: Duration?,
|
private val skipBackOnResume: Duration?,
|
||||||
|
|
@ -28,15 +29,22 @@ class PlaybackKeyHandler(
|
||||||
private val onStop: () -> Unit,
|
private val onStop: () -> Unit,
|
||||||
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
|
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
private var leftHandledByRepeat = false
|
||||||
|
private var rightHandledByRepeat = false
|
||||||
|
|
||||||
fun onKeyEvent(it: KeyEvent): Boolean {
|
fun onKeyEvent(it: KeyEvent): Boolean {
|
||||||
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
|
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
|
||||||
|
|
||||||
var result = true
|
|
||||||
if (!controlsEnabled) {
|
if (!controlsEnabled) {
|
||||||
result = false
|
return false
|
||||||
|
} else if (handleHoldSkip(it)) {
|
||||||
|
return true
|
||||||
} else if (it.type != KeyEventType.KeyUp) {
|
} else if (it.type != KeyEventType.KeyUp) {
|
||||||
result = false
|
return false
|
||||||
} else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
|
}
|
||||||
|
|
||||||
|
var result = true
|
||||||
|
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
|
||||||
if (!controllerViewState.controlsVisible) {
|
if (!controllerViewState.controlsVisible) {
|
||||||
if (skipWithLeftRight && isSkipBack(it)) {
|
if (skipWithLeftRight && isSkipBack(it)) {
|
||||||
updateSkipIndicator(-seekBack.inWholeMilliseconds)
|
updateSkipIndicator(-seekBack.inWholeMilliseconds)
|
||||||
|
|
@ -111,4 +119,84 @@ class PlaybackKeyHandler(
|
||||||
}
|
}
|
||||||
return result
|
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,7 +612,7 @@ fun Controller(
|
||||||
fontSize = subtitleTextSize,
|
fontSize = subtitleTextSize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showClock) {
|
|
||||||
var endTimeStr by remember { mutableStateOf("...") }
|
var endTimeStr by remember { mutableStateOf("...") }
|
||||||
LaunchedEffect(playerControls) {
|
LaunchedEffect(playerControls) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
|
@ -636,7 +636,6 @@ fun Controller(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// TODO need to move these up a level?
|
// TODO need to move these up a level?
|
||||||
val moreFocusRequester = remember { FocusRequester() }
|
val moreFocusRequester = remember { FocusRequester() }
|
||||||
val captionFocusRequester = remember { FocusRequester() }
|
val captionFocusRequester = remember { FocusRequester() }
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,6 @@ fun PlaybackPageContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AmbientPlayerListener(player)
|
|
||||||
var contentScale by remember(playerBackend) {
|
var contentScale by remember(playerBackend) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (playerBackend == PlayerBackend.MPV) {
|
if (playerBackend == PlayerBackend.MPV) {
|
||||||
|
|
@ -236,6 +235,7 @@ fun PlaybackPageContent(
|
||||||
skipWithLeftRight = true,
|
skipWithLeftRight = true,
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
getDurationMs = { player.duration.coerceAtLeast(0L) },
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
updateSkipIndicator = updateSkipIndicator,
|
updateSkipIndicator = updateSkipIndicator,
|
||||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
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.PlaylistCreationResult
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
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.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -140,6 +141,7 @@ class PlaybackViewModel
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val screensaverService: ScreensaverService,
|
||||||
@Assisted private val destination: Destination,
|
@Assisted private val destination: Destination,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
|
|
@ -189,7 +191,10 @@ class PlaybackViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
addCloseable { disconnectPlayer() }
|
addCloseable {
|
||||||
|
screensaverService.keepScreenOn(false)
|
||||||
|
disconnectPlayer()
|
||||||
|
}
|
||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1424,6 +1429,10 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
screensaverService.keepScreenOn(isPlaying)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PlayerState(
|
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.input.key.type
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
|
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -80,15 +79,16 @@ fun SteppedSeekBarImpl(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
progress = progressToUse,
|
progress = progressToUse,
|
||||||
bufferedProgress = bufferedProgress,
|
bufferedProgress = bufferedProgress,
|
||||||
onLeft = {
|
durationMs = durationMs,
|
||||||
|
onLeft = { multiplier ->
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
seekProgress = (progressToUse - offset).coerceAtLeast(0f)
|
seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f)
|
||||||
hasSeeked = true
|
hasSeeked = true
|
||||||
seek(seekProgress)
|
seek(seekProgress)
|
||||||
},
|
},
|
||||||
onRight = {
|
onRight = { multiplier ->
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
seekProgress = (progressToUse + offset).coerceAtMost(1f)
|
seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f)
|
||||||
hasSeeked = true
|
hasSeeked = true
|
||||||
seek(seekProgress)
|
seek(seekProgress)
|
||||||
},
|
},
|
||||||
|
|
@ -126,16 +126,19 @@ fun IntervalSeekBarImpl(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
progress = (progressToUse.toDouble() / durationMs).toFloat(),
|
progress = (progressToUse.toDouble() / durationMs).toFloat(),
|
||||||
bufferedProgress = bufferedProgress,
|
bufferedProgress = bufferedProgress,
|
||||||
onLeft = {
|
durationMs = durationMs,
|
||||||
|
onLeft = { multiplier ->
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
|
seekPositionMs =
|
||||||
|
(progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L)
|
||||||
hasSeeked = true
|
hasSeeked = true
|
||||||
onSeek(seekPositionMs)
|
onSeek(seekPositionMs)
|
||||||
},
|
},
|
||||||
onRight = {
|
onRight = { multiplier ->
|
||||||
controllerViewState.pulseControls()
|
controllerViewState.pulseControls()
|
||||||
seekPositionMs =
|
seekPositionMs =
|
||||||
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
|
(progressToUse + seekForward.inWholeMilliseconds * multiplier)
|
||||||
|
.coerceAtMost(durationMs)
|
||||||
hasSeeked = true
|
hasSeeked = true
|
||||||
onSeek(seekPositionMs)
|
onSeek(seekPositionMs)
|
||||||
},
|
},
|
||||||
|
|
@ -148,8 +151,9 @@ fun IntervalSeekBarImpl(
|
||||||
fun SeekBarDisplay(
|
fun SeekBarDisplay(
|
||||||
progress: Float,
|
progress: Float,
|
||||||
bufferedProgress: Float,
|
bufferedProgress: Float,
|
||||||
onLeft: () -> Unit,
|
durationMs: Long,
|
||||||
onRight: () -> Unit,
|
onLeft: (Int) -> Unit,
|
||||||
|
onRight: (Int) -> Unit,
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
|
@ -158,14 +162,13 @@ fun SeekBarDisplay(
|
||||||
val onSurface = MaterialTheme.colorScheme.onSurface
|
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
var leftHandledByRepeat by remember { mutableStateOf(false) }
|
||||||
|
var rightHandledByRepeat by remember { mutableStateOf(false) }
|
||||||
val animatedIndicatorHeight by animateDpAsState(
|
val animatedIndicatorHeight by animateDpAsState(
|
||||||
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
|
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -173,24 +176,79 @@ fun SeekBarDisplay(
|
||||||
.height(animatedIndicatorHeight)
|
.height(animatedIndicatorHeight)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
val trigger =
|
|
||||||
event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
|
|
||||||
when (event.nativeKeyEvent.keyCode) {
|
when (event.nativeKeyEvent.keyCode) {
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
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
|
return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
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
|
return@onPreviewKeyEvent true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}.handleDPadKeyEvents(
|
}.focusable(enabled = enabled, interactionSource = interactionSource),
|
||||||
onLeft = onLeft,
|
|
||||||
onRight = onRight,
|
|
||||||
).focusable(enabled = enabled, interactionSource = interactionSource),
|
|
||||||
onDraw = {
|
onDraw = {
|
||||||
val yOffset = size.height.div(2)
|
val yOffset = size.height.div(2)
|
||||||
drawLine(
|
drawLine(
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.SortedSet
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -226,7 +225,7 @@ fun <T> ComposablePreference(
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppMultiChoicePreference<*, *> -> {
|
is AppMultiChoicePreference<*, *> -> {
|
||||||
val values = stringArrayResource(preference.displayValues).toSortedSet()
|
val values = stringArrayResource(preference.displayValues)
|
||||||
val summary =
|
val summary =
|
||||||
preference.summary?.let { stringResource(it) }
|
preference.summary?.let { stringResource(it) }
|
||||||
?: preference.summary(context, value)
|
?: preference.summary(context, value)
|
||||||
|
|
@ -237,12 +236,15 @@ fun <T> ComposablePreference(
|
||||||
list
|
list
|
||||||
}
|
}
|
||||||
MultiChoicePreference(
|
MultiChoicePreference(
|
||||||
possibleValues = values as SortedSet<Any>,
|
possibleValues = preference.allValues,
|
||||||
selectedValues = selectedValues,
|
selectedValues = selectedValues,
|
||||||
title = title,
|
title = title,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
onValueChange = {
|
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(
|
fun <T> MultiChoicePreference(
|
||||||
title: String,
|
title: String,
|
||||||
summary: String?,
|
summary: String?,
|
||||||
possibleValues: Set<T>,
|
possibleValues: List<T>,
|
||||||
selectedValues: Set<T>,
|
selectedValues: Set<T>,
|
||||||
onValueChange: (List<T>) -> Unit,
|
onValueChange: (List<T>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
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 values = stringArrayResource(preference.displayValues).toList()
|
||||||
// val summary =
|
// val summary =
|
||||||
|
|
@ -59,7 +59,7 @@ fun <T> MultiChoicePreference(
|
||||||
items =
|
items =
|
||||||
possibleValues.mapIndexed { index, item ->
|
possibleValues.mapIndexed { index, item ->
|
||||||
DialogItem(
|
DialogItem(
|
||||||
headlineContent = { valueDisplay.invoke(item) },
|
headlineContent = { valueDisplay.invoke(index, item) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = selectedValues.contains(item),
|
checked = selectedValues.contains(item),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ enum class PreferenceScreenOption {
|
||||||
ADVANCED,
|
ADVANCED,
|
||||||
EXO_PLAYER,
|
EXO_PLAYER,
|
||||||
MPV,
|
MPV,
|
||||||
|
SCREENSAVER,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
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.ExoPlayerPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
import com.github.damontecres.wholphin.preferences.MpvPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
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.advancedPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.basicPreferences
|
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.preferences.updatePlaybackPreferences
|
||||||
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
|
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
|
|
@ -128,6 +130,7 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.ADVANCED -> advancedPreferences
|
PreferenceScreenOption.ADVANCED -> advancedPreferences
|
||||||
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
||||||
PreferenceScreenOption.MPV -> MpvPreferences
|
PreferenceScreenOption.MPV -> MpvPreferences
|
||||||
|
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
|
||||||
}
|
}
|
||||||
val screenTitle =
|
val screenTitle =
|
||||||
when (preferenceScreenOption) {
|
when (preferenceScreenOption) {
|
||||||
|
|
@ -135,6 +138,7 @@ fun PreferencesContent(
|
||||||
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
|
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
|
||||||
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
||||||
PreferenceScreenOption.MPV -> R.string.mpv_options
|
PreferenceScreenOption.MPV -> R.string.mpv_options
|
||||||
|
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
|
||||||
}
|
}
|
||||||
|
|
||||||
var visible by remember { mutableStateOf(false) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
|
@ -283,7 +287,9 @@ fun PreferencesContent(
|
||||||
if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
if (release != null && updateAvailable) {
|
if (release != null && updateAvailable) {
|
||||||
release?.let {
|
release?.let {
|
||||||
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
viewModel.navigationManager.navigateTo(
|
||||||
|
Destination.UpdateApp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateVM.init(preferences.updateUrl)
|
updateVM.init(preferences.updateUrl)
|
||||||
|
|
@ -313,7 +319,8 @@ fun PreferencesContent(
|
||||||
val summary =
|
val summary =
|
||||||
remember(cacheUsage) {
|
remember(cacheUsage) {
|
||||||
cacheUsage.let {
|
cacheUsage.let {
|
||||||
val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
|
val diskMB =
|
||||||
|
it.imageDiskUsed / AppPreference.MEGA_BIT
|
||||||
val memoryUsedMB =
|
val memoryUsedMB =
|
||||||
it.imageMemoryUsed / AppPreference.MEGA_BIT
|
it.imageMemoryUsed / AppPreference.MEGA_BIT
|
||||||
val memoryMaxMB =
|
val memoryMaxMB =
|
||||||
|
|
@ -392,7 +399,10 @@ fun PreferencesContent(
|
||||||
seerrDialogMode =
|
seerrDialogMode =
|
||||||
when (val conn = seerrConnection) {
|
when (val conn = seerrConnection) {
|
||||||
is SeerrConnectionStatus.Error -> {
|
is SeerrConnectionStatus.Error -> {
|
||||||
SeerrDialogMode.Error(conn.serverUrl, conn.ex)
|
SeerrDialogMode.Error(
|
||||||
|
conn.serverUrl,
|
||||||
|
conn.ex,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SeerrConnectionStatus.NotConfigured -> {
|
SeerrConnectionStatus.NotConfigured -> {
|
||||||
|
|
@ -409,9 +419,19 @@ fun PreferencesContent(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
summary =
|
summary =
|
||||||
when (seerrConnection) {
|
when (seerrConnection) {
|
||||||
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
|
is SeerrConnectionStatus.Error -> {
|
||||||
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
|
stringResource(R.string.voice_error_server)
|
||||||
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
|
}
|
||||||
|
|
||||||
|
SeerrConnectionStatus.NotConfigured -> {
|
||||||
|
stringResource(
|
||||||
|
R.string.add_server,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SeerrConnectionStatus.Success -> {
|
||||||
|
stringResource(R.string.enabled)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
interactionSource = interactionSource,
|
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 -> {
|
else -> {
|
||||||
val value = pref.getter.invoke(preferences)
|
val value = pref.getter.invoke(preferences)
|
||||||
ComposablePreference(
|
ComposablePreference(
|
||||||
|
|
@ -597,6 +630,7 @@ fun PreferencesPage(
|
||||||
PreferenceScreenOption.ADVANCED,
|
PreferenceScreenOption.ADVANCED,
|
||||||
PreferenceScreenOption.EXO_PLAYER,
|
PreferenceScreenOption.EXO_PLAYER,
|
||||||
PreferenceScreenOption.MPV,
|
PreferenceScreenOption.MPV,
|
||||||
|
PreferenceScreenOption.SCREENSAVER,
|
||||||
-> {
|
-> {
|
||||||
PreferencesContent(
|
PreferencesContent(
|
||||||
initialPreferences,
|
initialPreferences,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
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.services.SeerrServerRepository
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -35,6 +36,7 @@ class PreferencesViewModel
|
||||||
val preferenceDataStore: DataStore<AppPreferences>,
|
val preferenceDataStore: DataStore<AppPreferences>,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val backdropService: BackdropService,
|
val backdropService: BackdropService,
|
||||||
|
val screensaverService: ScreensaverService,
|
||||||
private val rememberTabManager: RememberTabManager,
|
private val rememberTabManager: RememberTabManager,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -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.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
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.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
|
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
|
||||||
import com.github.damontecres.wholphin.ui.playback.isDpad
|
import com.github.damontecres.wholphin.ui.playback.isDpad
|
||||||
|
|
@ -208,12 +205,6 @@ fun SlideshowPage(
|
||||||
LaunchedEffect(slideshowActive) {
|
LaunchedEffect(slideshowActive) {
|
||||||
player.repeatMode =
|
player.repeatMode =
|
||||||
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
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) }
|
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.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
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.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
|
@ -67,6 +68,7 @@ class SlideshowViewModel
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val screensaverService: ScreensaverService,
|
||||||
@Assisted val slideshowSettings: Destination.Slideshow,
|
@Assisted val slideshowSettings: Destination.Slideshow,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
Player.Listener {
|
Player.Listener {
|
||||||
|
|
@ -110,6 +112,7 @@ class SlideshowViewModel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable {
|
addCloseable {
|
||||||
|
screensaverService.keepScreenOn(false)
|
||||||
player.removeListener(this@SlideshowViewModel)
|
player.removeListener(this@SlideshowViewModel)
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +285,7 @@ class SlideshowViewModel
|
||||||
private var slideshowJob: Job? = null
|
private var slideshowJob: Job? = null
|
||||||
|
|
||||||
fun startSlideshow() {
|
fun startSlideshow() {
|
||||||
|
screensaverService.keepScreenOn(true)
|
||||||
_slideshow.update {
|
_slideshow.update {
|
||||||
SlideshowState(enabled = true, paused = false)
|
SlideshowState(enabled = true, paused = false)
|
||||||
}
|
}
|
||||||
|
|
@ -295,6 +299,7 @@ class SlideshowViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopSlideshow() {
|
fun stopSlideshow() {
|
||||||
|
screensaverService.keepScreenOn(false)
|
||||||
slideshowJob?.cancel()
|
slideshowJob?.cancel()
|
||||||
_slideshow.update {
|
_slideshow.update {
|
||||||
SlideshowState(enabled = false, paused = false)
|
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;
|
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 {
|
message InterfacePreferences {
|
||||||
ThemeSongVolume play_theme_songs = 1;
|
ThemeSongVolume play_theme_songs = 1;
|
||||||
bool remember_selected_tab = 2;
|
bool remember_selected_tab = 2;
|
||||||
|
|
@ -155,6 +165,7 @@ message InterfacePreferences {
|
||||||
LiveTvPreferences live_tv_preferences = 8;
|
LiveTvPreferences live_tv_preferences = 8;
|
||||||
BackdropStyle backdrop_style = 9;
|
BackdropStyle backdrop_style = 9;
|
||||||
SubtitlePreferences hdr_subtitles_preferences = 10;
|
SubtitlePreferences hdr_subtitles_preferences = 10;
|
||||||
|
ScreensaverPreferences screensaver_preference = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdvancedPreferences {
|
message AdvancedPreferences {
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,11 @@
|
||||||
<item quantity="one">%d second</item>
|
<item quantity="one">%d second</item>
|
||||||
<item quantity="other">%d seconds</item>
|
<item quantity="other">%d seconds</item>
|
||||||
</plurals>
|
</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">
|
<plurals name="movies">
|
||||||
<item quantity="zero">Movies</item>
|
<item quantity="zero">Movies</item>
|
||||||
|
|
@ -530,6 +535,19 @@
|
||||||
<string name="display_presets">Display presets</string>
|
<string name="display_presets">Display presets</string>
|
||||||
<string name="display_presets_description">Built-in presets to quickly style all rows</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="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_fit">Fit</string>
|
||||||
<string name="content_scale_crop">Crop</string>
|
<string name="content_scale_crop">Crop</string>
|
||||||
<string name="content_scale_fill">Fill</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="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string>
|
||||||
<string name="no_subtitles_found">No remote subtitles were found</string>
|
<string name="no_subtitles_found">No remote subtitles were found</string>
|
||||||
<string name="series_continueing">Present</string>
|
<string name="series_continueing">Present</string>
|
||||||
|
<string name="in_app_screensaver">Use in-app screensaver</string>
|
||||||
<string-array name="backdrop_style_options">
|
<string-array name="backdrop_style_options">
|
||||||
<item>@string/backdrop_style_dynamic</item>
|
<item>@string/backdrop_style_dynamic</item>
|
||||||
<item>@string/backdrop_style_image</item>
|
<item>@string/backdrop_style_image</item>
|
||||||
<item>@string/none</item>
|
<item>@string/none</item>
|
||||||
</string-array>
|
</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>
|
</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"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.4"
|
activityCompose = "1.12.4"
|
||||||
androidx-media3 = "1.9.2"
|
androidx-media3 = "1.9.2"
|
||||||
coil = "3.3.0"
|
coil = "3.4.0"
|
||||||
jellyfin-sdk = "1.7.1"
|
jellyfin-sdk = "1.7.1"
|
||||||
nav3Core = "1.0.1"
|
nav3Core = "1.0.1"
|
||||||
lifecycleViewmodelNav3 = "2.10.0"
|
lifecycleViewmodelNav3 = "2.10.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue