mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-22 06:46:46 +01:00
feat(android-tv): TV recommendations (#1575)
This commit is contained in:
@@ -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";
|
||||
|
||||
46
modules/tv-recommendations/android/build.gradle
Normal file
46
modules/tv-recommendations/android/build.gradle
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
8
modules/tv-recommendations/expo-module.config.json
Normal file
8
modules/tv-recommendations/expo-module.config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "tv-recommendations",
|
||||
"version": "1.0.0",
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.tvrecommendations.TvRecommendationsModule"]
|
||||
}
|
||||
}
|
||||
1
modules/tv-recommendations/index.ts
Normal file
1
modules/tv-recommendations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface TvRecommendationsModuleType {
|
||||
syncRecommendations(json: string): boolean;
|
||||
clearRecommendations(): boolean;
|
||||
refreshRecommendations(): boolean;
|
||||
}
|
||||
26
modules/tv-recommendations/src/TvRecommendationsModule.ts
Normal file
26
modules/tv-recommendations/src/TvRecommendationsModule.ts
Normal 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;
|
||||
}
|
||||
6
modules/tv-recommendations/src/index.ts
Normal file
6
modules/tv-recommendations/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { TvRecommendationsModuleType } from "./TvRecommendations.types";
|
||||
export {
|
||||
clearTvRecommendations,
|
||||
refreshTvRecommendations,
|
||||
syncTvRecommendations,
|
||||
} from "./TvRecommendationsModule";
|
||||
Reference in New Issue
Block a user