Merge branch 'main' into develop/jellyseerr

This commit is contained in:
Damontecres 2025-12-27 17:13:20 -05:00
commit 8f02dab6ed
No known key found for this signature in database
38 changed files with 1964 additions and 646 deletions

View file

@ -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

View file

@ -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)
} }

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Wholphin (Debug)</string>
</resources>

View file

@ -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>

View file

@ -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
?: DefaultUserConfiguration,
)
} }
var showContent by remember {
mutableStateOf(true)
}
if (!preferences.appPreferences.signInAutomatically) {
LifecycleStartEffect(Unit) {
onStopOrDispose {
showContent = false
}
}
}
if (showContent) {
ApplicationContent( ApplicationContent(
user = current.user, user = current.user,
server = current.server, server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager, navigationManager = navigationManager,
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
} }
} }
} }
@ -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,16 +386,16 @@ 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 {
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
if (prefs.signInAutomatically) { if (prefs.signInAutomatically) {
val current = val current =
withContext(Dispatchers.IO) {
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))
@ -300,9 +409,7 @@ class MainActivityViewModel
val currentServerId = prefs.currentServerId?.toUUIDOrNull() val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) { if (currentServerId != null) {
val currentServer = val currentServer =
withContext(Dispatchers.IO) {
serverRepository.serverDao.getServer(currentServerId)?.server serverRepository.serverDao.getServer(currentServerId)?.server
}
if (currentServer != null) { if (currentServer != null) {
navigationManager.navigateTo(SetupDestination.UserList(currentServer)) navigationManager.navigateTo(SetupDestination.UserList(currentServer))
} else { } else {
@ -312,6 +419,10 @@ class MainActivityViewModel
navigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
} }
} }
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
navigationManager.navigateTo(SetupDestination.ServerList)
}
} }
viewModelScope.launchIO { viewModelScope.launchIO {
// Create the mediaCodecCapabilitiesTest if needed // Create the mediaCodecCapabilitiesTest if needed

View file

@ -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

View file

@ -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 }

View file

@ -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,6 +131,15 @@ class ServerRepository
serverDao.getServer(serverId) serverDao.getServer(serverId)
} }
if (serverAndUsers != null) { if (serverAndUsers != null) {
val current = _current.value
if (current != null && current.server.id == serverId && current.user.id == userId) {
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 } val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) { if (user != null) {
// TODO pin-related // TODO pin-related
@ -135,6 +147,7 @@ class ServerRepository
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,
) )

View file

@ -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 =

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)
} }
} }

View file

@ -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 { } else {
null candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} else {
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)

View file

@ -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,
) )
} }
} }

View file

@ -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(),
)
}
}
}
}
}

View file

@ -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())
}
}
}

View file

@ -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(

View file

@ -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(),
) )

View file

@ -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

View file

@ -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,

View file

@ -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,
) )

View file

@ -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) }

View file

@ -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,48 +57,53 @@ 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 {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text( Text(
text = info.title, text = info.title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.headlineSmall,
) )
}
if (info.genres.isNotEmpty()) { if (info.genres.isNotEmpty()) {
item {
Text( Text(
text = info.genres.joinToString(", "), text = info.genres.joinToString(", "),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
) )
} }
}
if (info.overview.isNotNullOrBlank()) { if (info.overview.isNotNullOrBlank()) {
item {
Text( Text(
text = info.overview, text = info.overview,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyMedium,
) )
} }
} }
}
// Show detailed media information for the selected source (first one if multiple) // Show detailed media information for the selected source (first one if multiple)
info.files.forEachIndexed { index, source -> info.files.forEachIndexed { index, source ->
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,12 +210,17 @@ 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,
) )
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(start = 12.dp),
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
items.forEach { (label, value) -> items.forEach { (label, value) ->
Row( Row(
modifier = Modifier.padding(start = 12.dp), modifier = Modifier,
) { ) {
Text( Text(
text = "$label: ", text = "$label: ",
@ -211,6 +234,37 @@ private fun MediaInfoSection(
} }
} }
} }
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(
@ -218,40 +272,24 @@ private fun buildVideoStreamInfo(
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(

View file

@ -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 ->

View file

@ -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,
) )

View file

@ -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),

View file

@ -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),

View file

@ -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(

View file

@ -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,
loading,
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) { LaunchedEffect(Unit) {
if (seasons.isNotEmpty()) {
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

View file

@ -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,7 +170,7 @@ 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()
@ -180,13 +181,9 @@ fun SeriesOverviewContent(
} }
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)
}
firstItemFocusRequester.tryRequestFocus()
}
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -202,12 +199,7 @@ fun SeriesOverviewContent(
itemsIndexed(eps.episodes) { episodeIndex, episode -> itemsIndexed(eps.episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) { if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke( onFocusEpisode.invoke(episodeIndex)
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
} }
val cornerText = val cornerText =
episode?.data?.indexNumber?.let { "E$it" } episode?.data?.indexNumber?.let { "E$it" }
@ -264,7 +256,7 @@ fun SeriesOverviewContent(
} }
} }
} }
} // }
} }
focusedEpisode?.let { ep -> focusedEpisode?.let { ep ->

View file

@ -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 =
val episodeListDeferred =
if (seriesPageType == SeriesPageType.OVERVIEW) {
viewModelScope.async(Dispatchers.IO) {
if (seasonEpisodeIds != null) { if (seasonEpisodeIds != null) {
seasons.firstOrNull { loadEpisodesInternal(
equalsNotNull(it.id, seasonEpisodeIds.seasonId) || seasonEpisodeIds.seasonId,
equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber) seasonEpisodeIds.episodeId,
} seasonEpisodeIds.episodeNumber,
)
} else { } else {
seasons.firstOrNull() seasonsDeferred.await().firstOrNull()?.let {
}
val episodeInfo =
initialSeason?.let {
loadEpisodesInternal( loadEpisodesInternal(
it.id, it.id,
seasonEpisodeIds?.episodeId, null,
seasonEpisodeIds?.episodeNumber, null,
) )
} ?: EpisodeList.Error("Could not determine season for selected episode") } ?: EpisodeList.Error(message = "Could not determine season")
}
}
} else {
CompletableDeferred(value = EpisodeList.Loading)
}
val seasons = seasonsDeferred.await()
val episodes = episodeListDeferred.await()
Timber.v("Done")
if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) {
viewModelScope.launchIO {
val index =
(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,7 +231,11 @@ class SeriesViewModel
themeSongPlayer.stop() themeSongPlayer.stop()
} }
private suspend fun getSeasons(series: BaseItem): List<BaseItem> { private fun getSeasons(
series: BaseItem,
seasonNum: Int?,
): Deferred<List<BaseItem?>> =
viewModelScope.async(Dispatchers.IO) {
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = series.id, parentId = series.id,
@ -194,21 +244,31 @@ class SeriesViewModel
sortBy = listOf(ItemSortBy.INDEX_NUMBER), sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING), sortOrder = listOf(SortOrder.ASCENDING),
fields = fields =
if (seriesPageType == SeriesPageType.DETAILS) {
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.SEASON_USER_DATA,
),
) )
val seasons = } else {
GetItemsRequestHandler.execute(api, request).content.items.map { null
BaseItem.from( },
it, )
val pager =
ApiRequestPager(
api, api,
) request,
} GetItemsRequestHandler,
Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") viewModelScope,
return seasons 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(
@ -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
}

View file

@ -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 =

View file

@ -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")
if (reload) {
backdropService.clearBackdrop() 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
}

View file

@ -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()

View file

@ -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,
) )
} }

View file

@ -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,

View file

@ -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,
)

View file

@ -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" }