From 11a4f147328aaf0f349108914755f3eb6578300e Mon Sep 17 00:00:00 2001 From: Steve Byatt <47413006+stevebyatt10@users.noreply.github.com> Date: Thu, 21 May 2026 12:55:26 +0100 Subject: [PATCH] feat(android-tv): TV recommendations (#1575) --- README.md | 4 + components/home/Home.tv.tsx | 4 +- docs/tv-discovery.md | 136 +++++++ modules/index.ts | 6 + .../tv-recommendations/android/build.gradle | 46 +++ .../android/src/main/AndroidManifest.xml | 11 + .../TvRecommendationsModule.kt | 25 ++ .../TvRecommendationsPublisher.kt | 378 ++++++++++++++++++ .../TvRecommendationsReceiver.kt | 18 + .../expo-module.config.json | 8 + modules/tv-recommendations/index.ts | 1 + .../src/TvRecommendations.types.ts | 5 + .../src/TvRecommendationsModule.ts | 26 ++ modules/tv-recommendations/src/index.ts | 6 + providers/JellyfinProvider.tsx | 10 +- utils/topshelf/cache.ts | 139 +------ utils/tvDiscovery/payload.ts | 140 +++++++ utils/tvDiscovery/sync.ts | 88 ++++ 18 files changed, 923 insertions(+), 128 deletions(-) create mode 100644 docs/tv-discovery.md create mode 100644 modules/tv-recommendations/android/build.gradle create mode 100644 modules/tv-recommendations/android/src/main/AndroidManifest.xml create mode 100644 modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt create mode 100644 modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt create mode 100644 modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt create mode 100644 modules/tv-recommendations/expo-module.config.json create mode 100644 modules/tv-recommendations/index.ts create mode 100644 modules/tv-recommendations/src/TvRecommendations.types.ts create mode 100644 modules/tv-recommendations/src/TvRecommendationsModule.ts create mode 100644 modules/tv-recommendations/src/index.ts create mode 100644 utils/tvDiscovery/payload.ts create mode 100644 utils/tvDiscovery/sync.ts diff --git a/README.md b/README.md index b5b418d2..258005ef 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`. `npm run prebuild:tv` `npm run ios:tv or npm run android:tv` +TV platform integration notes: + +- [TV Discovery](./docs/tv-discovery.md) + ## 👋 Get in Touch with Us Need assistance or have any questions? diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 482bdc61..cffff54f 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -41,7 +41,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { scaleSize } from "@/utils/scaleSize"; -import { updateTopShelfCache } from "@/utils/topshelf/cache"; +import { updateTVDiscovery } from "@/utils/tvDiscovery/sync"; const HORIZONTAL_PADDING = scaleSize(60); const TOP_PADDING = scaleSize(100); @@ -260,7 +260,7 @@ export const Home = () => { }); useEffect(() => { - updateTopShelfCache({ + updateTVDiscovery({ api, sections: [ { diff --git a/docs/tv-discovery.md b/docs/tv-discovery.md new file mode 100644 index 00000000..b1a55165 --- /dev/null +++ b/docs/tv-discovery.md @@ -0,0 +1,136 @@ +# TV Discovery + +This document explains Streamyfin's platform-specific home screen discovery integrations for Apple TV and Android TV. + +## Overview + +Streamyfin currently publishes the same "Continue and Next Up" content to two different platform surfaces: + +- `tvOS`: Apple TV Top Shelf +- `Android TV`: preview channel recommendations + +Both integrations are fed by the same shared payload builder in [utils/tvDiscovery/payload.ts](../utils/tvDiscovery/payload.ts). + +## Shared Data Flow + +The TV home screen data starts in [components/home/Home.tv.tsx](../components/home/Home.tv.tsx), where the app fetches resume and next-up items and passes them into [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts). + +The sync layer: + +- builds a normalized TV discovery payload +- sends it to the tvOS Top Shelf cache writer on Apple TV +- sends it to the Android TV recommendations module on Android TV +- clears published content when server or user state changes + +## Apple TV Top Shelf + +Apple TV uses a Top Shelf extension target, not the main app process. + +Relevant files: + +- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js) +- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift) +- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift) +- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts) + +How it works: + +- the app builds a lightweight JSON payload +- the app stores that payload in the shared app group container +- the tvOS Top Shelf extension reads the cached payload +- the extension renders sections and items for Top Shelf + +Why the API key is stored on tvOS: + +- the Top Shelf extension runs outside the app process +- it may need authenticated image access when loading poster artwork +- the app stores the API key so the extension can build authenticated requests + +## Android TV Recommendations + +Android TV uses the TV provider APIs to publish a preview channel and preview programs. + +Relevant files: + +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt) +- [modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt](../modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt) +- [modules/tv-recommendations/android/src/main/AndroidManifest.xml](../modules/tv-recommendations/android/src/main/AndroidManifest.xml) +- [utils/tvDiscovery/sync.ts](../utils/tvDiscovery/sync.ts) + +How it works: + +- the app builds the shared TV discovery payload +- the Android native module creates or updates a single preview channel +- the module inserts or updates preview programs for each item +- the module stores the last payload in shared preferences +- the `INITIALIZE_PROGRAMS` receiver can replay the cached payload when requested by the system + +Important differences from tvOS: + +- Android TV does not use a separate extension target +- Android TV content is persisted through `TvContractCompat` +- artwork is currently published as poster URLs, not app-proxied local content + +## Logging + +### JavaScript logs + +Look for `TVDiscovery` in Metro or app logs. + +Examples: + +- payload prepared +- Android sync result +- clear operations + +### Native Android logs + +Use `adb logcat | grep TvRecommendations` + +Examples: + +- channel created or updated +- preview programs inserted or updated +- stale programs deleted +- cached payload replayed + +## Verifying Android TV Output + +1. Launch the TV build and let the home screen load. +2. Watch `adb logcat | grep TvRecommendations`. +3. Return to the Android TV / Google TV home screen. +4. Look for the `Continue and Next Up` row. +5. If needed, enable the Streamyfin channel in `Customize home` or `Manage channels`. + +Note: + +- some launchers delay or hide new preview channels +- some devices expose TV provider data per user/profile + +## Build Notes + +This feature does not currently require a fresh `prebuild` to work in the checked-in Android project. + +Why: + +- the Android integration is a local Expo module +- its receiver is declared in the module manifest +- Gradle merges it during normal Android TV builds + +Typical commands: + +- `bun run android:tv` +- `bun run ios:tv` + +## Current Limitations + +- Android TV artwork may fail on authenticated Jellyfin servers because the launcher fetches poster URLs outside the app +- Android TV currently publishes a preview channel only, not Watch Next +- tvOS and Android TV both use the same payload source, so section selection is shared unless explicitly split later + +## Future Improvements + +- add a local image proxy or cache for Android TV artwork +- add Watch Next support for resumable content +- add a native debug dump method for querying TV provider state from inside the app process diff --git a/modules/index.ts b/modules/index.ts index 6e27ca00..1f2b458f 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -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"; diff --git a/modules/tv-recommendations/android/build.gradle b/modules/tv-recommendations/android/build.gradle new file mode 100644 index 00000000..b9692ba0 --- /dev/null +++ b/modules/tv-recommendations/android/build.gradle @@ -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" + } +} diff --git a/modules/tv-recommendations/android/src/main/AndroidManifest.xml b/modules/tv-recommendations/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..87a7944c --- /dev/null +++ b/modules/tv-recommendations/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt new file mode 100644 index 00000000..11b29c39 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsModule.kt @@ -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) + } + } +} diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt new file mode 100644 index 00000000..349d89de --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt @@ -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() + + 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) + } + } +} diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt new file mode 100644 index 00000000..1fde77c7 --- /dev/null +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt @@ -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) + } +} diff --git a/modules/tv-recommendations/expo-module.config.json b/modules/tv-recommendations/expo-module.config.json new file mode 100644 index 00000000..e017f17f --- /dev/null +++ b/modules/tv-recommendations/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "tv-recommendations", + "version": "1.0.0", + "platforms": ["android"], + "android": { + "modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"] + } +} diff --git a/modules/tv-recommendations/index.ts b/modules/tv-recommendations/index.ts new file mode 100644 index 00000000..3bd16e17 --- /dev/null +++ b/modules/tv-recommendations/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/modules/tv-recommendations/src/TvRecommendations.types.ts b/modules/tv-recommendations/src/TvRecommendations.types.ts new file mode 100644 index 00000000..1b21e5ed --- /dev/null +++ b/modules/tv-recommendations/src/TvRecommendations.types.ts @@ -0,0 +1,5 @@ +export interface TvRecommendationsModuleType { + syncRecommendations(json: string): boolean; + clearRecommendations(): boolean; + refreshRecommendations(): boolean; +} diff --git a/modules/tv-recommendations/src/TvRecommendationsModule.ts b/modules/tv-recommendations/src/TvRecommendationsModule.ts new file mode 100644 index 00000000..625e34da --- /dev/null +++ b/modules/tv-recommendations/src/TvRecommendationsModule.ts @@ -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("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; +} diff --git a/modules/tv-recommendations/src/index.ts b/modules/tv-recommendations/src/index.ts new file mode 100644 index 00000000..c1798ae0 --- /dev/null +++ b/modules/tv-recommendations/src/index.ts @@ -0,0 +1,6 @@ +export type { TvRecommendationsModuleType } from "./TvRecommendations.types"; +export { + clearTvRecommendations, + refreshTvRecommendations, + syncTvRecommendations, +} from "./TvRecommendationsModule"; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 2007de1c..bd4d0f50 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -39,7 +39,7 @@ import { updateAccountToken, } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; -import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache"; +import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync"; interface Server { address: string; @@ -233,7 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const setServerMutation = useMutation({ mutationFn: async (server: Server) => { - clearTopShelfCacheSafely(); + clearTVDiscoverySafely(); const apiInstance = jellyfin?.createApi(server.address); if (!apiInstance?.basePath) throw new Error("Failed to connect"); @@ -252,7 +252,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const removeServerMutation = useMutation({ mutationFn: async () => { - clearTopShelfCacheSafely(); + clearTVDiscoverySafely(); storage.remove("serverUrl"); setApi(null); }, @@ -364,7 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ); storage.remove("token"); - clearTopShelfCacheSafely(); + clearTVDiscoverySafely(); setUser(null); setApi(null); setPluginSettings(undefined); @@ -535,7 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ (newUrl: string) => { if (!jellyfin || !api?.accessToken) return; - clearTopShelfCacheSafely(); + clearTVDiscoverySafely(); const newApi = jellyfin.createApi(newUrl, api.accessToken); setApi(newApi); // Note: We don't update storage.set("serverUrl") here diff --git a/utils/topshelf/cache.ts b/utils/topshelf/cache.ts index 867797fd..78f220c8 100644 --- a/utils/topshelf/cache.ts +++ b/utils/topshelf/cache.ts @@ -1,101 +1,11 @@ import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Platform } from "react-native"; +import { clearTopShelfCache, writeTopShelfCache } from "@/modules"; import { - clearTopShelfCache, - type TopShelfCachePayload, - type TopShelfCacheSection, - writeTopShelfCache, -} from "@/modules"; - -const TOP_SHELF_ITEM_LIMIT = 12; - -function getTopShelfImageUrl(item: BaseItemDto, api: Api): string | undefined { - const baseUrl = api.basePath; - - if (item.Type === "Episode") { - if (item.SeriesId && item.SeriesPrimaryImageTag) { - return `${baseUrl}/Items/${item.SeriesId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.SeriesPrimaryImageTag)}&width=500`; - } - - if (item.ParentPrimaryImageItemId && item.ParentPrimaryImageTag) { - return `${baseUrl}/Items/${item.ParentPrimaryImageItemId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.ParentPrimaryImageTag)}&width=500`; - } - } - - const primaryTag = item.ImageTags?.Primary; - if (item.Id && primaryTag) { - return `${baseUrl}/Items/${item.Id}/Images/Primary?quality=90&tag=${encodeURIComponent(primaryTag)}&width=500`; - } - - const backdropTag = item.BackdropImageTags?.[0]; - if (item.Id && backdropTag) { - return `${baseUrl}/Items/${item.Id}/Images/Backdrop/0?quality=90&tag=${encodeURIComponent(backdropTag)}&width=800`; - } - - return undefined; -} - -function formatEpisodeNumber(item: BaseItemDto): string | undefined { - const season = item.ParentIndexNumber; - const episode = item.IndexNumber; - - if (season != null && episode != null) { - return `S${season} • E${episode}`; - } - - if (season != null) return `Season ${season}`; - if (episode != null) return `Episode ${episode}`; - - return undefined; -} - -function getTopShelfTitle(item: BaseItemDto): string { - if (item.Type === "Episode") { - const episodeNumber = formatEpisodeNumber(item); - - if (item.SeriesName && episodeNumber) { - return `${item.SeriesName} - ${episodeNumber}`; - } - - if (item.SeriesName) return item.SeriesName; - if (episodeNumber) return episodeNumber; - return item.Name || ""; - } - - return item.Name || ""; -} - -function getTopShelfSubtitle(item: BaseItemDto): string | undefined { - if (item.Type === "Episode") return undefined; - - return item.ProductionYear ? String(item.ProductionYear) : item.Type; -} - -function sectionFromItems( - title: string, - items: BaseItemDto[] | undefined, - api: Api, -): TopShelfCacheSection | null { - const cacheItems = (items || []) - .filter((item) => item.Id && item.Name) - .slice(0, TOP_SHELF_ITEM_LIMIT) - .map((item) => ({ - id: item.Id!, - title: getTopShelfTitle(item), - subtitle: getTopShelfSubtitle(item), - imageUrl: getTopShelfImageUrl(item, api), - route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, - playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, - })); - - if (cacheItems.length === 0) return null; - - return { - title, - items: cacheItems, - }; -} + buildTVDiscoveryPayload, + type TVDiscoveryPayload, +} from "@/utils/tvDiscovery/payload"; export function updateTopShelfCache({ api, @@ -106,40 +16,29 @@ export function updateTopShelfCache({ }): void { if (Platform.OS !== "ios" || !Platform.isTV) return; - if (!api) { + const payload = buildTVDiscoveryPayload({ api, sections }); + if (!payload) { clearTopShelfCacheSafely(); return; } - const payloadSections = sections - .map((section) => sectionFromItems(section.title, section.items, api)) - .filter((section): section is TopShelfCacheSection => section !== null) - .slice(0, 3); + writeTopShelfPayload(payload, api?.accessToken || undefined); +} - if (payloadSections.length === 0) { - clearTopShelfCacheSafely(); - return; - } - - const payload: TopShelfCachePayload = { - version: 1, - updatedAt: new Date().toISOString(), - sections: payloadSections, - }; +export function writeTopShelfPayload( + payload: TVDiscoveryPayload, + apiKey?: string, +): void { + if (Platform.OS !== "ios" || !Platform.isTV) return; try { - const didWrite = writeTopShelfCache( - JSON.stringify(payload), - api.accessToken || undefined, - ); + const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey); - if (__DEV__ && !didWrite) { + if (!didWrite) { console.warn("[TopShelf] Native cache writer is unavailable"); } } catch (error) { - if (__DEV__) { - console.warn("[TopShelf] Failed to write cache", error); - } + console.warn("[TopShelf] Failed to write cache", error); } } @@ -149,12 +48,10 @@ export function clearTopShelfCacheSafely(): void { try { const didClear = clearTopShelfCache(); - if (__DEV__ && !didClear) { + if (!didClear) { console.warn("[TopShelf] Native cache clearer is unavailable"); } } catch (error) { - if (__DEV__) { - console.warn("[TopShelf] Failed to clear cache", error); - } + console.warn("[TopShelf] Failed to clear cache", error); } } diff --git a/utils/tvDiscovery/payload.ts b/utils/tvDiscovery/payload.ts new file mode 100644 index 00000000..6552c198 --- /dev/null +++ b/utils/tvDiscovery/payload.ts @@ -0,0 +1,140 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; + +const TV_DISCOVERY_ITEM_LIMIT = 12; +const TV_DISCOVERY_SECTION_LIMIT = 3; + +export interface TVDiscoveryItem { + id: string; + itemType?: string; + title: string; + subtitle?: string; + imageUrl?: string; + route: string; + playRoute?: string; +} + +export interface TVDiscoverySection { + title: string; + items: TVDiscoveryItem[]; +} + +export interface TVDiscoveryPayload { + version: 1; + updatedAt: string; + sections: TVDiscoverySection[]; +} + +function getTVDiscoveryImageUrl( + item: BaseItemDto, + api: Api, +): string | undefined { + const baseUrl = api.basePath; + + if (item.Type === "Episode") { + if (item.SeriesId && item.SeriesPrimaryImageTag) { + return `${baseUrl}/Items/${item.SeriesId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.SeriesPrimaryImageTag)}&width=500`; + } + + if (item.ParentPrimaryImageItemId && item.ParentPrimaryImageTag) { + return `${baseUrl}/Items/${item.ParentPrimaryImageItemId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.ParentPrimaryImageTag)}&width=500`; + } + } + + const primaryTag = item.ImageTags?.Primary; + if (item.Id && primaryTag) { + return `${baseUrl}/Items/${item.Id}/Images/Primary?quality=90&tag=${encodeURIComponent(primaryTag)}&width=500`; + } + + const backdropTag = item.BackdropImageTags?.[0]; + if (item.Id && backdropTag) { + return `${baseUrl}/Items/${item.Id}/Images/Backdrop/0?quality=90&tag=${encodeURIComponent(backdropTag)}&width=800`; + } + + return undefined; +} + +function formatEpisodeNumber(item: BaseItemDto): string | undefined { + const season = item.ParentIndexNumber; + const episode = item.IndexNumber; + + if (season != null && episode != null) { + return `S${season} • E${episode}`; + } + + if (season != null) return `Season ${season}`; + if (episode != null) return `Episode ${episode}`; + + return undefined; +} + +function getTVDiscoveryTitle(item: BaseItemDto): string { + if (item.Type === "Episode") { + const episodeNumber = formatEpisodeNumber(item); + + if (item.SeriesName && episodeNumber) { + return `${item.SeriesName} - ${episodeNumber}`; + } + + if (item.SeriesName) return item.SeriesName; + if (episodeNumber) return episodeNumber; + return item.Name || ""; + } + + return item.Name || ""; +} + +function getTVDiscoverySubtitle(item: BaseItemDto): string | undefined { + if (item.Type === "Episode") return undefined; + + return item.ProductionYear ? String(item.ProductionYear) : item.Type; +} + +function sectionFromItems( + title: string, + items: BaseItemDto[] | undefined, + api: Api, +): TVDiscoverySection | null { + const payloadItems = (items || []) + .filter((item) => item.Id && item.Name) + .slice(0, TV_DISCOVERY_ITEM_LIMIT) + .map((item) => ({ + id: item.Id!, + itemType: item.Type || undefined, + title: getTVDiscoveryTitle(item), + subtitle: getTVDiscoverySubtitle(item), + imageUrl: getTVDiscoveryImageUrl(item, api), + route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, + playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, + })); + + if (payloadItems.length === 0) return null; + + return { + title, + items: payloadItems, + }; +} + +export function buildTVDiscoveryPayload({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): TVDiscoveryPayload | null { + if (!api) return null; + + const payloadSections = sections + .map((section) => sectionFromItems(section.title, section.items, api)) + .filter((section): section is TVDiscoverySection => section !== null) + .slice(0, TV_DISCOVERY_SECTION_LIMIT); + + if (payloadSections.length === 0) return null; + + return { + version: 1, + updatedAt: new Date().toISOString(), + sections: payloadSections, + }; +} diff --git a/utils/tvDiscovery/sync.ts b/utils/tvDiscovery/sync.ts new file mode 100644 index 00000000..4b92602f --- /dev/null +++ b/utils/tvDiscovery/sync.ts @@ -0,0 +1,88 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Platform } from "react-native"; +import { clearTvRecommendations, syncTvRecommendations } from "@/modules"; +import { + clearTopShelfCacheSafely, + writeTopShelfPayload, +} from "@/utils/topshelf/cache"; +import { buildTVDiscoveryPayload } from "./payload"; + +export function updateTVDiscovery({ + api, + sections, +}: { + api: Api | null | undefined; + sections: Array<{ title: string; items: BaseItemDto[] | undefined }>; +}): void { + if (!Platform.isTV) return; + + const payload = buildTVDiscoveryPayload({ api, sections }); + + if (!payload) { + console.log("[TVDiscovery] No payload generated; clearing TV discovery"); + clearTVDiscoverySafely(); + return; + } + + const sectionSummary = payload.sections + .map((section) => `${section.title}:${section.items.length}`) + .join(", "); + console.log( + `[TVDiscovery] Sync payload prepared for ${Platform.OS} TV with ${payload.sections.length} section(s): ${sectionSummary}`, + ); + + if (Platform.OS === "ios") { + writeTopShelfPayload(payload, api?.accessToken || undefined); + return; + } + + if (Platform.OS === "android") { + try { + const didSync = syncTvRecommendations(JSON.stringify(payload)); + + console.log(`[TVDiscovery] Android sync result: ${didSync}`); + + if (!didSync) { + console.warn( + "[TVDiscovery] Android recommendations sync is unavailable", + ); + } + } catch (error) { + console.warn( + "[TVDiscovery] Failed to sync Android recommendations", + error, + ); + } + } +} + +export function clearTVDiscoverySafely(): void { + if (!Platform.isTV) return; + + console.log(`[TVDiscovery] Clearing TV discovery for ${Platform.OS} TV`); + + if (Platform.OS === "ios") { + clearTopShelfCacheSafely(); + return; + } + + if (Platform.OS === "android") { + try { + const didClear = clearTvRecommendations(); + + console.log(`[TVDiscovery] Android clear result: ${didClear}`); + + if (!didClear) { + console.warn( + "[TVDiscovery] Android recommendations clearer is unavailable", + ); + } + } catch (error) { + console.warn( + "[TVDiscovery] Failed to clear Android recommendations", + error, + ); + } + } +}