Add resume/continue watching items to Android TV watch next row (#372)

## Details

Adds resume and continue watching items for the logged in user to the
Android TV OS level watch next "channel"
(https://developer.android.com/training/tv/discovery/watch-next-add-programs).

## Issues
Closes #206
Related to #581
This commit is contained in:
Ray 2025-12-27 15:40:45 -05:00 committed by GitHub
parent 639ce0de71
commit 9ae4965c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 645 additions and 189 deletions

View file

@ -191,6 +191,9 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
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.datasource.okhttp)
@ -227,6 +230,7 @@ dependencies {
implementation(libs.androidx.palette.ktx)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)
implementation(libs.timber)
implementation(libs.slf4j2.timber)

View file

@ -1,5 +1,6 @@
<?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.RECORD_AUDIO" />
@ -11,6 +12,7 @@
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
@ -53,6 +55,10 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
</application>
</manifest>

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@ -47,10 +48,13 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchIO
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.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -60,6 +64,7 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.firstOrNull
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
@ -96,6 +101,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var refreshRateService: RefreshRateService
@Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)
@ -115,6 +123,7 @@ class MainActivity : AppCompatActivity() {
}
}
viewModel.appStart()
val requestedDestination = this.intent?.let(::extractDestination)
setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences ->
@ -225,6 +234,9 @@ class MainActivity : AppCompatActivity() {
ApplicationContent(
user = current.user,
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
@ -278,6 +290,7 @@ class MainActivity : AppCompatActivity() {
override fun onStop() {
super.onStop()
Timber.d("onStop")
tvProviderSchedulerService.launchOneTimeRefresh()
}
override fun onPause() {
@ -304,6 +317,54 @@ class MainActivity : AppCompatActivity() {
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

View file

@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy
import android.util.Log
import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import org.acra.ACRA
import org.acra.ReportField
@ -14,10 +16,13 @@ import org.acra.config.dialog
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import timber.log.Timber
import javax.inject.Inject
@OptIn(ExperimentalComposeRuntimeApi::class)
@HiltAndroidApp
class WholphinApplication : Application() {
class WholphinApplication :
Application(),
Configuration.Provider {
init {
instance = this
@ -94,6 +99,16 @@ class WholphinApplication : Application() {
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 {
lateinit var instance: WholphinApplication
private set

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()
}
/**
* Resets the backstack to the specified destination
*/
fun replace(destination: Destination) {
while (backStack.size > 1) {
backStack.removeLastOrNull()
}
backStack[0] = destination
log()
}
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.Forest.i("Current Destination: %s", dest)
Timber.i("Current Destination: %s", dest)
ACRA.errorReporter.putCustomData("destination", dest)
}
}

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

@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
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.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
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.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -58,7 +57,7 @@ class RecommendedTvShowViewModel
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val datePlayedService: DatePlayedService,
private val lastestNextUpService: LatestNextUpService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
@ -126,9 +125,7 @@ class RecommendedTvShowViewModel
val nextUpItems = nextUpItemsDeferred.await()
if (combineNextUp) {
val combined =
buildCombinedNextUp(
viewModelScope,
datePlayedService,
lastestNextUpService.buildCombined(
resumeItems,
nextUpItems,
)

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.DatePlayedService
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.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
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.CollectionType
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 kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class HomeViewModel
@ -61,6 +45,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService,
) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -101,10 +86,9 @@ class HomeViewModel
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
.filter { it is ServerNavDrawerItem }
.map { (it as ServerNavDrawerItem).itemId }
// TODO data is fetched all together which may be slow for large servers
val resume = getResume(userDto.id, limit, true)
val resume = latestNextUpService.getResume(userDto.id, limit, true)
val nextUp =
getNextUp(
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
@ -113,13 +97,7 @@ class HomeViewModel
val watching =
buildList {
if (prefs.combineContinueNext) {
val items =
buildCombinedNextUp(
viewModelScope,
datePlayedService,
resume,
nextUp,
)
val items = latestNextUpService.buildCombined(resume, nextUp)
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
@ -146,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) }
withContext(Dispatchers.Main) {
@ -156,127 +134,13 @@ class HomeViewModel
}
loadingState.value = LoadingState.Success
}
loadLatest(latest)
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(
itemId: UUID,
played: Boolean,
@ -317,38 +181,3 @@ data class LatestData(
val title: String,
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(
server: JellyfinServer,
user: JellyfinUser,
startDestination: Destination,
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
@ -80,7 +81,7 @@ fun ApplicationContent(
user,
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
) {
NavBackStack(Destination.Home())
NavBackStack(startDestination)
}
navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()

View file

@ -5,7 +5,9 @@ agp = "8.13.2"
auto-service = "1.1.1"
autoServiceKsp = "1.2.0"
desugar_jdk_libs = "2.1.5"
hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0"
kotlin = "2.2.21"
ksp = "2.3.0"
coreKtx = "1.17.0"
@ -33,6 +35,8 @@ protobuf-javalite = "4.33.2"
hilt = "2.57.2"
room = "2.8.4"
preferenceKtx = "1.2.1"
tvprovider = "1.1.0"
workRuntimeKtx = "2.11.0"
paletteKtx = "1.0.0"
[libraries]
@ -54,12 +58,16 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" }
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-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
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-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" }