mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-10 07:50:30 +01:00
Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface
This commit is contained in:
@@ -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?
|
||||
|
||||
1
app.json
1
app.json
@@ -140,6 +140,7 @@
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withTVOSAppIcon.js"],
|
||||
["./plugins/withTVOSTopShelf.js"],
|
||||
["./plugins/withTVXcodeEnv.js"],
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
|
||||
33
app/topshelf/item.tsx
Normal file
33
app/topshelf/item.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
export default function TopShelfItemRedirect() {
|
||||
const router = useRouter();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const { id, type } = useLocalSearchParams<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootNavigationState?.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
router.replace("/(auth)/(tabs)/(home)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "Series") {
|
||||
router.replace(`/(auth)/(tabs)/(home)/series/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`);
|
||||
}, [id, rootNavigationState?.key, router, type]);
|
||||
|
||||
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
|
||||
}
|
||||
32
app/topshelf/play.tsx
Normal file
32
app/topshelf/play.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
export default function TopShelfPlayRedirect() {
|
||||
const router = useRouter();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const { id } = useLocalSearchParams<{
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootNavigationState?.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
router.replace("/(auth)/(tabs)/(home)");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: id,
|
||||
offline: "false",
|
||||
});
|
||||
|
||||
router.replace(`/player/direct-player?${queryParams.toString()}`);
|
||||
}, [id, rootNavigationState?.key, router]);
|
||||
|
||||
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!expo-env.d.ts",
|
||||
"!modules/**/android/build",
|
||||
"!.expo",
|
||||
"!docs/jellyfin-openapi-stable.json"
|
||||
]
|
||||
|
||||
@@ -41,6 +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 { updateTVDiscovery } from "@/utils/tvDiscovery/sync";
|
||||
|
||||
const HORIZONTAL_PADDING = scaleSize(60);
|
||||
const TOP_PADDING = scaleSize(100);
|
||||
@@ -258,6 +259,18 @@ export const Home = () => {
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateTVDiscovery({
|
||||
api,
|
||||
sections: [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
items: heroItems,
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [api, heroItems, t]);
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries],
|
||||
|
||||
136
docs/tv-discovery.md
Normal file
136
docs/tv-discovery.md
Normal file
@@ -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
|
||||
@@ -24,3 +24,16 @@ export type {
|
||||
VideoSource as MpvVideoSource,
|
||||
} from "./mpv-player";
|
||||
export { MpvPlayerView } from "./mpv-player";
|
||||
// Top Shelf cache (tvOS)
|
||||
export type {
|
||||
TopShelfCacheItem,
|
||||
TopShelfCachePayload,
|
||||
TopShelfCacheSection,
|
||||
} from "./top-shelf-cache";
|
||||
export { clearTopShelfCache, writeTopShelfCache } from "./top-shelf-cache";
|
||||
// TV recommendations (Android TV)
|
||||
export {
|
||||
clearTvRecommendations,
|
||||
refreshTvRecommendations,
|
||||
syncTvRecommendations,
|
||||
} from "./tv-recommendations";
|
||||
|
||||
6
modules/top-shelf-cache/expo-module.config.json
Normal file
6
modules/top-shelf-cache/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["TopShelfCacheModule"]
|
||||
}
|
||||
}
|
||||
1
modules/top-shelf-cache/index.ts
Normal file
1
modules/top-shelf-cache/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
23
modules/top-shelf-cache/ios/TopShelfCache.podspec
Normal file
23
modules/top-shelf-cache/ios/TopShelfCache.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'TopShelfCache'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Shared Top Shelf cache writer for Streamyfin tvOS'
|
||||
s.description = 'Writes lightweight Top Shelf cache payloads to an App Group container for the tvOS extension.'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_VERSION' => '5.9'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift}"
|
||||
end
|
||||
112
modules/top-shelf-cache/ios/TopShelfCacheModule.swift
Normal file
112
modules/top-shelf-cache/ios/TopShelfCacheModule.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import ExpoModulesCore
|
||||
import Foundation
|
||||
import Security
|
||||
#if canImport(TVServices)
|
||||
import TVServices
|
||||
#endif
|
||||
|
||||
public class TopShelfCacheModule: Module {
|
||||
private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier"
|
||||
private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier"
|
||||
private let cacheKey = "TopShelfCache"
|
||||
private let apiKeyService = "StreamyfinTopShelf"
|
||||
private let apiKeyAccount = "JellyfinApiKey"
|
||||
private var appGroupIdentifier: String? {
|
||||
if let appGroupIdentifier = Bundle.main.object(
|
||||
forInfoDictionaryKey: appGroupInfoPlistKey
|
||||
) as? String {
|
||||
return appGroupIdentifier
|
||||
}
|
||||
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return "group.\(bundleIdentifier)"
|
||||
}
|
||||
private var keychainAccessGroupIdentifier: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: keychainAccessGroupInfoPlistKey) as? String
|
||||
}
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("TopShelfCache")
|
||||
|
||||
Function("writeCache") { (json: String, apiKey: String?) -> Bool in
|
||||
guard
|
||||
let appGroupIdentifier = appGroupIdentifier,
|
||||
let defaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
defaults.set(json, forKey: cacheKey)
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: "\(cacheKey)UpdatedAt")
|
||||
defaults.synchronize()
|
||||
let didSaveAPIKey = saveAPIKey(apiKey)
|
||||
|
||||
#if canImport(TVServices)
|
||||
TVTopShelfContentProvider.topShelfContentDidChange()
|
||||
#endif
|
||||
|
||||
return didSaveAPIKey
|
||||
}
|
||||
|
||||
Function("clearCache") { () -> Bool in
|
||||
guard
|
||||
let appGroupIdentifier = appGroupIdentifier,
|
||||
let defaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
defaults.removeObject(forKey: cacheKey)
|
||||
defaults.removeObject(forKey: "\(cacheKey)UpdatedAt")
|
||||
defaults.synchronize()
|
||||
let didDeleteAPIKey = deleteAPIKey()
|
||||
|
||||
#if canImport(TVServices)
|
||||
TVTopShelfContentProvider.topShelfContentDidChange()
|
||||
#endif
|
||||
|
||||
return didDeleteAPIKey
|
||||
}
|
||||
}
|
||||
|
||||
private func baseAPIKeyQuery() -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: apiKeyService,
|
||||
kSecAttrAccount as String: apiKeyAccount
|
||||
]
|
||||
|
||||
if let keychainAccessGroupIdentifier {
|
||||
query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
private func saveAPIKey(_ apiKey: String?) -> Bool {
|
||||
guard deleteAPIKey() else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard
|
||||
let apiKey,
|
||||
!apiKey.isEmpty,
|
||||
let data = apiKey.data(using: .utf8)
|
||||
else {
|
||||
return true
|
||||
}
|
||||
|
||||
var query = baseAPIKeyQuery()
|
||||
query[kSecValueData as String] = data
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
return status == errSecSuccess
|
||||
}
|
||||
|
||||
private func deleteAPIKey() -> Bool {
|
||||
let status = SecItemDelete(baseAPIKeyQuery() as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
}
|
||||
21
modules/top-shelf-cache/src/TopShelfCache.types.ts
Normal file
21
modules/top-shelf-cache/src/TopShelfCache.types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type TopShelfCacheModuleEvents = Record<string, never>;
|
||||
|
||||
export interface TopShelfCacheItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
route: string;
|
||||
playRoute?: string;
|
||||
}
|
||||
|
||||
export interface TopShelfCacheSection {
|
||||
title: string;
|
||||
items: TopShelfCacheItem[];
|
||||
}
|
||||
|
||||
export interface TopShelfCachePayload {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
sections: TopShelfCacheSection[];
|
||||
}
|
||||
37
modules/top-shelf-cache/src/TopShelfCacheModule.ts
Normal file
37
modules/top-shelf-cache/src/TopShelfCacheModule.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NativeModule, requireNativeModule } from "expo";
|
||||
import { Platform } from "react-native";
|
||||
import type { TopShelfCacheModuleEvents } from "./TopShelfCache.types";
|
||||
|
||||
declare class TopShelfCacheModuleType extends NativeModule<TopShelfCacheModuleEvents> {
|
||||
writeCache(json: string, apiKey?: string): boolean;
|
||||
clearCache(): boolean;
|
||||
}
|
||||
|
||||
let TopShelfCacheNativeModule: TopShelfCacheModuleType | null = null;
|
||||
|
||||
if (Platform.OS === "ios" && Platform.isTV) {
|
||||
try {
|
||||
TopShelfCacheNativeModule =
|
||||
requireNativeModule<TopShelfCacheModuleType>("TopShelfCache");
|
||||
} catch {
|
||||
TopShelfCacheNativeModule = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTopShelfCache(json: string, apiKey?: string): boolean {
|
||||
if (!TopShelfCacheNativeModule) return false;
|
||||
|
||||
try {
|
||||
return TopShelfCacheNativeModule.writeCache(json, apiKey);
|
||||
} catch {
|
||||
try {
|
||||
return TopShelfCacheNativeModule.writeCache(json);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTopShelfCache(): boolean {
|
||||
return TopShelfCacheNativeModule?.clearCache() ?? false;
|
||||
}
|
||||
2
modules/top-shelf-cache/src/index.ts
Normal file
2
modules/top-shelf-cache/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./TopShelfCache.types";
|
||||
export { clearTopShelfCache, writeTopShelfCache } from "./TopShelfCacheModule";
|
||||
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";
|
||||
196
plugins/withTVOSTopShelf.js
Normal file
196
plugins/withTVOSTopShelf.js
Normal file
@@ -0,0 +1,196 @@
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withInfoPlist,
|
||||
withXcodeProject,
|
||||
} = require("@expo/config-plugins");
|
||||
|
||||
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
||||
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
||||
const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
|
||||
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
||||
"StreamyfinKeychainAccessGroupIdentifier";
|
||||
|
||||
function getBundleIdentifier(config) {
|
||||
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
||||
}
|
||||
|
||||
function getAppGroupIdentifier(config) {
|
||||
return `group.${getBundleIdentifier(config)}`;
|
||||
}
|
||||
|
||||
function getKeychainAccessGroupIdentifier(config) {
|
||||
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
||||
}
|
||||
|
||||
function getBuildConfigurations(project, configurationListId) {
|
||||
const configurationList =
|
||||
project.hash.project.objects.XCConfigurationList[configurationListId];
|
||||
|
||||
if (!configurationList?.buildConfigurations) return [];
|
||||
|
||||
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
||||
return configurationList.buildConfigurations
|
||||
.map((config) => buildConfigurations[config.value])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureAppGroup(value, appGroupIdentifier) {
|
||||
const groups = Array.isArray(value) ? value : [];
|
||||
return groups.includes(appGroupIdentifier)
|
||||
? groups
|
||||
: [...groups, appGroupIdentifier];
|
||||
}
|
||||
|
||||
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
||||
const groups = Array.isArray(value) ? value : [];
|
||||
return groups.includes(keychainAccessGroupIdentifier)
|
||||
? groups
|
||||
: [...groups, keychainAccessGroupIdentifier];
|
||||
}
|
||||
|
||||
function ensureAppExtension(
|
||||
appExtensions,
|
||||
targetName,
|
||||
bundleIdentifier,
|
||||
appGroupIdentifier,
|
||||
keychainAccessGroupIdentifier,
|
||||
) {
|
||||
const extensionConfig = {
|
||||
targetName,
|
||||
bundleIdentifier,
|
||||
entitlements: {
|
||||
"com.apple.security.application-groups": [appGroupIdentifier],
|
||||
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
||||
},
|
||||
};
|
||||
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
|
||||
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
||||
const existingIndex = extensions.findIndex(
|
||||
(appExtension) => appExtension?.targetName === targetName,
|
||||
);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return [...extensions, extensionConfig];
|
||||
}
|
||||
|
||||
return extensions.map((appExtension, index) =>
|
||||
index === existingIndex ? extensionConfig : appExtension,
|
||||
);
|
||||
}
|
||||
|
||||
const withTVOSTopShelf = (config) => {
|
||||
const appGroupIdentifier = getAppGroupIdentifier(config);
|
||||
const keychainAccessGroupIdentifier =
|
||||
getKeychainAccessGroupIdentifier(config);
|
||||
const bundleIdentifier = getBundleIdentifier(config);
|
||||
const extensionBundleIdentifier = `${bundleIdentifier}.TopShelf`;
|
||||
const isTVBuild = process.env.EXPO_TV === "1";
|
||||
|
||||
if (isTVBuild) {
|
||||
config.extra = {
|
||||
...config.extra,
|
||||
eas: {
|
||||
...config.extra?.eas,
|
||||
build: {
|
||||
...config.extra?.eas?.build,
|
||||
experimental: {
|
||||
...config.extra?.eas?.build?.experimental,
|
||||
ios: {
|
||||
...config.extra?.eas?.build?.experimental?.ios,
|
||||
appExtensions: ensureAppExtension(
|
||||
config.extra?.eas?.build?.experimental?.ios?.appExtensions,
|
||||
EXTENSION_TARGET_NAME,
|
||||
extensionBundleIdentifier,
|
||||
appGroupIdentifier,
|
||||
keychainAccessGroupIdentifier,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
config = withInfoPlist(config, (config) => {
|
||||
config.modResults[APP_GROUP_INFO_PLIST_KEY] = appGroupIdentifier;
|
||||
config.modResults[KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY] =
|
||||
keychainAccessGroupIdentifier;
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withEntitlementsPlist(config, (config) => {
|
||||
config.modResults["com.apple.security.application-groups"] =
|
||||
ensureAppGroup(
|
||||
config.modResults["com.apple.security.application-groups"],
|
||||
appGroupIdentifier,
|
||||
);
|
||||
config.modResults["keychain-access-groups"] = ensureKeychainAccessGroup(
|
||||
config.modResults["keychain-access-groups"],
|
||||
keychainAccessGroupIdentifier,
|
||||
);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTVBuild) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return withXcodeProject(config, (config) => {
|
||||
const project = config.modResults;
|
||||
|
||||
if (project.pbxTargetByName(EXTENSION_TARGET_NAME)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const target = project.addTarget(
|
||||
EXTENSION_TARGET_NAME,
|
||||
"app_extension",
|
||||
EXTENSION_TARGET_NAME,
|
||||
extensionBundleIdentifier,
|
||||
);
|
||||
|
||||
project.addBuildPhase(
|
||||
[`${TARGET_SOURCE_DIR}/TopShelfProvider.swift`],
|
||||
"PBXSourcesBuildPhase",
|
||||
"Sources",
|
||||
target.uuid,
|
||||
);
|
||||
project.addBuildPhase(
|
||||
["TVServices.framework"],
|
||||
"PBXFrameworksBuildPhase",
|
||||
"Frameworks",
|
||||
target.uuid,
|
||||
);
|
||||
|
||||
const buildConfigurations = getBuildConfigurations(
|
||||
project,
|
||||
target.pbxNativeTarget.buildConfigurationList,
|
||||
);
|
||||
|
||||
for (const buildConfig of buildConfigurations) {
|
||||
buildConfig.buildSettings = {
|
||||
...buildConfig.buildSettings,
|
||||
CODE_SIGN_ENTITLEMENTS: `${TARGET_SOURCE_DIR}/${EXTENSION_TARGET_NAME}.entitlements`,
|
||||
APPLICATION_EXTENSION_API_ONLY: "YES",
|
||||
CURRENT_PROJECT_VERSION: "1",
|
||||
INFOPLIST_FILE: `${TARGET_SOURCE_DIR}/Info.plist`,
|
||||
IPHONEOS_DEPLOYMENT_TARGET:
|
||||
buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET || "15.6",
|
||||
MARKETING_VERSION: config.version || "1.0",
|
||||
PRODUCT_BUNDLE_IDENTIFIER: extensionBundleIdentifier,
|
||||
PRODUCT_NAME: `"${EXTENSION_TARGET_NAME}"`,
|
||||
SDKROOT: "appletvos",
|
||||
SKIP_INSTALL: "YES",
|
||||
SWIFT_VERSION: "5.9",
|
||||
APP_GROUP_IDENTIFIER: appGroupIdentifier,
|
||||
KEYCHAIN_ACCESS_GROUP_IDENTIFIER: `"${keychainAccessGroupIdentifier}"`,
|
||||
SUPPORTED_PLATFORMS: '"appletvos appletvsimulator"',
|
||||
TARGETED_DEVICE_FAMILY: "3",
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withTVOSTopShelf;
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
updateAccountToken,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -232,6 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const setServerMutation = useMutation({
|
||||
mutationFn: async (server: Server) => {
|
||||
clearTVDiscoverySafely();
|
||||
const apiInstance = jellyfin?.createApi(server.address);
|
||||
|
||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||
@@ -250,6 +252,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
clearTVDiscoverySafely();
|
||||
storage.remove("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
@@ -361,6 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
);
|
||||
|
||||
storage.remove("token");
|
||||
clearTVDiscoverySafely();
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
@@ -531,6 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
(newUrl: string) => {
|
||||
if (!jellyfin || !api?.accessToken) return;
|
||||
|
||||
clearTVDiscoverySafely();
|
||||
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
||||
setApi(newApi);
|
||||
// Note: We don't update storage.set("serverUrl") here
|
||||
@@ -661,10 +666,11 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
|
||||
if (loaded === false) return;
|
||||
|
||||
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
||||
const isTopShelfLaunchRoute = segments[0] === "topshelf";
|
||||
|
||||
if (!user?.Id && inAuthGroup) {
|
||||
router.replace("/login");
|
||||
} else if (user?.Id && !inAuthGroup) {
|
||||
} else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, [user, segments, loaded]);
|
||||
|
||||
40
targets/StreamyfinTopShelf/Info.plist
Normal file
40
targets/StreamyfinTopShelf/Info.plist
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>StreamyfinAppGroupIdentifier</key>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
<key>StreamyfinKeychainAccessGroupIdentifier</key>
|
||||
<string>$(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Streamyfin Top Shelf</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.tv-top-shelf</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).TopShelfProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
14
targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements
Normal file
14
targets/StreamyfinTopShelf/StreamyfinTopShelf.entitlements
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
125
targets/StreamyfinTopShelf/TopShelfProvider.swift
Normal file
125
targets/StreamyfinTopShelf/TopShelfProvider.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import TVServices
|
||||
|
||||
private let appGroupInfoPlistKey = "StreamyfinAppGroupIdentifier"
|
||||
private let keychainAccessGroupInfoPlistKey = "StreamyfinKeychainAccessGroupIdentifier"
|
||||
private let cacheKey = "TopShelfCache"
|
||||
private let apiKeyService = "StreamyfinTopShelf"
|
||||
private let apiKeyAccount = "JellyfinApiKey"
|
||||
|
||||
private struct TopShelfCachePayload: Decodable {
|
||||
let sections: [TopShelfCacheSection]
|
||||
}
|
||||
|
||||
private struct TopShelfCacheSection: Decodable {
|
||||
let title: String
|
||||
let items: [TopShelfCacheItem]
|
||||
}
|
||||
|
||||
private struct TopShelfCacheItem: Decodable {
|
||||
let id: String
|
||||
let title: String
|
||||
let imageUrl: String?
|
||||
let route: String
|
||||
let playRoute: String?
|
||||
}
|
||||
|
||||
final class TopShelfProvider: TVTopShelfContentProvider {
|
||||
override func loadTopShelfContent(
|
||||
completionHandler: @escaping (TVTopShelfContent?) -> Void
|
||||
) {
|
||||
guard
|
||||
let appGroupIdentifier = Bundle.main.object(
|
||||
forInfoDictionaryKey: appGroupInfoPlistKey
|
||||
) as? String,
|
||||
let defaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let json = defaults.string(forKey: cacheKey),
|
||||
let data = json.data(using: .utf8),
|
||||
let payload = try? JSONDecoder().decode(TopShelfCachePayload.self, from: data)
|
||||
else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let apiKey = readAPIKey()
|
||||
let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection<TVTopShelfSectionedItem>? in
|
||||
let items = section.items.compactMap { makeTopShelfItem($0, apiKey: apiKey) }
|
||||
guard !items.isEmpty else { return nil }
|
||||
|
||||
let collection = TVTopShelfItemCollection(items: items)
|
||||
collection.title = section.title
|
||||
return collection
|
||||
}
|
||||
|
||||
completionHandler(sections.isEmpty ? nil : TVTopShelfSectionedContent(sections: sections))
|
||||
}
|
||||
|
||||
private func makeTopShelfItem(
|
||||
_ cacheItem: TopShelfCacheItem,
|
||||
apiKey: String?
|
||||
) -> TVTopShelfSectionedItem? {
|
||||
guard let route = URL(string: cacheItem.route) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let item = TVTopShelfSectionedItem(identifier: cacheItem.id)
|
||||
item.title = cacheItem.title
|
||||
item.imageShape = .poster
|
||||
item.displayAction = TVTopShelfAction(url: route)
|
||||
|
||||
if let playRoute = cacheItem.playRoute, let playURL = URL(string: playRoute) {
|
||||
item.playAction = TVTopShelfAction(url: playURL)
|
||||
}
|
||||
|
||||
if let imageUrl = cacheItem.imageUrl,
|
||||
let url = imageURL(from: imageUrl, apiKey: apiKey) {
|
||||
item.setImageURL(url, for: .screenScale1x)
|
||||
item.setImageURL(url, for: .screenScale2x)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
private func imageURL(from imageUrl: String, apiKey: String?) -> URL? {
|
||||
guard var components = URLComponents(string: imageUrl) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let apiKey, !apiKey.isEmpty {
|
||||
var queryItems = components.queryItems ?? []
|
||||
queryItems.removeAll { $0.name == "api_key" }
|
||||
queryItems.append(URLQueryItem(name: "api_key", value: apiKey))
|
||||
components.queryItems = queryItems
|
||||
}
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func readAPIKey() -> String? {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: apiKeyService,
|
||||
kSecAttrAccount as String: apiKeyAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
if let keychainAccessGroupIdentifier = Bundle.main.object(
|
||||
forInfoDictionaryKey: keychainAccessGroupInfoPlistKey
|
||||
) as? String {
|
||||
query[kSecAttrAccessGroup as String] = keychainAccessGroupIdentifier
|
||||
}
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard
|
||||
status == errSecSuccess,
|
||||
let data = item as? Data
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
57
utils/topshelf/cache.ts
Normal file
57
utils/topshelf/cache.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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 {
|
||||
buildTVDiscoveryPayload,
|
||||
type TVDiscoveryPayload,
|
||||
} from "@/utils/tvDiscovery/payload";
|
||||
|
||||
export function updateTopShelfCache({
|
||||
api,
|
||||
sections,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
|
||||
}): void {
|
||||
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||
|
||||
const payload = buildTVDiscoveryPayload({ api, sections });
|
||||
if (!payload) {
|
||||
clearTopShelfCacheSafely();
|
||||
return;
|
||||
}
|
||||
|
||||
writeTopShelfPayload(payload, api?.accessToken || undefined);
|
||||
}
|
||||
|
||||
export function writeTopShelfPayload(
|
||||
payload: TVDiscoveryPayload,
|
||||
apiKey?: string,
|
||||
): void {
|
||||
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const didWrite = writeTopShelfCache(JSON.stringify(payload), apiKey);
|
||||
|
||||
if (!didWrite) {
|
||||
console.warn("[TopShelf] Native cache writer is unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[TopShelf] Failed to write cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTopShelfCacheSafely(): void {
|
||||
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const didClear = clearTopShelfCache();
|
||||
|
||||
if (!didClear) {
|
||||
console.warn("[TopShelf] Native cache clearer is unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[TopShelf] Failed to clear cache", error);
|
||||
}
|
||||
}
|
||||
140
utils/tvDiscovery/payload.ts
Normal file
140
utils/tvDiscovery/payload.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
88
utils/tvDiscovery/sync.ts
Normal file
88
utils/tvDiscovery/sync.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user