diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 650d79a51..62cec607b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -277,6 +277,14 @@ dependencies {
// Bundle real crypto-js (JS) for QuickJS plugins
implementation("org.webjars.npm:crypto-js:4.2.0")
+ // TV Recommendations (Home Screen Channels)
+ implementation(libs.androidx.tvprovider)
+
+ // WorkManager (periodic recommendation sync)
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.hilt.work.compiler)
+
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation("junit:junit:4.13.2")
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 4a1c9c0a5..dc441b99e 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -87,3 +87,15 @@
# Keep line numbers for crash reports
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
+
+# ── TV Recommendations (WorkManager + TvProvider) ─────────────────────────────
+# Keep HiltWorker-annotated classes
+-keep class com.nuvio.tv.data.worker.** { *; }
+# Keep BroadcastReceiver for INITIALIZE_PROGRAMS
+-keep class com.nuvio.tv.core.recommendations.RecommendationReceiver { *; }
+# Keep TvProvider / TvContractCompat classes
+-keep class androidx.tvprovider.** { *; }
+-dontwarn androidx.tvprovider.**
+# Keep WorkManager + Hilt integration
+-keep class androidx.work.** { *; }
+-keep class androidx.hilt.work.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cba12cfea..ca7d9068b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/nuvio/tv/MainActivity.kt b/app/src/main/java/com/nuvio/tv/MainActivity.kt
index 53831b5f2..6ecc9b40a 100644
--- a/app/src/main/java/com/nuvio/tv/MainActivity.kt
+++ b/app/src/main/java/com/nuvio/tv/MainActivity.kt
@@ -2,6 +2,8 @@ package com.nuvio.tv
import android.os.Bundle
import android.content.Context
+import android.content.Intent
+import android.net.Uri
import android.content.res.Configuration
import android.util.Log
import androidx.compose.ui.platform.LocalView
@@ -132,6 +134,7 @@ import kotlinx.coroutines.launch
import coil.compose.rememberAsyncImagePainter
import coil.decode.SvgDecoder
import coil.request.ImageRequest
+import com.nuvio.tv.core.recommendations.RecommendationConstants
import androidx.compose.ui.res.stringResource
import com.nuvio.tv.R
@@ -180,6 +183,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore
+ /** Deep link URI queued before the NavController is ready. */
+ private var pendingDeepLink = mutableStateOf(null)
+
private lateinit var jankStats: JankStats
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@@ -201,6 +207,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
+ handleRecommendationDeepLink(intent)
setContent {
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
@@ -349,6 +356,15 @@ class MainActivity : ComponentActivity() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
+ // Consume deep link from TV recommendation cards
+ val deepLinkUri by pendingDeepLink
+ LaunchedEffect(deepLinkUri) {
+ deepLinkUri?.let { uri ->
+ consumeDeepLink(uri, navController)
+ pendingDeepLink.value = null
+ }
+ }
+
val view = LocalView.current
LaunchedEffect(currentRoute) {
val holder = PerformanceMetricsState.getHolderForHierarchy(view)
@@ -494,6 +510,64 @@ class MainActivity : ComponentActivity() {
traktProgressService.refreshNow()
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleRecommendationDeepLink(intent)
+ }
+
+ /**
+ * Parses a `nuviotv://content/…` deep link from a TV recommendation card
+ * and queues it for consumption once the NavController is ready.
+ */
+ private fun handleRecommendationDeepLink(intent: Intent?) {
+ val uri = intent?.data ?: return
+ if (uri.scheme != RecommendationConstants.DEEP_LINK_SCHEME) return
+ pendingDeepLink.value = uri
+ }
+
+ /**
+ * Processes a queued deep link URI and navigates accordingly.
+ * Call from a Composable scope that has the NavController.
+ */
+ private fun consumeDeepLink(uri: Uri, navController: NavHostController) {
+ val pathSegments = uri.pathSegments ?: return
+ val action = pathSegments.getOrNull(0) ?: return
+ val contentId = pathSegments.getOrNull(1) ?: return
+ val contentType = uri.getQueryParameter(RecommendationConstants.PARAM_CONTENT_TYPE) ?: "movie"
+
+ when (action) {
+ RecommendationConstants.DEEP_LINK_PATH_DETAIL -> {
+ val route = Screen.Detail.createRoute(
+ itemId = contentId,
+ itemType = contentType
+ )
+ navController.navigate(route)
+ }
+ RecommendationConstants.DEEP_LINK_PATH_PLAY -> {
+ val videoId = uri.getQueryParameter(RecommendationConstants.PARAM_VIDEO_ID) ?: contentId
+ val season = uri.getQueryParameter(RecommendationConstants.PARAM_SEASON)?.toIntOrNull()
+ val episode = uri.getQueryParameter(RecommendationConstants.PARAM_EPISODE)?.toIntOrNull()
+ val name = uri.getQueryParameter(RecommendationConstants.PARAM_NAME)
+ val poster = uri.getQueryParameter(RecommendationConstants.PARAM_POSTER)
+ val backdrop = uri.getQueryParameter(RecommendationConstants.PARAM_BACKDROP)
+
+ val route = Screen.Stream.createRoute(
+ videoId = videoId,
+ contentType = contentType,
+ title = name ?: contentId,
+ poster = poster,
+ backdrop = backdrop,
+ season = season,
+ episode = episode,
+ contentId = contentId,
+ contentName = name
+ )
+ navController.navigate(route)
+ }
+ else -> { /* Unknown deep link action */ }
+ }
+ }
}
@OptIn(ExperimentalTvMaterial3Api::class)
diff --git a/app/src/main/java/com/nuvio/tv/NuvioApplication.kt b/app/src/main/java/com/nuvio/tv/NuvioApplication.kt
index 1843e46ea..4f02d4a71 100644
--- a/app/src/main/java/com/nuvio/tv/NuvioApplication.kt
+++ b/app/src/main/java/com/nuvio/tv/NuvioApplication.kt
@@ -1,22 +1,48 @@
package com.nuvio.tv
import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
+import com.nuvio.tv.core.recommendations.RecommendationConstants
+import com.nuvio.tv.core.recommendations.RecommendationDataStore
+import com.nuvio.tv.core.recommendations.TvRecommendationManager
import com.nuvio.tv.core.sync.StartupSyncService
+import com.nuvio.tv.data.worker.TvRecommendationWorker
import dagger.hilt.android.HiltAndroidApp
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidApp
-class NuvioApplication : Application(), ImageLoaderFactory {
+class NuvioApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@Inject lateinit var startupSyncService: StartupSyncService
+ @Inject lateinit var workerFactory: HiltWorkerFactory
+ @Inject lateinit var tvRecommendationManager: TvRecommendationManager
+ @Inject lateinit var recommendationDataStore: RecommendationDataStore
+
+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
override fun onCreate() {
super.onCreate()
+ initializeTvRecommendations()
}
override fun newImageLoader(): ImageLoader {
@@ -39,4 +65,46 @@ class NuvioApplication : Application(), ImageLoaderFactory {
.crossfade(false)
.build()
}
+
+ // ── TV Home Screen Recommendations ──
+
+ private fun initializeTvRecommendations() {
+ // Create channels asynchronously — no-op on non-TV devices
+ appScope.launch {
+ try {
+ tvRecommendationManager.initializeChannels()
+ } catch (_: Exception) {
+ }
+ }
+
+ // Schedule periodic background sync
+ scheduleRecommendationSync()
+ }
+
+ private fun scheduleRecommendationSync() {
+ appScope.launch {
+ recommendationDataStore.syncIntervalHoursFlow.collect { intervalHours ->
+ val workManager = WorkManager.getInstance(this@NuvioApplication)
+ val workName = RecommendationConstants.WORK_NAME_PERIODIC_SYNC
+
+ if (intervalHours <= 0) {
+ workManager.cancelUniqueWork(workName)
+ } else {
+ val workRequest = PeriodicWorkRequestBuilder(
+ intervalHours.toLong(), TimeUnit.HOURS
+ ).setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ ).build()
+
+ workManager.enqueueUniquePeriodicWork(
+ workName,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ workRequest
+ )
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/ChannelManager.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/ChannelManager.kt
new file mode 100644
index 000000000..ee9cafaca
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/ChannelManager.kt
@@ -0,0 +1,254 @@
+package com.nuvio.tv.core.recommendations
+
+import android.content.ContentUris
+import android.content.Context
+import android.database.Cursor
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.net.Uri
+import androidx.core.content.ContextCompat
+import androidx.tvprovider.media.tv.Channel
+import androidx.tvprovider.media.tv.ChannelLogoUtils
+import androidx.tvprovider.media.tv.PreviewProgram
+import androidx.tvprovider.media.tv.TvContractCompat
+import com.nuvio.tv.R
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Manages the lifecycle of TV recommendation channels (create / query / delete)
+ * and the preview programs within each channel.
+ */
+@Singleton
+class ChannelManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val dataStore: RecommendationDataStore
+) {
+
+ // ────────────────────────────────────────────────────────────────
+ // Channel operations
+ // ────────────────────────────────────────────────────────────────
+
+ /**
+ * Creates a channel on the TV launcher if it doesn't already exist.
+ * Returns the channel ID (from ContentProvider) or `null` on failure.
+ */
+ suspend fun getOrCreateChannel(
+ internalId: String,
+ displayName: String
+ ): Long? {
+ // 1. Check cached ID first
+ val cachedId = dataStore.getChannelId(internalId)
+ if (cachedId != null && channelExists(cachedId)) {
+ return cachedId
+ }
+
+ // 2. Search the provider for a channel we previously inserted
+ val existingId = findChannelByInternalId(internalId)
+ if (existingId != null) {
+ dataStore.setChannelId(internalId, existingId)
+ return existingId
+ }
+
+ // 3. Insert a brand-new channel
+ return try {
+ val channel = Channel.Builder()
+ .setType(TvContractCompat.Channels.TYPE_PREVIEW)
+ .setDisplayName(displayName)
+ .setAppLinkIntentUri(
+ Uri.parse("${RecommendationConstants.DEEP_LINK_SCHEME}://${RecommendationConstants.DEEP_LINK_HOST}/$internalId")
+ )
+ .setInternalProviderId(internalId)
+ .build()
+
+ val channelUri = context.contentResolver.insert(
+ TvContractCompat.Channels.CONTENT_URI,
+ channel.toContentValues()
+ )
+
+ if (channelUri == null) {
+ return null
+ }
+
+ val channelId = ContentUris.parseId(channelUri)
+ dataStore.setChannelId(internalId, channelId)
+
+ // Store a channel logo so the launcher can distinguish channels visually.
+ storeChannelLogo(channelId)
+
+ // Request the system to make this channel visible on the home screen.
+ // On first call the user gets a prompt; subsequent calls are no-ops.
+ try {
+ val intent = android.content.Intent(TvContractCompat.ACTION_REQUEST_CHANNEL_BROWSABLE)
+ intent.putExtra(TvContractCompat.EXTRA_CHANNEL_ID, channelId)
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ } catch (_: Exception) {
+ }
+
+ channelId
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Deletes all programs inside a channel so we can insert a fresh set.
+ */
+ fun clearProgramsForChannel(channelId: Long) {
+ try {
+ val uri = TvContractCompat.buildPreviewProgramsUriForChannel(channelId)
+ context.contentResolver.delete(uri, null, null)
+ } catch (_: Exception) {
+ }
+ }
+
+ /**
+ * Inserts a list of [PreviewProgram]s into a channel via bulk insert.
+ */
+ fun insertPrograms(programs: List) {
+ if (programs.isEmpty()) return
+ try {
+ val values = programs.map { it.toContentValues() }.toTypedArray()
+ context.contentResolver.bulkInsert(
+ TvContractCompat.PreviewPrograms.CONTENT_URI,
+ values
+ )
+ } catch (_: Exception) {
+ }
+ }
+
+ /**
+ * Deletes the channel and removes its cached id.
+ */
+ suspend fun deleteChannel(internalId: String) {
+ val channelId = dataStore.getChannelId(internalId) ?: return
+ try {
+ val uri = TvContractCompat.buildChannelUri(channelId)
+ context.contentResolver.delete(uri, null, null)
+ dataStore.clearChannelId(internalId)
+ } catch (_: Exception) {
+ }
+ }
+
+ /**
+ * Resolves the real internal channel ID without creating it.
+ */
+ suspend fun getChannelId(internalId: String): Long? {
+ val cachedId = dataStore.getChannelId(internalId)
+ if (cachedId != null && channelExists(cachedId)) {
+ return cachedId
+ }
+ val existingId = findChannelByInternalId(internalId)
+ if (existingId != null) {
+ dataStore.setChannelId(internalId, existingId)
+ return existingId
+ }
+ return null
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Helpers
+ // ────────────────────────────────────────────────────────────────
+
+ private fun channelExists(channelId: Long): Boolean {
+ var cursor: Cursor? = null
+ return try {
+ cursor = context.contentResolver.query(
+ TvContractCompat.buildChannelUri(channelId),
+ arrayOf(TvContractCompat.Channels._ID),
+ null, null, null
+ )
+ cursor != null && cursor.count > 0
+ } catch (e: Exception) {
+ false
+ } finally {
+ cursor?.close()
+ }
+ }
+
+ /**
+ * Stores the app launcher icon as the channel logo.
+ * Each channel gets its own copy so the launcher treats them as distinct.
+ */
+ private fun storeChannelLogo(channelId: Long) {
+ try {
+ val drawable = ContextCompat.getDrawable(context, R.drawable.ic_launcher) ?: return
+ val bitmap = if (drawable is android.graphics.drawable.BitmapDrawable) {
+ drawable.bitmap
+ } else {
+ val bmp = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(1),
+ drawable.intrinsicHeight.coerceAtLeast(1),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bmp)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ bmp
+ }
+ ChannelLogoUtils.storeChannelLogo(context, channelId, bitmap)
+ } catch (_: Exception) {
+ }
+ }
+
+ suspend fun cleanupLegacyChannels(validIds: List) {
+ var cursor: Cursor? = null
+ try {
+ cursor = context.contentResolver.query(
+ TvContractCompat.Channels.CONTENT_URI,
+ arrayOf(
+ TvContractCompat.Channels._ID,
+ TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_ID
+ ),
+ null, null, null
+ )
+ cursor?.let {
+ while (it.moveToNext()) {
+ val idIndex = it.getColumnIndex(TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_ID)
+ val channelIdIndex = it.getColumnIndex(TvContractCompat.Channels._ID)
+ if (idIndex >= 0 && channelIdIndex >= 0) {
+ val internalId = it.getString(idIndex)
+ if (internalId !in validIds) {
+ val channelId = it.getLong(channelIdIndex)
+ context.contentResolver.delete(TvContractCompat.buildChannelUri(channelId), null, null)
+ try { dataStore.clearChannelId(internalId) } catch (_: Exception) {}
+ }
+ }
+ }
+ }
+ } catch (_: Exception) {
+ } finally {
+ cursor?.close()
+ }
+ }
+
+ private fun findChannelByInternalId(internalId: String): Long? {
+ var cursor: Cursor? = null
+ return try {
+ cursor = context.contentResolver.query(
+ TvContractCompat.Channels.CONTENT_URI,
+ arrayOf(
+ TvContractCompat.Channels._ID,
+ TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_ID
+ ),
+ null, null, null
+ )
+ cursor?.let {
+ while (it.moveToNext()) {
+ val idIndex = it.getColumnIndex(TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_ID)
+ if (idIndex >= 0 && it.getString(idIndex) == internalId) {
+ val channelIdIndex = it.getColumnIndex(TvContractCompat.Channels._ID)
+ if (channelIdIndex >= 0) return it.getLong(channelIdIndex)
+ }
+ }
+ }
+ null
+ } catch (_: Exception) {
+ null
+ } finally {
+ cursor?.close()
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/ExportedImageProvider.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/ExportedImageProvider.kt
new file mode 100644
index 000000000..48c7234dc
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/ExportedImageProvider.kt
@@ -0,0 +1,32 @@
+package com.nuvio.tv.core.recommendations
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import java.io.File
+import java.io.FileNotFoundException
+
+class ExportedImageProvider : ContentProvider() {
+ override fun onCreate(): Boolean = true
+
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null
+ override fun getType(uri: Uri): String = "image/jpeg"
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
+
+ @Throws(FileNotFoundException::class)
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ val fileName = uri.lastPathSegment ?: throw FileNotFoundException()
+ if (fileName.contains("..") || fileName.contains("/")) throw FileNotFoundException()
+
+ val cacheDir = context?.cacheDir ?: throw FileNotFoundException()
+ val tvProgressDir = File(cacheDir, "tv_progress")
+ val file = File(tvProgressDir, fileName)
+
+ if (!file.exists()) throw FileNotFoundException(file.absolutePath)
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/ProgramBuilder.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/ProgramBuilder.kt
new file mode 100644
index 000000000..490ad69fd
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/ProgramBuilder.kt
@@ -0,0 +1,545 @@
+package com.nuvio.tv.core.recommendations
+
+import android.content.ContentUris
+import android.content.Context
+import android.net.Uri
+import androidx.core.graphics.drawable.toBitmap
+import androidx.tvprovider.media.tv.PreviewProgram
+import androidx.tvprovider.media.tv.TvContractCompat
+import androidx.tvprovider.media.tv.WatchNextProgram
+import com.nuvio.tv.domain.model.MetaPreview
+import com.nuvio.tv.domain.model.PosterShape
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.ui.screens.home.NextUpInfo
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Builds [PreviewProgram] and [WatchNextProgram] instances from domain models.
+ */
+@Singleton
+class ProgramBuilder @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ // ────────────────────────────────────────────────────────────────
+ // Continue Watching → PreviewProgram
+ // ────────────────────────────────────────────────────────────────
+
+ suspend fun buildContinueWatchingProgram(
+ channelId: Long,
+ progress: WatchProgress
+ ): PreviewProgram = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ val isMovie = progress.contentType == "movie"
+ val programType = if (isMovie) {
+ TvContractCompat.PreviewPrograms.TYPE_MOVIE
+ } else {
+ TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE
+ }
+
+ var description = if (!isMovie && progress.season != null && progress.episode != null) {
+ buildString {
+ append("S${progress.season}E${progress.episode}")
+ progress.episodeTitle?.let { append(" · $it") }
+ }
+ } else {
+ ""
+ }
+
+ // Android TV Launcher explicitly hides the visual red progress bar for PreviewPrograms
+ // (it's restricted to WatchNextPrograms). We inject a textual progress indicator here instead.
+ if (progress.duration > 0) {
+ val percent = (progress.position.toFloat() / progress.duration * 100).toInt().coerceIn(0, 100)
+ val remainingMs = progress.duration - progress.position
+ val remainingMin = (remainingMs / 60000).coerceAtLeast(1)
+
+ val progressInfo = if (remainingMin > 0 && percent < 95) {
+ "▶ %$percent (${remainingMin}m)"
+ } else {
+ "▶ %$percent"
+ }
+ description = if (description.isEmpty()) progressInfo else "$description • $progressInfo"
+ }
+
+ val builder = PreviewProgram.Builder()
+ .setChannelId(channelId)
+ .setType(programType)
+ .setTitle(progress.name)
+ .setInternalProviderId("cw_${progress.contentId}_${progress.videoId}")
+ .setIntentUri(buildPlayUri(progress))
+ .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
+ .setLive(false)
+
+ if (description.isNotEmpty()) {
+ builder.setDescription(description)
+ }
+
+ // Play Next row natively presents horizontal (16:9) backdrop cards.
+ val horizontalArt = progress.backdrop ?: progress.poster
+ var finalArtUri: Uri? = null
+ if (horizontalArt != null && progress.duration > 0) {
+ val file = createProgressImage(horizontalArt, progress)
+ if (file != null) {
+ finalArtUri = Uri.parse("content://${context.packageName}.tvimages/${file.name}")
+ }
+ }
+
+ if (finalArtUri == null && horizontalArt != null) {
+ finalArtUri = Uri.parse(horizontalArt)
+ }
+ finalArtUri?.let { builder.setPosterArtUri(it) }
+ progress.poster?.let { builder.setThumbnailUri(Uri.parse(it)) }
+
+ if (!isMovie) {
+ progress.season?.let { builder.setSeasonNumber(it) }
+ progress.episode?.let { builder.setEpisodeNumber(it) }
+ progress.episodeTitle?.let { builder.setEpisodeTitle(it) }
+ }
+
+ // We do not set position/duration here because otherwise
+ // third-party Android TV launchers (or even Google TV) will render their
+ // own native red progress bars, which conflict with our canvas-drawn ones.
+
+ return@withContext builder.build()
+ }
+
+ private suspend fun createProgressImage(url: String, progress: WatchProgress): java.io.File? {
+ try {
+ val loader = coil.ImageLoader(context)
+ val request = coil.request.ImageRequest.Builder(context)
+ .data(url)
+ .allowHardware(false)
+ .build()
+
+ val result = loader.execute(request)
+ if (result is coil.request.SuccessResult) {
+ val dr = result.drawable
+ val original = if (dr is android.graphics.drawable.BitmapDrawable) {
+ dr.bitmap
+ } else {
+ val fallback = android.graphics.Bitmap.createBitmap(dr.intrinsicWidth.coerceAtLeast(1), dr.intrinsicHeight.coerceAtLeast(1), android.graphics.Bitmap.Config.ARGB_8888)
+ val canvasFallback = android.graphics.Canvas(fallback)
+ dr.setBounds(0, 0, canvasFallback.width, canvasFallback.height)
+ dr.draw(canvasFallback)
+ fallback
+ }
+ val bitmap = original.copy(android.graphics.Bitmap.Config.ARGB_8888, true)
+ val canvas = android.graphics.Canvas(bitmap)
+ val w = bitmap.width.toFloat()
+ val h = bitmap.height.toFloat()
+
+ val pct = (progress.position.toFloat() / progress.duration).coerceIn(0f, 1f)
+ val marginX = w * 0.04f // slightly larger horizontal padding
+ val marginBottom = h * 0.04f // pushed up slightly more from the bottom
+ val barHeight = h * 0.035f // noticeably thicker height
+ val left = marginX
+ val right = w - marginX
+ val bottom = h - marginBottom
+ val top = bottom - barHeight
+ val radius = barHeight / 2f
+
+ // --------- Draw bottom gradient overlay for readability ---------
+ val gradientBgParams = intArrayOf(
+ android.graphics.Color.TRANSPARENT,
+ android.graphics.Color.parseColor("#B3000000"), // 70% black
+ android.graphics.Color.parseColor("#F2000000") // 95% black
+ )
+ val gradientPositions = floatArrayOf(0f, 0.5f, 1f)
+
+ val shadowPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
+ shader = android.graphics.LinearGradient(
+ 0f, h * 0.6f,
+ 0f, h,
+ gradientBgParams, gradientPositions,
+ android.graphics.Shader.TileMode.CLAMP
+ )
+ }
+ canvas.drawRect(0f, h * 0.6f, w, h, shadowPaint)
+
+ val bgPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
+ color = android.graphics.Color.parseColor("#4D000000") // Black 30% alpha
+ style = android.graphics.Paint.Style.FILL
+ }
+ canvas.drawRoundRect(left, top, right, bottom, radius, radius, bgPaint)
+
+ val fgPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
+ color = (0xFF9E9E9E).toInt() // NuvioColors.Primary
+ style = android.graphics.Paint.Style.FILL
+ }
+
+ // Draw clipped right edge for progress rect
+ val progressRight = left + (right - left) * pct
+ // Ensure a minimum width so radius can be drawn without visual bugs
+ if (progressRight > left + radius) {
+ canvas.drawRoundRect(left, top, progressRight, bottom, radius, radius, fgPaint)
+ }
+
+ // --------- Draw "41m left" badge in top right ---------
+ val remainingMs = progress.duration - progress.position
+ val totalMinutes = java.util.concurrent.TimeUnit.MILLISECONDS.toMinutes(remainingMs)
+ val hours = totalMinutes / 60
+ val minutes = totalMinutes % 60
+
+ val badgeText = when {
+ hours > 0 -> context.getString(com.nuvio.tv.R.string.cw_hours_min_left, hours, minutes)
+ minutes > 0 -> context.getString(com.nuvio.tv.R.string.cw_min_left, minutes)
+ else -> context.getString(com.nuvio.tv.R.string.cw_almost_done)
+ }
+
+ val textPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
+ color = (0xFFEAEAEA).toInt() // NuvioColors.TextPrimary approx
+ textSize = h * 0.057f // Small label text size
+ typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
+ }
+
+ val textBounds = android.graphics.Rect()
+ textPaint.getTextBounds(badgeText, 0, badgeText.length, textBounds)
+
+ // Match Compose padding: `padding(horizontal = 8.dp, vertical = 4.dp)`
+ val badgePadX = w * 0.02f // Approx 8dp
+ val badgePadY = w * 0.01f // Approx 4dp
+
+ // Match Compose margin: `padding(8.dp)` outside
+ val badgeMargin = w * 0.02f
+ val badgeRight = w - badgeMargin
+ val badgeTop = badgeMargin
+
+ val badgeWidth = textBounds.width() + badgePadX * 2
+ val badgeHeight = textBounds.height() + badgePadY * 2
+ val badgeLeft = badgeRight - badgeWidth
+ val badgeBottom = badgeTop + badgeHeight
+
+ val badgeBgPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
+ color = android.graphics.Color.parseColor("#CC141414") // NuvioColors.Background approx with 80% opacity
+ style = android.graphics.Paint.Style.FILL
+ }
+
+ // Match Compose generic badge radius shape: RoundedCornerShape(4.dp)
+ val badgeRadius = w * 0.01f
+ canvas.drawRoundRect(badgeLeft, badgeTop, badgeRight, badgeBottom, badgeRadius, badgeRadius, badgeBgPaint)
+
+ val textX = badgeLeft + badgePadX
+ val textY = badgeBottom - badgePadY - textBounds.bottom
+ canvas.drawText(badgeText, textX, textY, textPaint)
+
+ val pctInt = (pct * 100).toInt()
+ val cacheDir = java.io.File(context.cacheDir, "tv_progress")
+ cacheDir.mkdirs()
+
+ // Cleanup old files for this specific content to avoid bloating storage
+ cacheDir.listFiles { _, name ->
+ name.startsWith("progress_${progress.contentId}_${progress.videoId}")
+ }?.forEach { it.delete() }
+
+ // Add timestamp / pct to filename so Android TV Launcher invalidates its image cache
+ val finalName = "progress_${progress.contentId}_${progress.videoId}_${pctInt}_${System.currentTimeMillis()}.jpg"
+ val outFile = java.io.File(cacheDir, finalName)
+ java.io.FileOutputStream(outFile).use { out ->
+ bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, out)
+ }
+ return outFile
+ }
+ } catch(e: Exception) {
+ e.printStackTrace()
+ }
+ return null
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Next Up → PreviewProgram
+ // ────────────────────────────────────────────────────────────────
+
+ fun buildNextUpProgram(
+ channelId: Long,
+ nextUp: NextUpInfo
+ ): PreviewProgram {
+ val builder = PreviewProgram.Builder()
+ .setChannelId(channelId)
+ .setType(TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE)
+ .setTitle(nextUp.name)
+ .setDescription("S${nextUp.season}E${nextUp.episode}" +
+ (nextUp.episodeTitle?.let { " · $it" } ?: ""))
+ .setSeasonNumber(nextUp.season)
+ .setEpisodeNumber(nextUp.episode)
+ .setInternalProviderId("nu_${nextUp.contentId}_s${nextUp.season}e${nextUp.episode}")
+ .setIntentUri(buildNextUpPlayUri(nextUp))
+ .setLive(false)
+
+ nextUp.episodeTitle?.let { builder.setEpisodeTitle(it) }
+ nextUp.poster?.let { builder.setPosterArtUri(Uri.parse(it)) }
+ (nextUp.thumbnail ?: nextUp.backdrop)?.let { builder.setThumbnailUri(Uri.parse(it)) }
+
+ return builder.build()
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Catalog Item → PreviewProgram
+ // ────────────────────────────────────────────────────────────────
+
+ fun buildTrendingProgram(
+ channelId: Long,
+ item: MetaPreview,
+ useWidePoster: Boolean
+ ): PreviewProgram {
+ val programType = when (item.type.toApiString()) {
+ "series" -> TvContractCompat.PreviewPrograms.TYPE_TV_SERIES
+ else -> TvContractCompat.PreviewPrograms.TYPE_MOVIE
+ }
+
+ val aspectRatio = if (useWidePoster) {
+ TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9
+ } else {
+ when (item.posterShape) {
+ PosterShape.LANDSCAPE -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9
+ PosterShape.SQUARE -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_1_1
+ else -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3
+ }
+ }
+
+ val builder = PreviewProgram.Builder()
+ .setChannelId(channelId)
+ .setType(programType)
+ .setTitle(item.name)
+ .setInternalProviderId("tr_${item.id}")
+ .setIntentUri(buildDetailUri(item.id, item.type.toApiString()))
+ .setPosterArtAspectRatio(aspectRatio)
+ .setLive(false)
+
+ item.description?.let { builder.setDescription(it) }
+
+ if (useWidePoster) {
+ val horizontalArt = item.background ?: item.poster
+ horizontalArt?.let { builder.setPosterArtUri(Uri.parse(it)) }
+ } else {
+ item.poster?.let { builder.setPosterArtUri(Uri.parse(it)) }
+ item.background?.let { builder.setThumbnailUri(Uri.parse(it)) }
+ }
+
+ item.releaseInfo?.let { builder.setReleaseDate(it) }
+ item.genres.firstOrNull()?.let { builder.setGenre(it) }
+
+ return builder.build()
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Watch Next Row (system-managed row)
+ // ────────────────────────────────────────────────────────────────
+
+ fun buildWatchNextProgram(progress: WatchProgress): WatchNextProgram {
+ val isMovie = progress.contentType == "movie"
+ val programType = if (isMovie) {
+ TvContractCompat.WatchNextPrograms.TYPE_MOVIE
+ } else {
+ TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
+ }
+
+ val builder = WatchNextProgram.Builder()
+ .setType(programType)
+ .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
+ .setTitle(progress.name)
+ .setLastEngagementTimeUtcMillis(progress.lastWatched)
+ .setInternalProviderId("wn_${progress.contentId}")
+ .setIntentUri(buildPlayUri(progress))
+ // Play Next row should natively present horizontal (16:9) backdrop cards.
+ builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
+
+ // Prioritize backdrop (which is horizontal) over the vertical poster.
+ val horizontalArt = progress.backdrop ?: progress.poster
+ horizontalArt?.let {
+ // Android TV caches Watch Next images heavily based on URI.
+ // If the user was stuck on the vertical layout, we append a dummy query parameter
+ // to trick the system launcher into fetching and rendering the new horizontal image.
+ val uriWithCacheBuster = Uri.parse(it).buildUpon()
+ .appendQueryParameter("v", "horizontal_fix")
+ .build()
+ builder.setPosterArtUri(uriWithCacheBuster)
+ }
+
+ if (progress.duration > 0) {
+ builder.setLastPlaybackPositionMillis(progress.position.toInt())
+ builder.setDurationMillis(progress.duration.toInt())
+ }
+
+ if (!isMovie) {
+ progress.season?.let { builder.setSeasonNumber(it) }
+ progress.episode?.let { builder.setEpisodeNumber(it) }
+ progress.episodeTitle?.let { builder.setEpisodeTitle(it) }
+ }
+
+ return builder.build()
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Watch Next insert / update helpers
+ // ────────────────────────────────────────────────────────────────
+
+ /**
+ * Adds or updates a program in the system Watch Next row.
+ */
+ fun upsertWatchNextProgram(program: WatchNextProgram, internalId: String) {
+ try {
+ val existingId = findWatchNextByInternalId(internalId)
+ if (existingId != null) {
+ val uri = TvContractCompat.buildWatchNextProgramUri(existingId)
+ context.contentResolver.update(uri, program.toContentValues(), null, null)
+ } else {
+ context.contentResolver.insert(
+ TvContractCompat.WatchNextPrograms.CONTENT_URI,
+ program.toContentValues()
+ )
+ }
+ } catch (_: Exception) {
+ }
+ }
+
+ /**
+ * Removes a program from the Watch Next row by its internal provider id.
+ */
+ fun removeWatchNextProgram(internalId: String) {
+ try {
+ val existingId = findWatchNextByInternalId(internalId) ?: return
+ val uri = TvContractCompat.buildWatchNextProgramUri(existingId)
+ context.contentResolver.delete(uri, null, null)
+ } catch (_: Exception) {
+ }
+ }
+
+ /**
+ * Removes ALL Watch Next programs created by this app (identified by the "wn_" prefix).
+ */
+ fun clearAllWatchNextPrograms() {
+ var cursor: android.database.Cursor? = null
+ try {
+ cursor = context.contentResolver.query(
+ TvContractCompat.WatchNextPrograms.CONTENT_URI,
+ arrayOf(
+ TvContractCompat.WatchNextPrograms._ID,
+ TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
+ ),
+ null, null, null
+ )
+ cursor?.let {
+ while (it.moveToNext()) {
+ val idIdx = it.getColumnIndex(
+ TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
+ )
+ if (idIdx >= 0) {
+ val providerId = it.getString(idIdx)
+ if (providerId?.startsWith("wn_") == true) {
+ val pkIdx = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
+ if (pkIdx >= 0) {
+ val uri = TvContractCompat.buildWatchNextProgramUri(it.getLong(pkIdx))
+ context.contentResolver.delete(uri, null, null)
+ }
+ }
+ }
+ }
+ }
+ } catch (_: Exception) {
+ } finally {
+ cursor?.close()
+ }
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Deep-link URI builders
+ // ────────────────────────────────────────────────────────────────
+
+ private fun buildPlayUri(progress: WatchProgress): Uri =
+ Uri.Builder()
+ .scheme(RecommendationConstants.DEEP_LINK_SCHEME)
+ .authority(RecommendationConstants.DEEP_LINK_HOST)
+ .appendPath(RecommendationConstants.DEEP_LINK_PATH_PLAY)
+ .appendPath(progress.contentId)
+ .appendQueryParameter(RecommendationConstants.PARAM_CONTENT_TYPE, progress.contentType)
+ .appendQueryParameter(RecommendationConstants.PARAM_VIDEO_ID, progress.videoId)
+ .appendQueryParameter(RecommendationConstants.PARAM_NAME, progress.name)
+ .apply {
+ progress.season?.let {
+ appendQueryParameter(RecommendationConstants.PARAM_SEASON, it.toString())
+ }
+ progress.episode?.let {
+ appendQueryParameter(RecommendationConstants.PARAM_EPISODE, it.toString())
+ }
+ appendQueryParameter(
+ RecommendationConstants.PARAM_RESUME_POSITION,
+ progress.position.toString()
+ )
+ progress.poster?.let {
+ appendQueryParameter(RecommendationConstants.PARAM_POSTER, it)
+ }
+ progress.backdrop?.let {
+ appendQueryParameter(RecommendationConstants.PARAM_BACKDROP, it)
+ }
+ }
+ .build()
+
+ private fun buildNextUpPlayUri(nextUp: NextUpInfo): Uri =
+ Uri.Builder()
+ .scheme(RecommendationConstants.DEEP_LINK_SCHEME)
+ .authority(RecommendationConstants.DEEP_LINK_HOST)
+ .appendPath(RecommendationConstants.DEEP_LINK_PATH_PLAY)
+ .appendPath(nextUp.contentId)
+ .appendQueryParameter(RecommendationConstants.PARAM_CONTENT_TYPE, nextUp.contentType)
+ .appendQueryParameter(RecommendationConstants.PARAM_VIDEO_ID, nextUp.videoId)
+ .appendQueryParameter(RecommendationConstants.PARAM_NAME, nextUp.name)
+ .appendQueryParameter(RecommendationConstants.PARAM_SEASON, nextUp.season.toString())
+ .appendQueryParameter(RecommendationConstants.PARAM_EPISODE, nextUp.episode.toString())
+ .apply {
+ nextUp.poster?.let {
+ appendQueryParameter(RecommendationConstants.PARAM_POSTER, it)
+ }
+ (nextUp.thumbnail ?: nextUp.backdrop)?.let {
+ appendQueryParameter(RecommendationConstants.PARAM_BACKDROP, it)
+ }
+ }
+ .build()
+
+ private fun buildDetailUri(contentId: String, type: String): Uri =
+ Uri.Builder()
+ .scheme(RecommendationConstants.DEEP_LINK_SCHEME)
+ .authority(RecommendationConstants.DEEP_LINK_HOST)
+ .appendPath(RecommendationConstants.DEEP_LINK_PATH_DETAIL)
+ .appendPath(contentId)
+ .appendQueryParameter(RecommendationConstants.PARAM_CONTENT_TYPE, type)
+ .build()
+
+ // ────────────────────────────────────────────────────────────────
+ // Watch Next query helper
+ // ────────────────────────────────────────────────────────────────
+
+ private fun findWatchNextByInternalId(internalId: String): Long? {
+ return try {
+ val cursor = context.contentResolver.query(
+ TvContractCompat.WatchNextPrograms.CONTENT_URI,
+ arrayOf(
+ TvContractCompat.WatchNextPrograms._ID,
+ TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
+ ),
+ null,
+ null,
+ null
+ )
+ var foundId: Long? = null
+ cursor?.use {
+ while (it.moveToNext()) {
+ val providerIdIdx = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
+ if (providerIdIdx >= 0) {
+ val currentProviderId = it.getString(providerIdIdx)
+ if (currentProviderId == internalId) {
+ val idIdx = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
+ if (idIdx >= 0) {
+ foundId = it.getLong(idIdx)
+ break
+ }
+ }
+ }
+ }
+ }
+ foundId
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationConstants.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationConstants.kt
new file mode 100644
index 000000000..bebec205e
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationConstants.kt
@@ -0,0 +1,38 @@
+package com.nuvio.tv.core.recommendations
+
+/**
+ * Constants used across the TV Home Screen Recommendations feature.
+ */
+object RecommendationConstants {
+
+ // ── Channel internal IDs (used as internalProviderId for channel lookup) ──
+ // Dynamic catalog channels use their backend ID.
+
+ // ── Channel display names shown on the TV launcher ──
+ // Display names are now fetched dynamically from Addon metadata.
+
+ // ── Deep link URI components ──
+ const val DEEP_LINK_SCHEME = "nuviotv"
+ const val DEEP_LINK_HOST = "content"
+ const val DEEP_LINK_PATH_PLAY = "play"
+ const val DEEP_LINK_PATH_DETAIL = "detail"
+
+ // Query parameter keys used inside deep link URIs
+ const val PARAM_CONTENT_TYPE = "type"
+ const val PARAM_VIDEO_ID = "videoId"
+ const val PARAM_SEASON = "season"
+ const val PARAM_EPISODE = "episode"
+ const val PARAM_RESUME_POSITION = "position"
+ const val PARAM_NAME = "name"
+ const val PARAM_POSTER = "poster"
+ const val PARAM_BACKDROP = "backdrop"
+
+ // ── WorkManager ──
+ const val WORK_NAME_PERIODIC_SYNC = "tv_recommendation_sync"
+ const val SYNC_INTERVAL_MINUTES = 30L
+
+ // ── Item limits per channel ──
+ const val MAX_NEW_RELEASES_ITEMS = 25
+ const val MAX_TRENDING_ITEMS = 20
+ const val MAX_WATCH_NEXT_ITEMS = 10
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationDataStore.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationDataStore.kt
new file mode 100644
index 000000000..c2cb6e24c
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationDataStore.kt
@@ -0,0 +1,128 @@
+package com.nuvio.tv.core.recommendations
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.recommendationDataStore by preferencesDataStore(
+ name = "tv_recommendation_prefs"
+)
+
+/**
+ * Persists channel IDs created via [TvContractCompat] so they survive app restarts,
+ * and stores the global "recommendations enabled" toggle.
+ */
+@Singleton
+class RecommendationDataStore @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ companion object {
+ private val KEY_RECOMMENDATIONS_ENABLED =
+ booleanPreferencesKey("recommendations_enabled")
+
+ // This stores a set of catalog keys (e.g. from Addon config) that the user wants to push to Android TV
+ private val KEY_ENABLED_CATALOGS =
+ androidx.datastore.preferences.core.stringSetPreferencesKey("enabled_tv_catalogs")
+
+ private val KEY_SYNC_INTERVAL_HOURS = intPreferencesKey("sync_interval_hours")
+ private val KEY_MAX_ITEMS_PER_CHANNEL = intPreferencesKey("max_items_per_channel")
+ private val KEY_USE_WIDE_POSTER = booleanPreferencesKey("use_wide_poster")
+ private val KEY_PLAY_NEXT_ENABLED = booleanPreferencesKey("play_next_enabled")
+ }
+
+ // ── Configuration ──
+
+ val syncIntervalHoursFlow = context.recommendationDataStore.data.map {
+ it[KEY_SYNC_INTERVAL_HOURS] ?: 3
+ }
+
+ val maxItemsPerChannelFlow = context.recommendationDataStore.data.map {
+ it[KEY_MAX_ITEMS_PER_CHANNEL] ?: 25
+ }
+
+ val useWidePosterFlow = context.recommendationDataStore.data.map {
+ it[KEY_USE_WIDE_POSTER] ?: false
+ }
+
+ val playNextEnabledFlow = context.recommendationDataStore.data.map {
+ it[KEY_PLAY_NEXT_ENABLED] ?: true
+ }
+
+ suspend fun getSyncIntervalHours(): Int = syncIntervalHoursFlow.first()
+ suspend fun getMaxItemsPerChannel(): Int = maxItemsPerChannelFlow.first()
+ suspend fun getUseWidePoster(): Boolean = useWidePosterFlow.first()
+ suspend fun getPlayNextEnabled(): Boolean = playNextEnabledFlow.first()
+
+ suspend fun setSyncIntervalHours(hours: Int) {
+ context.recommendationDataStore.edit { it[KEY_SYNC_INTERVAL_HOURS] = hours }
+ }
+
+ suspend fun setMaxItemsPerChannel(max: Int) {
+ context.recommendationDataStore.edit { it[KEY_MAX_ITEMS_PER_CHANNEL] = max }
+ }
+
+ suspend fun setUseWidePoster(useWide: Boolean) {
+ context.recommendationDataStore.edit { it[KEY_USE_WIDE_POSTER] = useWide }
+ }
+
+ suspend fun setPlayNextEnabled(enabled: Boolean) {
+ context.recommendationDataStore.edit { it[KEY_PLAY_NEXT_ENABLED] = enabled }
+ }
+
+ // ── Enabled Catalogs ──
+
+ val enabledCatalogsFlow = context.recommendationDataStore.data.map {
+ it[KEY_ENABLED_CATALOGS] ?: emptySet()
+ }
+
+ suspend fun getEnabledCatalogs(): Set = enabledCatalogsFlow.first()
+
+ suspend fun setEnabledCatalogs(catalogs: Set) {
+ context.recommendationDataStore.edit {
+ it[KEY_ENABLED_CATALOGS] = catalogs
+ }
+ }
+
+ // ── Channel ID CRUD ──
+
+ suspend fun getChannelId(channelType: String): Long? {
+ val key = keyForType(channelType)
+ return context.recommendationDataStore.data.map { it[key] }.first()
+ }
+
+ suspend fun setChannelId(channelType: String, channelId: Long) {
+ val key = keyForType(channelType)
+ context.recommendationDataStore.edit { it[key] = channelId }
+ }
+
+ suspend fun clearChannelId(channelType: String) {
+ val key = keyForType(channelType)
+ context.recommendationDataStore.edit { it.remove(key) }
+ }
+
+ // ── Global toggle ──
+
+ val isEnabledFlow = context.recommendationDataStore.data.map {
+ it[KEY_RECOMMENDATIONS_ENABLED] ?: true
+ }
+
+ suspend fun isEnabled(): Boolean = isEnabledFlow.first()
+
+ suspend fun setEnabled(enabled: Boolean) {
+ context.recommendationDataStore.edit {
+ it[KEY_RECOMMENDATIONS_ENABLED] = enabled
+ }
+ }
+
+ // ── Helpers ──
+
+ private fun keyForType(channelType: String) = longPreferencesKey("channel_id_$channelType")
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationReceiver.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationReceiver.kt
new file mode 100644
index 000000000..142aacbdd
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/RecommendationReceiver.kt
@@ -0,0 +1,39 @@
+package com.nuvio.tv.core.recommendations
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Receives the system broadcast [android.media.tv.action.INITIALIZE_PROGRAMS]
+ * which is sent when the TV launcher needs our channels to be populated
+ * (e.g. after a device reboot or first install).
+ */
+@AndroidEntryPoint
+class RecommendationReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var recommendationManager: TvRecommendationManager
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == "android.media.tv.action.INITIALIZE_PROGRAMS") {
+ val pendingResult = goAsync()
+ scope.launch {
+ try {
+ recommendationManager.syncAllChannels()
+ } catch (_: Exception) {
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/recommendations/TvRecommendationManager.kt b/app/src/main/java/com/nuvio/tv/core/recommendations/TvRecommendationManager.kt
new file mode 100644
index 000000000..bd04b7b94
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/recommendations/TvRecommendationManager.kt
@@ -0,0 +1,227 @@
+package com.nuvio.tv.core.recommendations
+
+import android.content.Context
+import android.content.pm.PackageManager
+import com.nuvio.tv.domain.model.MetaPreview
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.async
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Top-level coordinator that orchestrates channel creation, program publishing,
+ * and Watch Next row updates for Android TV Home Screen recommendations.
+ *
+ * All public methods are safe to call from any dispatcher — heavy work is
+ * dispatched to [Dispatchers.IO] internally.
+ */
+@Singleton
+class TvRecommendationManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val channelManager: ChannelManager,
+ private val programBuilder: ProgramBuilder,
+ private val dataStore: RecommendationDataStore,
+ private val watchProgressRepository: WatchProgressRepository
+) {
+
+ /** Serializes channel-update operations to avoid races from multiple triggers. */
+ private val mutex = Mutex()
+
+ /** Tracks the last set of items per channel to avoid redundant ContentProvider writes. */
+ private val channelSignatures = mutableMapOf()
+
+ // ────────────────────────────────────────────────────────────────
+ // Public API
+ // ────────────────────────────────────────────────────────────────
+
+ /**
+ * One-time initialization — clears orphan channels not in the user's enabled catalogs.
+ * Called from [NuvioApplication.onCreate].
+ */
+ suspend fun initializeChannels() {
+ if (!isTvDevice()) return
+ withContext(Dispatchers.IO) {
+ try {
+ // Determine which catalogs are valid
+ val validIds = dataStore.getEnabledCatalogs().toMutableList()
+ validIds.add("nuvio_play_next")
+ channelManager.cleanupLegacyChannels(validIds)
+
+ // Force sync Watch Next items right on startup to refresh launcher UI and bust caches
+ updateWatchNext()
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ /**
+ * Updates an arbitrary TV channel for a catalog.
+ * Called from [HomeViewModel] after catalog rows are loaded.
+ */
+ suspend fun updateCatalogChannel(catalogKey: String, catalogName: String, items: List) {
+ if (!shouldRun()) return
+
+ // Ensure this catalog is still chosen by the user
+ val enabledCatalogs = dataStore.getEnabledCatalogs()
+ if (!enabledCatalogs.contains(catalogKey)) return
+
+ val maxItems = dataStore.getMaxItemsPerChannel()
+ val useWidePoster = dataStore.getUseWidePoster()
+
+ val trimmed = items.take(maxItems) // Dynamic Max Limit
+ val signature = trimmed.joinToString("|") { it.id } + "_wide_$useWidePoster"
+ if (signature == channelSignatures[catalogKey]) return
+
+ mutex.withLock {
+ withContext(Dispatchers.IO) {
+ try {
+ val channelId = channelManager.getOrCreateChannel(catalogKey, catalogName) ?: return@withContext
+ channelManager.clearProgramsForChannel(channelId)
+
+ val programs = trimmed.map { programBuilder.buildTrendingProgram(channelId, it, useWidePoster) }
+ channelManager.insertPrograms(programs)
+
+ channelSignatures[catalogKey] = signature
+ } catch (_: Exception) {
+ }
+ }
+ }
+ }
+
+ /**
+ * Updates the **Watch Next** system row with the user's in-progress items.
+ * Performs a full clear-and-rebuild to ensure no stale entries remain.
+ */
+ suspend fun updateWatchNext() {
+ if (!shouldRun()) return
+ mutex.withLock {
+ withContext(Dispatchers.IO) {
+ try {
+ // Clear ALL our Watch Next entries first to remove stale ones
+ programBuilder.clearAllWatchNextPrograms()
+
+ val items = deduplicateByContent(
+ watchProgressRepository.continueWatching.first()
+ ).take(RecommendationConstants.MAX_WATCH_NEXT_ITEMS)
+
+ for (progress in items) {
+ val program = programBuilder.buildWatchNextProgram(progress)
+ val internalId = "wn_${progress.contentId}"
+ programBuilder.upsertWatchNextProgram(program, internalId)
+ }
+
+ // --- Create a dedicated "Play Next" Preview Channel ---
+ // This is for Google TV / newer launchers where the global Watch Next row is hidden or restricted.
+ // We generate a standard channel that behaves exactly like "Continue Watching".
+ if (dataStore.getPlayNextEnabled()) {
+ val playNextChannelId = channelManager.getOrCreateChannel(
+ internalId = "nuvio_play_next",
+ displayName = "Play Next"
+ )
+
+ if (playNextChannelId != null) {
+ channelManager.clearProgramsForChannel(playNextChannelId)
+ val previewPrograms = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ kotlinx.coroutines.coroutineScope {
+ items.map { progress ->
+ async { programBuilder.buildContinueWatchingProgram(playNextChannelId, progress) }
+ }.map { it.await() }
+ }
+ }
+ channelManager.insertPrograms(previewPrograms)
+ }
+ } else {
+ val playNextChannelId = channelManager.getChannelId("nuvio_play_next")
+ if (playNextChannelId != null) {
+ channelManager.clearProgramsForChannel(playNextChannelId)
+ }
+ }
+ } catch (_: Exception) {
+ }
+ }
+ }
+ }
+
+ /**
+ * Convenience method called when a single progress entry is saved/updated.
+ * Refreshes Watch Next row.
+ */
+ suspend fun onProgressUpdated(progress: WatchProgress) {
+ if (!shouldRun()) return
+ updateWatchNext()
+ }
+
+ /**
+ * Full sync — updates all base channels. Called by [TvRecommendationWorker].
+ */
+ suspend fun syncAllChannels() {
+ if (!shouldRun()) return
+ initializeChannels()
+ updateWatchNext()
+ // Note: Dynamic catalogs are updated from HomeViewModel when the row is successfully fetched
+ }
+
+ /**
+ * Removes all dynamic channels and Watch Next entries created by this app.
+ */
+ suspend fun clearAll() {
+ withContext(Dispatchers.IO) {
+ // Delete ALL preview channels
+ channelManager.cleanupLegacyChannels(emptyList())
+
+ // Just clear the "Play Next" channel programs instead of deleting it
+ // so it hides but doesn't require user re-approval if toggled back on
+ val playNextChannelId = channelManager.getChannelId("nuvio_play_next")
+ if (playNextChannelId != null) {
+ channelManager.clearProgramsForChannel(playNextChannelId)
+ }
+
+ // Delete ALL watch next items
+ programBuilder.clearAllWatchNextPrograms()
+
+ channelSignatures.clear()
+ }
+ }
+
+ /**
+ * Called when a watch progress entry is removed by the user.
+ * Removes the Watch Next entry.
+ */
+ suspend fun onProgressRemoved(contentId: String) {
+ if (!shouldRun()) return
+ withContext(Dispatchers.IO) {
+ try {
+ programBuilder.removeWatchNextProgram("wn_$contentId")
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Helpers
+ // ────────────────────────────────────────────────────────────────
+
+ /**
+ * Deduplicates progress entries per contentId, keeping only the most
+ * recently watched entry for each content item. This prevents showing
+ * multiple episodes of the same series in Continue Watching / Watch Next.
+ */
+ private fun deduplicateByContent(items: List): List {
+ return items
+ .sortedByDescending { it.lastWatched }
+ .distinctBy { it.contentId }
+ }
+
+ private suspend fun shouldRun(): Boolean =
+ isTvDevice() && dataStore.isEnabled()
+
+ private fun isTvDevice(): Boolean =
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt
index 39b6f5bc4..28b958765 100644
--- a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt
+++ b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt
@@ -2,6 +2,7 @@ package com.nuvio.tv.data.repository
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.core.recommendations.TvRecommendationManager
import com.nuvio.tv.core.sync.WatchProgressSyncService
import com.nuvio.tv.core.sync.WatchedItemsSyncService
import android.util.Log
@@ -33,6 +34,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
+import dagger.Lazy
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@@ -44,7 +46,8 @@ class WatchProgressRepositoryImpl @Inject constructor(
private val watchedItemsPreferences: WatchedItemsPreferences,
private val watchedItemsSyncService: WatchedItemsSyncService,
private val authManager: AuthManager,
- private val metaRepository: MetaRepository
+ private val metaRepository: MetaRepository,
+ private val tvRecommendationManagerLazy: Lazy
) : WatchProgressRepository {
companion object {
private const val TAG = "WatchProgressRepo"
@@ -346,10 +349,11 @@ class WatchProgressRepositoryImpl @Inject constructor(
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
traktProgressService.applyOptimisticProgress(progress)
watchProgressPreferences.saveProgress(progress)
+ triggerRecommendationUpdate(progress)
return
}
watchProgressPreferences.saveProgress(progress)
-
+ triggerRecommendationUpdate(progress)
if (syncRemote && authManager.isAuthenticated) {
syncScope.launch {
@@ -359,7 +363,6 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
}
}
-
if (progress.isCompleted()) {
watchedItemsPreferences.markAsWatched(
WatchedItem(
@@ -385,6 +388,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
traktProgressService.applyOptimisticRemoval(contentId, season, episode)
traktProgressService.removeProgress(contentId, season, episode)
watchProgressPreferences.removeProgress(contentId, season, episode)
+ triggerRecommendationRemoval(contentId)
return
}
val remoteDeleteKeys = resolveRemoteDeleteKeys(contentId, season, episode)
@@ -396,12 +400,14 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
}
triggerRemoteSync()
+ triggerRecommendationRemoval(contentId)
}
override suspend fun removeFromHistory(contentId: String, season: Int?, episode: Int?) {
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
traktProgressService.removeFromHistory(contentId, season, episode)
watchProgressPreferences.removeProgress(contentId, season, episode)
+ triggerRecommendationRemoval(contentId)
return
}
val remoteDeleteKeys = resolveRemoteDeleteKeys(contentId, season, episode)
@@ -415,6 +421,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
triggerRemoteSync()
triggerWatchedItemsSync()
+ triggerRecommendationRemoval(contentId)
}
override suspend fun markAsCompleted(progress: WatchProgress) {
@@ -498,6 +505,32 @@ class WatchProgressRepositoryImpl @Inject constructor(
.distinct()
}
+ /**
+ * Fire-and-forget update of TV Home Screen recommendation channels.
+ * Failures are silently swallowed and never propagate to the caller.
+ */
+ private fun triggerRecommendationUpdate(progress: WatchProgress) {
+ syncScope.launch {
+ try {
+ tvRecommendationManagerLazy.get().onProgressUpdated(progress)
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ /**
+ * Fire-and-forget removal of a Watch Next entry and Continue Watching channel refresh.
+ * Failures are silently swallowed and never propagate to the caller.
+ */
+ private fun triggerRecommendationRemoval(contentId: String) {
+ syncScope.launch {
+ try {
+ tvRecommendationManagerLazy.get().onProgressRemoved(contentId)
+ } catch (_: Exception) {
+ }
+ }
+ }
+
private fun mergeProgressLists(
remoteItems: List,
localItems: List
diff --git a/app/src/main/java/com/nuvio/tv/data/worker/TvRecommendationWorker.kt b/app/src/main/java/com/nuvio/tv/data/worker/TvRecommendationWorker.kt
new file mode 100644
index 000000000..81a7c3a29
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/worker/TvRecommendationWorker.kt
@@ -0,0 +1,102 @@
+package com.nuvio.tv.data.worker
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.nuvio.tv.core.recommendations.RecommendationDataStore
+import com.nuvio.tv.core.recommendations.TvRecommendationManager
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.repository.AddonRepository
+import com.nuvio.tv.domain.repository.CatalogRepository
+import kotlinx.coroutines.flow.firstOrNull
+
+/**
+ * Periodically syncs TV Home Screen recommendation channels in the background.
+ * Scheduled via WorkManager every 30 minutes (configurable in [RecommendationConstants]).
+ *
+ * Retries up to 3 times on transient failures; after that it reports failure
+ * so the periodic schedule continues on the next window.
+ */
+@HiltWorker
+class TvRecommendationWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted params: WorkerParameters,
+ private val recommendationManager: TvRecommendationManager,
+ private val recommendationDataStore: RecommendationDataStore,
+ private val addonRepository: AddonRepository,
+ private val catalogRepository: CatalogRepository
+) : CoroutineWorker(context, params) {
+
+
+ override suspend fun doWork(): Result {
+ return try {
+ if (!recommendationDataStore.isEnabled()) {
+ recommendationManager.clearAll()
+ return Result.success()
+ }
+
+ // 1. Sync WatchNext and cleanup legacy channels
+ recommendationManager.syncAllChannels()
+
+ // 2. Refresh dynamic channels from Addons
+ val addons = addonRepository.getInstalledAddons().firstOrNull() ?: emptyList()
+ if (addons.isNotEmpty()) {
+ val enabledCatalogs = recommendationDataStore.getEnabledCatalogs()
+ val catalogs = loadEssentialCatalogs(addons, enabledCatalogs)
+
+ catalogs.forEach { row ->
+ val catalogKey = "${row.addonId}_${row.apiType}_${row.catalogId}"
+ val catalogName = "${row.catalogName} (${row.addonName})"
+ if (enabledCatalogs.contains(catalogKey)) {
+ recommendationManager.updateCatalogChannel(
+ catalogKey = catalogKey,
+ catalogName = catalogName,
+ items = row.items
+ )
+ }
+ }
+ }
+
+ Result.success()
+ } catch (_: Exception) {
+ if (runAttemptCount < 3) Result.retry() else Result.failure()
+ }
+ }
+
+ private suspend fun loadEssentialCatalogs(
+ addons: List,
+ enabledCatalogs: Set
+ ): List {
+ val loadedRows = mutableListOf()
+
+ for (addon in addons) {
+ val catalogsToLoad = addon.catalogs
+ .filter { catalog ->
+ val key = "${addon.id}_${catalog.apiType}_${catalog.id}"
+ enabledCatalogs.contains(key)
+ }
+
+ for (catalog in catalogsToLoad) {
+ val result = catalogRepository.getCatalog(
+ addonBaseUrl = addon.baseUrl,
+ addonId = addon.id,
+ addonName = addon.displayName,
+ catalogId = catalog.id,
+ catalogName = catalog.name,
+ type = catalog.apiType,
+ skip = 0,
+ supportsSkip = catalog.extra.any { it.name == "skip" }
+ ).firstOrNull()
+
+ if (result is NetworkResult.Success && result.data.items.isNotEmpty()) {
+ loadedRows.add(result.data)
+ }
+ }
+ }
+ return loadedRows
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/di/RecommendationModule.kt b/app/src/main/java/com/nuvio/tv/di/RecommendationModule.kt
new file mode 100644
index 000000000..209ffedac
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/di/RecommendationModule.kt
@@ -0,0 +1,35 @@
+package com.nuvio.tv.di
+
+import com.nuvio.tv.core.recommendations.ChannelManager
+import com.nuvio.tv.core.recommendations.ProgramBuilder
+import com.nuvio.tv.core.recommendations.RecommendationDataStore
+import com.nuvio.tv.core.recommendations.TvRecommendationManager
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+
+/**
+ * Hilt module that exposes TV recommendation singletons to the DI graph.
+ *
+ * Note: [RecommendationDataStore], [ChannelManager], [ProgramBuilder], and
+ * [TvRecommendationManager] are all `@Singleton @Inject constructor(…)` classes,
+ * so Hilt can construct them automatically. This module only exists for the
+ * few cases where we need explicit `@Provides` bindings (currently none),
+ * and as a documentation anchor for the recommendation dependency graph.
+ *
+ * If all classes use `@Inject constructor`, this module can remain empty and
+ * still serve as the install-point for future explicit bindings.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object RecommendationModule {
+ // All recommendation classes use @Singleton + @Inject constructor,
+ // so Hilt constructs them without explicit @Provides methods.
+ // This module is kept as a placeholder for future bindings
+ // (e.g., Trending channel data source).
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
index ee2fabe5f..49853daaf 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.core.tmdb.TmdbService
+import com.nuvio.tv.core.recommendations.TvRecommendationManager
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
import com.nuvio.tv.data.local.TmdbSettingsDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
@@ -47,7 +48,8 @@ class HomeViewModel @Inject constructor(
internal val tmdbService: TmdbService,
internal val tmdbMetadataService: TmdbMetadataService,
internal val trailerService: TrailerService,
- internal val watchedItemsPreferences: WatchedItemsPreferences
+ internal val watchedItemsPreferences: WatchedItemsPreferences,
+ internal val tvRecommendationManager: TvRecommendationManager
) : ViewModel() {
companion object {
internal const val TAG = "HomeViewModel"
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt
index e5306d0ee..7bda49f42 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt
@@ -452,6 +452,26 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() {
}
schedulePosterStatusReconcilePipeline(displayRows)
+
+ // Sync each fetched row with the Android TV launcher channels (if enabled by user)
+ viewModelScope.launch {
+ displayRows.forEach { row ->
+ if (row.items.isNotEmpty()) {
+ val catalogKey = "${row.addonId}_${row.apiType}_${row.catalogId}"
+ val catalogName = "${row.catalogName} (${row.addonName})"
+
+ try {
+ tvRecommendationManager.updateCatalogChannel(
+ catalogKey = catalogKey,
+ catalogName = catalogName,
+ items = row.items
+ )
+ } catch (_: Exception) {
+ // Ignore transient background sync failures
+ }
+ }
+ }
+ }
}
internal fun HomeViewModel.schedulePosterStatusReconcilePipeline(rows: List) {
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt
index b8272fae2..e32fd529c 100644
--- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -75,7 +76,8 @@ private enum class IntegrationSettingsSection {
Hub,
Tmdb,
MdbList,
- AnimeSkip
+ AnimeSkip,
+ TvRecommendations
}
internal enum class SettingsSectionDestination {
@@ -214,6 +216,7 @@ fun SettingsScreen(
val integrationTmdbFocusRequester = remember { FocusRequester() }
val integrationMdbListFocusRequester = remember { FocusRequester() }
val integrationAnimeSkipFocusRequester = remember { FocusRequester() }
+ val integrationTvRecommendationsFocusRequester = remember { FocusRequester() }
var integrationSection by remember { mutableStateOf(IntegrationSettingsSection.Hub) }
var pendingContentFocusCategory by remember { mutableStateOf(null) }
var pendingContentFocusRequestId by remember { mutableLongStateOf(0L) }
@@ -392,6 +395,7 @@ fun SettingsScreen(
tmdbFocusRequester = integrationTmdbFocusRequester,
mdbListFocusRequester = integrationMdbListFocusRequester,
animeSkipFocusRequester = integrationAnimeSkipFocusRequester,
+ tvRecommendationsFocusRequester = integrationTvRecommendationsFocusRequester,
autoFocusEnabled = allowDetailAutofocus
)
SettingsCategory.ABOUT -> AboutSettingsContent(
@@ -478,6 +482,7 @@ private fun IntegrationSettingsContent(
tmdbFocusRequester: FocusRequester,
mdbListFocusRequester: FocusRequester,
animeSkipFocusRequester: FocusRequester,
+ tvRecommendationsFocusRequester: FocusRequester,
autoFocusEnabled: Boolean
) {
BackHandler(enabled = selectedSection != IntegrationSettingsSection.Hub) {
@@ -492,6 +497,7 @@ private fun IntegrationSettingsContent(
IntegrationSettingsSection.Tmdb -> tmdbFocusRequester
IntegrationSettingsSection.MdbList -> mdbListFocusRequester
IntegrationSettingsSection.AnimeSkip -> animeSkipFocusRequester
+ IntegrationSettingsSection.TvRecommendations -> tvRecommendationsFocusRequester
}
runCatching { requester.requestFocus() }
}
@@ -537,6 +543,13 @@ private fun IntegrationSettingsContent(
onClick = { onSelectSection(IntegrationSettingsSection.AnimeSkip) }
)
}
+ item(key = "integration_hub_tv_recommendations") {
+ SettingsActionRow(
+ title = stringResource(R.string.settings_tv_recommendations),
+ subtitle = stringResource(R.string.settings_tv_recommendations_subtitle),
+ onClick = { onSelectSection(IntegrationSettingsSection.TvRecommendations) }
+ )
+ }
}
}
}
@@ -559,5 +572,11 @@ private fun IntegrationSettingsContent(
initialFocusRequester = animeSkipFocusRequester
)
}
+
+ IntegrationSettingsSection.TvRecommendations -> {
+ TvRecommendationsSettingsContent(
+ initialFocusRequester = tvRecommendationsFocusRequester
+ )
+ }
}
}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/TvRecommendationsSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/TvRecommendationsSettingsScreen.kt
new file mode 100644
index 000000000..8567160e8
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/TvRecommendationsSettingsScreen.kt
@@ -0,0 +1,224 @@
+@file:OptIn(ExperimentalTvMaterial3Api::class)
+
+package com.nuvio.tv.ui.screens.settings
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.R
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@Composable
+fun TvRecommendationsSettingsContent(
+ initialFocusRequester: FocusRequester? = null,
+ viewModel: TvRecommendationsSettingsViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val isRecommendationsEnabled by viewModel.isRecommendationsEnabled.collectAsStateWithLifecycle()
+ val isPlayNextEnabled by viewModel.isPlayNextEnabled.collectAsStateWithLifecycle()
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ SettingsDetailHeader(
+ title = stringResource(R.string.settings_tv_recommendations),
+ subtitle = stringResource(R.string.settings_tv_recommendations_subtitle)
+ )
+
+ SettingsGroupCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ item(key = "tv_recommendations_toggle") {
+ SettingsToggleRow(
+ title = stringResource(R.string.settings_tv_recommendations_toggle),
+ subtitle = stringResource(R.string.settings_tv_recommendations_toggle_sub),
+ checked = isRecommendationsEnabled,
+ onToggle = {
+ viewModel.setRecommendationsEnabled(!isRecommendationsEnabled)
+ },
+ modifier = initialFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier,
+ onFocused = {}
+ )
+ }
+
+ item(key = "tv_recommendations_catalogs_selection") {
+ AnimatedVisibility(
+ visible = isRecommendationsEnabled && uiState.availableCatalogs.isNotEmpty(),
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.settings_tv_recommendations_catalogs),
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioColors.TextSecondary
+ )
+ Text(
+ text = stringResource(R.string.settings_tv_recommendations_catalogs_sub),
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextTertiary
+ )
+ }
+
+ LazyRow(
+ contentPadding = PaddingValues(end = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(
+ items = uiState.availableCatalogs,
+ key = { it.key }
+ ) { catalog ->
+ SettingsChoiceChip(
+ label = "${catalog.name} (${catalog.addonName})",
+ selected = uiState.enabledCatalogs.contains(catalog.key),
+ onClick = {
+ viewModel.toggleCatalog(catalog.key)
+ },
+ onFocused = {}
+ )
+ }
+ }
+ }
+ }
+ }
+
+ item(key = "tv_recommendations_appearance_header") {
+ AnimatedVisibility(
+ visible = isRecommendationsEnabled,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(top = 10.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.settings_tv_recommendations_appearance),
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioColors.TextSecondary
+ )
+ Text(
+ text = stringResource(R.string.settings_tv_recommendations_appearance_sub),
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextTertiary
+ )
+ }
+ }
+ }
+
+ item(key = "tv_recommendations_play_next") {
+ AnimatedVisibility(
+ visible = isRecommendationsEnabled,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ SettingsToggleRow(
+ title = stringResource(R.string.settings_tv_recommendations_play_next_title),
+ subtitle = stringResource(R.string.settings_tv_recommendations_play_next_sub),
+ checked = isPlayNextEnabled,
+ onToggle = { viewModel.setPlayNextEnabled(!isPlayNextEnabled) }
+ )
+ }
+ }
+
+ item(key = "tv_recommendations_poster_style") {
+ AnimatedVisibility(
+ visible = isRecommendationsEnabled,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ SettingsToggleRow(
+ title = stringResource(R.string.settings_tv_recommendations_poster_style_title),
+ subtitle = stringResource(R.string.settings_tv_recommendations_poster_style_sub),
+ checked = uiState.useWidePoster,
+ onToggle = { viewModel.setUseWidePoster(!uiState.useWidePoster) }
+ )
+ }
+ }
+
+ item(key = "tv_recommendations_item_limit") {
+ AnimatedVisibility(
+ visible = isRecommendationsEnabled,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ val limits = listOf(15, 25, 40, 60)
+ val currentIndex = limits.indexOf(uiState.maxItemsPerChannel).takeIf { it >= 0 } ?: 1
+
+ SettingsActionRow(
+ title = stringResource(R.string.settings_tv_recommendations_item_limit_title),
+ subtitle = stringResource(R.string.settings_tv_recommendations_item_limit_sub),
+ value = uiState.maxItemsPerChannel.toString(),
+ onClick = {
+ val nextIndex = (currentIndex + 1) % limits.size
+ viewModel.setMaxItemsPerChannel(limits[nextIndex])
+ }
+ )
+ }
+ }
+
+ item(key = "tv_recommendations_sync_interval") {
+ AnimatedVisibility(
+ visible = isRecommendationsEnabled,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ val intervals = listOf(1, 3, 6, 12, 24, 0)
+ val currentIndex = intervals.indexOf(uiState.syncIntervalHours).takeIf { it >= 0 } ?: 1
+ val valueDisplay = if (uiState.syncIntervalHours > 0) {
+ stringResource(R.string.settings_tv_recommendations_interval_hours, uiState.syncIntervalHours)
+ } else {
+ stringResource(R.string.settings_tv_recommendations_interval_off)
+ }
+
+ SettingsActionRow(
+ title = stringResource(R.string.settings_tv_recommendations_sync_interval_title),
+ subtitle = stringResource(R.string.settings_tv_recommendations_sync_interval_sub),
+ value = valueDisplay,
+ onClick = {
+ val nextIndex = (currentIndex + 1) % intervals.size
+ viewModel.setSyncIntervalHours(intervals[nextIndex])
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/TvRecommendationsSettingsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/TvRecommendationsSettingsViewModel.kt
new file mode 100644
index 000000000..4fccdc08d
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/TvRecommendationsSettingsViewModel.kt
@@ -0,0 +1,166 @@
+package com.nuvio.tv.ui.screens.settings
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import com.nuvio.tv.core.recommendations.RecommendationDataStore
+import com.nuvio.tv.data.worker.TvRecommendationWorker
+import com.nuvio.tv.domain.repository.AddonRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class TvRecommendationsSettingsUiState(
+ val availableCatalogs: List = emptyList(),
+ val enabledCatalogs: Set = emptySet(),
+ val syncIntervalHours: Int = 3,
+ val maxItemsPerChannel: Int = 25,
+ val useWidePoster: Boolean = false
+)
+
+@HiltViewModel
+class TvRecommendationsSettingsViewModel @Inject constructor(
+ private val dataStore: RecommendationDataStore,
+ private val addonRepository: AddonRepository,
+ @ApplicationContext private val context: Context
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(TvRecommendationsSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ val isRecommendationsEnabled: StateFlow = dataStore.isEnabledFlow.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = true
+ )
+
+ val isPlayNextEnabled: StateFlow = dataStore.playNextEnabledFlow.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = true
+ )
+
+ init {
+ // Load Addon Catalogs
+ viewModelScope.launch {
+ addonRepository.getInstalledAddons().collectLatest { addons ->
+ val catalogs = addons.flatMap { addon ->
+ addon.catalogs
+ .filter { catalog ->
+ !catalog.extra.any { it.name.equals("search", ignoreCase = true) && it.isRequired }
+ }
+ .map { catalog ->
+ CatalogInfo(
+ key = "${addon.id}_${catalog.apiType}_${catalog.id}",
+ name = catalog.name,
+ addonName = addon.displayName
+ )
+ }
+ }
+ _uiState.update { it.copy(availableCatalogs = catalogs) }
+ }
+ }
+
+ // Load Enabled Catalogs Selection
+ viewModelScope.launch {
+ dataStore.enabledCatalogsFlow.distinctUntilChanged().collectLatest { enabledSettings ->
+ _uiState.update { it.copy(enabledCatalogs = enabledSettings) }
+ }
+ }
+
+ // Load Configuration Settings
+ viewModelScope.launch {
+ dataStore.syncIntervalHoursFlow.distinctUntilChanged().collectLatest { interval ->
+ _uiState.update { it.copy(syncIntervalHours = interval) }
+ }
+ }
+ viewModelScope.launch {
+ dataStore.maxItemsPerChannelFlow.distinctUntilChanged().collectLatest { maxItems ->
+ _uiState.update { it.copy(maxItemsPerChannel = maxItems) }
+ }
+ }
+ viewModelScope.launch {
+ dataStore.useWidePosterFlow.distinctUntilChanged().collectLatest { useWide ->
+ _uiState.update { it.copy(useWidePoster = useWide) }
+ }
+ }
+ }
+
+ fun setRecommendationsEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ dataStore.setEnabled(enabled)
+ triggerImmediateSync()
+ }
+ }
+
+ fun setPlayNextEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ dataStore.setPlayNextEnabled(enabled)
+ triggerImmediateSync()
+ }
+ }
+
+ fun toggleCatalog(catalogKey: String) {
+ viewModelScope.launch {
+ val current = _uiState.value.enabledCatalogs.toMutableSet()
+ if (current.contains(catalogKey)) {
+ current.remove(catalogKey)
+ } else {
+ current.add(catalogKey)
+ }
+ dataStore.setEnabledCatalogs(current)
+ triggerImmediateSync()
+ }
+ }
+
+ fun setSyncIntervalHours(hours: Int) {
+ viewModelScope.launch {
+ dataStore.setSyncIntervalHours(hours)
+ // Interval changes are handled globally by App, no forced sync needed
+ }
+ }
+
+ fun setMaxItemsPerChannel(max: Int) {
+ viewModelScope.launch {
+ dataStore.setMaxItemsPerChannel(max)
+ triggerImmediateSync()
+ }
+ }
+
+ fun setUseWidePoster(useWide: Boolean) {
+ viewModelScope.launch {
+ dataStore.setUseWidePoster(useWide)
+ triggerImmediateSync()
+ }
+ }
+
+ private fun triggerImmediateSync() {
+ val workRequest = OneTimeWorkRequestBuilder()
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "manual_tv_sync",
+ ExistingWorkPolicy.REPLACE,
+ workRequest
+ )
+ }
+}
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 894012289..d59e4b22d 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -868,4 +868,29 @@
Ayarları Aç
Yükle
Atla
+
+
+ Android TV Entegrasyonu
+ Ana ekran önerilerini yönet
+ Ana Ekran Kanalları
+ Keşfet ve İzlemeye Devam Et içeriklerini Android TV ana ekranına iletmeyi etkinleştirir
+ Katalogları Seç
+ Ana ekranda gösterilecek listeleri belirle
+
+ Görünüm & Senkronizasyon
+ Poster stili, satır limitleri ve güncelleme sıklığını ayarla
+
+ Sıradakini İzle Kanalı
+ İzlemeye Devam Et satırını desteklemeyen cihazlar için yatay ve izleme çubuklu özel bir kanal oluştur
+
+ Geniş Posterler Kullan
+ Standart dikey posterler yerine manzara görünümlü (backdrop) posterler göster
+
+ Satır Başına Öğe
+ Ana ekran kanallarında gösterilecek maksimum içerik sayısı
+
+ Arka Plan Senkronizasyonu
+ Uygulama kapalıyken kanalların hangi sıklıkta yenileneceği
+ %1$d saat
+ Kapalı
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f792a7ed8..e09381387 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -895,4 +895,29 @@
Open Settings
Install
Ignore
+
+
+ Android TV Integration
+ Manage home screen recommendations
+ Home Screen Channels
+ Enable sending Discovery and Continue Watching content to Android TV launcher
+ Home Screen Catalogs
+ Choose which lists to display as channels on the launcher
+
+ Appearance & Sync
+ Configure poster style, row limits, and update frequency
+
+ Play Next Channel
+ Create a dedicated row simulating Continue Watching with progress bars
+
+ Use Wide Posters
+ Display landscape (backdrop) posters instead of standard vertical ones
+
+ Items Per Row
+ Maximum number of items to show in each home screen channel
+
+ Background Sync
+ How often channels refresh when the app is closed
+ %1$d hours
+ Manual
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ba3b6d0c0..2b2537e5e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -38,6 +38,8 @@ androidxTestRunner = "1.6.2"
uiautomator = "2.3.0"
profileinstaller = "1.4.1"
baselineProfilePlugin = "1.4.1"
+tvProvider = "1.1.0"
+workRuntime = "2.10.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -124,6 +126,14 @@ androidx-test-runner = { group = "androidx.test", name = "runner", version.ref =
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
+# TV Recommendations
+androidx-tvprovider = { group = "androidx.tvprovider", name = "tvprovider", version.ref = "tvProvider" }
+
+# WorkManager
+androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" }
+androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
+hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }