feat(android-tv): TV recommendations (#1575)

This commit is contained in:
Steve Byatt
2026-05-21 12:55:26 +01:00
committed by GitHub
parent 121ff0eea0
commit 11a4f14732
18 changed files with 923 additions and 128 deletions

View File

@@ -31,3 +31,9 @@ export type {
TopShelfCacheSection,
} from "./top-shelf-cache";
export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache";
// TV recommendations (Android TV)
export {
clearTvRecommendations,
refreshTvRecommendations,
syncTvRecommendations,
} from "./tv-recommendations";

View File

@@ -0,0 +1,46 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.tvrecommendations'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.tvrecommendations"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "androidx.tvprovider:tvprovider:1.1.0"
implementation "androidx.core:core-ktx:1.13.1"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = "17"
}
}

View File

@@ -0,0 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,25 @@
package expo.modules.tvrecommendations
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class TvRecommendationsModule : Module() {
override fun definition() = ModuleDefinition {
Name("TvRecommendations")
Function("syncRecommendations") { json: String ->
val context = appContext.reactContext ?: return@Function false
TvRecommendationsPublisher.sync(context, json)
}
Function("clearRecommendations") {
val context = appContext.reactContext ?: return@Function false
TvRecommendationsPublisher.clear(context)
}
Function("refreshRecommendations") {
val context = appContext.reactContext ?: return@Function false
TvRecommendationsPublisher.refreshFromCache(context)
}
}
}

View File

@@ -0,0 +1,378 @@
package expo.modules.tvrecommendations
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID = "channelId"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
fun sync(context: Context, payloadJson: String): Boolean {
val payload = try {
JSONObject(payloadJson)
} catch (error: Exception) {
Log.e(TAG, "Failed to parse recommendations payload", error)
return false
}
val sectionCount = payload.optJSONArray("sections")?.length() ?: 0
Log.d(TAG, "sync(): received payload with $sectionCount section(s)")
preferences(context)
.edit()
.putString(KEY_PAYLOAD, payloadJson)
.apply()
return synchronize(context, payload)
}
fun refreshFromCache(context: Context): Boolean {
val payloadJson = preferences(context).getString(KEY_PAYLOAD, null) ?: return false
val payload = try {
JSONObject(payloadJson)
} catch (error: Exception) {
Log.e(TAG, "Failed to parse cached recommendations payload", error)
return false
}
val sectionCount = payload.optJSONArray("sections")?.length() ?: 0
Log.d(TAG, "refreshFromCache(): replaying cached payload with $sectionCount section(s)")
return synchronize(context, payload)
}
fun clear(context: Context): Boolean {
val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver
if (programIds != null) {
var deletedPrograms = 0
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = programIds.optLong(key, -1L)
if (programId > 0L) {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
}
}
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
}
if (channelId > 0L) {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
Log.d(TAG, "clear(): notified channel $channelId")
}
prefs.edit()
.remove(KEY_PAYLOAD)
.remove(KEY_PROGRAM_IDS)
.apply()
return true
}
private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
)
return true
}
private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply()
}
val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
) ?: return -1L
val channelId = ContentUris.parseId(channelUri)
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
return channelId
}
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
item: JSONObject,
previousProgramId: Long,
weight: Int
): Long {
val providerId = item.optString("id")
val imageUrl = item.optString("imageUrl")
val builder = PreviewProgram.Builder()
.setChannelId(channelId)
.setType(programTypeFor(item.optString("itemType")))
.setTitle(item.optString("title"))
.setInternalProviderId(providerId)
.setContentId(providerId)
.setIntentUri(buildIntentUri(context, item.optString("playRoute").ifBlank { item.optString("route") }))
.setWeight(weight)
item.optString("subtitle").takeIf { it.isNotBlank() }?.let {
builder.setDescription(it)
}
imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it)
builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri)
}
val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver
if (previousProgramId > 0L) {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
}
val insertedUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
) ?: return -1L
val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
`package` = context.packageName
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
return Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))
}
private fun programTypeFor(itemType: String): Int {
return when (itemType) {
"Movie" -> TvContractCompat.PreviewPrograms.TYPE_MOVIE
"Episode" -> TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE
"Series" -> TvContractCompat.PreviewPrograms.TYPE_TV_SERIES
else -> TvContractCompat.PreviewPrograms.TYPE_CLIP
}
}
private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
}
private fun applicationIconBitmap(context: Context): Bitmap? {
val drawable = try {
context.packageManager.getApplicationIcon(context.packageName)
} catch (error: PackageManager.NameNotFoundException) {
Log.w(TAG, "Unable to load application icon", error)
return null
}
return drawable.toBitmap()
}
private fun Drawable.toBitmap(): Bitmap {
if (this is BitmapDrawable && bitmap != null) {
return bitmap
}
val width = intrinsicWidth.takeIf { it > 0 } ?: 256
val height = intrinsicHeight.takeIf { it > 0 } ?: 256
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
return bitmap
}
private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver
try {
contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
val packageNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_PACKAGE_NAME)
val displayNameIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_DISPLAY_NAME)
val browsable = if (browsableIndex >= 0) cursor.getInt(browsableIndex) else -1
val packageName = if (packageNameIndex >= 0) cursor.getString(packageNameIndex) else "unknown"
val displayName = if (displayNameIndex >= 0) cursor.getString(displayNameIndex) else "unknown"
Log.d(
TAG,
"logProviderState(): channelId=$channelId exists=true browsable=$browsable packageName=$packageName displayName=$displayName"
)
} else {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
}
}

View File

@@ -0,0 +1,18 @@
package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
return
}
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "tv-recommendations",
"version": "1.0.0",
"platforms": ["android"],
"android": {
"modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"]
}
}

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,5 @@
export interface TvRecommendationsModuleType {
syncRecommendations(json: string): boolean;
clearRecommendations(): boolean;
refreshRecommendations(): boolean;
}

View File

@@ -0,0 +1,26 @@
import { requireNativeModule } from "expo-modules-core";
import { Platform } from "react-native";
import type { TvRecommendationsModuleType } from "./TvRecommendations.types";
let TvRecommendationsModule: TvRecommendationsModuleType | null = null;
if (Platform.OS === "android" && Platform.isTV) {
try {
TvRecommendationsModule =
requireNativeModule<TvRecommendationsModuleType>("TvRecommendations");
} catch {
TvRecommendationsModule = null;
}
}
export function syncTvRecommendations(json: string): boolean {
return TvRecommendationsModule?.syncRecommendations(json) ?? false;
}
export function clearTvRecommendations(): boolean {
return TvRecommendationsModule?.clearRecommendations() ?? false;
}
export function refreshTvRecommendations(): boolean {
return TvRecommendationsModule?.refreshRecommendations() ?? false;
}

View File

@@ -0,0 +1,6 @@
export type { TvRecommendationsModuleType } from "./TvRecommendations.types";
export {
clearTvRecommendations,
refreshTvRecommendations,
syncTvRecommendations,
} from "./TvRecommendationsModule";