mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +00:00
Merge branch 'NuvioMedia:main' into Mal
This commit is contained in:
commit
fb21c07077
18 changed files with 91690 additions and 396 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,18 +41,27 @@ 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>
|
||||||
|
|
@ -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 = {
|
||||||
|
|
@ -1013,7 +1020,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;
|
||||||
|
|
@ -1082,7 +1089,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);
|
||||||
|
|
@ -1599,10 +1606,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1831,7 +1838,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) {
|
||||||
|
|
@ -2011,16 +2018,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 {
|
||||||
|
|
@ -2031,12 +2040,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
|
||||||
|
|
@ -2113,7 +2127,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);
|
||||||
}
|
}
|
||||||
|
|
@ -2192,12 +2206,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
|
||||||
|
|
@ -2228,7 +2242,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 ? {
|
||||||
|
|
@ -2301,7 +2315,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);
|
||||||
|
|
@ -2328,7 +2342,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;
|
||||||
}
|
}
|
||||||
|
|
@ -2336,7 +2350,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2359,7 +2373,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);
|
||||||
|
|
@ -2408,7 +2422,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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -636,9 +634,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 +892,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}
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,8 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private canBrowseCatalog(catalog: StreamingCatalog): boolean {
|
private canBrowseCatalog(catalog: StreamingCatalog): boolean {
|
||||||
|
// Exclude non-standard types like anime.series, anime.movie from discover browsing
|
||||||
|
if (catalog.type && catalog.type.includes('.')) return false;
|
||||||
const requiredExtras = this.getRequiredCatalogExtras(catalog);
|
const requiredExtras = this.getRequiredCatalogExtras(catalog);
|
||||||
return requiredExtras.every(extraName => extraName === 'genre');
|
return requiredExtras.every(extraName => extraName === 'genre');
|
||||||
}
|
}
|
||||||
|
|
@ -1534,9 +1536,24 @@ class CatalogService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedupe within addon and against global
|
// Within this addon's results, if the same ID appears under both a generic
|
||||||
|
// type (e.g. "series") and a specific type (e.g. "anime.series"), keep only
|
||||||
|
// the specific one. This handles addons that expose both catalog types.
|
||||||
|
const bestByIdWithinAddon = new Map<string, StreamingContent>();
|
||||||
|
for (const item of addonResults) {
|
||||||
|
const existing = bestByIdWithinAddon.get(item.id);
|
||||||
|
if (!existing) {
|
||||||
|
bestByIdWithinAddon.set(item.id, item);
|
||||||
|
} else if (!existing.type.includes('.') && item.type.includes('.')) {
|
||||||
|
// Prefer the more specific type
|
||||||
|
bestByIdWithinAddon.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deduped = Array.from(bestByIdWithinAddon.values());
|
||||||
|
|
||||||
|
// Dedupe against global seen (keyed by type:id to avoid cross-addon ID collisions)
|
||||||
const localSeen = new Set<string>();
|
const localSeen = new Set<string>();
|
||||||
const unique = addonResults.filter(item => {
|
const unique = deduped.filter(item => {
|
||||||
const key = `${item.type}:${item.id}`;
|
const key = `${item.type}:${item.id}`;
|
||||||
if (localSeen.has(key) || globalSeen.has(key)) return false;
|
if (localSeen.has(key) || globalSeen.has(key)) return false;
|
||||||
localSeen.add(key);
|
localSeen.add(key);
|
||||||
|
|
@ -1626,6 +1643,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