mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-03 16:59:08 +00:00
Merge branch 'NuvioMedia:main' into main
This commit is contained in:
commit
ca4edfc638
19 changed files with 91868 additions and 435 deletions
|
|
@ -92,8 +92,8 @@ android {
|
||||||
applicationId 'com.nuvio.app'
|
applicationId 'com.nuvio.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 38
|
versionCode 40
|
||||||
versionName "1.4.2"
|
versionName "1.4.4"
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ android {
|
||||||
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||||
applicationVariants.all { variant ->
|
applicationVariants.all { variant ->
|
||||||
variant.outputs.each { output ->
|
variant.outputs.each { output ->
|
||||||
def baseVersionCode = 38 // Current versionCode 38 from defaultConfig
|
def baseVersionCode = 40 // Current versionCode 40 from defaultConfig
|
||||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||||
|
|
||||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@
|
||||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||||
<string name="expo_runtime_version">1.4.2</string>
|
<string name="expo_runtime_version">1.4.4</string>
|
||||||
</resources>
|
</resources>
|
||||||
8
app.json
8
app.json
|
|
@ -2,7 +2,7 @@
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Nuvio",
|
"name": "Nuvio",
|
||||||
"slug": "nuvio",
|
"slug": "nuvio",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"backgroundColor": "#020404",
|
"backgroundColor": "#020404",
|
||||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||||
"buildNumber": "38",
|
"buildNumber": "40",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS"
|
||||||
],
|
],
|
||||||
"package": "com.nuvio.app",
|
"package": "com.nuvio.app",
|
||||||
"versionCode": 38,
|
"versionCode": 40,
|
||||||
"architectures": [
|
"architectures": [
|
||||||
"arm64-v8a",
|
"arm64-v8a",
|
||||||
"armeabi-v7a",
|
"armeabi-v7a",
|
||||||
|
|
@ -113,6 +113,6 @@
|
||||||
"fallbackToCacheTimeout": 30000,
|
"fallbackToCacheTimeout": 30000,
|
||||||
"url": "https://ota.nuvioapp.space/api/manifest"
|
"url": "https://ota.nuvioapp.space/api/manifest"
|
||||||
},
|
},
|
||||||
"runtimeVersion": "1.4.2"
|
"runtimeVersion": "1.4.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.4.2</string>
|
<string>1.4.4</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>38</string>
|
<string>40</string>
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>vlc</string>
|
<string>vlc</string>
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||||
this.player = player
|
this.player = player
|
||||||
playerView.player = player
|
playerView.player = player
|
||||||
player?.addListener(playerListener)
|
player?.addListener(playerListener)
|
||||||
|
|
||||||
|
pendingResizeMode?.let { resizeMode ->
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setResizeMode(@ResizeMode.Mode mode: Int) {
|
fun setResizeMode(@ResizeMode.Mode mode: Int) {
|
||||||
|
|
@ -122,11 +126,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||||
ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||||
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
}
|
}
|
||||||
if (playerView.width > 0 && playerView.height > 0) {
|
pendingResizeMode = resizeMode
|
||||||
playerView.resizeMode = resizeMode
|
playerView.resizeMode = resizeMode
|
||||||
} else {
|
playerView.requestLayout()
|
||||||
pendingResizeMode = resizeMode
|
requestLayout()
|
||||||
}
|
|
||||||
|
|
||||||
// Re-assert subtitle rendering mode for the current style.
|
// Re-assert subtitle rendering mode for the current style.
|
||||||
updateSubtitleRenderingMode()
|
updateSubtitleRenderingMode()
|
||||||
|
|
@ -198,7 +201,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||||
|
|
||||||
val replacementPlayerView = createPlayerView(viewType).apply {
|
val replacementPlayerView = createPlayerView(viewType).apply {
|
||||||
layoutParams = previousLayoutParams
|
layoutParams = previousLayoutParams
|
||||||
resizeMode = previousResizeMode
|
resizeMode = pendingResizeMode ?: previousResizeMode
|
||||||
useController = previousUseController
|
useController = previousUseController
|
||||||
controllerAutoShow = previousControllerAutoShow
|
controllerAutoShow = previousControllerAutoShow
|
||||||
controllerHideOnTouch = previousControllerHideOnTouch
|
controllerHideOnTouch = previousControllerHideOnTouch
|
||||||
|
|
@ -350,7 +353,11 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||||
|
|
||||||
fun invalidateAspectRatio() {
|
fun invalidateAspectRatio() {
|
||||||
playerView.post {
|
playerView.post {
|
||||||
|
pendingResizeMode?.let { resizeMode ->
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
playerView.requestLayout()
|
playerView.requestLayout()
|
||||||
|
requestLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,20 +371,45 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||||
|
|
||||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
playerView.post {
|
playerView.post {
|
||||||
|
pendingResizeMode?.let { resizeMode ->
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
playerView.requestLayout()
|
playerView.requestLayout()
|
||||||
|
requestLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
|
||||||
|
pendingResizeMode?.let { resizeMode ->
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
|
playerView.requestLayout()
|
||||||
|
requestLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
private val layoutRunnable = Runnable {
|
||||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
measure(
|
||||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||||
if (width > 0 && height > 0) {
|
)
|
||||||
|
layout(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestLayout() {
|
||||||
|
super.requestLayout()
|
||||||
|
post(layoutRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||||
|
super.onLayout(changed, left, top, right, bottom)
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
pendingResizeMode?.let { resizeMode ->
|
pendingResizeMode?.let { resizeMode ->
|
||||||
playerView.resizeMode = resizeMode
|
playerView.resizeMode = resizeMode
|
||||||
}
|
}
|
||||||
// Re-apply bottomPaddingFraction once we have a concrete height.
|
|
||||||
updateSubtitleRenderingMode()
|
updateSubtitleRenderingMode()
|
||||||
applySubtitleStyle(localStyle)
|
applySubtitleStyle(localStyle)
|
||||||
}
|
}
|
||||||
|
|
@ -399,7 +431,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
||||||
setShowSubtitleButton(showSubtitleButton)
|
setShowSubtitleButton(showSubtitleButton)
|
||||||
setUseArtwork(false)
|
setUseArtwork(false)
|
||||||
setDefaultArtwork(null)
|
setDefaultArtwork(null)
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = pendingResizeMode ?: AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
|
||||||
if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
(videoSurfaceView as? SurfaceView)?.setSecure(true)
|
(videoSurfaceView as? SurfaceView)?.setSecure(true)
|
||||||
|
|
|
||||||
|
|
@ -732,7 +732,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||||
|
|
||||||
DefaultRenderersFactory renderersFactory =
|
DefaultRenderersFactory renderersFactory =
|
||||||
new DefaultRenderersFactory(getContext())
|
new DefaultRenderersFactory(getContext())
|
||||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
|
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
|
||||||
.setEnableDecoderFallback(true)
|
.setEnableDecoderFallback(true)
|
||||||
.forceEnableMediaCodecAsynchronousQueueing();
|
.forceEnableMediaCodecAsynchronousQueueing();
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -193,38 +193,36 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
case 'watched': {
|
case 'watched': {
|
||||||
const targetWatched = !isWatched;
|
const targetWatched = !isWatched;
|
||||||
setIsWatched(targetWatched);
|
setIsWatched(targetWatched);
|
||||||
try {
|
|
||||||
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
|
|
||||||
} catch { }
|
|
||||||
showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
|
|
||||||
setTimeout(() => {
|
|
||||||
DeviceEventEmitter.emit('watchedStatusChanged');
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Best-effort sync: record local progress and push to Trakt if available
|
// Use the centralized watchedService to handle all the sync logic (Supabase, Trakt, Simkl, MAL)
|
||||||
if (targetWatched) {
|
import('../../services/watchedService').then(({ watchedService }) => {
|
||||||
try {
|
if (targetWatched) {
|
||||||
await storageService.setWatchProgress(
|
if (item.type === 'movie') {
|
||||||
item.id,
|
// Pass the title so it correctly populates the database instead of defaulting to IMDb ID
|
||||||
item.type,
|
watchedService.markMovieAsWatched(item.id, new Date(), undefined, undefined, item.name);
|
||||||
{ currentTime: 1, duration: 1, lastUpdated: Date.now() },
|
} else {
|
||||||
undefined,
|
// For series from the homescreen drop-down, we mark S1E1 as watched as a baseline
|
||||||
{ forceNotify: true, forceWrite: true }
|
watchedService.markEpisodeAsWatched(item.id, item.id, 1, 1, new Date(), undefined, item.name);
|
||||||
);
|
}
|
||||||
} catch { }
|
} else {
|
||||||
|
if (item.type === 'movie') {
|
||||||
if (item.type === 'movie') {
|
watchedService.unmarkMovieAsWatched(item.id);
|
||||||
try {
|
} else {
|
||||||
const trakt = TraktService.getInstance();
|
// Unmarking a series from the top level is tricky as we don't know the exact episodes.
|
||||||
if (await trakt.isAuthenticated()) {
|
// For safety and consistency with old behavior, we just clear the legacy flag.
|
||||||
await trakt.addToWatchedMovies(item.id);
|
mmkvStorage.removeItem(`watched:${item.type}:${item.id}`);
|
||||||
try {
|
}
|
||||||
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
showInfo(
|
||||||
|
targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'),
|
||||||
|
targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched')
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
DeviceEventEmitter.emit('watchedStatusChanged');
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
setMenuVisible(false);
|
setMenuVisible(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,24 @@ interface AddonSectionProps {
|
||||||
currentTheme: any;
|
currentTheme: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
'movie': 'search.movies',
|
||||||
|
'series': 'search.tv_shows',
|
||||||
|
'anime.movie': 'search.anime_movies',
|
||||||
|
'anime.series': 'search.anime_series',
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleStyle = (currentTheme: any) => ({
|
||||||
|
color: currentTheme.colors.lightGray,
|
||||||
|
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||||
|
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||||
|
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24,
|
||||||
|
};
|
||||||
|
|
||||||
export const AddonSection = React.memo(({
|
export const AddonSection = React.memo(({
|
||||||
addonGroup,
|
addonGroup,
|
||||||
addonIndex,
|
addonIndex,
|
||||||
|
|
@ -23,25 +41,34 @@ export const AddonSection = React.memo(({
|
||||||
}: AddonSectionProps) => {
|
}: AddonSectionProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const movieResults = useMemo(() =>
|
// Group results by their exact type, preserving order of first appearance
|
||||||
addonGroup.results.filter(item => item.type === 'movie'),
|
const groupedByType = useMemo(() => {
|
||||||
[addonGroup.results]
|
const order: string[] = [];
|
||||||
);
|
const groups: Record<string, StreamingContent[]> = {};
|
||||||
const seriesResults = useMemo(() =>
|
|
||||||
addonGroup.results.filter(item => item.type === 'series'),
|
for (const item of addonGroup.results) {
|
||||||
[addonGroup.results]
|
if (!groups[item.type]) {
|
||||||
);
|
order.push(item.type);
|
||||||
const otherResults = useMemo(() =>
|
groups[item.type] = [];
|
||||||
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
|
}
|
||||||
[addonGroup.results]
|
groups[item.type].push(item);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return order.map(type => ({ type, items: groups[type] }));
|
||||||
|
}, [addonGroup.results]);
|
||||||
|
|
||||||
|
const getLabelForType = (type: string): string => {
|
||||||
|
if (TYPE_LABELS[type]) return t(TYPE_LABELS[type]);
|
||||||
|
// Fallback: capitalise and replace dots/underscores for unknown types
|
||||||
|
return type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{/* Addon Header */}
|
{/* Addon Header */}
|
||||||
<View style={styles.addonHeaderContainer}>
|
<View style={styles.addonHeaderContainer}>
|
||||||
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
|
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
|
||||||
{addonGroup.addonName}
|
{(addonGroup as any).sectionName || addonGroup.addonName}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||||
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
|
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
|
||||||
|
|
@ -50,22 +77,13 @@ export const AddonSection = React.memo(({
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Movies */}
|
{groupedByType.map(({ type, items }) => (
|
||||||
{movieResults.length > 0 && (
|
<View key={type} style={[styles.carouselContainer, containerStyle]}>
|
||||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
<Text style={[styles.carouselSubtitle, subtitleStyle(currentTheme)]}>
|
||||||
<Text style={[
|
{getLabelForType(type)} ({items.length})
|
||||||
styles.carouselSubtitle,
|
|
||||||
{
|
|
||||||
color: currentTheme.colors.lightGray,
|
|
||||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
|
||||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
|
||||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
{t('search.movies')} ({movieResults.length})
|
|
||||||
</Text>
|
</Text>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={movieResults}
|
data={items}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<SearchResultItem
|
<SearchResultItem
|
||||||
item={item}
|
item={item}
|
||||||
|
|
@ -74,81 +92,16 @@ export const AddonSection = React.memo(({
|
||||||
onLongPress={onItemLongPress}
|
onLongPress={onItemLongPress}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
|
keyExtractor={item => `${addonGroup.addonId}-${type}-${item.id}`}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.horizontalListContent}
|
contentContainerStyle={styles.horizontalListContent}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{/* TV Shows */}
|
|
||||||
{seriesResults.length > 0 && (
|
|
||||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
|
||||||
<Text style={[
|
|
||||||
styles.carouselSubtitle,
|
|
||||||
{
|
|
||||||
color: currentTheme.colors.lightGray,
|
|
||||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
|
||||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
|
||||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
{t('search.tv_shows')} ({seriesResults.length})
|
|
||||||
</Text>
|
|
||||||
<FlatList
|
|
||||||
data={seriesResults}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<SearchResultItem
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
onPress={onItemPress}
|
|
||||||
onLongPress={onItemLongPress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.horizontalListContent}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Other types */}
|
|
||||||
{otherResults.length > 0 && (
|
|
||||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
|
||||||
<Text style={[
|
|
||||||
styles.carouselSubtitle,
|
|
||||||
{
|
|
||||||
color: currentTheme.colors.lightGray,
|
|
||||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
|
||||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
|
||||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
|
|
||||||
</Text>
|
|
||||||
<FlatList
|
|
||||||
data={otherResults}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<SearchResultItem
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
onPress={onItemPress}
|
|
||||||
onLongPress={onItemLongPress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.horizontalListContent}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
// Only re-render if this section's reference changed
|
|
||||||
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
|
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ interface DiscoverBottomSheetsProps {
|
||||||
typeSheetRef: RefObject<BottomSheetModal>;
|
typeSheetRef: RefObject<BottomSheetModal>;
|
||||||
catalogSheetRef: RefObject<BottomSheetModal>;
|
catalogSheetRef: RefObject<BottomSheetModal>;
|
||||||
genreSheetRef: RefObject<BottomSheetModal>;
|
genreSheetRef: RefObject<BottomSheetModal>;
|
||||||
selectedDiscoverType: 'movie' | 'series';
|
selectedDiscoverType: string;
|
||||||
selectedCatalog: DiscoverCatalog | null;
|
selectedCatalog: DiscoverCatalog | null;
|
||||||
selectedDiscoverGenre: string | null;
|
selectedDiscoverGenre: string | null;
|
||||||
filteredCatalogs: DiscoverCatalog[];
|
filteredCatalogs: DiscoverCatalog[];
|
||||||
availableGenres: string[];
|
availableGenres: string[];
|
||||||
onTypeSelect: (type: 'movie' | 'series') => void;
|
availableTypes: string[];
|
||||||
|
onTypeSelect: (type: string) => void;
|
||||||
onCatalogSelect: (catalog: DiscoverCatalog) => void;
|
onCatalogSelect: (catalog: DiscoverCatalog) => void;
|
||||||
onGenreSelect: (genre: string | null) => void;
|
onGenreSelect: (genre: string | null) => void;
|
||||||
currentTheme: any;
|
currentTheme: any;
|
||||||
|
|
@ -31,6 +32,7 @@ export const DiscoverBottomSheets = ({
|
||||||
selectedDiscoverGenre,
|
selectedDiscoverGenre,
|
||||||
filteredCatalogs,
|
filteredCatalogs,
|
||||||
availableGenres,
|
availableGenres,
|
||||||
|
availableTypes,
|
||||||
onTypeSelect,
|
onTypeSelect,
|
||||||
onCatalogSelect,
|
onCatalogSelect,
|
||||||
onGenreSelect,
|
onGenreSelect,
|
||||||
|
|
@ -38,7 +40,20 @@ export const DiscoverBottomSheets = ({
|
||||||
}: DiscoverBottomSheetsProps) => {
|
}: DiscoverBottomSheetsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const typeSnapPoints = useMemo(() => ['25%'], []);
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
'movie': t('search.movies'),
|
||||||
|
'series': t('search.tv_shows'),
|
||||||
|
'anime.movie': t('search.anime_movies'),
|
||||||
|
'anime.series': t('search.anime_series'),
|
||||||
|
};
|
||||||
|
const getLabelForType = (type: string) =>
|
||||||
|
TYPE_LABELS[type] ?? type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
|
||||||
|
const typeSnapPoints = useMemo(() => {
|
||||||
|
const itemCount = availableTypes.length;
|
||||||
|
const snapPct = Math.min(20 + itemCount * 10, 60);
|
||||||
|
return [`${snapPct}%`];
|
||||||
|
}, [availableTypes]);
|
||||||
const catalogSnapPoints = useMemo(() => ['50%'], []);
|
const catalogSnapPoints = useMemo(() => ['50%'], []);
|
||||||
const genreSnapPoints = useMemo(() => ['50%'], []);
|
const genreSnapPoints = useMemo(() => ['50%'], []);
|
||||||
const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null);
|
const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null);
|
||||||
|
|
@ -225,47 +240,25 @@ export const DiscoverBottomSheets = ({
|
||||||
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
|
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
|
||||||
contentContainerStyle={styles.bottomSheetContent}
|
contentContainerStyle={styles.bottomSheetContent}
|
||||||
>
|
>
|
||||||
{/* Movies option */}
|
{availableTypes.map((type) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
key={type}
|
||||||
styles.bottomSheetItem,
|
style={[
|
||||||
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
|
styles.bottomSheetItem,
|
||||||
]}
|
selectedDiscoverType === type && styles.bottomSheetItemSelected
|
||||||
onPress={() => onTypeSelect('movie')}
|
]}
|
||||||
>
|
onPress={() => onTypeSelect(type)}
|
||||||
<View style={styles.bottomSheetItemContent}>
|
>
|
||||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
<View style={styles.bottomSheetItemContent}>
|
||||||
{t('search.movies')}
|
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||||
</Text>
|
{getLabelForType(type)}
|
||||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
</Text>
|
||||||
{t('search.browse_movies')}
|
</View>
|
||||||
</Text>
|
{selectedDiscoverType === type && (
|
||||||
</View>
|
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
||||||
{selectedDiscoverType === 'movie' && (
|
)}
|
||||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
</TouchableOpacity>
|
||||||
)}
|
))}
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* TV Shows option */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.bottomSheetItem,
|
|
||||||
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
|
|
||||||
]}
|
|
||||||
onPress={() => onTypeSelect('series')}
|
|
||||||
>
|
|
||||||
<View style={styles.bottomSheetItemContent}>
|
|
||||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
|
||||||
{t('search.tv_shows')}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
|
||||||
{t('search.browse_tv')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{selectedDiscoverType === 'series' && (
|
|
||||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ interface DiscoverSectionProps {
|
||||||
pendingDiscoverResults: StreamingContent[];
|
pendingDiscoverResults: StreamingContent[];
|
||||||
loadingMore: boolean;
|
loadingMore: boolean;
|
||||||
selectedCatalog: DiscoverCatalog | null;
|
selectedCatalog: DiscoverCatalog | null;
|
||||||
selectedDiscoverType: 'movie' | 'series';
|
selectedDiscoverType: string;
|
||||||
selectedDiscoverGenre: string | null;
|
selectedDiscoverGenre: string | null;
|
||||||
availableGenres: string[];
|
availableGenres: string[];
|
||||||
typeSheetRef: React.RefObject<BottomSheetModal>;
|
typeSheetRef: React.RefObject<BottomSheetModal>;
|
||||||
|
|
@ -78,7 +78,11 @@ export const DiscoverSection = ({
|
||||||
onPress={() => typeSheetRef.current?.present()}
|
onPress={() => typeSheetRef.current?.present()}
|
||||||
>
|
>
|
||||||
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
|
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
|
||||||
{selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')}
|
{selectedDiscoverType === 'movie' ? t('search.movies')
|
||||||
|
: selectedDiscoverType === 'series' ? t('search.tv_shows')
|
||||||
|
: selectedDiscoverType === 'anime.movie' ? t('search.anime_movies')
|
||||||
|
: selectedDiscoverType === 'anime.series' ? t('search.anime_series')
|
||||||
|
: selectedDiscoverType.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
</Text>
|
</Text>
|
||||||
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
@ -112,8 +116,13 @@ export const DiscoverSection = ({
|
||||||
{selectedCatalog && (
|
{selectedCatalog && (
|
||||||
<View style={styles.discoverFilterSummary}>
|
<View style={styles.discoverFilterSummary}>
|
||||||
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
|
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
|
||||||
{selectedCatalog.addonName} • {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
|
{selectedCatalog.addonName} • {
|
||||||
{selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''}
|
selectedCatalog.type === 'movie' ? t('search.movies')
|
||||||
|
: selectedCatalog.type === 'series' ? t('search.tv_shows')
|
||||||
|
: selectedCatalog.type === 'anime.movie' ? t('search.anime_movies')
|
||||||
|
: selectedCatalog.type === 'anime.series' ? t('search.anime_series')
|
||||||
|
: selectedCatalog.type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
}{selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ interface UseMetadataReturn {
|
||||||
|
|
||||||
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
|
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
|
||||||
const { settings, isLoaded: settingsLoaded } = useSettings();
|
const { settings, isLoaded: settingsLoaded } = useSettings();
|
||||||
|
|
||||||
|
// Normalize anime subtypes to their base types for all internal logic.
|
||||||
|
// anime.series behaves like series; anime.movie behaves like movie.
|
||||||
|
const normalizedType = type === 'anime.series' ? 'series'
|
||||||
|
: type === 'anime.movie' ? 'movie'
|
||||||
|
: type;
|
||||||
|
|
||||||
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
|
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -427,7 +434,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cachedCast = cacheService.getCast(id, type);
|
const cachedCast = cacheService.getCast(id, normalizedType);
|
||||||
if (cachedCast) {
|
if (cachedCast) {
|
||||||
if (__DEV__) logger.log('[loadCast] Using cached cast data');
|
if (__DEV__) logger.log('[loadCast] Using cached cast data');
|
||||||
setCast(cachedCast);
|
setCast(cachedCast);
|
||||||
|
|
@ -439,7 +446,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
if (id.startsWith('tmdb:')) {
|
if (id.startsWith('tmdb:')) {
|
||||||
const tmdbId = id.split(':')[1];
|
const tmdbId = id.split(':')[1];
|
||||||
if (__DEV__) logger.log('[loadCast] Using TMDB ID directly:', tmdbId);
|
if (__DEV__) logger.log('[loadCast] Using TMDB ID directly:', tmdbId);
|
||||||
const castData = await tmdbService.getCredits(parseInt(tmdbId), type);
|
const castData = await tmdbService.getCredits(parseInt(tmdbId), normalizedType);
|
||||||
if (castData && castData.cast) {
|
if (castData && castData.cast) {
|
||||||
const formattedCast = castData.cast.map((actor: any) => ({
|
const formattedCast = castData.cast.map((actor: any) => ({
|
||||||
id: actor.id,
|
id: actor.id,
|
||||||
|
|
@ -464,7 +471,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
if (tmdbId) {
|
if (tmdbId) {
|
||||||
if (__DEV__) logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId);
|
if (__DEV__) logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId);
|
||||||
const castData = await tmdbService.getCredits(tmdbId, type);
|
const castData = await tmdbService.getCredits(tmdbId, normalizedType);
|
||||||
if (castData && castData.cast) {
|
if (castData && castData.cast) {
|
||||||
const formattedCast = castData.cast.map((actor: any) => ({
|
const formattedCast = castData.cast.map((actor: any) => ({
|
||||||
id: actor.id,
|
id: actor.id,
|
||||||
|
|
@ -511,7 +518,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setLoadAttempts(prev => prev + 1);
|
setLoadAttempts(prev => prev + 1);
|
||||||
|
|
||||||
// Check metadata screen cache
|
// Check metadata screen cache
|
||||||
const cachedScreen = cacheService.getMetadataScreen(id, type);
|
const cachedScreen = cacheService.getMetadataScreen(id, normalizedType);
|
||||||
if (cachedScreen) {
|
if (cachedScreen) {
|
||||||
console.log('🔍 [useMetadata] Using cached metadata:', {
|
console.log('🔍 [useMetadata] Using cached metadata:', {
|
||||||
id,
|
id,
|
||||||
|
|
@ -523,7 +530,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
});
|
});
|
||||||
setMetadata(cachedScreen.metadata);
|
setMetadata(cachedScreen.metadata);
|
||||||
setCast(cachedScreen.cast);
|
setCast(cachedScreen.cast);
|
||||||
if (type === 'series' && cachedScreen.episodes) {
|
if (normalizedType === 'series' && cachedScreen.episodes) {
|
||||||
setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes);
|
setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes);
|
||||||
setEpisodes(cachedScreen.episodes.currentEpisodes);
|
setEpisodes(cachedScreen.episodes.currentEpisodes);
|
||||||
setSelectedSeason(cachedScreen.episodes.selectedSeason);
|
setSelectedSeason(cachedScreen.episodes.selectedSeason);
|
||||||
|
|
@ -567,7 +574,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
} else {
|
} else {
|
||||||
const tmdbId = id.split(':')[1];
|
const tmdbId = id.split(':')[1];
|
||||||
// For TMDB IDs, we need to handle metadata differently
|
// For TMDB IDs, we need to handle metadata differently
|
||||||
if (type === 'movie') {
|
if (normalizedType === 'movie') {
|
||||||
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
|
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
|
||||||
const movieDetails = await tmdbService.getMovieDetails(
|
const movieDetails = await tmdbService.getMovieDetails(
|
||||||
tmdbId,
|
tmdbId,
|
||||||
|
|
@ -639,7 +646,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
|
|
||||||
setMetadata(formattedMovie);
|
setMetadata(formattedMovie);
|
||||||
cacheService.setMetadata(id, type, formattedMovie);
|
cacheService.setMetadata(id, normalizedType, formattedMovie);
|
||||||
(async () => {
|
(async () => {
|
||||||
const items = await catalogService.getLibraryItems();
|
const items = await catalogService.getLibraryItems();
|
||||||
const isInLib = items.some(item => item.id === id);
|
const isInLib = items.some(item => item.id === id);
|
||||||
|
|
@ -649,7 +656,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === 'series') {
|
} else if (normalizedType === 'series') {
|
||||||
// Handle TV shows with TMDB IDs
|
// Handle TV shows with TMDB IDs
|
||||||
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
|
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
|
||||||
try {
|
try {
|
||||||
|
|
@ -719,7 +726,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
|
|
||||||
setMetadata(formattedShow);
|
setMetadata(formattedShow);
|
||||||
cacheService.setMetadata(id, type, formattedShow);
|
cacheService.setMetadata(id, normalizedType, formattedShow);
|
||||||
|
|
||||||
// Load series data (episodes)
|
// Load series data (episodes)
|
||||||
setTmdbId(parseInt(tmdbId));
|
setTmdbId(parseInt(tmdbId));
|
||||||
|
|
@ -779,7 +786,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
for (const addon of externalMetaAddons) {
|
for (const addon of externalMetaAddons) {
|
||||||
try {
|
try {
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
stremioService.getMetaDetails(type, actualId, addon.id),
|
stremioService.getMetaDetails(normalizedType, actualId, addon.id),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -799,7 +806,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// If no external addon worked, fall back to catalog addon
|
// If no external addon worked, fall back to catalog addon
|
||||||
console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon');
|
console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon');
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
catalogService.getEnhancedContentDetails(type, actualId, addonId),
|
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
);
|
);
|
||||||
if (actualId.startsWith('tt')) {
|
if (actualId.startsWith('tt')) {
|
||||||
|
|
@ -831,7 +838,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...');
|
console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...');
|
||||||
console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
|
console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
catalogService.getEnhancedContentDetails(type, actualId, addonId),
|
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
);
|
);
|
||||||
console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL');
|
console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL');
|
||||||
|
|
@ -871,13 +878,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback');
|
console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback');
|
||||||
const tmdbRaw = id.split(':')[1];
|
const tmdbRaw = id.split(':')[1];
|
||||||
try {
|
try {
|
||||||
const stremioId = await catalogService.getStremioId(type === 'series' ? 'tv' : 'movie', tmdbRaw);
|
const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw);
|
||||||
if (stremioId && stremioId !== id) {
|
if (stremioId && stremioId !== id) {
|
||||||
console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
|
console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
|
||||||
const [content, castData] = await Promise.allSettled([
|
const [content, castData] = await Promise.allSettled([
|
||||||
withRetry(async () => {
|
withRetry(async () => {
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
catalogService.getEnhancedContentDetails(type, stremioId, addonId),
|
catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
);
|
);
|
||||||
if (stremioId.startsWith('tt')) {
|
if (stremioId.startsWith('tt')) {
|
||||||
|
|
@ -934,7 +941,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
if (finalTmdbId) {
|
if (finalTmdbId) {
|
||||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||||
if (type === 'movie') {
|
if (normalizedType === 'movie') {
|
||||||
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
|
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
|
||||||
if (localized) {
|
if (localized) {
|
||||||
const movieDetailsObj = {
|
const movieDetailsObj = {
|
||||||
|
|
@ -1011,7 +1018,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
|
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
|
||||||
const tmdbService = TMDBService.getInstance();
|
const tmdbService = TMDBService.getInstance();
|
||||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||||
const contentType = type === 'series' ? 'tv' : 'movie';
|
const contentType = normalizedType === 'series' ? 'tv' : 'movie';
|
||||||
|
|
||||||
// Get TMDB ID
|
// Get TMDB ID
|
||||||
let tmdbIdForLogo = null;
|
let tmdbIdForLogo = null;
|
||||||
|
|
@ -1080,7 +1087,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
cacheService.setMetadata(id, type, finalMetadata);
|
cacheService.setMetadata(id, normalizedType, finalMetadata);
|
||||||
(async () => {
|
(async () => {
|
||||||
const items = await catalogService.getLibraryItems();
|
const items = await catalogService.getLibraryItems();
|
||||||
const isInLib = items.some(item => item.id === id);
|
const isInLib = items.some(item => item.id === id);
|
||||||
|
|
@ -1597,10 +1604,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
|
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
|
||||||
try {
|
try {
|
||||||
let externalIds = null;
|
let externalIds = null;
|
||||||
if (type === 'movie') {
|
if (normalizedType === 'movie') {
|
||||||
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
|
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
|
||||||
externalIds = movieDetails?.external_ids;
|
externalIds = movieDetails?.external_ids;
|
||||||
} else if (type === 'series') {
|
} else if (normalizedType === 'series') {
|
||||||
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
|
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1829,7 +1836,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestedEpisodeType = type;
|
const requestedEpisodeType = normalizedType;
|
||||||
let streamAddons = pickStreamCapableAddons(requestedEpisodeType);
|
let streamAddons = pickStreamCapableAddons(requestedEpisodeType);
|
||||||
|
|
||||||
if (streamAddons.length === 0) {
|
if (streamAddons.length === 0) {
|
||||||
|
|
@ -1917,13 +1924,16 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
const cleanEpisodeId = episodeId.replace(/^series:/, '');
|
const cleanEpisodeId = episodeId.replace(/^series:/, '');
|
||||||
const parts = cleanEpisodeId.split(':');
|
const parts = cleanEpisodeId.split(':');
|
||||||
|
// Check the episode ID's own namespace, not the show-level id.
|
||||||
|
// e.g. show id may be "tt12343534" but episodeId may be "kitsu:48363:8"
|
||||||
|
const episodeIsImdb = parts[0].startsWith('tt');
|
||||||
|
|
||||||
if (isImdb && parts.length === 3) {
|
if (episodeIsImdb && parts.length === 3) {
|
||||||
// Format: ttXXX:season:episode
|
// Format: ttXXX:season:episode
|
||||||
showIdStr = parts[0];
|
showIdStr = parts[0];
|
||||||
seasonNum = parts[1];
|
seasonNum = parts[1];
|
||||||
episodeNum = parts[2];
|
episodeNum = parts[2];
|
||||||
} else if (!isImdb && parts.length === 3) {
|
} else if (!episodeIsImdb && parts.length === 3) {
|
||||||
// Format: prefix:id:episode (no season for MAL/Kitsu/etc)
|
// Format: prefix:id:episode (no season for MAL/Kitsu/etc)
|
||||||
showIdStr = `${parts[0]}:${parts[1]}`;
|
showIdStr = `${parts[0]}:${parts[1]}`;
|
||||||
episodeNum = parts[2];
|
episodeNum = parts[2];
|
||||||
|
|
@ -2009,16 +2019,18 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
|
if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
|
||||||
|
|
||||||
// Ensure consistent format
|
// Ensure consistent format or fallback to episodeId if parsing failed.
|
||||||
// Ensure consistent format or fallback to episodeId if parsing failed
|
// If the episode's namespace differs from the show's tt id (e.g. kitsu:48363:8
|
||||||
// This handles cases where 'tt' is used for a unique episode ID directly
|
// on a tt-identified show), use showIdStr so we request via the correct namespace.
|
||||||
if (!seasonNum && !episodeNum) {
|
if (!seasonNum && !episodeNum) {
|
||||||
stremioEpisodeId = episodeId;
|
stremioEpisodeId = episodeId;
|
||||||
} else if (!seasonNum) {
|
} else if (!seasonNum) {
|
||||||
// No season (e.g., mal:57658:1) - use id:episode format
|
// No season (e.g., kitsu:48363:8, mal:57658:1)
|
||||||
stremioEpisodeId = `${id}:${episodeNum}`;
|
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
|
||||||
|
stremioEpisodeId = `${baseId}:${episodeNum}`;
|
||||||
} else {
|
} else {
|
||||||
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`;
|
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
|
||||||
|
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
|
||||||
}
|
}
|
||||||
if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId);
|
if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2029,12 +2041,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Remove 'series:' prefix if present to be safe, though parsing logic above usually handles it
|
// Remove 'series:' prefix if present to be safe, though parsing logic above usually handles it
|
||||||
stremioEpisodeId = episodeId.replace(/^series:/, '');
|
stremioEpisodeId = episodeId.replace(/^series:/, '');
|
||||||
} else if (!seasonNum) {
|
} else if (!seasonNum) {
|
||||||
// No season (e.g., mal:57658:1) - use id:episode format
|
// No season (e.g., kitsu:12345:1, mal:57658:1) - use showIdStr:episode format.
|
||||||
stremioEpisodeId = `${id}:${episodeNum}`;
|
// Use showIdStr (parsed from episodeId) rather than outer `id` so that when the
|
||||||
|
// show has multiple IDs (e.g. tvdb+kitsu), we preserve the namespace that the
|
||||||
|
// episode actually belongs to (e.g. kitsu:animeId:epNum, not tvdb:showId:epNum).
|
||||||
|
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
|
||||||
|
stremioEpisodeId = `${baseId}:${episodeNum}`;
|
||||||
} else {
|
} else {
|
||||||
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`;
|
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
|
||||||
|
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
|
||||||
}
|
}
|
||||||
if (__DEV__) console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
|
if (__DEV__) console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract episode info from the episodeId for logging
|
// Extract episode info from the episodeId for logging
|
||||||
|
|
@ -2111,7 +2128,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
if (!metadata) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
if (inLibrary) {
|
if (inLibrary) {
|
||||||
catalogService.removeFromLibrary(type, id);
|
catalogService.removeFromLibrary(normalizedType, id);
|
||||||
} else {
|
} else {
|
||||||
catalogService.addToLibrary(metadata);
|
catalogService.addToLibrary(metadata);
|
||||||
}
|
}
|
||||||
|
|
@ -2190,12 +2207,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
try {
|
try {
|
||||||
const tmdbService = TMDBService.getInstance();
|
const tmdbService = TMDBService.getInstance();
|
||||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||||
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang);
|
const results = await tmdbService.getRecommendations(normalizedType === 'movie' ? 'movie' : 'tv', String(tmdbId), lang);
|
||||||
|
|
||||||
// Convert TMDB results to StreamingContent format (simplified)
|
// Convert TMDB results to StreamingContent format (simplified)
|
||||||
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
|
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
|
||||||
id: `tmdb:${item.id}`,
|
id: `tmdb:${item.id}`,
|
||||||
type: type === 'movie' ? 'movie' : 'series',
|
type: normalizedType === 'movie' ? 'movie' : 'series',
|
||||||
name: item.title || item.name || 'Untitled',
|
name: item.title || item.name || 'Untitled',
|
||||||
poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback
|
poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback
|
||||||
year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback
|
year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback
|
||||||
|
|
@ -2226,7 +2243,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setTmdbId(fetchedTmdbId);
|
setTmdbId(fetchedTmdbId);
|
||||||
// Fetch certification only if granular setting is enabled
|
// Fetch certification only if granular setting is enabled
|
||||||
if (settings.tmdbEnrichCertification) {
|
if (settings.tmdbEnrichCertification) {
|
||||||
const certification = await tmdbService.getCertification(type, fetchedTmdbId);
|
const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId);
|
||||||
if (certification) {
|
if (certification) {
|
||||||
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
|
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
|
||||||
setMetadata(prev => prev ? {
|
setMetadata(prev => prev ? {
|
||||||
|
|
@ -2299,7 +2316,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tmdbSvc = TMDBService.getInstance();
|
const tmdbSvc = TMDBService.getInstance();
|
||||||
const cert = await tmdbSvc.getCertification(type, tmdbId);
|
const cert = await tmdbSvc.getCertification(normalizedType, tmdbId);
|
||||||
if (cert) {
|
if (cert) {
|
||||||
if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
|
if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
|
||||||
setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev);
|
setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev);
|
||||||
|
|
@ -2326,7 +2343,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentKey = `${type}-${tmdbId}`;
|
const contentKey = `${normalizedType}-${tmdbId}`;
|
||||||
if (productionInfoFetchedRef.current === contentKey) {
|
if (productionInfoFetchedRef.current === contentKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2334,7 +2351,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Only skip if networks are set AND collection is already set (for movies)
|
// Only skip if networks are set AND collection is already set (for movies)
|
||||||
const hasNetworks = !!(metadata as any).networks;
|
const hasNetworks = !!(metadata as any).networks;
|
||||||
const hasCollection = !!(metadata as any).collection;
|
const hasCollection = !!(metadata as any).collection;
|
||||||
if (hasNetworks && (type !== 'movie' || hasCollection)) {
|
if (hasNetworks && (normalizedType !== 'movie' || hasCollection)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2357,7 +2374,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
collectionsEnabled: settings.tmdbEnrichCollections
|
collectionsEnabled: settings.tmdbEnrichCollections
|
||||||
});
|
});
|
||||||
|
|
||||||
if (type === 'series') {
|
if (normalizedType === 'series') {
|
||||||
// Fetch networks and additional details for TV shows
|
// Fetch networks and additional details for TV shows
|
||||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||||
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
|
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
|
||||||
|
|
@ -2406,7 +2423,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === 'movie') {
|
} else if (normalizedType === 'movie') {
|
||||||
// Fetch production companies and additional details for movies
|
// Fetch production companies and additional details for movies
|
||||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||||
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);
|
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,34 @@ const HomeScreen = () => {
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
if (addon.catalogs) {
|
if (addon.catalogs) {
|
||||||
for (const catalog of addon.catalogs) {
|
for (const catalog of addon.catalogs) {
|
||||||
// Check if this catalog is enabled (default to true if no setting exists)
|
// ── Manifest-level hard gates (cannot be overridden by user settings) ──
|
||||||
|
|
||||||
|
// 1. Never show search catalogs (search.movie, search.series, etc.)
|
||||||
|
if (
|
||||||
|
(catalog.id && catalog.id.startsWith('search.')) ||
|
||||||
|
(catalog.type && catalog.type.startsWith('search'))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Never show catalogs that have any required extra
|
||||||
|
// (e.g. required genre, calendarVideosIds — these need params to load)
|
||||||
|
const requiredExtras = (catalog.extra || [])
|
||||||
|
.filter((e: any) => e.isRequired)
|
||||||
|
.map((e: any) => e.name);
|
||||||
|
if (requiredExtras.length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Respect showInHome flag — if the addon uses it on any catalog,
|
||||||
|
// only catalogs with showInHome:true are eligible for home.
|
||||||
|
const addonUsesShowInHome = addon.catalogs.some((c: any) => c.showInHome === true);
|
||||||
|
if (addonUsesShowInHome && !(catalog as any).showInHome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User toggle (mmkv) — applied on top of manifest gates ──
|
||||||
|
// Default is true unless the manifest gates above have already filtered it out.
|
||||||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||||
const isEnabled = catalogSettings[settingKey] ?? true;
|
const isEnabled = catalogSettings[settingKey] ?? true;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ const SearchScreen = () => {
|
||||||
// Discover section state
|
// Discover section state
|
||||||
const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]);
|
const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]);
|
||||||
const [selectedCatalog, setSelectedCatalog] = useState<DiscoverCatalog | null>(null);
|
const [selectedCatalog, setSelectedCatalog] = useState<DiscoverCatalog | null>(null);
|
||||||
const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie');
|
const [selectedDiscoverType, setSelectedDiscoverType] = useState<string>('movie');
|
||||||
const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState<string | null>(null);
|
const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
@ -127,7 +127,7 @@ const SearchScreen = () => {
|
||||||
try {
|
try {
|
||||||
// Load saved type
|
// Load saved type
|
||||||
const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY);
|
const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY);
|
||||||
if (savedType && (savedType === 'movie' || savedType === 'series')) {
|
if (savedType) {
|
||||||
setSelectedDiscoverType(savedType);
|
setSelectedDiscoverType(savedType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@ const SearchScreen = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Save discover settings when they change
|
// Save discover settings when they change
|
||||||
const saveDiscoverSettings = useCallback(async (type: 'movie' | 'series', catalog: DiscoverCatalog | null, genre: string | null) => {
|
const saveDiscoverSettings = useCallback(async (type: string, catalog: DiscoverCatalog | null, genre: string | null) => {
|
||||||
try {
|
try {
|
||||||
// Save type
|
// Save type
|
||||||
await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type);
|
await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type);
|
||||||
|
|
@ -267,10 +267,8 @@ const SearchScreen = () => {
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
const allCatalogs: DiscoverCatalog[] = [];
|
const allCatalogs: DiscoverCatalog[] = [];
|
||||||
for (const [type, catalogs] of Object.entries(filters.catalogsByType)) {
|
for (const [type, catalogs] of Object.entries(filters.catalogsByType)) {
|
||||||
if (type === 'movie' || type === 'series') {
|
for (const catalog of catalogs) {
|
||||||
for (const catalog of catalogs) {
|
allCatalogs.push({ ...catalog, type });
|
||||||
allCatalogs.push({ ...catalog, type });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDiscoverCatalogs(allCatalogs);
|
setDiscoverCatalogs(allCatalogs);
|
||||||
|
|
@ -517,7 +515,14 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
setResults(prev => {
|
setResults(prev => {
|
||||||
if (!isMounted.current) return prev;
|
if (!isMounted.current) return prev;
|
||||||
const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER;
|
// Use catalogIndex from the section for deterministic ordering.
|
||||||
|
// Falls back to addonOrderRankRef for legacy single-catalog sections.
|
||||||
|
const getRank = (section: AddonSearchResults) => {
|
||||||
|
if (section.catalogIndex !== undefined) return section.catalogIndex;
|
||||||
|
if (addonOrderRankRef.current[section.addonId] !== undefined) return addonOrderRankRef.current[section.addonId] * 1000;
|
||||||
|
const baseAddonId = section.addonId.includes('||') ? section.addonId.split('||')[0] : section.addonId;
|
||||||
|
return (addonOrderRankRef.current[baseAddonId] ?? Number.MAX_SAFE_INTEGER - 1) * 1000 + 500;
|
||||||
|
};
|
||||||
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
|
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
|
|
@ -526,10 +531,10 @@ const SearchScreen = () => {
|
||||||
return { byAddon: copy, allResults: prev.allResults };
|
return { byAddon: copy, allResults: prev.allResults };
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertRank = getRank(section.addonId);
|
const insertRank = getRank(section);
|
||||||
let insertAt = prev.byAddon.length;
|
let insertAt = prev.byAddon.length;
|
||||||
for (let i = 0; i < prev.byAddon.length; i++) {
|
for (let i = 0; i < prev.byAddon.length; i++) {
|
||||||
if (getRank(prev.byAddon[i].addonId) > insertRank) {
|
if (getRank(prev.byAddon[i]) > insertRank) {
|
||||||
insertAt = i;
|
insertAt = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -636,9 +641,10 @@ const SearchScreen = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]);
|
const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]);
|
||||||
|
const availableTypes = useMemo(() => [...new Set(discoverCatalogs.map(c => c.type))], [discoverCatalogs]);
|
||||||
const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]);
|
const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]);
|
||||||
|
|
||||||
const handleTypeSelect = (type: 'movie' | 'series') => {
|
const handleTypeSelect = (type: string) => {
|
||||||
setSelectedDiscoverType(type);
|
setSelectedDiscoverType(type);
|
||||||
|
|
||||||
// Save type setting
|
// Save type setting
|
||||||
|
|
@ -893,6 +899,7 @@ const SearchScreen = () => {
|
||||||
selectedDiscoverGenre={selectedDiscoverGenre}
|
selectedDiscoverGenre={selectedDiscoverGenre}
|
||||||
filteredCatalogs={filteredCatalogs}
|
filteredCatalogs={filteredCatalogs}
|
||||||
availableGenres={availableGenres}
|
availableGenres={availableGenres}
|
||||||
|
availableTypes={availableTypes}
|
||||||
onTypeSelect={handleTypeSelect}
|
onTypeSelect={handleTypeSelect}
|
||||||
onCatalogSelect={handleCatalogSelect}
|
onCatalogSelect={handleCatalogSelect}
|
||||||
onGenreSelect={handleGenreSelect}
|
onGenreSelect={handleGenreSelect}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ export interface StreamingAddon {
|
||||||
export interface AddonSearchResults {
|
export interface AddonSearchResults {
|
||||||
addonId: string;
|
addonId: string;
|
||||||
addonName: string;
|
addonName: string;
|
||||||
|
sectionName: string; // Display name — catalog name for named catalogs, addon name otherwise
|
||||||
|
catalogIndex: number; // Position in addon manifest — used for deterministic sort within same addon
|
||||||
results: StreamingContent[];
|
results: StreamingContent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,10 +365,53 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private canBrowseCatalog(catalog: StreamingCatalog): boolean {
|
private canBrowseCatalog(catalog: StreamingCatalog): boolean {
|
||||||
|
// Exclude search-only catalogs from discover browsing
|
||||||
|
if (
|
||||||
|
(catalog.id && catalog.id.startsWith('search.')) ||
|
||||||
|
(catalog.type && catalog.type.startsWith('search'))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const requiredExtras = this.getRequiredCatalogExtras(catalog);
|
const requiredExtras = this.getRequiredCatalogExtras(catalog);
|
||||||
return requiredExtras.every(extraName => extraName === 'genre');
|
return requiredExtras.every(extraName => extraName === 'genre');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a catalog should appear on the home screen, based purely on the
|
||||||
|
* addon manifest — no user settings / mmkv involved.
|
||||||
|
*
|
||||||
|
* Rules (in order):
|
||||||
|
* 1. Search catalogs (id/type starts with "search") → never on home
|
||||||
|
* 2. Catalogs with any required extra (including required genre) → never on home
|
||||||
|
* 3. Addon uses showInHome flag on at least one catalog:
|
||||||
|
* → only catalogs with showInHome:true appear on home
|
||||||
|
* 4. No showInHome flag on any catalog → all browseable catalogs appear on home
|
||||||
|
*/
|
||||||
|
private isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean {
|
||||||
|
// Rule 1: never show search catalogs
|
||||||
|
if (
|
||||||
|
(catalog.id && catalog.id.startsWith('search.')) ||
|
||||||
|
(catalog.type && catalog.type.startsWith('search'))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: never show catalogs with any required extra (e.g. required genre, calendarVideosIds)
|
||||||
|
const requiredExtras = this.getRequiredCatalogExtras(catalog);
|
||||||
|
if (requiredExtras.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: respect showInHome if the addon uses it on any catalog
|
||||||
|
const addonUsesShowInHome = addonCatalogs.some((c: any) => c.showInHome === true);
|
||||||
|
if (addonUsesShowInHome) {
|
||||||
|
return (catalog as any).showInHome === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 4: no showInHome flag used — show all browseable catalogs
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private canSearchCatalog(catalog: StreamingCatalog): boolean {
|
private canSearchCatalog(catalog: StreamingCatalog): boolean {
|
||||||
if (!this.catalogSupportsExtra(catalog, 'search')) {
|
if (!this.catalogSupportsExtra(catalog, 'search')) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -379,24 +424,13 @@ class CatalogService {
|
||||||
async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
|
async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
|
||||||
const addons = await this.getAllAddons();
|
const addons = await this.getAllAddons();
|
||||||
|
|
||||||
// Load enabled/disabled settings
|
// Collect catalogs visible on home using manifest-only rules (no mmkv/user settings)
|
||||||
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
|
|
||||||
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
|
|
||||||
|
|
||||||
// Collect all potential catalogs first
|
|
||||||
const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = [];
|
const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = [];
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
if (addon.catalogs) {
|
if (addon.catalogs) {
|
||||||
for (const catalog of addon.catalogs) {
|
for (const catalog of addon.catalogs) {
|
||||||
if (!this.canBrowseCatalog(catalog)) {
|
if (this.isVisibleOnHome(catalog, addon.catalogs)) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
|
||||||
const isEnabled = catalogSettings[settingKey] ?? true;
|
|
||||||
|
|
||||||
if (isEnabled) {
|
|
||||||
potentialCatalogs.push({ addon, catalog });
|
potentialCatalogs.push({ addon, catalog });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -510,7 +544,9 @@ class CatalogService {
|
||||||
const catalogPromises: Promise<CatalogContent | null>[] = [];
|
const catalogPromises: Promise<CatalogContent | null>[] = [];
|
||||||
|
|
||||||
for (const addon of typeAddons) {
|
for (const addon of typeAddons) {
|
||||||
const typeCatalogs = addon.catalogs.filter(catalog => catalog.type === type);
|
const typeCatalogs = addon.catalogs.filter((catalog: StreamingCatalog) =>
|
||||||
|
catalog.type === type && this.isVisibleOnHome(catalog, addon.catalogs)
|
||||||
|
);
|
||||||
|
|
||||||
for (const catalog of typeCatalogs) {
|
for (const catalog of typeCatalogs) {
|
||||||
const catalogPromise = (async () => {
|
const catalogPromise = (async () => {
|
||||||
|
|
@ -1496,6 +1532,10 @@ class CatalogService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build addon order map for deterministic section sorting
|
||||||
|
const addonOrderRef: Record<string, number> = {};
|
||||||
|
searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; });
|
||||||
|
|
||||||
// Global dedupe across emitted results
|
// Global dedupe across emitted results
|
||||||
const globalSeen = new Set<string>();
|
const globalSeen = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -1503,7 +1543,6 @@ class CatalogService {
|
||||||
searchableAddons.map(async (addon) => {
|
searchableAddons.map(async (addon) => {
|
||||||
if (controller.cancelled) return;
|
if (controller.cancelled) return;
|
||||||
try {
|
try {
|
||||||
// Get the manifest to ensure we have the correct URL
|
|
||||||
const manifest = manifestMap.get(addon.id);
|
const manifest = manifestMap.get(addon.id);
|
||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
|
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
|
||||||
|
|
@ -1511,7 +1550,6 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
|
const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
|
||||||
|
|
||||||
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
|
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
|
||||||
|
|
||||||
// Fetch all catalogs for this addon in parallel
|
// Fetch all catalogs for this addon in parallel
|
||||||
|
|
@ -1520,33 +1558,114 @@ class CatalogService {
|
||||||
);
|
);
|
||||||
if (controller.cancelled) return;
|
if (controller.cancelled) return;
|
||||||
|
|
||||||
const addonResults: StreamingContent[] = [];
|
// If addon has multiple search catalogs, emit each as its own section.
|
||||||
for (const s of settled) {
|
// If only one, emit as a single addon section (original behaviour).
|
||||||
if (s.status === 'fulfilled' && Array.isArray(s.value)) {
|
const hasMultipleCatalogs = searchableCatalogs.length > 1;
|
||||||
addonResults.push(...s.value);
|
|
||||||
|
const catalogResultsList: { catalog: any; results: StreamingContent[] }[] = [];
|
||||||
|
for (let i = 0; i < searchableCatalogs.length; i++) {
|
||||||
|
const s = settled[i];
|
||||||
|
if (s.status === 'fulfilled' && Array.isArray(s.value) && s.value.length > 0) {
|
||||||
|
catalogResultsList.push({ catalog: searchableCatalogs[i], results: s.value });
|
||||||
} else if (s.status === 'rejected') {
|
} else if (s.status === 'rejected') {
|
||||||
logger.warn(`Search failed for catalog in ${addon.name}:`, s.reason);
|
logger.warn(`Search failed for catalog ${searchableCatalogs[i].id} in ${addon.name}:`, s.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addonResults.length === 0) {
|
if (catalogResultsList.length === 0) {
|
||||||
logger.log(`No results from ${addon.name}`);
|
logger.log(`No results from ${addon.name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedupe within addon and against global
|
if (hasMultipleCatalogs) {
|
||||||
const localSeen = new Set<string>();
|
// Human-readable labels for known content types used as fallback section names
|
||||||
const unique = addonResults.filter(item => {
|
const CATALOG_TYPE_LABELS: Record<string, string> = {
|
||||||
const key = `${item.type}:${item.id}`;
|
'movie': 'Movies',
|
||||||
if (localSeen.has(key) || globalSeen.has(key)) return false;
|
'series': 'TV Shows',
|
||||||
localSeen.add(key);
|
'anime.series': 'Anime Series',
|
||||||
globalSeen.add(key);
|
'anime.movie': 'Anime Movies',
|
||||||
return true;
|
'other': 'Other',
|
||||||
});
|
'tv': 'TV',
|
||||||
|
'channel': 'Channels',
|
||||||
|
};
|
||||||
|
|
||||||
if (unique.length > 0 && !controller.cancelled) {
|
// Emit each catalog as its own section, in manifest order
|
||||||
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
|
for (let ci = 0; ci < catalogResultsList.length; ci++) {
|
||||||
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique });
|
const { catalog, results } = catalogResultsList[ci];
|
||||||
|
if (controller.cancelled) return;
|
||||||
|
|
||||||
|
// Within-catalog dedup: prefer dot-type over generic for same ID
|
||||||
|
const bestById = new Map<string, StreamingContent>();
|
||||||
|
for (const item of results) {
|
||||||
|
const existing = bestById.get(item.id);
|
||||||
|
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
|
||||||
|
bestById.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp catalog type onto results
|
||||||
|
const stamped = Array.from(bestById.values()).map(item => {
|
||||||
|
if (catalog.type && item.type !== catalog.type) {
|
||||||
|
return { ...item, type: catalog.type };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dedupe against global seen
|
||||||
|
const unique = stamped.filter(item => {
|
||||||
|
const key = `${item.type}:${item.id}`;
|
||||||
|
if (globalSeen.has(key)) return false;
|
||||||
|
globalSeen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unique.length > 0 && !controller.cancelled) {
|
||||||
|
// Build section name:
|
||||||
|
// - If catalog.name is generic ("Search") or same as addon name, use type label instead
|
||||||
|
// - Otherwise use catalog.name as-is
|
||||||
|
const GENERIC_NAMES = new Set(['search', 'Search']);
|
||||||
|
const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
|
||||||
|
|| catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
|
||||||
|
const catalogLabel = (!catalog.name || GENERIC_NAMES.has(catalog.name) || catalog.name === addon.name)
|
||||||
|
? typeLabel
|
||||||
|
: catalog.name;
|
||||||
|
const sectionName = `${addon.name} - ${catalogLabel}`;
|
||||||
|
|
||||||
|
// catalogIndex encodes addon rank + position within addon for deterministic ordering
|
||||||
|
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const catalogIndex = addonRank * 1000 + ci;
|
||||||
|
|
||||||
|
logger.log(`Emitting ${unique.length} results from ${sectionName}`);
|
||||||
|
onAddonResults({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: unique });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single catalog — one section per addon
|
||||||
|
const allResults = catalogResultsList.flatMap(c => c.results);
|
||||||
|
|
||||||
|
const bestByIdWithinAddon = new Map<string, StreamingContent>();
|
||||||
|
for (const item of allResults) {
|
||||||
|
const existing = bestByIdWithinAddon.get(item.id);
|
||||||
|
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
|
||||||
|
bestByIdWithinAddon.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deduped = Array.from(bestByIdWithinAddon.values());
|
||||||
|
|
||||||
|
const localSeen = new Set<string>();
|
||||||
|
const unique = deduped.filter(item => {
|
||||||
|
const key = `${item.type}:${item.id}`;
|
||||||
|
if (localSeen.has(key) || globalSeen.has(key)) return false;
|
||||||
|
localSeen.add(key);
|
||||||
|
globalSeen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unique.length > 0 && !controller.cancelled) {
|
||||||
|
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
|
||||||
|
onAddonResults({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: unique });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
|
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
|
||||||
|
|
@ -1626,6 +1745,12 @@ class CatalogService {
|
||||||
const items = metas.map(meta => {
|
const items = metas.map(meta => {
|
||||||
const content = this.convertMetaToStreamingContent(meta);
|
const content = this.convertMetaToStreamingContent(meta);
|
||||||
content.addonId = manifest.id;
|
content.addonId = manifest.id;
|
||||||
|
// The meta's own type field may be generic (e.g. "series") even when
|
||||||
|
// the catalog it came from is more specific (e.g. "anime.series").
|
||||||
|
// Stamp the catalog type so grouping in the UI is correct.
|
||||||
|
if (type && content.type !== type) {
|
||||||
|
content.type = type;
|
||||||
|
}
|
||||||
return content;
|
return content;
|
||||||
});
|
});
|
||||||
logger.log(`Found ${items.length} results from ${manifest.name}`);
|
logger.log(`Found ${items.length} results from ${manifest.name}`);
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,6 @@ class StremioService {
|
||||||
if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true;
|
if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true;
|
||||||
return lowerId.length > lowerPrefix.length;
|
return lowerId.length > lowerPrefix.length;
|
||||||
});
|
});
|
||||||
if (__DEV__) console.log(`🔍 [isValidContentId] Prefix match result: ${result} for ID '${id}'`);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1135,8 +1135,16 @@ class SupabaseSyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isExternalProgressSyncConnected(): Promise<boolean> {
|
private async isExternalProgressSyncConnected(): Promise<boolean> {
|
||||||
if (await this.isTraktConnected()) return true;
|
const trakt = await this.isTraktConnected();
|
||||||
return await this.isSimklConnected();
|
if (trakt) {
|
||||||
|
logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Trakt is connected, returning true');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const simkl = await this.isSimklConnected();
|
||||||
|
if (simkl) {
|
||||||
|
logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Simkl is connected, returning true');
|
||||||
|
}
|
||||||
|
return simkl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pullPluginsToLocal(): Promise<void> {
|
private async pullPluginsToLocal(): Promise<void> {
|
||||||
|
|
@ -1409,62 +1417,90 @@ class SupabaseSyncService {
|
||||||
|
|
||||||
private async pushWatchProgressFromLocal(): Promise<void> {
|
private async pushWatchProgressFromLocal(): Promise<void> {
|
||||||
const all = await storageService.getAllWatchProgress();
|
const all = await storageService.getAllWatchProgress();
|
||||||
|
const allKeys = Object.keys(all);
|
||||||
|
|
||||||
const nextSeenKeys = new Set<string>();
|
const nextSeenKeys = new Set<string>();
|
||||||
const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = [];
|
const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = [];
|
||||||
|
let skippedSameSignature = 0;
|
||||||
|
let skippedParseFailure = 0;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(all)) {
|
for (const [key, value] of Object.entries(all)) {
|
||||||
nextSeenKeys.add(key);
|
nextSeenKeys.add(key);
|
||||||
const signature = this.getWatchProgressEntrySignature(value);
|
const signature = this.getWatchProgressEntrySignature(value);
|
||||||
if (this.watchProgressPushedSignatures.get(key) === signature) {
|
if (this.watchProgressPushedSignatures.get(key) === signature) {
|
||||||
|
skippedSameSignature++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = this.parseWatchProgressKey(key);
|
const parsed = this.parseWatchProgressKey(key);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
skippedParseFailure++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
changedEntries.push({
|
const row: WatchProgressRow = {
|
||||||
key,
|
content_id: parsed.contentId,
|
||||||
signature,
|
content_type: parsed.contentType,
|
||||||
row: {
|
video_id: parsed.videoId,
|
||||||
content_id: parsed.contentId,
|
season: parsed.season,
|
||||||
content_type: parsed.contentType,
|
episode: parsed.episode,
|
||||||
video_id: parsed.videoId,
|
position: this.secondsToMsLong(value.currentTime),
|
||||||
season: parsed.season,
|
duration: this.secondsToMsLong(value.duration),
|
||||||
episode: parsed.episode,
|
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
|
||||||
position: this.secondsToMsLong(value.currentTime),
|
progress_key: parsed.progressKey,
|
||||||
duration: this.secondsToMsLong(value.duration),
|
};
|
||||||
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
|
|
||||||
progress_key: parsed.progressKey,
|
changedEntries.push({ key, signature, row });
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune signatures for entries no longer present locally (deletes are handled separately).
|
// Prune signatures for entries no longer present locally (deletes are handled separately).
|
||||||
|
let prunedSignatures = 0;
|
||||||
for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) {
|
for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) {
|
||||||
if (!nextSeenKeys.has(existingKey)) {
|
if (!nextSeenKeys.has(existingKey)) {
|
||||||
this.watchProgressPushedSignatures.delete(existingKey);
|
this.watchProgressPushedSignatures.delete(existingKey);
|
||||||
|
prunedSignatures++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: skippedSameSignature=${skippedSameSignature} skippedParseFailure=${skippedParseFailure} prunedStaleSignatures=${prunedSignatures}`);
|
||||||
|
|
||||||
if (changedEntries.length === 0) {
|
if (changedEntries.length === 0) {
|
||||||
logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push');
|
logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.callRpc<void>('sync_push_watch_progress', {
|
const rpcPayload = changedEntries.map((entry) => entry.row);
|
||||||
p_entries: changedEntries.map((entry) => entry.row),
|
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: calling sync_push_watch_progress with ${rpcPayload.length} entries`);
|
||||||
});
|
try {
|
||||||
|
await this.callRpc<void>('sync_push_watch_progress', {
|
||||||
|
p_entries: rpcPayload,
|
||||||
|
});
|
||||||
|
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC success`);
|
||||||
|
} catch (rpcError: any) {
|
||||||
|
logger.error(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC FAILED`, rpcError?.message || rpcError);
|
||||||
|
throw rpcError;
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of changedEntries) {
|
for (const entry of changedEntries) {
|
||||||
this.watchProgressPushedSignatures.set(entry.key, entry.signature);
|
this.watchProgressPushedSignatures.set(entry.key, entry.signature);
|
||||||
}
|
}
|
||||||
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${Object.keys(all).length}`);
|
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${allKeys.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pullLibraryToLocal(): Promise<void> {
|
private async pullLibraryToLocal(): Promise<void> {
|
||||||
const rows = await this.callRpc<LibraryRow[]>('sync_pull_library', {});
|
const PAGE_SIZE = 500;
|
||||||
|
const rows: LibraryRow[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const page = await this.callRpc<LibraryRow[]>('sync_pull_library', {
|
||||||
|
p_limit: PAGE_SIZE,
|
||||||
|
p_offset: offset,
|
||||||
|
});
|
||||||
|
if (!page || page.length === 0) break;
|
||||||
|
rows.push(...page);
|
||||||
|
if (page.length < PAGE_SIZE) break;
|
||||||
|
offset += PAGE_SIZE;
|
||||||
|
}
|
||||||
const localItems = await catalogService.getLibraryItems();
|
const localItems = await catalogService.getLibraryItems();
|
||||||
const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
|
const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
|
||||||
const remoteSet = new Set<string>();
|
const remoteSet = new Set<string>();
|
||||||
|
|
@ -1532,6 +1568,8 @@ class SupabaseSyncService {
|
||||||
|
|
||||||
private async pushWatchedItemsFromLocal(): Promise<void> {
|
private async pushWatchedItemsFromLocal(): Promise<void> {
|
||||||
const items = await watchedService.getAllWatchedItems();
|
const items = await watchedService.getAllWatchedItems();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
const payload: WatchedRow[] = items.map((item) => ({
|
const payload: WatchedRow[] = items.map((item) => ({
|
||||||
content_id: item.content_id,
|
content_id: item.content_id,
|
||||||
content_type: item.content_type,
|
content_type: item.content_type,
|
||||||
|
|
@ -1540,7 +1578,13 @@ class SupabaseSyncService {
|
||||||
episode: item.episode,
|
episode: item.episode,
|
||||||
watched_at: item.watched_at,
|
watched_at: item.watched_at,
|
||||||
}));
|
}));
|
||||||
await this.callRpc<void>('sync_push_watched_items', { p_items: payload });
|
|
||||||
|
try {
|
||||||
|
await this.callRpc<void>('sync_push_watched_items', { p_items: payload });
|
||||||
|
} catch (rpcError: any) {
|
||||||
|
logger.error(`[SupabaseSyncService] pushWatchedItemsFromLocal: RPC FAILED`, rpcError?.message || rpcError);
|
||||||
|
throw rpcError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,13 +175,22 @@ class WatchedService {
|
||||||
.filter((item) => Boolean(item.content_id));
|
.filter((item) => Boolean(item.content_id));
|
||||||
|
|
||||||
// Guard: do not wipe local watched data if backend temporarily returns empty.
|
// Guard: do not wipe local watched data if backend temporarily returns empty.
|
||||||
|
|
||||||
if (normalizedRemote.length === 0) {
|
if (normalizedRemote.length === 0) {
|
||||||
|
logger.log('[WatchedService] reconcileRemoteWatchedItems: remote is empty, doing nothing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentLocal = await this.loadWatchedItems();
|
||||||
|
const remoteKeys = new Set(normalizedRemote.map(r => this.watchedKey(r)));
|
||||||
|
|
||||||
|
// Find local items that need to be removed because they don't exist remotely
|
||||||
|
const toRemove = currentLocal.filter(l => !remoteKeys.has(this.watchedKey(l)));
|
||||||
|
|
||||||
await this.saveWatchedItems(normalizedRemote);
|
await this.saveWatchedItems(normalizedRemote);
|
||||||
this.notifyWatchedSubscribers();
|
this.notifyWatchedSubscribers();
|
||||||
|
|
||||||
|
// 1. Set watched status for all remote items
|
||||||
for (const item of normalizedRemote) {
|
for (const item of normalizedRemote) {
|
||||||
if (item.content_type === 'movie') {
|
if (item.content_type === 'movie') {
|
||||||
await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at));
|
await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at));
|
||||||
|
|
@ -192,8 +201,27 @@ class WatchedService {
|
||||||
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
|
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
|
||||||
await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at));
|
await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Unset watched status for local items that were deleted remotely
|
||||||
|
for (const item of toRemove) {
|
||||||
|
if (item.content_type === 'movie') {
|
||||||
|
await this.setLocalWatchedStatus(item.content_id, 'movie', false);
|
||||||
|
} else if (item.season != null && item.episode != null) {
|
||||||
|
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
|
||||||
|
await this.setLocalWatchedStatus(item.content_id, 'series', false, episodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
logger.log(`[WatchedService] reconcileRemoteWatchedItems: Removed ${toRemove.length} local items that were deleted remotely`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a movie as watched
|
||||||
|
* @param imdbId - The IMDb ID of the movie
|
||||||
|
* @param watchedAt - Optional date when watched
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Mark a movie as watched
|
* Mark a movie as watched
|
||||||
* @param imdbId - The IMDb ID of the movie
|
* @param imdbId - The IMDb ID of the movie
|
||||||
|
|
@ -207,7 +235,7 @@ class WatchedService {
|
||||||
title?: string
|
title?: string
|
||||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||||
try {
|
try {
|
||||||
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
|
logger.log(`[WatchedService] Marking movie as watched: ${imdbId} (${title || 'No title'})`);
|
||||||
|
|
||||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
let syncedToTrakt = false;
|
let syncedToTrakt = false;
|
||||||
|
|
@ -247,7 +275,7 @@ class WatchedService {
|
||||||
{
|
{
|
||||||
content_id: imdbId,
|
content_id: imdbId,
|
||||||
content_type: 'movie',
|
content_type: 'movie',
|
||||||
title: imdbId,
|
title: title || imdbId,
|
||||||
season: null,
|
season: null,
|
||||||
episode: null,
|
episode: null,
|
||||||
watched_at: watchedAt.getTime(),
|
watched_at: watchedAt.getTime(),
|
||||||
|
|
@ -373,7 +401,7 @@ class WatchedService {
|
||||||
{
|
{
|
||||||
content_id: showImdbId,
|
content_id: showImdbId,
|
||||||
content_type: 'series',
|
content_type: 'series',
|
||||||
title: showImdbId,
|
title: showTitle || showImdbId,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
watched_at: watchedAt.getTime(),
|
watched_at: watchedAt.getTime(),
|
||||||
|
|
@ -398,7 +426,8 @@ class WatchedService {
|
||||||
showImdbId: string,
|
showImdbId: string,
|
||||||
showId: string,
|
showId: string,
|
||||||
episodes: Array<{ season: number; episode: number }>,
|
episodes: Array<{ season: number; episode: number }>,
|
||||||
watchedAt: Date = new Date()
|
watchedAt: Date = new Date(),
|
||||||
|
showTitle?: string
|
||||||
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||||
try {
|
try {
|
||||||
if (episodes.length === 0) {
|
if (episodes.length === 0) {
|
||||||
|
|
@ -479,7 +508,8 @@ class WatchedService {
|
||||||
showId: string,
|
showId: string,
|
||||||
season: number,
|
season: number,
|
||||||
episodeNumbers: number[],
|
episodeNumbers: number[],
|
||||||
watchedAt: Date = new Date()
|
watchedAt: Date = new Date(),
|
||||||
|
showTitle?: string
|
||||||
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||||
try {
|
try {
|
||||||
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
|
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
|
||||||
|
|
@ -525,7 +555,7 @@ class WatchedService {
|
||||||
episodeNumbers.map((episode) => ({
|
episodeNumbers.map((episode) => ({
|
||||||
content_id: showImdbId,
|
content_id: showImdbId,
|
||||||
content_type: 'series' as const,
|
content_type: 'series' as const,
|
||||||
title: showImdbId,
|
title: showTitle || showImdbId,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
watched_at: watchedAt.getTime(),
|
watched_at: watchedAt.getTime(),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Single source of truth for the app version displayed in Settings
|
// Single source of truth for the app version displayed in Settings
|
||||||
// Update this when bumping app version
|
// Update this when bumping app version
|
||||||
|
|
||||||
export const APP_VERSION = '1.4.2';
|
export const APP_VERSION = '1.4.4';
|
||||||
|
|
||||||
export function getDisplayedAppVersion(): string {
|
export function getDisplayedAppVersion(): string {
|
||||||
return APP_VERSION;
|
return APP_VERSION;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue