mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
Merge branch 'main' into develop/jellyseerr
This commit is contained in:
commit
8f02dab6ed
38 changed files with 1964 additions and 646 deletions
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
- name: Build app
|
- name: Build app
|
||||||
id: buildapp
|
id: buildapp
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleDebug --no-daemon
|
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
|
||||||
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
|
||||||
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
echo "apks=$apks" >> "$GITHUB_OUTPUT"
|
||||||
- name: Tar build dirs
|
- name: Tar build dirs
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ android {
|
||||||
debug {
|
debug {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
@ -230,6 +231,9 @@ dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.protobuf.kotlin.lite)
|
implementation(libs.protobuf.kotlin.lite)
|
||||||
|
implementation(libs.androidx.tvprovider)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
|
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
|
|
@ -266,6 +270,7 @@ dependencies {
|
||||||
implementation(libs.androidx.palette.ktx)
|
implementation(libs.androidx.palette.ktx)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
ksp(libs.hilt.android.compiler)
|
ksp(libs.hilt.android.compiler)
|
||||||
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.slf4j2.timber)
|
implementation(libs.slf4j2.timber)
|
||||||
|
|
@ -291,5 +296,6 @@ dependencies {
|
||||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// implementation(project(":seerr-api"))
|
testImplementation(libs.mockk.android)
|
||||||
|
testImplementation(libs.mockk.agent)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
app/src/debug/res/values/strings.xml
Normal file
4
app/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Wholphin (Debug)</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -53,6 +55,10 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
|
@ -13,13 +14,16 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
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
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
|
|
@ -32,7 +36,6 @@ import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
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.DefaultUserConfiguration
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
|
@ -47,10 +50,13 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
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.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
|
@ -58,11 +64,9 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
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.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -105,12 +109,15 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var userSwitchListener: UserSwitchListener
|
lateinit var userSwitchListener: UserSwitchListener
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Timber.i("MainActivity.onCreate")
|
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||||
lifecycle.addObserver(playbackLifecycleObserver)
|
lifecycle.addObserver(playbackLifecycleObserver)
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
appUpgradeHandler.copySubfont(false)
|
appUpgradeHandler.copySubfont(false)
|
||||||
|
|
@ -124,6 +131,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
|
val requestedDestination = this.intent?.let(::extractDestination)
|
||||||
setContent {
|
setContent {
|
||||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||||
appPreferences?.let { appPreferences ->
|
appPreferences?.let { appPreferences ->
|
||||||
|
|
@ -217,20 +225,41 @@ class MainActivity : AppCompatActivity() {
|
||||||
appPreferences,
|
appPreferences,
|
||||||
)
|
)
|
||||||
val preferences =
|
val preferences =
|
||||||
remember(appPreferences, current) {
|
remember(appPreferences) {
|
||||||
UserPreferences(
|
UserPreferences(appPreferences)
|
||||||
appPreferences,
|
}
|
||||||
current.userDto.configuration
|
var showContent by remember {
|
||||||
?: DefaultUserConfiguration,
|
mutableStateOf(true)
|
||||||
|
}
|
||||||
|
if (!preferences.appPreferences.signInAutomatically) {
|
||||||
|
LifecycleStartEffect(Unit) {
|
||||||
|
onStopOrDispose {
|
||||||
|
showContent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showContent) {
|
||||||
|
ApplicationContent(
|
||||||
|
user = current.user,
|
||||||
|
server = current.server,
|
||||||
|
startDestination =
|
||||||
|
requestedDestination
|
||||||
|
?: Destination.Home(),
|
||||||
|
navigationManager = navigationManager,
|
||||||
|
preferences = preferences,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(200.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.border,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ApplicationContent(
|
}
|
||||||
user = current.user,
|
|
||||||
server = current.server,
|
|
||||||
navigationManager = navigationManager,
|
|
||||||
preferences = preferences,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +275,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
Timber.d("onResume")
|
||||||
lifecycleScope.launchIO {
|
lifecycleScope.launchIO {
|
||||||
appUpgradeHandler.run()
|
appUpgradeHandler.run()
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +283,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onRestart() {
|
override fun onRestart() {
|
||||||
super.onRestart()
|
super.onRestart()
|
||||||
Timber.i("onRestart")
|
Timber.d("onRestart")
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
// val signInAutomatically =
|
// val signInAutomatically =
|
||||||
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
|
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
|
||||||
|
|
@ -264,6 +294,85 @@ class MainActivity : AppCompatActivity() {
|
||||||
// serverRepository.closeSession()
|
// serverRepository.closeSession()
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
Timber.d("onStop")
|
||||||
|
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
Timber.d("onPause")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
Timber.d("onStart")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
Timber.d("onSaveInstanceState")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
super.onRestoreInstanceState(savedInstanceState)
|
||||||
|
Timber.d("onRestoreInstanceState")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
Timber.d("onDestroy")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
Timber.v("onNewIntent")
|
||||||
|
extractDestination(intent)?.let {
|
||||||
|
navigationManager.replace(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractDestination(intent: Intent): Destination? =
|
||||||
|
intent.let {
|
||||||
|
val itemId =
|
||||||
|
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
|
||||||
|
val type =
|
||||||
|
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
|
||||||
|
if (itemId != null && type != null) {
|
||||||
|
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
|
||||||
|
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
|
||||||
|
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
|
||||||
|
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
|
||||||
|
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
|
||||||
|
Destination.SeriesOverview(
|
||||||
|
itemId = seriesId,
|
||||||
|
type = BaseItemKind.SERIES,
|
||||||
|
seasonEpisode =
|
||||||
|
SeasonEpisodeIds(
|
||||||
|
seasonId = seasonId,
|
||||||
|
seasonNumber = seasonNumber,
|
||||||
|
episodeId = itemId,
|
||||||
|
episodeNumber = episodeNumber,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.MediaItem(itemId, type)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INTENT_ITEM_ID = "itemId"
|
||||||
|
const val INTENT_ITEM_TYPE = "itemType"
|
||||||
|
const val INTENT_SERIES_ID = "seriesId"
|
||||||
|
const val INTENT_EPISODE_NUMBER = "epNum"
|
||||||
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||||
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -277,40 +386,42 @@ class MainActivityViewModel
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun appStart() {
|
fun appStart() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launchIO {
|
||||||
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
try {
|
||||||
if (prefs.signInAutomatically) {
|
val prefs =
|
||||||
val current =
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
withContext(Dispatchers.IO) {
|
if (prefs.signInAutomatically) {
|
||||||
|
val current =
|
||||||
serverRepository.restoreSession(
|
serverRepository.restoreSession(
|
||||||
prefs.currentServerId?.toUUIDOrNull(),
|
prefs.currentServerId?.toUUIDOrNull(),
|
||||||
prefs.currentUserId?.toUUIDOrNull(),
|
prefs.currentUserId?.toUUIDOrNull(),
|
||||||
)
|
)
|
||||||
}
|
if (current != null) {
|
||||||
if (current != null) {
|
// Restored
|
||||||
// Restored
|
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
|
||||||
} else {
|
|
||||||
// Did not restore
|
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navigationManager.navigateTo(SetupDestination.Loading)
|
|
||||||
backdropService.clearBackdrop()
|
|
||||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
|
||||||
if (currentServerId != null) {
|
|
||||||
val currentServer =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
|
||||||
}
|
|
||||||
if (currentServer != null) {
|
|
||||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
|
||||||
} else {
|
} else {
|
||||||
|
// Did not restore
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
navigationManager.navigateTo(SetupDestination.Loading)
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||||
|
if (currentServerId != null) {
|
||||||
|
val currentServer =
|
||||||
|
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||||
|
if (currentServer != null) {
|
||||||
|
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
||||||
|
} else {
|
||||||
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error during appStart")
|
||||||
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ 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
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
|
|
@ -14,10 +16,13 @@ import org.acra.config.dialog
|
||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class WholphinApplication : Application() {
|
class WholphinApplication :
|
||||||
|
Application(),
|
||||||
|
Configuration.Provider {
|
||||||
init {
|
init {
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
|
|
@ -94,6 +99,16 @@ class WholphinApplication : Application() {
|
||||||
ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString())
|
ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() =
|
||||||
|
Configuration
|
||||||
|
.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: WholphinApplication
|
lateinit var instance: WholphinApplication
|
||||||
private set
|
private set
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ItemPlaybackRepository
|
||||||
)
|
)
|
||||||
val subtitleStream =
|
val subtitleStream =
|
||||||
streamChoiceService.chooseSubtitleStream(
|
streamChoiceService.chooseSubtitleStream(
|
||||||
audioStream = audioStream,
|
audioStreamLang = audioStream?.language,
|
||||||
candidates =
|
candidates =
|
||||||
source.mediaStreams
|
source.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,11 @@ class ServerRepository
|
||||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||||
val current: LiveData<CurrentUser?> = _current
|
val current: LiveData<CurrentUser?> = _current
|
||||||
|
|
||||||
|
private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null)
|
||||||
|
val currentUserDto: LiveData<UserDto?> = _currentUserDto
|
||||||
|
|
||||||
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
|
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
|
||||||
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
|
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
|
||||||
val currentUserDto: LiveData<UserDto?> get() = _current.map { it?.userDto }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||||
|
|
@ -101,7 +103,8 @@ class ServerRepository
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_current.value = CurrentUser(updatedServer, updatedUser, userDto)
|
_current.value = CurrentUser(updatedServer, updatedUser)
|
||||||
|
_currentUserDto.value = userDto
|
||||||
}
|
}
|
||||||
sharedPreferences.edit(true) {
|
sharedPreferences.edit(true) {
|
||||||
putString(SERVER_URL_KEY, updatedServer.url)
|
putString(SERVER_URL_KEY, updatedServer.url)
|
||||||
|
|
@ -128,11 +131,21 @@ class ServerRepository
|
||||||
serverDao.getServer(serverId)
|
serverDao.getServer(serverId)
|
||||||
}
|
}
|
||||||
if (serverAndUsers != null) {
|
if (serverAndUsers != null) {
|
||||||
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
val current = _current.value
|
||||||
if (user != null) {
|
if (current != null && current.server.id == serverId && current.user.id == userId) {
|
||||||
// TODO pin-related
|
Timber.v("Restoring session for current user, so shortcut")
|
||||||
|
apiClient.update(
|
||||||
|
baseUrl = current.server.url,
|
||||||
|
accessToken = current.user.accessToken,
|
||||||
|
)
|
||||||
|
return current
|
||||||
|
} else {
|
||||||
|
val user = serverAndUsers.users.firstOrNull { it.id == userId }
|
||||||
|
if (user != null) {
|
||||||
|
// TODO pin-related
|
||||||
// if (user != null && !user.hasPin) {
|
// if (user != null && !user.hasPin) {
|
||||||
return changeUser(serverAndUsers.server, user)
|
return changeUser(serverAndUsers.server, user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
@ -229,7 +242,6 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun switchServerOrUser() {
|
suspend fun switchServerOrUser() {
|
||||||
apiClient.update(baseUrl = null, accessToken = null)
|
|
||||||
userPreferencesDataStore.updateData {
|
userPreferencesDataStore.updateData {
|
||||||
it
|
it
|
||||||
.toBuilder()
|
.toBuilder()
|
||||||
|
|
@ -271,5 +283,4 @@ class ServerRepository
|
||||||
data class CurrentUser(
|
data class CurrentUser(
|
||||||
val server: JellyfinServer,
|
val server: JellyfinServer,
|
||||||
val user: JellyfinUser,
|
val user: JellyfinUser,
|
||||||
val userDto: UserDto,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
*/
|
*/
|
||||||
data class UserPreferences(
|
data class UserPreferences(
|
||||||
val appPreferences: AppPreferences,
|
val appPreferences: AppPreferences,
|
||||||
val userConfig: UserConfiguration,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val DefaultUserConfiguration =
|
val DefaultUserConfiguration =
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class ImageUrlService
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
fillWidth: Int? = null,
|
fillWidth: Int? = null,
|
||||||
fillHeight: Int? = null,
|
fillHeight: Int? = null,
|
||||||
): String =
|
): String? =
|
||||||
when (imageType) {
|
when (imageType) {
|
||||||
ImageType.BACKDROP,
|
ImageType.BACKDROP,
|
||||||
ImageType.LOGO,
|
ImageType.LOGO,
|
||||||
|
|
@ -124,8 +124,9 @@ class ImageUrlService
|
||||||
backgroundColor: String? = null,
|
backgroundColor: String? = null,
|
||||||
foregroundLayer: String? = null,
|
foregroundLayer: String? = null,
|
||||||
imageIndex: Int? = null,
|
imageIndex: Int? = null,
|
||||||
): String =
|
): String? {
|
||||||
api.imageApi.getItemImageUrl(
|
if (api.baseUrl.isNullOrBlank()) return null
|
||||||
|
return api.imageApi.getItemImageUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
maxWidth = maxWidth,
|
maxWidth = maxWidth,
|
||||||
|
|
@ -144,6 +145,7 @@ class ImageUrlService
|
||||||
foregroundLayer = foregroundLayer,
|
foregroundLayer = foregroundLayer,
|
||||||
imageIndex = imageIndex,
|
imageIndex = imageIndex,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.main.LatestData
|
||||||
|
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class LatestNextUpService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val datePlayedService: DatePlayedService,
|
||||||
|
) {
|
||||||
|
suspend fun getResume(
|
||||||
|
userId: UUID,
|
||||||
|
limit: Int,
|
||||||
|
includeEpisodes: Boolean,
|
||||||
|
): List<BaseItem> {
|
||||||
|
val request =
|
||||||
|
GetResumeItemsRequest(
|
||||||
|
userId = userId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
limit = limit,
|
||||||
|
includeItemTypes =
|
||||||
|
if (includeEpisodes) {
|
||||||
|
supportItemKinds
|
||||||
|
} else {
|
||||||
|
supportItemKinds
|
||||||
|
.toMutableSet()
|
||||||
|
.apply {
|
||||||
|
remove(BaseItemKind.EPISODE)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val items =
|
||||||
|
api.itemsApi
|
||||||
|
.getResumeItems(request)
|
||||||
|
.content
|
||||||
|
.items
|
||||||
|
.map { BaseItem.from(it, api, true) }
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNextUp(
|
||||||
|
userId: UUID,
|
||||||
|
limit: Int,
|
||||||
|
enableRewatching: Boolean,
|
||||||
|
enableResumable: Boolean,
|
||||||
|
): List<BaseItem> {
|
||||||
|
val request =
|
||||||
|
GetNextUpRequest(
|
||||||
|
userId = userId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
parentId = null,
|
||||||
|
limit = limit,
|
||||||
|
enableResumable = enableResumable,
|
||||||
|
enableUserData = true,
|
||||||
|
enableRewatching = enableRewatching,
|
||||||
|
)
|
||||||
|
val nextUp =
|
||||||
|
api.tvShowsApi
|
||||||
|
.getNextUp(request)
|
||||||
|
.content
|
||||||
|
.items
|
||||||
|
.map { BaseItem.from(it, api, true) }
|
||||||
|
return nextUp
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatest(
|
||||||
|
user: UserDto,
|
||||||
|
limit: Int,
|
||||||
|
includedIds: List<UUID>,
|
||||||
|
): List<LatestData> {
|
||||||
|
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
||||||
|
val views by api.userViewsApi.getUserViews()
|
||||||
|
val latestData =
|
||||||
|
views.items
|
||||||
|
.filter {
|
||||||
|
it.id in includedIds && it.id !in excluded &&
|
||||||
|
it.collectionType in supportedLatestCollectionTypes
|
||||||
|
}.map { view ->
|
||||||
|
val title =
|
||||||
|
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
||||||
|
?: context.getString(R.string.recently_added)
|
||||||
|
val request =
|
||||||
|
GetLatestMediaRequest(
|
||||||
|
fields = SlimItemFields,
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
parentId = view.id,
|
||||||
|
groupItems = true,
|
||||||
|
limit = limit,
|
||||||
|
isPlayed = null, // Server will handle user's preference
|
||||||
|
)
|
||||||
|
LatestData(title, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestData
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
||||||
|
val rows =
|
||||||
|
latestData.mapNotNull { (title, request) ->
|
||||||
|
try {
|
||||||
|
val latest =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getLatestMedia(request)
|
||||||
|
.content
|
||||||
|
.map { BaseItem.from(it, api, true) }
|
||||||
|
if (latest.isNotEmpty()) {
|
||||||
|
HomeRowLoadingState.Success(
|
||||||
|
title = title,
|
||||||
|
items = latest,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception fetching %s", title)
|
||||||
|
HomeRowLoadingState.Error(
|
||||||
|
title = title,
|
||||||
|
exception = ex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun buildCombined(
|
||||||
|
resume: List<BaseItem>,
|
||||||
|
nextUp: List<BaseItem>,
|
||||||
|
): List<BaseItem> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
val semaphore = Semaphore(3)
|
||||||
|
val deferred =
|
||||||
|
nextUp
|
||||||
|
.filter { it.data.seriesId != null }
|
||||||
|
.map { item ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
semaphore.withPermit {
|
||||||
|
datePlayedService.getLastPlayed(item)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching %s", item.id)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextUpLastPlayed = deferred.awaitAll()
|
||||||
|
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
||||||
|
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
||||||
|
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
||||||
|
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
||||||
|
val duration = (System.currentTimeMillis() - start).milliseconds
|
||||||
|
Timber.v("buildCombined took %s", duration)
|
||||||
|
return@withContext result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,9 +68,20 @@ class NavigationManager
|
||||||
log()
|
log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the backstack to the specified destination
|
||||||
|
*/
|
||||||
|
fun replace(destination: Destination) {
|
||||||
|
while (backStack.size > 1) {
|
||||||
|
backStack.removeLastOrNull()
|
||||||
|
}
|
||||||
|
backStack[0] = destination
|
||||||
|
log()
|
||||||
|
}
|
||||||
|
|
||||||
private fun log() {
|
private fun log() {
|
||||||
val dest = backStack.lastOrNull().toString()
|
val dest = backStack.lastOrNull().toString()
|
||||||
Timber.Forest.i("Current Destination: %s", dest)
|
Timber.i("Current Destination: %s", dest)
|
||||||
ACRA.errorReporter.putCustomData("destination", dest)
|
ACRA.errorReporter.putCustomData("destination", dest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||||
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -26,6 +27,8 @@ class StreamChoiceService
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||||
) {
|
) {
|
||||||
|
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration
|
||||||
|
|
||||||
suspend fun updateAudio(
|
suspend fun updateAudio(
|
||||||
dto: BaseItemDto,
|
dto: BaseItemDto,
|
||||||
audioLang: String,
|
audioLang: String,
|
||||||
|
|
@ -117,7 +120,7 @@ class StreamChoiceService
|
||||||
val seriesLang =
|
val seriesLang =
|
||||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||||
// If the user has chosen a different language for the series, prefer that
|
// If the user has chosen a different language for the series, prefer that
|
||||||
val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference
|
val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
|
||||||
|
|
||||||
if (audioLanguage.isNotNullOrBlank()) {
|
if (audioLanguage.isNotNullOrBlank()) {
|
||||||
val sorted =
|
val sorted =
|
||||||
|
|
@ -150,7 +153,7 @@ class StreamChoiceService
|
||||||
return source.mediaStreams?.letNotEmpty { streams ->
|
return source.mediaStreams?.letNotEmpty { streams ->
|
||||||
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
chooseSubtitleStream(
|
chooseSubtitleStream(
|
||||||
audioStream,
|
audioStream?.language,
|
||||||
candidates,
|
candidates,
|
||||||
itemPlayback,
|
itemPlayback,
|
||||||
plc,
|
plc,
|
||||||
|
|
@ -160,7 +163,7 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
|
|
||||||
fun chooseSubtitleStream(
|
fun chooseSubtitleStream(
|
||||||
audioStream: MediaStream?,
|
audioStreamLang: String?,
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
playbackLanguageChoice: PlaybackLanguageChoice?,
|
playbackLanguageChoice: PlaybackLanguageChoice?,
|
||||||
|
|
@ -174,7 +177,7 @@ class StreamChoiceService
|
||||||
val seriesLang =
|
val seriesLang =
|
||||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||||
val subtitleLanguage =
|
val subtitleLanguage =
|
||||||
(seriesLang ?: prefs.userConfig.subtitleLanguagePreference)
|
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
||||||
?.takeIf { it.isNotNullOrBlank() }
|
?.takeIf { it.isNotNullOrBlank() }
|
||||||
|
|
||||||
val subtitleMode =
|
val subtitleMode =
|
||||||
|
|
@ -192,51 +195,62 @@ class StreamChoiceService
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// Fallback to the user's preference
|
// Fallback to the user's preference
|
||||||
prefs.userConfig.subtitleMode
|
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val candidates =
|
||||||
|
candidates.sortedWith(
|
||||||
|
compareBy<MediaStream>(
|
||||||
|
{ it.isDefault },
|
||||||
|
{ !it.isForced && it.language.equals(subtitleLanguage, true) },
|
||||||
|
{ it.isForced && it.language.equals(subtitleLanguage, true) },
|
||||||
|
{ it.isForced && it.language.isUnknown },
|
||||||
|
{ it.isForced },
|
||||||
|
),
|
||||||
|
)
|
||||||
return when (subtitleMode) {
|
return when (subtitleMode) {
|
||||||
SubtitlePlaybackMode.ALWAYS -> {
|
SubtitlePlaybackMode.ALWAYS -> {
|
||||||
if (subtitleLanguage != null) {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
candidates.firstOrNull {
|
||||||
|
it.language.equals(subtitleLanguage, true) ||
|
||||||
|
it.language.isUnknown
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull()
|
candidates.firstOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.ONLY_FORCED -> {
|
SubtitlePlaybackMode.ONLY_FORCED -> {
|
||||||
if (subtitleLanguage != null) {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
|
||||||
|
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull { it.isForced }
|
candidates.firstOrNull { it.isForced }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.SMART -> {
|
SubtitlePlaybackMode.SMART -> {
|
||||||
val audioLanguage = prefs.userConfig.audioLanguagePreference
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
val audioStreamLang = audioStream?.language
|
val audioLanguage = userConfig?.audioLanguagePreference
|
||||||
if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
|
if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
|
||||||
candidates.firstOrNull { it.language == subtitleLanguage }
|
candidates.firstOrNull { it.language == subtitleLanguage }
|
||||||
|
?: candidates.firstOrNull { it.language.isUnknown }
|
||||||
|
} else {
|
||||||
|
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
|
||||||
|
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
candidates.firstOrNull { it.isForced }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.DEFAULT -> {
|
SubtitlePlaybackMode.DEFAULT -> {
|
||||||
subtitleLanguage?.let { lang ->
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
// Find best track that is in the preferred language
|
candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
|
||||||
(
|
?: candidates.firstOrNull { it.isDefault || it.isForced }
|
||||||
candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang }
|
} else {
|
||||||
?: candidates.firstOrNull { it.isDefault && it.language == lang }
|
candidates.firstOrNull { it.isDefault || it.isForced }
|
||||||
?: candidates.firstOrNull { it.isForced && it.language == lang }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
?: (
|
|
||||||
// If none in preferred language, just find the best track
|
|
||||||
candidates.firstOrNull { it.isDefault && it.isForced }
|
|
||||||
?: candidates.firstOrNull { it.isDefault }
|
|
||||||
?: candidates.firstOrNull { it.isForced }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitlePlaybackMode.NONE -> {
|
SubtitlePlaybackMode.NONE -> {
|
||||||
|
|
@ -246,3 +260,12 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val String?.isUnknown: Boolean
|
||||||
|
get() =
|
||||||
|
this == null ||
|
||||||
|
this.equals("unknown", true) ||
|
||||||
|
this.equals("und", true) ||
|
||||||
|
this.equals("undetermined", true) ||
|
||||||
|
this.equals("mul", true) ||
|
||||||
|
this.equals("zxx", true)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.services
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
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.DefaultUserConfiguration
|
|
||||||
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 javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -21,7 +20,6 @@ class UserPreferencesService
|
||||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
UserPreferences(
|
UserPreferences(
|
||||||
appPrefs,
|
appPrefs,
|
||||||
userConfig ?: DefaultUserConfiguration,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.github.damontecres.wholphin.services.tvprovider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.await
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
@ActivityScoped
|
||||||
|
class TvProviderSchedulerService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ActivityContext private val context: Context,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
) {
|
||||||
|
private val activity = (context as AppCompatActivity)
|
||||||
|
private val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
private val supportsTvProvider =
|
||||||
|
// TODO <=25 has limited support
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||||
|
|
||||||
|
init {
|
||||||
|
serverRepository.current.observe(activity) { user ->
|
||||||
|
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||||
|
if (supportsTvProvider) {
|
||||||
|
if (user != null) {
|
||||||
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
|
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||||
|
workManager
|
||||||
|
.enqueueUniquePeriodicWork(
|
||||||
|
uniqueWorkName = TvProviderWorker.WORK_NAME,
|
||||||
|
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
request =
|
||||||
|
PeriodicWorkRequestBuilder<TvProviderWorker>(
|
||||||
|
repeatInterval = 1.hours.toJavaDuration(),
|
||||||
|
).setBackoffCriteria(
|
||||||
|
BackoffPolicy.LINEAR,
|
||||||
|
15.minutes.toJavaDuration(),
|
||||||
|
).setInputData(
|
||||||
|
workDataOf(
|
||||||
|
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||||
|
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||||
|
),
|
||||||
|
).build(),
|
||||||
|
).await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchOneTimeRefresh() {
|
||||||
|
if (supportsTvProvider) {
|
||||||
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
|
serverRepository.current.value?.let { user ->
|
||||||
|
Timber.i("Scheduling on-time TvProviderWorker for ${user.user}")
|
||||||
|
workManager.enqueue(
|
||||||
|
OneTimeWorkRequestBuilder<TvProviderWorker>()
|
||||||
|
.setInputData(
|
||||||
|
workDataOf(
|
||||||
|
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||||
|
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||||
|
),
|
||||||
|
).build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
package com.github.damontecres.wholphin.services.tvprovider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.database.Cursor
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
||||||
|
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.github.damontecres.wholphin.MainActivity
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@HiltWorker
|
||||||
|
class TvProviderWorker
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@Assisted private val context: Context,
|
||||||
|
@Assisted workerParams: WorkerParameters,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val preferences: DataStore<AppPreferences>,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val latestNextUpService: LatestNextUpService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
Timber.d("Start")
|
||||||
|
val serverId =
|
||||||
|
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||||
|
val userId =
|
||||||
|
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||||
|
|
||||||
|
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||||
|
// Not active
|
||||||
|
var currentUser = serverRepository.current.value
|
||||||
|
if (currentUser == null) {
|
||||||
|
serverRepository.restoreSession(serverId, userId)
|
||||||
|
currentUser = serverRepository.current.value
|
||||||
|
}
|
||||||
|
if (currentUser == null) {
|
||||||
|
Timber.w("No user found during run")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
|
val potentialItemsToAdd =
|
||||||
|
getPotentialItems(
|
||||||
|
userId,
|
||||||
|
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||||
|
prefs.homePagePreferences.combineContinueNext,
|
||||||
|
)
|
||||||
|
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||||
|
|
||||||
|
Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds)
|
||||||
|
val currentItems = getCurrentTvChannelNextUp()
|
||||||
|
val currentItemIds = currentItems.map { it.internalProviderId }
|
||||||
|
|
||||||
|
val toRemove =
|
||||||
|
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
|
||||||
|
|
||||||
|
val userRemoved = currentItems.filterNot { it.isBrowsable }
|
||||||
|
val userRemovedIds = userRemoved.map { it.internalProviderId }
|
||||||
|
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
|
||||||
|
val toAdd =
|
||||||
|
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds }
|
||||||
|
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
|
||||||
|
|
||||||
|
// Remove existing items if they are no longer in the next up from server
|
||||||
|
(toRemove + userRemoved)
|
||||||
|
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
|
||||||
|
.forEach {
|
||||||
|
context.contentResolver.delete(it, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new ones
|
||||||
|
val addedCount =
|
||||||
|
context.contentResolver.bulkInsert(
|
||||||
|
WatchNextPrograms.CONTENT_URI,
|
||||||
|
toAdd
|
||||||
|
.map { convert(it).toContentValues() }
|
||||||
|
.toTypedArray(),
|
||||||
|
)
|
||||||
|
Timber.v("Added %s", addedCount)
|
||||||
|
|
||||||
|
Timber.d("Completed successfully")
|
||||||
|
} catch (_: ApiClientException) {
|
||||||
|
return Result.retry()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getPotentialItems(
|
||||||
|
userId: UUID,
|
||||||
|
enableRewatching: Boolean,
|
||||||
|
combineContinueNext: Boolean,
|
||||||
|
): List<BaseItem> {
|
||||||
|
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||||
|
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
|
||||||
|
val nextUpItems =
|
||||||
|
latestNextUpService
|
||||||
|
.getNextUp(userId, 10, enableRewatching, false)
|
||||||
|
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||||
|
return if (combineContinueNext) {
|
||||||
|
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||||
|
} else {
|
||||||
|
resumeItems + nextUpItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||||
|
context.contentResolver
|
||||||
|
.query(
|
||||||
|
WatchNextPrograms.CONTENT_URI,
|
||||||
|
WatchNextProgram.PROJECTION,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.map(WatchNextProgram::fromCursor)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
private fun convert(item: BaseItem): WatchNextProgram =
|
||||||
|
WatchNextProgram
|
||||||
|
.Builder()
|
||||||
|
.apply {
|
||||||
|
val dto = item.data
|
||||||
|
setInternalProviderId(item.id.toString())
|
||||||
|
|
||||||
|
val type =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||||
|
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||||
|
else -> WatchNextPrograms.TYPE_CLIP
|
||||||
|
}
|
||||||
|
setType(type)
|
||||||
|
|
||||||
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
|
||||||
|
if (resumePosition != null && resumePosition >= 2.minutes) {
|
||||||
|
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
|
||||||
|
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||||
|
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
|
||||||
|
} else {
|
||||||
|
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||||
|
}
|
||||||
|
dto.runTimeTicks
|
||||||
|
?.ticks
|
||||||
|
?.inWholeMilliseconds
|
||||||
|
?.toInt()
|
||||||
|
?.let(::setDurationMillis)
|
||||||
|
|
||||||
|
setLastEngagementTimeUtcMillis(
|
||||||
|
dto.userData
|
||||||
|
?.lastPlayedDate
|
||||||
|
?.atZone(ZoneId.systemDefault())
|
||||||
|
?.toEpochSecond()
|
||||||
|
?: Date().time, // TODO
|
||||||
|
)
|
||||||
|
|
||||||
|
setTitle(item.title)
|
||||||
|
setDescription(dto.overview)
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
setEpisodeTitle(item.name)
|
||||||
|
dto.indexNumber?.let(::setEpisodeNumber)
|
||||||
|
dto.parentIndexNumber?.let(::setSeasonNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
|
||||||
|
val imageType =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.EPISODE -> ImageType.THUMB
|
||||||
|
else -> ImageType.PRIMARY
|
||||||
|
}
|
||||||
|
setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri())
|
||||||
|
|
||||||
|
setIntent(
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||||
|
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||||
|
.apply {
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SERIES_ID,
|
||||||
|
dto.seriesId?.toString(),
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SEASON_ID,
|
||||||
|
dto.seasonId?.toString(),
|
||||||
|
)
|
||||||
|
dto.parentIndexNumber?.let {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SEASON_NUMBER,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dto.indexNumber?.let {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_EPISODE_NUMBER,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
|
||||||
|
const val PARAM_USER_ID = "userId"
|
||||||
|
const val PARAM_SERVER_ID = "serverId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Cursor.map(transform: (Cursor) -> T): List<T> =
|
||||||
|
this.use {
|
||||||
|
buildList {
|
||||||
|
if (moveToFirst()) {
|
||||||
|
do {
|
||||||
|
add(transform.invoke(this@map))
|
||||||
|
} while (moveToNext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import coil3.request.ErrorResult
|
import coil3.request.ErrorResult
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
|
@ -167,6 +169,26 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RequestOrRestoreFocus(
|
||||||
|
focusRequester: FocusRequester?,
|
||||||
|
debugKey: String? = null,
|
||||||
|
) {
|
||||||
|
if (focusRequester != null) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) }
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) }
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun Modifier.enableMarquee(focused: Boolean) =
|
fun Modifier.enableMarquee(focused: Boolean) =
|
||||||
if (focused) {
|
if (focused) {
|
||||||
basicMarquee(
|
basicMarquee(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -77,7 +78,7 @@ fun GenreCard(
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.alpha(.6f)
|
.alpha(.75f)
|
||||||
.aspectRatio(AspectRatios.WIDE)
|
.aspectRatio(AspectRatios.WIDE)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -67,7 +68,7 @@ import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.GridCard
|
import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
|
@ -85,17 +86,18 @@ import com.github.damontecres.wholphin.ui.playback.scale
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -114,13 +116,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.TreeSet
|
import java.util.TreeSet
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class)
|
||||||
class CollectionFolderViewModel
|
class CollectionFolderViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
|
|
@ -128,7 +130,25 @@ class CollectionFolderViewModel
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
@Assisted itemId: String,
|
||||||
|
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||||
|
@Assisted("recursive") private val recursive: Boolean,
|
||||||
|
@Assisted private val collectionFilter: CollectionFolderFilter,
|
||||||
|
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
||||||
|
@Assisted defaultViewOptions: ViewOptions,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(
|
||||||
|
itemId: String,
|
||||||
|
initialSortAndDirection: SortAndDirection?,
|
||||||
|
@Assisted("recursive") recursive: Boolean,
|
||||||
|
collectionFilter: CollectionFolderFilter,
|
||||||
|
@Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean,
|
||||||
|
defaultViewOptions: ViewOptions,
|
||||||
|
): CollectionFolderViewModel
|
||||||
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val pager = MutableLiveData<List<BaseItem?>>(listOf())
|
val pager = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
|
@ -136,26 +156,19 @@ class CollectionFolderViewModel
|
||||||
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
||||||
val viewOptions = MutableLiveData<ViewOptions>()
|
val viewOptions = MutableLiveData<ViewOptions>()
|
||||||
|
|
||||||
private var useSeriesForPrimary: Boolean = true
|
var position: Int
|
||||||
private lateinit var collectionFilter: CollectionFolderFilter
|
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||||
|
set(value) {
|
||||||
|
savedStateHandle["position"] = value
|
||||||
|
}
|
||||||
|
|
||||||
fun init(
|
init {
|
||||||
itemId: String,
|
|
||||||
initialSortAndDirection: SortAndDirection?,
|
|
||||||
recursive: Boolean,
|
|
||||||
collectionFilter: CollectionFolderFilter,
|
|
||||||
useSeriesForPrimary: Boolean,
|
|
||||||
defaultViewOptions: ViewOptions,
|
|
||||||
): Job =
|
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
loading,
|
||||||
context.getString(R.string.error_loading_collection, itemId),
|
context.getString(R.string.error_loading_collection, itemId),
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
this@CollectionFolderViewModel.collectionFilter = collectionFilter
|
|
||||||
this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary
|
|
||||||
this@CollectionFolderViewModel.itemId = itemId
|
|
||||||
itemId.toUUIDOrNull()?.let {
|
itemId.toUUIDOrNull()?.let {
|
||||||
fetchItem(it)
|
fetchItem(it)
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +197,7 @@ class CollectionFolderViewModel
|
||||||
|
|
||||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveLibraryDisplayInfo(
|
private fun saveLibraryDisplayInfo(
|
||||||
newFilter: GetItemsFilter = this.filter.value!!,
|
newFilter: GetItemsFilter = this.filter.value!!,
|
||||||
|
|
@ -537,25 +551,27 @@ fun CollectionFolderGrid(
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
defaultViewOptions: ViewOptions,
|
defaultViewOptions: ViewOptions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId),
|
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
|
||||||
initialSortAndDirection: SortAndDirection? = null,
|
initialSortAndDirection: SortAndDirection? = null,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
useSeriesForPrimary: Boolean = true,
|
useSeriesForPrimary: Boolean = true,
|
||||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||||
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
|
viewModel: CollectionFolderViewModel =
|
||||||
|
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||||
|
key = itemId,
|
||||||
|
) {
|
||||||
|
it.create(
|
||||||
|
itemId = itemId,
|
||||||
|
initialSortAndDirection = initialSortAndDirection,
|
||||||
|
recursive = recursive,
|
||||||
|
collectionFilter = initialFilter,
|
||||||
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
|
defaultViewOptions = defaultViewOptions,
|
||||||
|
)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
OneTimeLaunchedEffect {
|
|
||||||
viewModel.init(
|
|
||||||
itemId,
|
|
||||||
initialSortAndDirection,
|
|
||||||
recursive,
|
|
||||||
initialFilter,
|
|
||||||
useSeriesForPrimary,
|
|
||||||
defaultViewOptions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
||||||
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
@ -589,6 +605,7 @@ fun CollectionFolderGrid(
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
CollectionFolderGridContent(
|
CollectionFolderGridContent(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
initialPosition = viewModel.position,
|
||||||
item = item,
|
item = item,
|
||||||
title = title,
|
title = title,
|
||||||
pager = pager,
|
pager = pager,
|
||||||
|
|
@ -611,7 +628,10 @@ fun CollectionFolderGrid(
|
||||||
},
|
},
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
sortOptions = sortOptions,
|
sortOptions = sortOptions,
|
||||||
positionCallback = positionCallback,
|
positionCallback = { columns, position ->
|
||||||
|
viewModel.position = position
|
||||||
|
positionCallback?.invoke(columns, position)
|
||||||
|
},
|
||||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||||
viewOptions = viewOptions,
|
viewOptions = viewOptions,
|
||||||
defaultViewOptions = defaultViewOptions,
|
defaultViewOptions = defaultViewOptions,
|
||||||
|
|
@ -728,6 +748,7 @@ fun CollectionFolderGridContent(
|
||||||
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
||||||
onClickPlay: (Int, BaseItem) -> Unit,
|
onClickPlay: (Int, BaseItem) -> Unit,
|
||||||
onChangeBackdrop: (BaseItem) -> Unit,
|
onChangeBackdrop: (BaseItem) -> Unit,
|
||||||
|
initialPosition: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||||
|
|
@ -742,10 +763,10 @@ fun CollectionFolderGridContent(
|
||||||
var viewOptions by remember { mutableStateOf(viewOptions) }
|
var viewOptions by remember { mutableStateOf(viewOptions) }
|
||||||
|
|
||||||
val gridFocusRequester = remember { FocusRequester() }
|
val gridFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
|
RequestOrRestoreFocus(gridFocusRequester)
|
||||||
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
|
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
var position by rememberInt(0)
|
var position by rememberInt(initialPosition)
|
||||||
val focusedItem = pager.getOrNull(position)
|
val focusedItem = pager.getOrNull(position)
|
||||||
if (viewOptions.showDetails) {
|
if (viewOptions.showDetails) {
|
||||||
LaunchedEffect(focusedItem) {
|
LaunchedEffect(focusedItem) {
|
||||||
|
|
@ -861,7 +882,7 @@ fun CollectionFolderGridContent(
|
||||||
showJumpButtons = false, // TODO add preference
|
showJumpButtons = false, // TODO add preference
|
||||||
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
initialPosition = 0,
|
initialPosition = initialPosition,
|
||||||
positionCallback = { columns, newPosition ->
|
positionCallback = { columns, newPosition ->
|
||||||
showHeader = newPosition < columns
|
showHeader = newPosition < columns
|
||||||
position = newPosition
|
position = newPosition
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.times
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
@ -68,7 +72,10 @@ class GenreViewModel
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val genres = MutableLiveData<List<Genre>>(listOf())
|
val genres = MutableLiveData<List<Genre>>(listOf())
|
||||||
|
|
||||||
fun init(itemId: UUID) {
|
fun init(
|
||||||
|
itemId: UUID,
|
||||||
|
cardWidthPx: Int,
|
||||||
|
) {
|
||||||
loading.value = LoadingState.Loading
|
loading.value = LoadingState.Loading
|
||||||
this.itemId = itemId
|
this.itemId = itemId
|
||||||
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
|
||||||
|
|
@ -96,7 +103,7 @@ class GenreViewModel
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
// val excludeItemIds = mutableSetOf<UUID>()
|
// val excludeItemIds = mutableSetOf<UUID>()
|
||||||
val genreToUrl = ConcurrentHashMap<UUID, String>()
|
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||||
val semaphore = Semaphore(4)
|
val semaphore = Semaphore(4)
|
||||||
genres
|
genres
|
||||||
.map { genre ->
|
.map { genre ->
|
||||||
|
|
@ -133,7 +140,8 @@ class GenreViewModel
|
||||||
item.type,
|
item.type,
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
ImageType.THUMB,
|
ImageType.BACKDROP,
|
||||||
|
fillWidth = cardWidthPx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -179,8 +187,23 @@ fun GenreCardGrid(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: GenreViewModel = hiltViewModel(),
|
viewModel: GenreViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val columns = 4
|
||||||
|
val spacing = 16.dp
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val cardWidthPx =
|
||||||
|
remember {
|
||||||
|
with(density) {
|
||||||
|
// Grid has 16dp padding on either side & 16dp spacing between 4 cards
|
||||||
|
// This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster
|
||||||
|
// E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required
|
||||||
|
(configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing))
|
||||||
|
.div(columns)
|
||||||
|
.roundToPx()
|
||||||
|
}
|
||||||
|
}
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init(itemId)
|
viewModel.init(itemId, cardWidthPx)
|
||||||
}
|
}
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
|
||||||
val genres by viewModel.genres.observeAsState(listOf())
|
val genres by viewModel.genres.observeAsState(listOf())
|
||||||
|
|
@ -231,7 +254,8 @@ fun GenreCardGrid(
|
||||||
initialPosition = 0,
|
initialPosition = 0,
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
},
|
},
|
||||||
columns = 4,
|
columns = columns,
|
||||||
|
spacing = spacing,
|
||||||
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
|
||||||
GenreCard(
|
GenreCard(
|
||||||
genre = item,
|
genre = item,
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,11 @@ 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 com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp
|
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -58,7 +57,7 @@ class RecommendedTvShowViewModel
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val lastestNextUpService: LatestNextUpService,
|
||||||
@Assisted val parentId: UUID,
|
@Assisted val parentId: UUID,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
favoriteWatchManager: FavoriteWatchManager,
|
||||||
|
|
@ -126,9 +125,7 @@ class RecommendedTvShowViewModel
|
||||||
val nextUpItems = nextUpItemsDeferred.await()
|
val nextUpItems = nextUpItemsDeferred.await()
|
||||||
if (combineNextUp) {
|
if (combineNextUp) {
|
||||||
val combined =
|
val combined =
|
||||||
buildCombinedNextUp(
|
lastestNextUpService.buildCombined(
|
||||||
viewModelScope,
|
|
||||||
datePlayedService,
|
|
||||||
resumeItems,
|
resumeItems,
|
||||||
nextUpItems,
|
nextUpItems,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -49,8 +50,8 @@ fun TabRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(selectedTabIndex) {
|
||||||
state.animateScrollToItem(selectedTabIndex)
|
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
|
||||||
}
|
}
|
||||||
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
||||||
var rowHasFocus by remember { mutableStateOf(false) }
|
var rowHasFocus by remember { mutableStateOf(false) }
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -30,6 +32,7 @@ import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.VideoRange
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
data class ItemDetailsDialogInfo(
|
data class ItemDetailsDialogInfo(
|
||||||
|
|
@ -54,33 +57,32 @@ fun ItemDetailsDialog(
|
||||||
val subtitleLabel = stringResource(R.string.subtitle)
|
val subtitleLabel = stringResource(R.string.subtitle)
|
||||||
val bitrateLabel = stringResource(R.string.bitrate)
|
val bitrateLabel = stringResource(R.string.bitrate)
|
||||||
val unknown = stringResource(R.string.unknown)
|
val unknown = stringResource(R.string.unknown)
|
||||||
|
val runtimeLabel = stringResource(R.string.runtime_sort)
|
||||||
|
|
||||||
ScrollableDialog(
|
ScrollableDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
width = 720.dp,
|
width = 680.dp,
|
||||||
maxHeight = 480.dp,
|
maxHeight = 440.dp,
|
||||||
itemSpacing = 4.dp,
|
itemSpacing = 8.dp,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
text = info.title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (info.genres.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
Text(
|
||||||
text = info.genres.joinToString(", "),
|
text = info.title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (info.overview.isNotNullOrBlank()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = info.overview,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
)
|
||||||
|
if (info.genres.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = info.genres.joinToString(", "),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (info.overview.isNotNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = info.overview,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,13 +91,19 @@ fun ItemDetailsDialog(
|
||||||
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
source.mediaStreams?.letNotEmpty { mediaStreams ->
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// General file information
|
// General file information
|
||||||
item {
|
item {
|
||||||
val containerLabel = stringResource(R.string.container)
|
val containerLabel = stringResource(R.string.container)
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = stringResource(R.string.general),
|
title =
|
||||||
|
titleIndex(
|
||||||
|
stringResource(R.string.general),
|
||||||
|
index,
|
||||||
|
info.files.size,
|
||||||
|
),
|
||||||
items =
|
items =
|
||||||
buildList {
|
buildList {
|
||||||
source.container?.let { add(containerLabel to it) }
|
source.container?.let { add(containerLabel to it) }
|
||||||
|
|
@ -111,31 +119,38 @@ fun ItemDetailsDialog(
|
||||||
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
bitrateLabel to formatBytes(it, byteRateSuffixes),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
source.runTimeTicks?.let {
|
||||||
|
add(runtimeLabel to it.ticks.toString())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video streams
|
// Video streams
|
||||||
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
|
val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO }
|
||||||
|
itemsIndexed(videoStreams) { index, stream ->
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = videoLabel,
|
title = titleIndex(videoLabel, index, videoStreams.size),
|
||||||
items = buildVideoStreamInfo(context, stream),
|
items = remember { buildVideoStreamInfo(context, stream) },
|
||||||
|
additional = remember { buildVideoStreamInfoAdditional(context, stream) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio streams - display multiple per row
|
// Audio streams - display multiple per row
|
||||||
items(
|
val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO }
|
||||||
mediaStreams
|
itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup ->
|
||||||
.filter { it.type == MediaStreamType.AUDIO }
|
|
||||||
.chunked(3),
|
|
||||||
) { streamGroup ->
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
streamGroup.forEach { stream ->
|
streamGroup.forEachIndexed { index, stream ->
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = audioLabel,
|
title =
|
||||||
|
titleIndex(
|
||||||
|
audioLabel,
|
||||||
|
groupIndex * 3 + index,
|
||||||
|
audioStreams.size,
|
||||||
|
),
|
||||||
items = buildAudioStreamInfo(context, stream),
|
items = buildAudioStreamInfo(context, stream),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
@ -148,19 +163,21 @@ fun ItemDetailsDialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtitle streams - display multiple per row
|
// Subtitle streams - display multiple per row
|
||||||
items(
|
val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
mediaStreams
|
itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup ->
|
||||||
.filter { it.type == MediaStreamType.SUBTITLE }
|
|
||||||
.chunked(3),
|
|
||||||
) { streamGroup ->
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
streamGroup.forEach { stream ->
|
streamGroup.forEachIndexed { index, stream ->
|
||||||
MediaInfoSection(
|
MediaInfoSection(
|
||||||
title = subtitleLabel,
|
title =
|
||||||
items = buildSubtitleStreamInfo(context, stream),
|
titleIndex(
|
||||||
|
subtitleLabel,
|
||||||
|
groupIndex * 3 + index,
|
||||||
|
subtitleStreams.size,
|
||||||
|
),
|
||||||
|
items = buildSubtitleStreamInfo(context, stream, showFilePath),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +202,7 @@ private fun MediaInfoSection(
|
||||||
title: String,
|
title: String,
|
||||||
items: List<Pair<String, String>>,
|
items: List<Pair<String, String>>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
additional: List<Pair<String, String>> = listOf(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
|
@ -192,66 +210,86 @@ private fun MediaInfoSection(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
items.forEach { (label, value) ->
|
Row(
|
||||||
Row(
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
text = "$label: ",
|
items.forEach { (label, value) ->
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Row(
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
modifier = Modifier,
|
||||||
)
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = "$label: ",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additional.isNotEmpty()) {
|
||||||
|
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
|
additional.forEach { (label, value) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$label: ",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun titleIndex(
|
||||||
|
title: String,
|
||||||
|
index: Int,
|
||||||
|
total: Int,
|
||||||
|
) = if (total > 1) {
|
||||||
|
"$title (${index + 1})"
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildVideoStreamInfo(
|
private fun buildVideoStreamInfo(
|
||||||
context: Context,
|
context: Context,
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
): List<Pair<String, String>> =
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
buildList {
|
||||||
val titleLabel = context.getString(R.string.title)
|
|
||||||
val codecLabel = context.getString(R.string.codec)
|
|
||||||
val avcLabel = context.getString(R.string.avc)
|
|
||||||
val profileLabel = context.getString(R.string.profile)
|
|
||||||
val levelLabel = context.getString(R.string.level)
|
|
||||||
val resolutionLabel = context.getString(R.string.resolution)
|
|
||||||
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
|
|
||||||
val anamorphicLabel = context.getString(R.string.anamorphic)
|
|
||||||
val interlacedLabel = context.getString(R.string.interlaced)
|
|
||||||
val framerateLabel = context.getString(R.string.framerate)
|
|
||||||
val bitrateLabel = context.getString(R.string.bitrate)
|
|
||||||
val bitDepthLabel = context.getString(R.string.bit_depth)
|
|
||||||
val videoRangeLabel = context.getString(R.string.video_range)
|
|
||||||
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
|
|
||||||
val colorSpaceLabel = context.getString(R.string.color_space)
|
|
||||||
val colorTransferLabel = context.getString(R.string.color_transfer)
|
|
||||||
val colorPrimariesLabel = context.getString(R.string.color_primaries)
|
|
||||||
val pixelFormatLabel = context.getString(R.string.pixel_format)
|
|
||||||
val refFramesLabel = context.getString(R.string.ref_frames)
|
|
||||||
val nalLabel = context.getString(R.string.nal)
|
|
||||||
val yesStr = context.getString(R.string.yes)
|
val yesStr = context.getString(R.string.yes)
|
||||||
val noStr = context.getString(R.string.no)
|
val noStr = context.getString(R.string.no)
|
||||||
|
|
||||||
|
val titleLabel = context.getString(R.string.title)
|
||||||
|
val codecLabel = context.getString(R.string.codec)
|
||||||
|
val resolutionLabel = context.getString(R.string.resolution)
|
||||||
|
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
|
||||||
|
val framerateLabel = context.getString(R.string.framerate)
|
||||||
|
val bitrateLabel = context.getString(R.string.bitrate)
|
||||||
|
val profileLabel = context.getString(R.string.profile)
|
||||||
|
val levelLabel = context.getString(R.string.level)
|
||||||
|
val interlacedLabel = context.getString(R.string.interlaced)
|
||||||
|
val videoRangeLabel = context.getString(R.string.video_range)
|
||||||
val sdrStr = context.getString(R.string.sdr)
|
val sdrStr = context.getString(R.string.sdr)
|
||||||
val hdrStr = context.getString(R.string.hdr)
|
val hdrStr = context.getString(R.string.hdr)
|
||||||
val hdr10Str = context.getString(R.string.hdr10)
|
|
||||||
val hdr10PlusStr = context.getString(R.string.hdr10_plus)
|
|
||||||
val hlgStr = context.getString(R.string.hlg)
|
|
||||||
val bitUnit = context.getString(R.string.bit_unit)
|
|
||||||
|
|
||||||
stream.title?.let { add(titleLabel to it) }
|
stream.title?.let { add(titleLabel to it) }
|
||||||
stream.codec?.let { add(codecLabel to it.uppercase()) }
|
stream.codec?.let { add(codecLabel to it.uppercase()) }
|
||||||
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
|
|
||||||
stream.profile?.let { add(profileLabel to it) }
|
|
||||||
stream.level?.let { add(levelLabel to it.toString()) }
|
|
||||||
if (stream.width != null && stream.height != null) {
|
if (stream.width != null && stream.height != null) {
|
||||||
add(resolutionLabel to "${stream.width}x${stream.height}")
|
add(resolutionLabel to "${stream.width}x${stream.height}")
|
||||||
}
|
}
|
||||||
|
|
@ -259,23 +297,55 @@ private fun buildVideoStreamInfo(
|
||||||
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
|
||||||
add(aspectRatioLabel to aspectRatio)
|
add(aspectRatioLabel to aspectRatio)
|
||||||
}
|
}
|
||||||
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
|
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||||
stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) }
|
|
||||||
stream.averageFrameRate?.let {
|
stream.averageFrameRate?.let {
|
||||||
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
|
||||||
}
|
}
|
||||||
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
|
||||||
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
|
stream.videoRange.let {
|
||||||
stream.videoRange?.let {
|
|
||||||
val rangeStr =
|
val rangeStr =
|
||||||
when (it) {
|
when (it) {
|
||||||
VideoRange.SDR -> sdrStr
|
VideoRange.SDR -> sdrStr
|
||||||
VideoRange.HDR -> hdrStr
|
VideoRange.HDR -> hdrStr
|
||||||
VideoRange.UNKNOWN -> null
|
VideoRange.UNKNOWN -> null
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
rangeStr?.let { add(videoRangeLabel to it) }
|
rangeStr?.let { add(videoRangeLabel to it) }
|
||||||
}
|
}
|
||||||
|
stream.profile?.let { add(profileLabel to it) }
|
||||||
|
stream.level?.let { add(levelLabel to it.toString()) }
|
||||||
|
stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildVideoStreamInfoAdditional(
|
||||||
|
context: Context,
|
||||||
|
stream: MediaStream,
|
||||||
|
): List<Pair<String, String>> =
|
||||||
|
buildList {
|
||||||
|
val yesStr = context.getString(R.string.yes)
|
||||||
|
val noStr = context.getString(R.string.no)
|
||||||
|
|
||||||
|
val avcLabel = context.getString(R.string.avc)
|
||||||
|
val anamorphicLabel = context.getString(R.string.anamorphic)
|
||||||
|
val bitDepthLabel = context.getString(R.string.bit_depth)
|
||||||
|
|
||||||
|
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
|
||||||
|
val colorSpaceLabel = context.getString(R.string.color_space)
|
||||||
|
val colorTransferLabel = context.getString(R.string.color_transfer)
|
||||||
|
val colorPrimariesLabel = context.getString(R.string.color_primaries)
|
||||||
|
val pixelFormatLabel = context.getString(R.string.pixel_format)
|
||||||
|
val refFramesLabel = context.getString(R.string.ref_frames)
|
||||||
|
val nalLabel = context.getString(R.string.nal)
|
||||||
|
val dolbyVisionLabel = context.getString(R.string.dolby_vision)
|
||||||
|
|
||||||
|
val sdrStr = context.getString(R.string.sdr)
|
||||||
|
val hdr10Str = context.getString(R.string.hdr10)
|
||||||
|
val hdr10PlusStr = context.getString(R.string.hdr10_plus)
|
||||||
|
val hlgStr = context.getString(R.string.hlg)
|
||||||
|
val bitUnit = context.getString(R.string.bit_unit)
|
||||||
|
|
||||||
|
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
|
||||||
|
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
|
||||||
|
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
|
||||||
stream.videoRangeType?.let {
|
stream.videoRangeType?.let {
|
||||||
val rangeTypeStr =
|
val rangeTypeStr =
|
||||||
when (it) {
|
when (it) {
|
||||||
|
|
@ -304,7 +374,8 @@ private fun buildVideoStreamInfo(
|
||||||
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
|
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
|
||||||
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
|
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
|
||||||
stream.refFrames?.let { add(refFramesLabel to it.toString()) }
|
stream.refFrames?.let { add(refFramesLabel to it.toString()) }
|
||||||
stream.nalLengthSize?.let { add(nalLabel to it.toString()) }
|
stream.nalLengthSize?.let { add(nalLabel to it) }
|
||||||
|
stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildAudioStreamInfo(
|
private fun buildAudioStreamInfo(
|
||||||
|
|
@ -341,17 +412,18 @@ private fun buildAudioStreamInfo(
|
||||||
private fun buildSubtitleStreamInfo(
|
private fun buildSubtitleStreamInfo(
|
||||||
context: Context,
|
context: Context,
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
|
showPath: Boolean,
|
||||||
): List<Pair<String, String>> =
|
): List<Pair<String, String>> =
|
||||||
buildList {
|
buildList {
|
||||||
val titleLabel = context.getString(R.string.title)
|
val titleLabel = context.getString(R.string.title)
|
||||||
val languageLabel = context.getString(R.string.language)
|
val languageLabel = context.getString(R.string.language)
|
||||||
val codecLabel = context.getString(R.string.codec)
|
val codecLabel = context.getString(R.string.codec)
|
||||||
val avcLabel = context.getString(R.string.avc)
|
|
||||||
val defaultLabel = context.getString(R.string.default_track)
|
val defaultLabel = context.getString(R.string.default_track)
|
||||||
val forcedLabel = context.getString(R.string.forced_track)
|
val forcedLabel = context.getString(R.string.forced_track)
|
||||||
val externalLabel = context.getString(R.string.external_track)
|
val externalLabel = context.getString(R.string.external_track)
|
||||||
val yesStr = context.getString(R.string.yes)
|
val yesStr = context.getString(R.string.yes)
|
||||||
val noStr = context.getString(R.string.no)
|
val noStr = context.getString(R.string.no)
|
||||||
|
val pathLabel = context.getString(R.string.path)
|
||||||
|
|
||||||
stream.title?.let { add(titleLabel to it) }
|
stream.title?.let { add(titleLabel to it) }
|
||||||
stream.language?.let { add(languageLabel to languageName(it)) }
|
stream.language?.let { add(languageLabel to languageName(it)) }
|
||||||
|
|
@ -359,10 +431,15 @@ private fun buildSubtitleStreamInfo(
|
||||||
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
|
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
|
||||||
add(codecLabel to formattedCodec)
|
add(codecLabel to formattedCodec)
|
||||||
}
|
}
|
||||||
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
|
|
||||||
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||||
stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) }
|
stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) }
|
||||||
stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) }
|
stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) }
|
||||||
|
stream.isHearingImpaired?.let {
|
||||||
|
add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr)
|
||||||
|
}
|
||||||
|
if (showPath) {
|
||||||
|
stream.path?.let { pathLabel to it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateAspectRatio(
|
private fun calculateAspectRatio(
|
||||||
|
|
|
||||||
|
|
@ -117,12 +117,16 @@ fun <T : CardGridItem> CardGrid(
|
||||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||||
|
|
||||||
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
|
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
|
||||||
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
|
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
||||||
|
val gridState =
|
||||||
|
rememberLazyGridState(
|
||||||
|
cacheWindow = fractionCacheWindow,
|
||||||
|
initialFirstVisibleItemIndex = focusedIndex,
|
||||||
|
)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
val zeroFocus = remember { FocusRequester() }
|
val zeroFocus = remember { FocusRequester() }
|
||||||
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
|
|
||||||
|
|
||||||
var alphabetFocus by remember { mutableStateOf(false) }
|
var alphabetFocus by remember { mutableStateOf(false) }
|
||||||
val focusOn = { index: Int ->
|
val focusOn = { index: Int ->
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ fun DebugPage(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "User server settings: ${preferences.userConfig}",
|
text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -31,6 +30,7 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -48,7 +48,6 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -291,9 +290,7 @@ fun EpisodeDetailsContent(
|
||||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||||
|
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
LaunchedEffect(Unit) {
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
|
||||||
}
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -49,6 +48,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.cards.ChapterRow
|
import com.github.damontecres.wholphin.ui.cards.ChapterRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
|
|
@ -73,7 +73,6 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -391,9 +390,9 @@ fun MovieDetailsContent(
|
||||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||||
|
|
||||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
}
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -46,6 +45,7 @@ import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
|
|
@ -74,7 +74,6 @@ import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -88,13 +87,15 @@ fun SeriesDetails(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.MediaItem,
|
destination: Destination.MediaItem,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel =
|
||||||
|
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
|
||||||
|
creationCallback = {
|
||||||
|
it.create(destination.itemId, null, SeriesPageType.DETAILS)
|
||||||
|
},
|
||||||
|
),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.init(preferences, destination.itemId, null, true)
|
|
||||||
}
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
|
|
@ -285,7 +286,7 @@ private const val SIMILAR_ROW = EXTRAS_ROW + 1
|
||||||
fun SeriesDetailsContent(
|
fun SeriesDetailsContent(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem>,
|
seasons: List<BaseItem?>,
|
||||||
similar: List<BaseItem>,
|
similar: List<BaseItem>,
|
||||||
trailers: List<Trailer>,
|
trailers: List<Trailer>,
|
||||||
extras: List<ExtrasItem>,
|
extras: List<ExtrasItem>,
|
||||||
|
|
@ -311,9 +312,7 @@ fun SeriesDetailsContent(
|
||||||
var position by rememberInt()
|
var position by rememberInt()
|
||||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
RequestOrRestoreFocus(focusRequesters.getOrNull(position))
|
||||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
|
||||||
}
|
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ package com.github.damontecres.wholphin.ui.detail.series
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -22,7 +21,6 @@ import androidx.lifecycle.map
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -36,13 +34,12 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -52,7 +49,6 @@ import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -80,10 +76,15 @@ data class SeriesOverviewPosition(
|
||||||
fun SeriesOverview(
|
fun SeriesOverview(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
destination: Destination.SeriesOverview,
|
destination: Destination.SeriesOverview,
|
||||||
|
initialSeasonEpisode: SeasonEpisodeIds?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel =
|
||||||
|
hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(
|
||||||
|
creationCallback = {
|
||||||
|
it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW)
|
||||||
|
},
|
||||||
|
),
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
initialSeasonEpisode: SeasonEpisodeIds? = null,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val firstItemFocusRequester = remember { FocusRequester() }
|
val firstItemFocusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -91,18 +92,6 @@ fun SeriesOverview(
|
||||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||||
val guestStarRowFocusRequester = remember { FocusRequester() }
|
val guestStarRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
var initialLoadDone by rememberSaveable { mutableStateOf(false) }
|
|
||||||
OneTimeLaunchedEffect {
|
|
||||||
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
|
||||||
viewModel.init(
|
|
||||||
preferences,
|
|
||||||
destination.itemId,
|
|
||||||
initialSeasonEpisode,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
initialLoadDone = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
val series by viewModel.item.observeAsState(null)
|
val series by viewModel.item.observeAsState(null)
|
||||||
|
|
@ -111,27 +100,9 @@ fun SeriesOverview(
|
||||||
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
|
val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf())
|
||||||
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
val episodeList = (episodes as? EpisodeList.Success)?.episodes
|
||||||
|
|
||||||
var position by rememberSaveable(
|
val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0))
|
||||||
destination,
|
LaunchedEffect(Unit) {
|
||||||
loading,
|
if (seasons.isNotEmpty()) {
|
||||||
stateSaver =
|
|
||||||
Saver(
|
|
||||||
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
|
|
||||||
restore = { SeriesOverviewPosition(it[0], it[1]) },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
mutableStateOf(
|
|
||||||
SeriesOverviewPosition(
|
|
||||||
seasons.indexOfFirstOrNull {
|
|
||||||
equalsNotNull(it.id, initialSeasonEpisode?.seasonId) ||
|
|
||||||
equalsNotNull(it.indexNumber, initialSeasonEpisode?.seasonNumber)
|
|
||||||
} ?: 0,
|
|
||||||
(episodes as? EpisodeList.Success)?.initialIndex ?: 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (initialLoadDone) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
seasons.getOrNull(position.seasonTabIndex)?.let {
|
seasons.getOrNull(position.seasonTabIndex)?.let {
|
||||||
viewModel.loadEpisodes(it.id)
|
viewModel.loadEpisodes(it.id)
|
||||||
}
|
}
|
||||||
|
|
@ -301,13 +272,20 @@ fun SeriesOverview(
|
||||||
episodeRowFocusRequester = episodeRowFocusRequester,
|
episodeRowFocusRequester = episodeRowFocusRequester,
|
||||||
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
castCrewRowFocusRequester = castCrewRowFocusRequester,
|
||||||
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
guestStarRowFocusRequester = guestStarRowFocusRequester,
|
||||||
onFocus = {
|
onChangeSeason = { index ->
|
||||||
if (it.seasonTabIndex != position.seasonTabIndex) {
|
if (index != position.seasonTabIndex) {
|
||||||
seasons.getOrNull(it.seasonTabIndex)?.let { season ->
|
seasons.getOrNull(index)?.let { season ->
|
||||||
viewModel.loadEpisodes(season.id)
|
viewModel.loadEpisodes(season.id)
|
||||||
|
viewModel.position.update {
|
||||||
|
SeriesOverviewPosition(index, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
position = it
|
},
|
||||||
|
onFocusEpisode = { episodeIndex ->
|
||||||
|
viewModel.position.update {
|
||||||
|
it.copy(episodeRowIndex = episodeIndex)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
rowFocused = EPISODE_ROW
|
rowFocused = EPISODE_ROW
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -50,7 +49,7 @@ 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.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -80,7 +79,8 @@ fun SeriesOverviewContent(
|
||||||
episodeRowFocusRequester: FocusRequester,
|
episodeRowFocusRequester: FocusRequester,
|
||||||
castCrewRowFocusRequester: FocusRequester,
|
castCrewRowFocusRequester: FocusRequester,
|
||||||
guestStarRowFocusRequester: FocusRequester,
|
guestStarRowFocusRequester: FocusRequester,
|
||||||
onFocus: (SeriesOverviewPosition) -> Unit,
|
onChangeSeason: (Int) -> Unit,
|
||||||
|
onFocusEpisode: (Int) -> Unit,
|
||||||
onClick: (BaseItem) -> Unit,
|
onClick: (BaseItem) -> Unit,
|
||||||
onLongClick: (BaseItem) -> Unit,
|
onLongClick: (BaseItem) -> Unit,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
|
|
@ -141,11 +141,12 @@ fun SeriesOverviewContent(
|
||||||
tabs =
|
tabs =
|
||||||
seasons.mapNotNull {
|
seasons.mapNotNull {
|
||||||
it?.name
|
it?.name
|
||||||
?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}")
|
?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" }
|
||||||
|
?: ""
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTabIndex = it
|
selectedTabIndex = it
|
||||||
onFocus.invoke(SeriesOverviewPosition(it, 0))
|
onChangeSeason.invoke(it)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -169,102 +170,93 @@ fun SeriesOverviewContent(
|
||||||
modifier = Modifier.fillMaxWidth(.6f),
|
modifier = Modifier.fillMaxWidth(.6f),
|
||||||
)
|
)
|
||||||
|
|
||||||
key(position.seasonTabIndex) {
|
// key(position.seasonTabIndex) {
|
||||||
when (val eps = episodes) {
|
when (val eps = episodes) {
|
||||||
EpisodeList.Loading -> {
|
EpisodeList.Loading -> {
|
||||||
LoadingPage()
|
LoadingPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
is EpisodeList.Error -> {
|
is EpisodeList.Error -> {
|
||||||
ErrorMessage(eps.message, eps.exception)
|
ErrorMessage(eps.message, eps.exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
is EpisodeList.Success -> {
|
is EpisodeList.Success -> {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState(position.episodeRowIndex)
|
||||||
OneTimeLaunchedEffect {
|
RequestOrRestoreFocus(firstItemFocusRequester)
|
||||||
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
|
||||||
state.scrollToItem(position.episodeRowIndex)
|
LazyRow(
|
||||||
|
state = state,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRestorer(firstItemFocusRequester)
|
||||||
|
.focusRequester(episodeRowFocusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
cardRowHasFocus = it.hasFocus
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
itemsIndexed(eps.episodes) { episodeIndex, episode ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
if (interactionSource.collectIsFocusedAsState().value) {
|
||||||
|
onFocusEpisode.invoke(episodeIndex)
|
||||||
}
|
}
|
||||||
firstItemFocusRequester.tryRequestFocus()
|
val cornerText =
|
||||||
}
|
episode?.data?.indexNumber?.let { "E$it" }
|
||||||
LazyRow(
|
?: episode?.data?.premiereDate?.let(::formatDateTime)
|
||||||
state = state,
|
BannerCard(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
name = episode?.name,
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
item = episode,
|
||||||
modifier =
|
aspectRatio =
|
||||||
Modifier
|
episode
|
||||||
.focusRestorer(firstItemFocusRequester)
|
?.aspectRatio
|
||||||
.focusRequester(episodeRowFocusRequester)
|
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
||||||
.onFocusChanged {
|
?: (AspectRatios.WIDE),
|
||||||
cardRowHasFocus = it.hasFocus
|
cornerText = cornerText,
|
||||||
},
|
played = episode?.data?.userData?.played ?: false,
|
||||||
) {
|
playPercent =
|
||||||
itemsIndexed(eps.episodes) { episodeIndex, episode ->
|
episode?.data?.userData?.playedPercentage
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
?: 0.0,
|
||||||
if (interactionSource.collectIsFocusedAsState().value) {
|
onClick = { if (episode != null) onClick.invoke(episode) },
|
||||||
onFocus.invoke(
|
onLongClick = {
|
||||||
SeriesOverviewPosition(
|
if (episode != null) {
|
||||||
selectedTabIndex,
|
onLongClick.invoke(
|
||||||
episodeIndex,
|
episode,
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
}
|
},
|
||||||
val cornerText =
|
modifier =
|
||||||
episode?.data?.indexNumber?.let { "E$it" }
|
Modifier
|
||||||
?: episode?.data?.premiereDate?.let(::formatDateTime)
|
.ifElse(
|
||||||
BannerCard(
|
episodeIndex == position.episodeRowIndex,
|
||||||
name = episode?.name,
|
Modifier.focusRequester(firstItemFocusRequester),
|
||||||
item = episode,
|
).ifElse(
|
||||||
aspectRatio =
|
episodeIndex != position.episodeRowIndex,
|
||||||
episode
|
Modifier
|
||||||
?.aspectRatio
|
.background(
|
||||||
?.coerceAtLeast(AspectRatios.FOUR_THREE)
|
Color.Black,
|
||||||
?: (AspectRatios.WIDE),
|
shape = RoundedCornerShape(8.dp),
|
||||||
cornerText = cornerText,
|
).alpha(dimming),
|
||||||
played = episode?.data?.userData?.played ?: false,
|
).onFocusChanged {
|
||||||
playPercent =
|
if (it.isFocused) {
|
||||||
episode?.data?.userData?.playedPercentage
|
scope.launch {
|
||||||
?: 0.0,
|
bringIntoViewRequester.bringIntoView()
|
||||||
onClick = { if (episode != null) onClick.invoke(episode) },
|
|
||||||
onLongClick = {
|
|
||||||
if (episode != null) {
|
|
||||||
onLongClick.invoke(
|
|
||||||
episode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.ifElse(
|
|
||||||
episodeIndex == position.episodeRowIndex,
|
|
||||||
Modifier.focusRequester(firstItemFocusRequester),
|
|
||||||
).ifElse(
|
|
||||||
episodeIndex != position.episodeRowIndex,
|
|
||||||
Modifier
|
|
||||||
.background(
|
|
||||||
Color.Black,
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
).alpha(dimming),
|
|
||||||
).onFocusChanged {
|
|
||||||
if (it.isFocused) {
|
|
||||||
scope.launch {
|
|
||||||
bringIntoViewRequester.bringIntoView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.onKeyEvent {
|
}
|
||||||
if (episode != null && isPlayKeyUp(it)) {
|
}.onKeyEvent {
|
||||||
onClick.invoke(episode)
|
if (episode != null && isPlayKeyUp(it)) {
|
||||||
return@onKeyEvent true
|
onClick.invoke(episode)
|
||||||
}
|
return@onKeyEvent true
|
||||||
return@onKeyEvent false
|
}
|
||||||
},
|
return@onKeyEvent false
|
||||||
interactionSource = interactionSource,
|
},
|
||||||
cardHeight = 120.dp,
|
interactionSource = interactionSource,
|
||||||
)
|
cardHeight = 120.dp,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
focusedEpisode?.let { ep ->
|
focusedEpisode?.let { ep ->
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
|
@ -26,8 +25,10 @@ import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
import com.github.damontecres.wholphin.ui.lt
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
|
@ -37,11 +38,19 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -57,11 +66,10 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
|
||||||
class SeriesViewModel
|
class SeriesViewModel
|
||||||
@Inject
|
@AssistedInject
|
||||||
constructor(
|
constructor(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
|
|
@ -76,11 +84,21 @@ class SeriesViewModel
|
||||||
val streamChoiceService: StreamChoiceService,
|
val streamChoiceService: StreamChoiceService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
@Assisted val seriesId: UUID,
|
||||||
|
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
|
@Assisted val seriesPageType: SeriesPageType,
|
||||||
) : ItemViewModel(api) {
|
) : ItemViewModel(api) {
|
||||||
private lateinit var seriesId: UUID
|
@AssistedFactory
|
||||||
private lateinit var prefs: UserPreferences
|
interface Factory {
|
||||||
|
fun create(
|
||||||
|
seriesId: UUID,
|
||||||
|
seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
|
seriesPageType: SeriesPageType,
|
||||||
|
): SeriesViewModel
|
||||||
|
}
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val seasons = MutableLiveData<List<BaseItem>>(listOf())
|
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
||||||
|
|
||||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||||
|
|
@ -90,48 +108,76 @@ class SeriesViewModel
|
||||||
|
|
||||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||||
|
|
||||||
fun init(
|
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||||
prefs: UserPreferences,
|
|
||||||
itemId: UUID,
|
init {
|
||||||
seasonEpisodeIds: SeasonEpisodeIds?,
|
|
||||||
loadAdditionalDetails: Boolean,
|
|
||||||
) {
|
|
||||||
this.seriesId = itemId
|
|
||||||
this.prefs = prefs
|
|
||||||
viewModelScope.launch(
|
viewModelScope.launch(
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
loading,
|
||||||
"Error loading series $seriesId",
|
"Error loading series $seriesId",
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
|
Timber.v("Start")
|
||||||
val item = fetchItem(seriesId)
|
val item = fetchItem(seriesId)
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
val seasons = getSeasons(item)
|
|
||||||
|
|
||||||
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||||
val initialSeason =
|
|
||||||
if (seasonEpisodeIds != null) {
|
val episodeListDeferred =
|
||||||
seasons.firstOrNull {
|
if (seriesPageType == SeriesPageType.OVERVIEW) {
|
||||||
equalsNotNull(it.id, seasonEpisodeIds.seasonId) ||
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber)
|
if (seasonEpisodeIds != null) {
|
||||||
|
loadEpisodesInternal(
|
||||||
|
seasonEpisodeIds.seasonId,
|
||||||
|
seasonEpisodeIds.episodeId,
|
||||||
|
seasonEpisodeIds.episodeNumber,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
seasonsDeferred.await().firstOrNull()?.let {
|
||||||
|
loadEpisodesInternal(
|
||||||
|
it.id,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
} ?: EpisodeList.Error(message = "Could not determine season")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
seasons.firstOrNull()
|
CompletableDeferred(value = EpisodeList.Loading)
|
||||||
}
|
}
|
||||||
val episodeInfo =
|
val seasons = seasonsDeferred.await()
|
||||||
initialSeason?.let {
|
val episodes = episodeListDeferred.await()
|
||||||
loadEpisodesInternal(
|
Timber.v("Done")
|
||||||
it.id,
|
|
||||||
seasonEpisodeIds?.episodeId,
|
if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) {
|
||||||
seasonEpisodeIds?.episodeNumber,
|
viewModelScope.launchIO {
|
||||||
)
|
val index =
|
||||||
} ?: EpisodeList.Error("Could not determine season for selected episode")
|
(seasons as? ApiRequestPager<*>)?.let {
|
||||||
|
findIndexOf(
|
||||||
|
seasonEpisodeIds.seasonNumber,
|
||||||
|
seasonEpisodeIds.seasonId,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
} ?: 0
|
||||||
|
Timber.v("Got initial season index: $index")
|
||||||
|
position.update {
|
||||||
|
it.copy(seasonTabIndex = index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
this@SeriesViewModel.position.update {
|
||||||
|
it.copy(
|
||||||
|
episodeRowIndex =
|
||||||
|
(episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
this@SeriesViewModel.seasons.value = seasons
|
this@SeriesViewModel.seasons.value = seasons
|
||||||
episodes.value = episodeInfo
|
this@SeriesViewModel.episodes.value = episodes
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
if (loadAdditionalDetails) {
|
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val trailers = trailerService.getTrailers(item)
|
val trailers = trailerService.getTrailers(item)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -153,7 +199,7 @@ class SeriesViewModel
|
||||||
.getSimilarItems(
|
.getSimilarItems(
|
||||||
GetSimilarItemsRequest(
|
GetSimilarItemsRequest(
|
||||||
userId = serverRepository.currentUser.value?.id,
|
userId = serverRepository.currentUser.value?.id,
|
||||||
itemId = itemId,
|
itemId = seriesId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
),
|
),
|
||||||
|
|
@ -185,31 +231,45 @@ class SeriesViewModel
|
||||||
themeSongPlayer.stop()
|
themeSongPlayer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSeasons(series: BaseItem): List<BaseItem> {
|
private fun getSeasons(
|
||||||
val request =
|
series: BaseItem,
|
||||||
GetItemsRequest(
|
seasonNum: Int?,
|
||||||
parentId = series.id,
|
): Deferred<List<BaseItem?>> =
|
||||||
recursive = false,
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
includeItemTypes = listOf(BaseItemKind.SEASON),
|
val request =
|
||||||
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
|
GetItemsRequest(
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
parentId = series.id,
|
||||||
fields =
|
recursive = false,
|
||||||
listOf(
|
includeItemTypes = listOf(BaseItemKind.SEASON),
|
||||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
|
||||||
ItemFields.CHILD_COUNT,
|
sortOrder = listOf(SortOrder.ASCENDING),
|
||||||
ItemFields.SEASON_USER_DATA,
|
fields =
|
||||||
),
|
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
)
|
listOf(
|
||||||
val seasons =
|
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||||
GetItemsRequestHandler.execute(api, request).content.items.map {
|
)
|
||||||
BaseItem.from(
|
} else {
|
||||||
it,
|
null
|
||||||
api,
|
},
|
||||||
)
|
)
|
||||||
}
|
val pager =
|
||||||
Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
|
ApiRequestPager(
|
||||||
return seasons
|
api,
|
||||||
}
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
pageSize = 10,
|
||||||
|
).init(seasonNum ?: 0)
|
||||||
|
// val seasons =
|
||||||
|
// GetItemsRequestHandler.execute(api, request).content.items.map {
|
||||||
|
// BaseItem.from(
|
||||||
|
// it,
|
||||||
|
// api,
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}")
|
||||||
|
pager
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadEpisodesInternal(
|
private suspend fun loadEpisodesInternal(
|
||||||
seasonId: UUID,
|
seasonId: UUID,
|
||||||
|
|
@ -233,14 +293,11 @@ class SeriesViewModel
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
||||||
pager.init()
|
pager.init(episodeNumber ?: 0)
|
||||||
val initialIndex =
|
val initialIndex =
|
||||||
if (episodeId != null || episodeNumber != null) {
|
if (episodeId != null || episodeNumber != null) {
|
||||||
pager
|
findIndexOf(episodeNumber, episodeId, pager)
|
||||||
.indexOfBlocking {
|
.coerceAtLeast(0)
|
||||||
equalsNotNull(it?.id, episodeId) ||
|
|
||||||
equalsNotNull(it?.indexNumber, episodeNumber)
|
|
||||||
}.coerceAtLeast(0)
|
|
||||||
} else {
|
} else {
|
||||||
// Force the first page to to be fetched
|
// Force the first page to to be fetched
|
||||||
if (pager.isNotEmpty()) {
|
if (pager.isNotEmpty()) {
|
||||||
|
|
@ -272,7 +329,7 @@ class SeriesViewModel
|
||||||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||||
(episodes as? EpisodeList.Success)
|
(episodes as? EpisodeList.Success)
|
||||||
?.let {
|
?.let {
|
||||||
it.episodes.getOrNull(it.initialIndex)
|
it.episodes.getOrNull(it.initialEpisodeIndex)
|
||||||
}?.let { lookupPeopleInEpisode(it) }
|
}?.let { lookupPeopleInEpisode(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -312,7 +369,7 @@ class SeriesViewModel
|
||||||
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
setWatched(seasonId, played, null)
|
setWatched(seasonId, played, null)
|
||||||
val series = fetchItem(seriesId)
|
val series = fetchItem(seriesId)
|
||||||
val seasons = getSeasons(series)
|
val seasons = getSeasons(series, null).await()
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,7 +377,7 @@ class SeriesViewModel
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
favoriteWatchManager.setWatched(seriesId, played)
|
favoriteWatchManager.setWatched(seriesId, played)
|
||||||
val series = fetchItem(seriesId)
|
val series = fetchItem(seriesId)
|
||||||
val seasons = getSeasons(series)
|
val seasons = getSeasons(series, null).await()
|
||||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,7 +526,7 @@ sealed interface EpisodeList {
|
||||||
data class Success(
|
data class Success(
|
||||||
val seasonId: UUID,
|
val seasonId: UUID,
|
||||||
val episodes: ApiRequestPager<GetEpisodesRequest>,
|
val episodes: ApiRequestPager<GetEpisodesRequest>,
|
||||||
val initialIndex: Int,
|
val initialEpisodeIndex: Int,
|
||||||
) : EpisodeList
|
) : EpisodeList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,3 +534,49 @@ data class PeopleInItem(
|
||||||
val itemId: UUID? = null,
|
val itemId: UUID? = null,
|
||||||
val people: List<Person> = listOf(),
|
val people: List<Person> = listOf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class SeriesPageType {
|
||||||
|
DETAILS,
|
||||||
|
OVERVIEW,
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun findIndexOf(
|
||||||
|
targetNum: Int?,
|
||||||
|
targetId: UUID?,
|
||||||
|
pager: ApiRequestPager<*>,
|
||||||
|
): Int {
|
||||||
|
val index =
|
||||||
|
if (targetId != null && (targetNum == null || targetNum !in pager.indices)) {
|
||||||
|
// No hint info, so have to check everything
|
||||||
|
pager.indexOfBlocking { equalsNotNull(it?.id, targetId) }
|
||||||
|
} else if (targetNum != null && targetNum in pager.indices) {
|
||||||
|
// Start searching from the season number and choose direction from there
|
||||||
|
val num = pager.getBlocking(targetNum)?.indexNumber
|
||||||
|
if (num.lt(targetNum)) {
|
||||||
|
for (i in targetNum + 1 until pager.lastIndex) {
|
||||||
|
val season = pager.getBlocking(i)
|
||||||
|
if (equalsNotNull(season?.indexNumber, targetNum) ||
|
||||||
|
equalsNotNull(season?.id, targetId)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} else if (num.gt(targetNum)) {
|
||||||
|
for (i in targetNum - 1 downTo 0) {
|
||||||
|
val season = pager.getBlocking(i)
|
||||||
|
if (equalsNotNull(season?.indexNumber, targetNum) ||
|
||||||
|
equalsNotNull(season?.id, targetId)
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
targetNum
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
|
|
@ -360,13 +361,21 @@ fun HomePageContent(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.animateItem(),
|
.animateItem(),
|
||||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||||
|
val cornerText =
|
||||||
|
remember {
|
||||||
|
item?.data?.indexNumber?.let { "E$it" }
|
||||||
|
?: item
|
||||||
|
?.data
|
||||||
|
?.userData
|
||||||
|
?.unplayedItemCount
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { abbreviateNumber(it) }
|
||||||
|
}
|
||||||
BannerCard(
|
BannerCard(
|
||||||
name = item?.data?.seriesName ?: item?.name,
|
name = item?.data?.seriesName ?: item?.name,
|
||||||
item = item,
|
item = item,
|
||||||
aspectRatio = AspectRatios.TALL,
|
aspectRatio = AspectRatios.TALL,
|
||||||
cornerText =
|
cornerText = cornerText,
|
||||||
item?.data?.indexNumber?.let { "E$it" }
|
|
||||||
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },
|
|
||||||
played = item?.data?.userData?.played ?: false,
|
played = item?.data?.userData?.played ?: false,
|
||||||
favorite = item?.favorite ?: false,
|
favorite = item?.favorite ?: false,
|
||||||
playPercent =
|
playPercent =
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
|
@ -21,34 +21,18 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
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.tvShowsApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel
|
class HomeViewModel
|
||||||
|
|
@ -61,6 +45,7 @@ class HomeViewModel
|
||||||
val navDrawerItemRepository: NavDrawerItemRepository,
|
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
|
private val latestNextUpService: LatestNextUpService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
|
|
@ -91,7 +76,9 @@ class HomeViewModel
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Timber.d("init HomeViewModel")
|
Timber.d("init HomeViewModel")
|
||||||
backdropService.clearBackdrop()
|
if (reload) {
|
||||||
|
backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto.value?.let { userDto ->
|
||||||
val includedIds =
|
val includedIds =
|
||||||
|
|
@ -99,10 +86,9 @@ class HomeViewModel
|
||||||
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
||||||
.filter { it is ServerNavDrawerItem }
|
.filter { it is ServerNavDrawerItem }
|
||||||
.map { (it as ServerNavDrawerItem).itemId }
|
.map { (it as ServerNavDrawerItem).itemId }
|
||||||
// TODO data is fetched all together which may be slow for large servers
|
val resume = latestNextUpService.getResume(userDto.id, limit, true)
|
||||||
val resume = getResume(userDto.id, limit, true)
|
|
||||||
val nextUp =
|
val nextUp =
|
||||||
getNextUp(
|
latestNextUpService.getNextUp(
|
||||||
userDto.id,
|
userDto.id,
|
||||||
limit,
|
limit,
|
||||||
prefs.enableRewatchingNextUp,
|
prefs.enableRewatchingNextUp,
|
||||||
|
|
@ -111,13 +97,7 @@ class HomeViewModel
|
||||||
val watching =
|
val watching =
|
||||||
buildList {
|
buildList {
|
||||||
if (prefs.combineContinueNext) {
|
if (prefs.combineContinueNext) {
|
||||||
val items =
|
val items = latestNextUpService.buildCombined(resume, nextUp)
|
||||||
buildCombinedNextUp(
|
|
||||||
viewModelScope,
|
|
||||||
datePlayedService,
|
|
||||||
resume,
|
|
||||||
nextUp,
|
|
||||||
)
|
|
||||||
add(
|
add(
|
||||||
HomeRowLoadingState.Success(
|
HomeRowLoadingState.Success(
|
||||||
title = context.getString(R.string.continue_watching),
|
title = context.getString(R.string.continue_watching),
|
||||||
|
|
@ -144,7 +124,7 @@ class HomeViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val latest = getLatest(userDto, limit, includedIds)
|
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
|
||||||
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -154,127 +134,13 @@ class HomeViewModel
|
||||||
}
|
}
|
||||||
loadingState.value = LoadingState.Success
|
loadingState.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
loadLatest(latest)
|
|
||||||
refreshState.setValueOnMain(LoadingState.Success)
|
refreshState.setValueOnMain(LoadingState.Success)
|
||||||
|
val loadedLatest = latestNextUpService.loadLatest(latest)
|
||||||
|
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getResume(
|
|
||||||
userId: UUID,
|
|
||||||
limit: Int,
|
|
||||||
includeEpisodes: Boolean,
|
|
||||||
): List<BaseItem> {
|
|
||||||
val request =
|
|
||||||
GetResumeItemsRequest(
|
|
||||||
userId = userId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
limit = limit,
|
|
||||||
includeItemTypes =
|
|
||||||
if (includeEpisodes) {
|
|
||||||
supportItemKinds
|
|
||||||
} else {
|
|
||||||
supportItemKinds
|
|
||||||
.toMutableSet()
|
|
||||||
.apply {
|
|
||||||
remove(BaseItemKind.EPISODE)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val items =
|
|
||||||
api.itemsApi
|
|
||||||
.getResumeItems(request)
|
|
||||||
.content
|
|
||||||
.items
|
|
||||||
.map { BaseItem.from(it, api, true) }
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getNextUp(
|
|
||||||
userId: UUID,
|
|
||||||
limit: Int,
|
|
||||||
enableRewatching: Boolean,
|
|
||||||
enableResumable: Boolean,
|
|
||||||
): List<BaseItem> {
|
|
||||||
val request =
|
|
||||||
GetNextUpRequest(
|
|
||||||
userId = userId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
imageTypeLimit = 1,
|
|
||||||
parentId = null,
|
|
||||||
limit = limit,
|
|
||||||
enableResumable = enableResumable,
|
|
||||||
enableUserData = true,
|
|
||||||
enableRewatching = enableRewatching,
|
|
||||||
)
|
|
||||||
val nextUp =
|
|
||||||
api.tvShowsApi
|
|
||||||
.getNextUp(request)
|
|
||||||
.content
|
|
||||||
.items
|
|
||||||
.map { BaseItem.from(it, api, true) }
|
|
||||||
return nextUp
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getLatest(
|
|
||||||
user: UserDto,
|
|
||||||
limit: Int,
|
|
||||||
includedIds: List<UUID>,
|
|
||||||
): List<LatestData> {
|
|
||||||
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
|
||||||
val views by api.userViewsApi.getUserViews()
|
|
||||||
val latestData =
|
|
||||||
views.items
|
|
||||||
.filter {
|
|
||||||
it.id in includedIds && it.id !in excluded &&
|
|
||||||
it.collectionType in supportedLatestCollectionTypes
|
|
||||||
}.map { view ->
|
|
||||||
val title =
|
|
||||||
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
|
||||||
?: context.getString(R.string.recently_added)
|
|
||||||
val request =
|
|
||||||
GetLatestMediaRequest(
|
|
||||||
fields = SlimItemFields,
|
|
||||||
imageTypeLimit = 1,
|
|
||||||
parentId = view.id,
|
|
||||||
groupItems = true,
|
|
||||||
limit = limit,
|
|
||||||
isPlayed = null, // Server will handle user's preference
|
|
||||||
)
|
|
||||||
LatestData(title, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestData
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadLatest(latestData: List<LatestData>) {
|
|
||||||
val rows =
|
|
||||||
latestData.mapNotNull { (title, request) ->
|
|
||||||
try {
|
|
||||||
val latest =
|
|
||||||
api.userLibraryApi
|
|
||||||
.getLatestMedia(request)
|
|
||||||
.content
|
|
||||||
.map { BaseItem.from(it, api, true) }
|
|
||||||
if (latest.isNotEmpty()) {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
title = title,
|
|
||||||
items = latest,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception fetching %s", title)
|
|
||||||
HomeRowLoadingState.Error(
|
|
||||||
title = title,
|
|
||||||
exception = ex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
latestRows.setValueOnMain(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWatched(
|
fun setWatched(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
|
|
@ -315,38 +181,3 @@ data class LatestData(
|
||||||
val title: String,
|
val title: String,
|
||||||
val request: GetLatestMediaRequest,
|
val request: GetLatestMediaRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun buildCombinedNextUp(
|
|
||||||
scope: CoroutineScope,
|
|
||||||
datePlayedService: DatePlayedService,
|
|
||||||
resume: List<BaseItem>,
|
|
||||||
nextUp: List<BaseItem>,
|
|
||||||
): List<BaseItem> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val start = System.currentTimeMillis()
|
|
||||||
val semaphore = Semaphore(3)
|
|
||||||
val deferred =
|
|
||||||
nextUp
|
|
||||||
.filter { it.data.seriesId != null }
|
|
||||||
.map { item ->
|
|
||||||
scope.async(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
semaphore.withPermit {
|
|
||||||
datePlayedService.getLastPlayed(item)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Error fetching %s", item.id)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextUpLastPlayed = deferred.awaitAll()
|
|
||||||
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
|
||||||
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
|
||||||
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
|
||||||
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
|
||||||
val duration = (System.currentTimeMillis() - start).milliseconds
|
|
||||||
Timber.v("buildCombined took %s", duration)
|
|
||||||
return@withContext result
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ class ApplicationContentViewModel
|
||||||
fun ApplicationContent(
|
fun ApplicationContent(
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
|
startDestination: Destination,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -80,7 +81,7 @@ fun ApplicationContent(
|
||||||
user,
|
user,
|
||||||
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
|
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
|
||||||
) {
|
) {
|
||||||
NavBackStack(Destination.Home())
|
NavBackStack(startDestination)
|
||||||
}
|
}
|
||||||
navigationManager.backStack = backStack
|
navigationManager.backStack = backStack
|
||||||
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,10 @@ fun DestinationContent(
|
||||||
|
|
||||||
is Destination.SeriesOverview -> {
|
is Destination.SeriesOverview -> {
|
||||||
SeriesOverview(
|
SeriesOverview(
|
||||||
preferences,
|
preferences = preferences,
|
||||||
destination,
|
destination = destination,
|
||||||
modifier,
|
|
||||||
initialSeasonEpisode = destination.seasonEpisode,
|
initialSeasonEpisode = destination.seasonEpisode,
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,7 @@ fun PlaybackPage(
|
||||||
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.reportInteraction()
|
viewModel.reportInteraction()
|
||||||
|
controllerViewState.hideControls()
|
||||||
viewModel.playNextUp()
|
viewModel.playNextUp()
|
||||||
},
|
},
|
||||||
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,540 @@
|
||||||
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.Parameterized
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class TestStreamChoiceServiceBasic(
|
||||||
|
val input: TestInput,
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
fun test() {
|
||||||
|
runTest(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Parameterized.Parameters(name = "{index}: {0}")
|
||||||
|
fun data(): Collection<TestInput> =
|
||||||
|
listOf(
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.NONE,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.NONE,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
plc = plc(subtitleLang = "spa"),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.ALWAYS,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.ALWAYS,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
userSubtitleLang = "spa",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.ALWAYS,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
plc = plc(subtitlesDisabled = true),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.ALWAYS,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.ALWAYS,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class TestStreamChoiceServiceDefault(
|
||||||
|
val input: TestInput,
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
fun test() {
|
||||||
|
runTest(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Parameterized.Parameters(name = "{index}: {0}")
|
||||||
|
fun data(): Collection<TestInput> =
|
||||||
|
listOf(
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
userSubtitleLang = null,
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", true),
|
||||||
|
),
|
||||||
|
userSubtitleLang = null,
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", true),
|
||||||
|
),
|
||||||
|
userSubtitleLang = null,
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
itemPlayback = itemPlayback(subtitleIndex = 1),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
plc = plc(subtitleLang = "spa"),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
plc = plc(subtitleLang = "spa"),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
plc = plc(subtitlesDisabled = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class TestStreamChoiceServiceSmart(
|
||||||
|
val input: TestInput,
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
fun test() {
|
||||||
|
runTest(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Parameterized.Parameters(name = "{index}: {0}")
|
||||||
|
fun data(): Collection<TestInput> =
|
||||||
|
listOf(
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = null,
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "spa",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "spa",
|
||||||
|
plc = plc(subtitleLang = "spa"),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", false),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "spa",
|
||||||
|
plc = plc(subtitlesDisabled = true),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
userSubtitleLang = "spa",
|
||||||
|
userAudioLang = "spa",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.SMART,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", true),
|
||||||
|
subtitle(1, "spa", false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
userSubtitleLang = "spa",
|
||||||
|
userAudioLang = "eng",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class TestStreamChoiceServiceOnlyForced(
|
||||||
|
val input: TestInput,
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
fun test() {
|
||||||
|
runTest(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Parameterized.Parameters(name = "{index}: {0}")
|
||||||
|
fun data(): Collection<TestInput> =
|
||||||
|
listOf(
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = false),
|
||||||
|
subtitle(1, "spa", forced = false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = false),
|
||||||
|
subtitle(1, "spa", forced = false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
itemPlayback = itemPlayback(subtitleIndex = 1),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = false),
|
||||||
|
subtitle(1, "spa", forced = false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
plc = plc(subtitleLang = "eng"),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = false),
|
||||||
|
subtitle(1, "spa", forced = false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
plc = plc(subtitlesDisabled = true),
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
0,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = true),
|
||||||
|
subtitle(1, "spa", forced = false),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
null,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = false),
|
||||||
|
subtitle(1, "spa", forced = true),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
TestInput(
|
||||||
|
1,
|
||||||
|
SubtitlePlaybackMode.ONLY_FORCED,
|
||||||
|
userSubtitleLang = null,
|
||||||
|
subtitles =
|
||||||
|
listOf(
|
||||||
|
subtitle(0, "eng", forced = false),
|
||||||
|
subtitle(1, "spa", forced = true),
|
||||||
|
),
|
||||||
|
streamAudioLang = "eng",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TestInput(
|
||||||
|
val expectedIndex: Int?,
|
||||||
|
val userSubtitleMode: SubtitlePlaybackMode?,
|
||||||
|
val userAudioLang: String? = "eng",
|
||||||
|
val userSubtitleLang: String? = "eng",
|
||||||
|
val streamAudioLang: String? = "eng",
|
||||||
|
val subtitles: List<MediaStream>,
|
||||||
|
val itemPlayback: ItemPlayback? = null,
|
||||||
|
val plc: PlaybackLanguageChoice? = null,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
|
||||||
|
|
||||||
|
private fun serverRepo(
|
||||||
|
audioLang: String?,
|
||||||
|
subtitleMode: SubtitlePlaybackMode?,
|
||||||
|
subtitleLang: String?,
|
||||||
|
): ServerRepository {
|
||||||
|
val mocked = mockk<ServerRepository>()
|
||||||
|
every { mocked.currentUserDto } returns
|
||||||
|
MutableLiveData(
|
||||||
|
UserDto(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
hasPassword = true,
|
||||||
|
hasConfiguredPassword = true,
|
||||||
|
hasConfiguredEasyPassword = true,
|
||||||
|
configuration =
|
||||||
|
DefaultUserConfiguration.copy(
|
||||||
|
audioLanguagePreference = audioLang,
|
||||||
|
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
|
||||||
|
subtitleLanguagePreference = subtitleLang,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return mocked
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runTest(input: TestInput) {
|
||||||
|
val service =
|
||||||
|
StreamChoiceService(
|
||||||
|
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
|
||||||
|
mockk<PlaybackLanguageChoiceDao>(),
|
||||||
|
)
|
||||||
|
val result =
|
||||||
|
service.chooseSubtitleStream(
|
||||||
|
audioStreamLang = input.streamAudioLang,
|
||||||
|
candidates = input.subtitles,
|
||||||
|
itemPlayback = input.itemPlayback,
|
||||||
|
playbackLanguageChoice = input.plc,
|
||||||
|
prefs = UserPreferences(AppPreferences.getDefaultInstance()),
|
||||||
|
)
|
||||||
|
Assert.assertEquals(input.expectedIndex, result?.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subtitle(
|
||||||
|
index: Int,
|
||||||
|
lang: String?,
|
||||||
|
default: Boolean = false,
|
||||||
|
forced: Boolean = false,
|
||||||
|
): MediaStream =
|
||||||
|
MediaStream(
|
||||||
|
type = MediaStreamType.SUBTITLE,
|
||||||
|
language = lang,
|
||||||
|
isDefault = default,
|
||||||
|
isForced = forced,
|
||||||
|
isHearingImpaired = false,
|
||||||
|
isInterlaced = false,
|
||||||
|
index = index,
|
||||||
|
isExternal = false,
|
||||||
|
isTextSubtitleStream = true,
|
||||||
|
supportsExternalStream = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun itemPlayback(
|
||||||
|
audioIndex: Int = TrackIndex.UNSPECIFIED,
|
||||||
|
subtitleIndex: Int = TrackIndex.UNSPECIFIED,
|
||||||
|
): ItemPlayback =
|
||||||
|
ItemPlayback(
|
||||||
|
rowId = 1,
|
||||||
|
userId = 1,
|
||||||
|
itemId = UUID.randomUUID(),
|
||||||
|
sourceId = UUID.randomUUID(),
|
||||||
|
audioIndex = audioIndex,
|
||||||
|
subtitleIndex = subtitleIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun plc(
|
||||||
|
audioLang: String? = null,
|
||||||
|
subtitleLang: String? = null,
|
||||||
|
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
|
||||||
|
): PlaybackLanguageChoice =
|
||||||
|
PlaybackLanguageChoice(
|
||||||
|
userId = 1,
|
||||||
|
seriesId = UUID.randomUUID(),
|
||||||
|
audioLanguage = audioLang,
|
||||||
|
subtitleLanguage = subtitleLang,
|
||||||
|
subtitlesDisabled = subtitlesDisabled,
|
||||||
|
)
|
||||||
|
|
@ -5,13 +5,16 @@ agp = "8.13.2"
|
||||||
auto-service = "1.1.1"
|
auto-service = "1.1.1"
|
||||||
autoServiceKsp = "1.2.0"
|
autoServiceKsp = "1.2.0"
|
||||||
desugar_jdk_libs = "2.1.5"
|
desugar_jdk_libs = "2.1.5"
|
||||||
|
hiltCompiler = "1.3.0"
|
||||||
hiltNavigationCompose = "1.3.0"
|
hiltNavigationCompose = "1.3.0"
|
||||||
|
hiltWork = "1.3.0"
|
||||||
kotlin = "2.2.21"
|
kotlin = "2.2.21"
|
||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
ksp = "2.3.0"
|
ksp = "2.3.0"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2025.12.01"
|
||||||
|
mockk = "1.14.7"
|
||||||
multiplatformMarkdownRenderer = "0.38.1"
|
multiplatformMarkdownRenderer = "0.38.1"
|
||||||
okhttpBom = "5.3.2"
|
okhttpBom = "5.3.2"
|
||||||
programguide = "1.6.0"
|
programguide = "1.6.0"
|
||||||
|
|
@ -34,6 +37,8 @@ protobuf-javalite = "4.33.2"
|
||||||
hilt = "2.57.2"
|
hilt = "2.57.2"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
preferenceKtx = "1.2.1"
|
preferenceKtx = "1.2.1"
|
||||||
|
tvprovider = "1.1.0"
|
||||||
|
workRuntimeKtx = "2.11.0"
|
||||||
paletteKtx = "1.0.0"
|
paletteKtx = "1.0.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|
@ -55,18 +60,24 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
||||||
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
|
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
|
||||||
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
|
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
|
||||||
|
|
||||||
|
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" }
|
||||||
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
||||||
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
|
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
||||||
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||||
|
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
|
||||||
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
|
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
|
||||||
auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" }
|
auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" }
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
||||||
|
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
||||||
|
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||||
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp" }
|
okhttp = { module = "com.squareup.okhttp3:okhttp" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue