mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
639ce0de71
commit
9ae4965c2a
12 changed files with 645 additions and 189 deletions
|
|
@ -191,6 +191,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)
|
||||||
|
|
@ -227,6 +230,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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -53,6 +55,10 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
|
@ -47,10 +48,13 @@ import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
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.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
|
||||||
|
|
@ -60,6 +64,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
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
|
||||||
|
|
@ -96,6 +101,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var refreshRateService: RefreshRateService
|
lateinit var refreshRateService: RefreshRateService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -115,6 +123,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 ->
|
||||||
|
|
@ -225,6 +234,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
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(),
|
||||||
|
|
@ -278,6 +290,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
Timber.d("onStop")
|
Timber.d("onStop")
|
||||||
|
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
|
@ -304,6 +317,54 @@ class MainActivity : AppCompatActivity() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Timber.d("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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.main.LatestData
|
||||||
|
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
|
||||||
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class LatestNextUpService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val datePlayedService: DatePlayedService,
|
||||||
|
) {
|
||||||
|
suspend fun getResume(
|
||||||
|
userId: UUID,
|
||||||
|
limit: Int,
|
||||||
|
includeEpisodes: Boolean,
|
||||||
|
): List<BaseItem> {
|
||||||
|
val request =
|
||||||
|
GetResumeItemsRequest(
|
||||||
|
userId = userId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
limit = limit,
|
||||||
|
includeItemTypes =
|
||||||
|
if (includeEpisodes) {
|
||||||
|
supportItemKinds
|
||||||
|
} else {
|
||||||
|
supportItemKinds
|
||||||
|
.toMutableSet()
|
||||||
|
.apply {
|
||||||
|
remove(BaseItemKind.EPISODE)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val items =
|
||||||
|
api.itemsApi
|
||||||
|
.getResumeItems(request)
|
||||||
|
.content
|
||||||
|
.items
|
||||||
|
.map { BaseItem.from(it, api, true) }
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getNextUp(
|
||||||
|
userId: UUID,
|
||||||
|
limit: Int,
|
||||||
|
enableRewatching: Boolean,
|
||||||
|
enableResumable: Boolean,
|
||||||
|
): List<BaseItem> {
|
||||||
|
val request =
|
||||||
|
GetNextUpRequest(
|
||||||
|
userId = userId,
|
||||||
|
fields = SlimItemFields,
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
parentId = null,
|
||||||
|
limit = limit,
|
||||||
|
enableResumable = enableResumable,
|
||||||
|
enableUserData = true,
|
||||||
|
enableRewatching = enableRewatching,
|
||||||
|
)
|
||||||
|
val nextUp =
|
||||||
|
api.tvShowsApi
|
||||||
|
.getNextUp(request)
|
||||||
|
.content
|
||||||
|
.items
|
||||||
|
.map { BaseItem.from(it, api, true) }
|
||||||
|
return nextUp
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatest(
|
||||||
|
user: UserDto,
|
||||||
|
limit: Int,
|
||||||
|
includedIds: List<UUID>,
|
||||||
|
): List<LatestData> {
|
||||||
|
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
||||||
|
val views by api.userViewsApi.getUserViews()
|
||||||
|
val latestData =
|
||||||
|
views.items
|
||||||
|
.filter {
|
||||||
|
it.id in includedIds && it.id !in excluded &&
|
||||||
|
it.collectionType in supportedLatestCollectionTypes
|
||||||
|
}.map { view ->
|
||||||
|
val title =
|
||||||
|
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
||||||
|
?: context.getString(R.string.recently_added)
|
||||||
|
val request =
|
||||||
|
GetLatestMediaRequest(
|
||||||
|
fields = SlimItemFields,
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
parentId = view.id,
|
||||||
|
groupItems = true,
|
||||||
|
limit = limit,
|
||||||
|
isPlayed = null, // Server will handle user's preference
|
||||||
|
)
|
||||||
|
LatestData(title, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestData
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
||||||
|
val rows =
|
||||||
|
latestData.mapNotNull { (title, request) ->
|
||||||
|
try {
|
||||||
|
val latest =
|
||||||
|
api.userLibraryApi
|
||||||
|
.getLatestMedia(request)
|
||||||
|
.content
|
||||||
|
.map { BaseItem.from(it, api, true) }
|
||||||
|
if (latest.isNotEmpty()) {
|
||||||
|
HomeRowLoadingState.Success(
|
||||||
|
title = title,
|
||||||
|
items = latest,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception fetching %s", title)
|
||||||
|
HomeRowLoadingState.Error(
|
||||||
|
title = title,
|
||||||
|
exception = ex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun buildCombined(
|
||||||
|
resume: List<BaseItem>,
|
||||||
|
nextUp: List<BaseItem>,
|
||||||
|
): List<BaseItem> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
val semaphore = Semaphore(3)
|
||||||
|
val deferred =
|
||||||
|
nextUp
|
||||||
|
.filter { it.data.seriesId != null }
|
||||||
|
.map { item ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
semaphore.withPermit {
|
||||||
|
datePlayedService.getLastPlayed(item)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching %s", item.id)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextUpLastPlayed = deferred.awaitAll()
|
||||||
|
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
||||||
|
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
||||||
|
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
||||||
|
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
||||||
|
val duration = (System.currentTimeMillis() - start).milliseconds
|
||||||
|
Timber.v("buildCombined took %s", duration)
|
||||||
|
return@withContext result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,9 +68,20 @@ class NavigationManager
|
||||||
log()
|
log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the backstack to the specified destination
|
||||||
|
*/
|
||||||
|
fun replace(destination: Destination) {
|
||||||
|
while (backStack.size > 1) {
|
||||||
|
backStack.removeLastOrNull()
|
||||||
|
}
|
||||||
|
backStack[0] = destination
|
||||||
|
log()
|
||||||
|
}
|
||||||
|
|
||||||
private fun log() {
|
private fun log() {
|
||||||
val dest = backStack.lastOrNull().toString()
|
val dest = backStack.lastOrNull().toString()
|
||||||
Timber.Forest.i("Current Destination: %s", dest)
|
Timber.i("Current Destination: %s", dest)
|
||||||
ACRA.errorReporter.putCustomData("destination", dest)
|
ACRA.errorReporter.putCustomData("destination", dest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.github.damontecres.wholphin.services.tvprovider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.await
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
@ActivityScoped
|
||||||
|
class TvProviderSchedulerService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ActivityContext private val context: Context,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
) {
|
||||||
|
private val activity = (context as AppCompatActivity)
|
||||||
|
private val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
private val supportsTvProvider =
|
||||||
|
// TODO <=25 has limited support
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
|
||||||
|
|
||||||
|
init {
|
||||||
|
serverRepository.current.observe(activity) { user ->
|
||||||
|
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||||
|
if (supportsTvProvider) {
|
||||||
|
if (user != null) {
|
||||||
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
|
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||||
|
workManager
|
||||||
|
.enqueueUniquePeriodicWork(
|
||||||
|
uniqueWorkName = TvProviderWorker.WORK_NAME,
|
||||||
|
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
request =
|
||||||
|
PeriodicWorkRequestBuilder<TvProviderWorker>(
|
||||||
|
repeatInterval = 1.hours.toJavaDuration(),
|
||||||
|
).setBackoffCriteria(
|
||||||
|
BackoffPolicy.LINEAR,
|
||||||
|
15.minutes.toJavaDuration(),
|
||||||
|
).setInputData(
|
||||||
|
workDataOf(
|
||||||
|
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||||
|
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||||
|
),
|
||||||
|
).build(),
|
||||||
|
).await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchOneTimeRefresh() {
|
||||||
|
if (supportsTvProvider) {
|
||||||
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
|
serverRepository.current.value?.let { user ->
|
||||||
|
Timber.i("Scheduling on-time TvProviderWorker for ${user.user}")
|
||||||
|
workManager.enqueue(
|
||||||
|
OneTimeWorkRequestBuilder<TvProviderWorker>()
|
||||||
|
.setInputData(
|
||||||
|
workDataOf(
|
||||||
|
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||||
|
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||||
|
),
|
||||||
|
).build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
package com.github.damontecres.wholphin.services.tvprovider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.database.Cursor
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
||||||
|
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.github.damontecres.wholphin.MainActivity
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@HiltWorker
|
||||||
|
class TvProviderWorker
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@Assisted private val context: Context,
|
||||||
|
@Assisted workerParams: WorkerParameters,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val preferences: DataStore<AppPreferences>,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val latestNextUpService: LatestNextUpService,
|
||||||
|
private val imageUrlService: ImageUrlService,
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
Timber.d("Start")
|
||||||
|
val serverId =
|
||||||
|
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||||
|
val userId =
|
||||||
|
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
|
||||||
|
|
||||||
|
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||||
|
// Not active
|
||||||
|
var currentUser = serverRepository.current.value
|
||||||
|
if (currentUser == null) {
|
||||||
|
serverRepository.restoreSession(serverId, userId)
|
||||||
|
currentUser = serverRepository.current.value
|
||||||
|
}
|
||||||
|
if (currentUser == null) {
|
||||||
|
Timber.w("No user found during run")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
|
val potentialItemsToAdd =
|
||||||
|
getPotentialItems(
|
||||||
|
userId,
|
||||||
|
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||||
|
prefs.homePagePreferences.combineContinueNext,
|
||||||
|
)
|
||||||
|
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||||
|
|
||||||
|
Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds)
|
||||||
|
val currentItems = getCurrentTvChannelNextUp()
|
||||||
|
val currentItemIds = currentItems.map { it.internalProviderId }
|
||||||
|
|
||||||
|
val toRemove =
|
||||||
|
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
|
||||||
|
|
||||||
|
val userRemoved = currentItems.filterNot { it.isBrowsable }
|
||||||
|
val userRemovedIds = userRemoved.map { it.internalProviderId }
|
||||||
|
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
|
||||||
|
val toAdd =
|
||||||
|
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds }
|
||||||
|
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
|
||||||
|
|
||||||
|
// Remove existing items if they are no longer in the next up from server
|
||||||
|
(toRemove + userRemoved)
|
||||||
|
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
|
||||||
|
.forEach {
|
||||||
|
context.contentResolver.delete(it, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new ones
|
||||||
|
val addedCount =
|
||||||
|
context.contentResolver.bulkInsert(
|
||||||
|
WatchNextPrograms.CONTENT_URI,
|
||||||
|
toAdd
|
||||||
|
.map { convert(it).toContentValues() }
|
||||||
|
.toTypedArray(),
|
||||||
|
)
|
||||||
|
Timber.v("Added %s", addedCount)
|
||||||
|
|
||||||
|
Timber.d("Completed successfully")
|
||||||
|
} catch (_: ApiClientException) {
|
||||||
|
return Result.retry()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getPotentialItems(
|
||||||
|
userId: UUID,
|
||||||
|
enableRewatching: Boolean,
|
||||||
|
combineContinueNext: Boolean,
|
||||||
|
): List<BaseItem> {
|
||||||
|
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||||
|
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
|
||||||
|
val nextUpItems =
|
||||||
|
latestNextUpService
|
||||||
|
.getNextUp(userId, 10, enableRewatching, false)
|
||||||
|
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||||
|
return if (combineContinueNext) {
|
||||||
|
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||||
|
} else {
|
||||||
|
resumeItems + nextUpItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||||
|
context.contentResolver
|
||||||
|
.query(
|
||||||
|
WatchNextPrograms.CONTENT_URI,
|
||||||
|
WatchNextProgram.PROJECTION,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.map(WatchNextProgram::fromCursor)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
private fun convert(item: BaseItem): WatchNextProgram =
|
||||||
|
WatchNextProgram
|
||||||
|
.Builder()
|
||||||
|
.apply {
|
||||||
|
val dto = item.data
|
||||||
|
setInternalProviderId(item.id.toString())
|
||||||
|
|
||||||
|
val type =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||||
|
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||||
|
else -> WatchNextPrograms.TYPE_CLIP
|
||||||
|
}
|
||||||
|
setType(type)
|
||||||
|
|
||||||
|
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
|
||||||
|
if (resumePosition != null && resumePosition >= 2.minutes) {
|
||||||
|
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
|
||||||
|
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||||
|
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
|
||||||
|
} else {
|
||||||
|
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||||
|
}
|
||||||
|
dto.runTimeTicks
|
||||||
|
?.ticks
|
||||||
|
?.inWholeMilliseconds
|
||||||
|
?.toInt()
|
||||||
|
?.let(::setDurationMillis)
|
||||||
|
|
||||||
|
setLastEngagementTimeUtcMillis(
|
||||||
|
dto.userData
|
||||||
|
?.lastPlayedDate
|
||||||
|
?.atZone(ZoneId.systemDefault())
|
||||||
|
?.toEpochSecond()
|
||||||
|
?: Date().time, // TODO
|
||||||
|
)
|
||||||
|
|
||||||
|
setTitle(item.title)
|
||||||
|
setDescription(dto.overview)
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
setEpisodeTitle(item.name)
|
||||||
|
dto.indexNumber?.let(::setEpisodeNumber)
|
||||||
|
dto.parentIndexNumber?.let(::setSeasonNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
|
||||||
|
val imageType =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.EPISODE -> ImageType.THUMB
|
||||||
|
else -> ImageType.PRIMARY
|
||||||
|
}
|
||||||
|
setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri())
|
||||||
|
|
||||||
|
setIntent(
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||||
|
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||||
|
.apply {
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SERIES_ID,
|
||||||
|
dto.seriesId?.toString(),
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SEASON_ID,
|
||||||
|
dto.seasonId?.toString(),
|
||||||
|
)
|
||||||
|
dto.parentIndexNumber?.let {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_SEASON_NUMBER,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dto.indexNumber?.let {
|
||||||
|
putExtra(
|
||||||
|
MainActivity.INTENT_EPISODE_NUMBER,
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
|
||||||
|
const val PARAM_USER_ID = "userId"
|
||||||
|
const val PARAM_SERVER_ID = "serverId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Cursor.map(transform: (Cursor) -> T): List<T> =
|
||||||
|
this.use {
|
||||||
|
buildList {
|
||||||
|
if (moveToFirst()) {
|
||||||
|
do {
|
||||||
|
add(transform.invoke(this@map))
|
||||||
|
} while (moveToNext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -101,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,
|
||||||
|
|
@ -113,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),
|
||||||
|
|
@ -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) }
|
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -156,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,
|
||||||
|
|
@ -317,38 +181,3 @@ data class LatestData(
|
||||||
val title: String,
|
val title: String,
|
||||||
val request: GetLatestMediaRequest,
|
val request: GetLatestMediaRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun buildCombinedNextUp(
|
|
||||||
scope: CoroutineScope,
|
|
||||||
datePlayedService: DatePlayedService,
|
|
||||||
resume: List<BaseItem>,
|
|
||||||
nextUp: List<BaseItem>,
|
|
||||||
): List<BaseItem> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val start = System.currentTimeMillis()
|
|
||||||
val semaphore = Semaphore(3)
|
|
||||||
val deferred =
|
|
||||||
nextUp
|
|
||||||
.filter { it.data.seriesId != null }
|
|
||||||
.map { item ->
|
|
||||||
scope.async(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
semaphore.withPermit {
|
|
||||||
datePlayedService.getLastPlayed(item)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Error fetching %s", item.id)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextUpLastPlayed = deferred.awaitAll()
|
|
||||||
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
|
||||||
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
|
||||||
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
|
||||||
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
|
||||||
val duration = (System.currentTimeMillis() - start).milliseconds
|
|
||||||
Timber.v("buildCombined took %s", duration)
|
|
||||||
return@withContext result
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ class ApplicationContentViewModel
|
||||||
fun ApplicationContent(
|
fun ApplicationContent(
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
|
startDestination: Destination,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -80,7 +81,7 @@ fun ApplicationContent(
|
||||||
user,
|
user,
|
||||||
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
|
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
|
||||||
) {
|
) {
|
||||||
NavBackStack(Destination.Home())
|
NavBackStack(startDestination)
|
||||||
}
|
}
|
||||||
navigationManager.backStack = backStack
|
navigationManager.backStack = backStack
|
||||||
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ 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"
|
||||||
ksp = "2.3.0"
|
ksp = "2.3.0"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
|
|
@ -33,6 +35,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]
|
||||||
|
|
@ -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 = { 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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue