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