This commit is contained in:
tapframe 2025-09-16 01:34:38 +05:30
parent 2b3069a988
commit 680a1b1ea6
7 changed files with 139 additions and 64 deletions

View file

@ -12,7 +12,7 @@ import {
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { StreamingContent, catalogService } from '../../services/catalogService';
@ -214,6 +214,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const progressPercent = (progress.currentTime / progress.duration) * 100;
// Skip fully watched movies
if (type === 'movie' && progressPercent >= 85) continue;
// Skip movies with no actual progress (ensure > 0%)
if (type === 'movie' && (!isFinite(progressPercent) || progressPercent <= 0)) continue;
const contentKey = `${type}:${id}`;
if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] };
contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent });
@ -447,7 +449,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
refreshTimerRef.current = setTimeout(() => {
// Trigger a background refresh
loadContinueWatching(true);
}, 2000); // Increased debounce time significantly to reduce churn
}, 800); // Shorter debounce for snappier UI without battery impact
};
// Try to set up a custom event listener or use a timer as fallback
@ -484,6 +486,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
loadContinueWatching();
}, [loadContinueWatching]);
// Refresh on screen focus (lightweight, no polling)
useFocusEffect(
useCallback(() => {
loadContinueWatching(true);
return () => {};
}, [loadContinueWatching])
);
// Expose the refresh function via the ref
React.useImperativeHandle(ref, () => ({
refresh: async () => {
@ -619,7 +629,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
const isUpNext = item.type === 'series' && item.progress === 0;
return (
<View style={styles.titleRow}>
<Text

View file

@ -1407,11 +1407,18 @@ const AndroidVideoPlayer: React.FC = () => {
(error?.error?.localizedDescription &&
error.error.localizedDescription.includes('server is not correctly configured'));
// Expand audio decoder error detection to include DTS/TrueHD/Atmos families
const isHeavyCodecDecoderError =
(error?.error?.errorString && /(dts|true\s?hd|truehd|atmos)/i.test(String(error.error.errorString))) ||
(error?.error?.errorException && /(dts|true\s?hd|truehd|atmos)/i.test(String(error.error.errorException)));
// Format error details for user display
let errorMessage = 'An unknown error occurred';
if (error) {
if (isDolbyCodecError) {
errorMessage = 'Audio codec compatibility issue detected. The video contains Dolby Digital Plus audio which is not supported on this device. Please try selecting a different audio track or use an alternative video source.';
} else if (isHeavyCodecDecoderError) {
errorMessage = 'Audio codec issue (DTS/TrueHD/Atmos). Switching to a stereo/standard audio track may help.';
} else if (isServerConfigError) {
errorMessage = 'Stream server configuration issue. This may be a temporary problem with the video source.';
} else if (typeof error === 'string') {
@ -2627,7 +2634,8 @@ const AndroidVideoPlayer: React.FC = () => {
ignoreSilentSwitch="ignore"
mixWithOthers="inherit"
progressUpdateInterval={1000}
maxBitRate={2000000}
// Remove artificial bit rate cap to allow high-bitrate streams (e.g., Blu-ray remux) to play
// maxBitRate intentionally omitted
disableFocus={true}
// iOS AVPlayer startup tuning
automaticallyWaitsToMinimizeStalling={true as any}
@ -2636,11 +2644,14 @@ const AndroidVideoPlayer: React.FC = () => {
preventsDisplaySleepDuringVideoPlayback={true as any}
// ExoPlayer HLS optimization
bufferConfig={{
minBufferMs: 15000,
maxBufferMs: 50000,
// Larger buffers for high-bitrate remuxes to reduce rebuffering/crashes
minBufferMs: 60000,
maxBufferMs: 180000,
bufferForPlaybackMs: 2500,
bufferForPlaybackAfterRebufferMs: 5000,
bufferForPlaybackAfterRebufferMs: 8000,
} as any}
// Use SurfaceView on Android to lower memory pressure with 4K/high-bitrate content
useTextureView={Platform.OS === 'android' ? false : (undefined as any)}
/>
</TouchableOpacity>
</View>

View file

@ -88,6 +88,7 @@ interface UseMetadataReturn {
loadingStreams: boolean;
episodeStreams: GroupedStreams;
loadingEpisodeStreams: boolean;
addonResponseOrder: string[];
preloadedStreams: GroupedStreams;
preloadedEpisodeStreams: { [episodeId: string]: GroupedStreams };
selectedEpisode: string | null;
@ -134,6 +135,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
const [scraperStatuses, setScraperStatuses] = useState<ScraperStatus[]>([]);
const [activeFetchingScrapers, setActiveFetchingScrapers] = useState<string[]>([]);
// Track response order for addons to preserve actual response order
const [addonResponseOrder, setAddonResponseOrder] = useState<string[]>([]);
// Prevent re-initializing season selection repeatedly for the same series
const initializedSeasonRef = useRef(false);
@ -287,6 +290,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
};
};
// Track response order for addons
setAddonResponseOrder(prevOrder => {
if (!prevOrder.includes(addonId)) {
return [...prevOrder, addonId];
}
return prevOrder;
});
if (isEpisode) {
setEpisodeStreams(updateState);
setLoadingEpisodeStreams(false);
@ -824,6 +835,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Reset scraper tracking
setScraperStatuses([]);
setActiveFetchingScrapers([]);
setAddonResponseOrder([]); // Reset response order
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
if (__DEV__) console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
@ -990,6 +1002,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Reset scraper tracking for episodes
setScraperStatuses([]);
setActiveFetchingScrapers([]);
setAddonResponseOrder([]); // Reset response order
// Initialize scraper tracking for episodes
try {
@ -1358,6 +1371,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
loadingStreams,
episodeStreams,
loadingEpisodeStreams,
addonResponseOrder,
preloadedStreams,
preloadedEpisodeStreams,
selectedEpisode,

View file

@ -705,26 +705,36 @@ const StatusBadge: React.FC<{
const getStatusConfig = () => {
switch (status) {
case 'enabled':
return { color: '#34C759', text: 'Active', icon: 'checkmark-circle' };
return { color: '#34C759', text: 'Active' };
case 'disabled':
return { color: colors.mediumGray, text: 'Disabled', icon: 'close-circle' };
return { color: colors.mediumGray, text: 'Disabled' };
case 'available':
return { color: colors.primary, text: 'Available', icon: 'download' };
return { color: colors.primary, text: 'Available' };
case 'platform-disabled':
return { color: '#FF9500', text: 'Platform Disabled', icon: 'phone-portrait' };
return { color: '#FF9500', text: 'Platform Disabled' };
case 'error':
return { color: '#FF3B30', text: 'Error', icon: 'warning' };
return { color: '#FF3B30', text: 'Error' };
default:
return { color: colors.mediumGray, text: 'Unknown', icon: 'help-circle' };
return { color: colors.mediumGray, text: 'Unknown' };
}
};
const config = getStatusConfig();
return (
<View style={[{ backgroundColor: config.color, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, gap: 4 }]}>
<Ionicons name={config.icon as any} size={12} color="white" />
<Text style={{ color: 'white', fontSize: 11, fontWeight: '600' }}>{config.text}</Text>
<View style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 9999,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: config.color,
gap: 6,
}}>
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: config.color }} />
<Text style={{ color: config.color, fontSize: 11, fontWeight: '600' }}>{config.text}</Text>
</View>
);
};
@ -874,7 +884,7 @@ const PluginsScreen: React.FC = () => {
setShowAddRepositoryModal(false);
Alert.alert('Success', 'Repository added and refreshed successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to add repository:', error);
logger.error('[PluginsScreen] Failed to add repository:', error);
Alert.alert('Error', 'Failed to add repository');
} finally {
setIsLoading(false);
@ -1447,7 +1457,7 @@ const PluginsScreen: React.FC = () => {
<View style={styles.scraperLogo} />
)}
<View style={styles.scraperCardInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<Text style={styles.scraperName}>{scraper.name}</Text>
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
</View>

View file

@ -455,6 +455,7 @@ export const StreamsScreen = () => {
imdbId,
scraperStatuses,
activeFetchingScrapers,
addonResponseOrder,
} = useMetadata({ id, type });
// Get backdrop from metadata assets
@ -1378,13 +1379,20 @@ export const StreamsScreen = () => {
return addonId === selectedProvider;
})
.sort(([addonIdA], [addonIdB]) => {
// Sort by Stremio addon installation order
const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
// Sort by response order (actual order addons responded)
const indexA = addonResponseOrder.indexOf(addonIdA);
const indexB = addonResponseOrder.indexOf(addonIdB);
// If both are in response order, sort by response order
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// If only one is in response order, prioritize it
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// If neither is in response order, maintain original order
return 0;
});
@ -1408,17 +1416,16 @@ export const StreamsScreen = () => {
pluginOriginalCount += providerStreams.length;
}
// Apply quality filtering and sorting to streams
// Apply quality filtering only; keep original addon stream order
const filteredStreams = filterStreamsByQuality(providerStreams);
const sortedStreams = sortStreams(filteredStreams);
if (isInstalledAddon) {
addonStreams.push(...sortedStreams);
addonStreams.push(...filteredStreams);
if (!addonNames.includes(addonName)) {
addonNames.push(addonName);
}
} else {
pluginStreams.push(...sortedStreams);
pluginStreams.push(...filteredStreams);
if (!pluginNames.includes(addonName)) {
pluginNames.push(addonName);
}
@ -1427,14 +1434,10 @@ export const StreamsScreen = () => {
const sections = [];
if (addonStreams.length > 0) {
// Apply final sorting to the combined addon streams for quality-first mode
const finalSortedAddonStreams = settings.streamSortMode === 'quality-then-scraper' ?
sortStreams(addonStreams) : addonStreams;
sections.push({
title: addonNames.join(', '),
addonId: 'grouped-addons',
data: finalSortedAddonStreams
data: addonStreams
});
} else if (addonOriginalCount > 0 && addonStreams.length === 0) {
// Show empty section with message for addons that had streams but all were filtered
@ -1447,14 +1450,10 @@ export const StreamsScreen = () => {
}
if (pluginStreams.length > 0) {
// Apply final sorting to the combined plugin streams for quality-first mode
const finalSortedPluginStreams = settings.streamSortMode === 'quality-then-scraper' ?
sortStreams(pluginStreams) : pluginStreams;
sections.push({
title: localScraperService.getRepositoryName(),
addonId: 'grouped-plugins',
data: finalSortedPluginStreams
data: pluginStreams
});
} else if (pluginOriginalCount > 0 && pluginStreams.length === 0) {
// Show empty section with message for plugins that had streams but all were filtered
@ -1473,21 +1472,20 @@ export const StreamsScreen = () => {
// Count original streams before filtering
const originalCount = providerStreams.length;
// Apply quality filtering and sorting to streams
// Apply quality filtering only; keep original addon stream order
const filteredStreams = filterStreamsByQuality(providerStreams);
const sortedStreams = sortStreams(filteredStreams);
const isEmptyDueToQualityFilter = originalCount > 0 && sortedStreams.length === 0;
const isEmptyDueToQualityFilter = originalCount > 0 && filteredStreams.length === 0;
return {
title: addonName,
addonId,
data: isEmptyDueToQualityFilter ? [{ isEmptyPlaceholder: true } as any] : sortedStreams,
data: isEmptyDueToQualityFilter ? [{ isEmptyPlaceholder: true } as any] : filteredStreams,
isEmptyDueToQualityFilter
};
});
}
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, sortStreams]);
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, sortStreams, addonResponseOrder]);
const episodeImage = useMemo(() => {
if (episodeThumbnail) {

View file

@ -113,19 +113,28 @@ class SyncService {
await storageService.addWatchProgressTombstone(id, type, episodeId || undefined, remoteUpdated);
} catch {}
} else {
// Preserve the most recent timestamp between local and remote to maintain proper continue watching order
const remoteTimestamp = row.last_updated_ms || Date.now();
const existingProgress = await storageService.getWatchProgress(id, type, (row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined);
const localTimestamp = existingProgress?.lastUpdated || 0;
// Use the newer timestamp to maintain proper continue watching order across devices
const finalTimestamp = Math.max(remoteTimestamp, localTimestamp);
await storageService.setWatchProgress(
id,
type,
{
currentTime: row.current_time_seconds || 0,
duration: row.duration_seconds || 0,
lastUpdated: row.last_updated_ms || Date.now(),
lastUpdated: finalTimestamp,
traktSynced: row.trakt_synced ?? undefined,
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
traktProgress: row.trakt_progress_percent ?? undefined,
},
// Ensure we pass through the full remote episode_id as-is; empty string becomes undefined
(row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined
(row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined,
{ preserveTimestamp: true, forceNotify: true, forceWrite: true }
);
}
} catch {}
@ -372,19 +381,32 @@ class SyncService {
if (wp && Array.isArray(wp)) {
const remoteActiveKeys = new Set<string>();
for (const row of wp as any[]) {
// Preserve the most recent timestamp between local and remote to maintain proper continue watching order
const remoteTimestamp = row.last_updated_ms || Date.now();
const existingProgress = await storageService.getWatchProgress(
row.media_id,
row.media_type,
(row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined
);
const localTimestamp = existingProgress?.lastUpdated || 0;
// Use the newer timestamp to maintain proper continue watching order across devices
const finalTimestamp = Math.max(remoteTimestamp, localTimestamp);
await storageService.setWatchProgress(
row.media_id,
row.media_type,
{
currentTime: row.current_time_seconds,
duration: row.duration_seconds,
lastUpdated: row.last_updated_ms,
lastUpdated: finalTimestamp,
traktSynced: row.trakt_synced ?? undefined,
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
traktProgress: row.trakt_progress_percent ?? undefined,
},
// Ensure full episode_id is preserved; treat empty as undefined
(row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined
(row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined,
{ preserveTimestamp: true, forceNotify: true, forceWrite: true }
);
remoteActiveKeys.add(`${row.media_type}|${row.media_id}|${row.episode_id || ''}`);
}

View file

@ -223,10 +223,11 @@ class StorageService {
}
public async setWatchProgress(
id: string,
type: string,
id: string,
type: string,
progress: WatchProgress,
episodeId?: string
episodeId?: string,
options?: { preserveTimestamp?: boolean; forceNotify?: boolean; forceWrite?: boolean }
): Promise<void> {
try {
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
@ -243,23 +244,32 @@ class StorageService {
}
} catch {}
// Check if progress has actually changed significantly
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress) {
const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime);
const durationDiff = Math.abs(progress.duration - existingProgress.duration);
// Only update if there's a significant change (>5 seconds or duration change)
if (timeDiff < 5 && durationDiff < 1) {
return; // Skip update for minor changes
// Check if progress has actually changed significantly, unless forceWrite is requested
if (!options?.forceWrite) {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress) {
const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime);
const durationDiff = Math.abs(progress.duration - existingProgress.duration);
// Only update if there's a significant change (>5 seconds or duration change)
if (timeDiff < 5 && durationDiff < 1) {
return; // Skip update for minor changes
}
}
}
const updated = { ...progress, lastUpdated: Date.now() };
await AsyncStorage.setItem(key, JSON.stringify(updated));
// Use debounced notification to reduce spam
this.debouncedNotifySubscribers();
const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number')
? progress.lastUpdated
: Date.now();
const updated = { ...progress, lastUpdated: timestamp };
await AsyncStorage.setItem(key, JSON.stringify(updated));
// Notify subscribers; allow forcing immediate notification
if (options?.forceNotify) {
this.notifyWatchProgressSubscribers();
} else {
this.debouncedNotifySubscribers();
}
} catch (error) {
logger.error('Error setting watch progress:', error);
}