mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-24 07:46:37 +01:00
feat(tvos): Add TopShelf Extension (#1561)
This commit is contained in:
1
app.json
1
app.json
@@ -140,6 +140,7 @@
|
|||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withTVOSAppIcon.js"],
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
|
["./plugins/withTVOSTopShelf.js"],
|
||||||
["./plugins/withTVXcodeEnv.js"],
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.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",
|
"!android",
|
||||||
"!Streamyfin.app",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr",
|
"!utils/jellyseerr",
|
||||||
|
"!expo-env.d.ts",
|
||||||
|
"!modules/**/android/build",
|
||||||
"!.expo",
|
"!.expo",
|
||||||
"!docs/jellyfin-openapi-stable.json"
|
"!docs/jellyfin-openapi-stable.json"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
import { updateTopShelfCache } from "@/utils/topshelf/cache";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = scaleSize(60);
|
const HORIZONTAL_PADDING = scaleSize(60);
|
||||||
const TOP_PADDING = scaleSize(100);
|
const TOP_PADDING = scaleSize(100);
|
||||||
@@ -258,6 +259,18 @@ export const Home = () => {
|
|||||||
refetchInterval: 60 * 1000,
|
refetchInterval: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateTopShelfCache({
|
||||||
|
api,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: t("home.continue_and_next_up"),
|
||||||
|
items: heroItems,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, [api, heroItems, t]);
|
||||||
|
|
||||||
const userViews = useMemo(
|
const userViews = useMemo(
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
[data, settings?.hiddenLibraries],
|
[data, settings?.hiddenLibraries],
|
||||||
|
|||||||
@@ -24,3 +24,10 @@ export type {
|
|||||||
VideoSource as MpvVideoSource,
|
VideoSource as MpvVideoSource,
|
||||||
} from "./mpv-player";
|
} from "./mpv-player";
|
||||||
export { MpvPlayerView } 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";
|
||||||
|
|||||||
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";
|
||||||
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,
|
updateAccountToken,
|
||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -232,6 +233,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const setServerMutation = useMutation({
|
const setServerMutation = useMutation({
|
||||||
mutationFn: async (server: Server) => {
|
mutationFn: async (server: Server) => {
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
const apiInstance = jellyfin?.createApi(server.address);
|
const apiInstance = jellyfin?.createApi(server.address);
|
||||||
|
|
||||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||||
@@ -250,6 +252,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const removeServerMutation = useMutation({
|
const removeServerMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
storage.remove("serverUrl");
|
storage.remove("serverUrl");
|
||||||
setApi(null);
|
setApi(null);
|
||||||
},
|
},
|
||||||
@@ -361,6 +364,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
setPluginSettings(undefined);
|
setPluginSettings(undefined);
|
||||||
@@ -531,6 +535,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
(newUrl: string) => {
|
(newUrl: string) => {
|
||||||
if (!jellyfin || !api?.accessToken) return;
|
if (!jellyfin || !api?.accessToken) return;
|
||||||
|
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
||||||
setApi(newApi);
|
setApi(newApi);
|
||||||
// Note: We don't update storage.set("serverUrl") here
|
// Note: We don't update storage.set("serverUrl") here
|
||||||
@@ -661,10 +666,11 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
|
|||||||
if (loaded === false) return;
|
if (loaded === false) return;
|
||||||
|
|
||||||
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
||||||
|
const isTopShelfLaunchRoute = segments[0] === "topshelf";
|
||||||
|
|
||||||
if (!user?.Id && inAuthGroup) {
|
if (!user?.Id && inAuthGroup) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
} else if (user?.Id && !inAuthGroup) {
|
} else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) {
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
}
|
}
|
||||||
}, [user, segments, loaded]);
|
}, [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)
|
||||||
|
}
|
||||||
|
}
|
||||||
160
utils/topshelf/cache.ts
Normal file
160
utils/topshelf/cache.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTopShelfCache({
|
||||||
|
api,
|
||||||
|
sections,
|
||||||
|
}: {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
sections: Array<{ title: string; items: BaseItemDto[] | undefined }>;
|
||||||
|
}): void {
|
||||||
|
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadSections = sections
|
||||||
|
.map((section) => sectionFromItems(section.title, section.items, api))
|
||||||
|
.filter((section): section is TopShelfCacheSection => section !== null)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (payloadSections.length === 0) {
|
||||||
|
clearTopShelfCacheSafely();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: TopShelfCachePayload = {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
sections: payloadSections,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const didWrite = writeTopShelfCache(
|
||||||
|
JSON.stringify(payload),
|
||||||
|
api.accessToken || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (__DEV__ && !didWrite) {
|
||||||
|
console.warn("[TopShelf] Native cache writer is unavailable");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (__DEV__) {
|
||||||
|
console.warn("[TopShelf] Failed to write cache", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTopShelfCacheSafely(): void {
|
||||||
|
if (Platform.OS !== "ios" || !Platform.isTV) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const didClear = clearTopShelfCache();
|
||||||
|
|
||||||
|
if (__DEV__ && !didClear) {
|
||||||
|
console.warn("[TopShelf] Native cache clearer is unavailable");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (__DEV__) {
|
||||||
|
console.warn("[TopShelf] Failed to clear cache", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user