mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 00:32:04 +00:00
fixes
This commit is contained in:
parent
2b3069a988
commit
680a1b1ea6
7 changed files with 139 additions and 64 deletions
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
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 { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import { StreamingContent, catalogService } from '../../services/catalogService';
|
import { StreamingContent, catalogService } from '../../services/catalogService';
|
||||||
|
|
@ -214,6 +214,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
// Skip fully watched movies
|
// Skip fully watched movies
|
||||||
if (type === 'movie' && progressPercent >= 85) continue;
|
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}`;
|
const contentKey = `${type}:${id}`;
|
||||||
if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] };
|
if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] };
|
||||||
contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent });
|
contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent });
|
||||||
|
|
@ -447,7 +449,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
refreshTimerRef.current = setTimeout(() => {
|
refreshTimerRef.current = setTimeout(() => {
|
||||||
// Trigger a background refresh
|
// Trigger a background refresh
|
||||||
loadContinueWatching(true);
|
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
|
// 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();
|
||||||
}, [loadContinueWatching]);
|
}, [loadContinueWatching]);
|
||||||
|
|
||||||
|
// Refresh on screen focus (lightweight, no polling)
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadContinueWatching(true);
|
||||||
|
return () => {};
|
||||||
|
}, [loadContinueWatching])
|
||||||
|
);
|
||||||
|
|
||||||
// Expose the refresh function via the ref
|
// Expose the refresh function via the ref
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
|
|
@ -619,7 +629,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
<View style={styles.contentDetails}>
|
<View style={styles.contentDetails}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const isUpNext = item.progress === 0;
|
const isUpNext = item.type === 'series' && item.progress === 0;
|
||||||
return (
|
return (
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Text
|
<Text
|
||||||
|
|
|
||||||
|
|
@ -1407,11 +1407,18 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
(error?.error?.localizedDescription &&
|
(error?.error?.localizedDescription &&
|
||||||
error.error.localizedDescription.includes('server is not correctly configured'));
|
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
|
// Format error details for user display
|
||||||
let errorMessage = 'An unknown error occurred';
|
let errorMessage = 'An unknown error occurred';
|
||||||
if (error) {
|
if (error) {
|
||||||
if (isDolbyCodecError) {
|
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.';
|
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) {
|
} else if (isServerConfigError) {
|
||||||
errorMessage = 'Stream server configuration issue. This may be a temporary problem with the video source.';
|
errorMessage = 'Stream server configuration issue. This may be a temporary problem with the video source.';
|
||||||
} else if (typeof error === 'string') {
|
} else if (typeof error === 'string') {
|
||||||
|
|
@ -2627,7 +2634,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
ignoreSilentSwitch="ignore"
|
ignoreSilentSwitch="ignore"
|
||||||
mixWithOthers="inherit"
|
mixWithOthers="inherit"
|
||||||
progressUpdateInterval={1000}
|
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}
|
disableFocus={true}
|
||||||
// iOS AVPlayer startup tuning
|
// iOS AVPlayer startup tuning
|
||||||
automaticallyWaitsToMinimizeStalling={true as any}
|
automaticallyWaitsToMinimizeStalling={true as any}
|
||||||
|
|
@ -2636,11 +2644,14 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
preventsDisplaySleepDuringVideoPlayback={true as any}
|
preventsDisplaySleepDuringVideoPlayback={true as any}
|
||||||
// ExoPlayer HLS optimization
|
// ExoPlayer HLS optimization
|
||||||
bufferConfig={{
|
bufferConfig={{
|
||||||
minBufferMs: 15000,
|
// Larger buffers for high-bitrate remuxes to reduce rebuffering/crashes
|
||||||
maxBufferMs: 50000,
|
minBufferMs: 60000,
|
||||||
|
maxBufferMs: 180000,
|
||||||
bufferForPlaybackMs: 2500,
|
bufferForPlaybackMs: 2500,
|
||||||
bufferForPlaybackAfterRebufferMs: 5000,
|
bufferForPlaybackAfterRebufferMs: 8000,
|
||||||
} as any}
|
} as any}
|
||||||
|
// Use SurfaceView on Android to lower memory pressure with 4K/high-bitrate content
|
||||||
|
useTextureView={Platform.OS === 'android' ? false : (undefined as any)}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ interface UseMetadataReturn {
|
||||||
loadingStreams: boolean;
|
loadingStreams: boolean;
|
||||||
episodeStreams: GroupedStreams;
|
episodeStreams: GroupedStreams;
|
||||||
loadingEpisodeStreams: boolean;
|
loadingEpisodeStreams: boolean;
|
||||||
|
addonResponseOrder: string[];
|
||||||
preloadedStreams: GroupedStreams;
|
preloadedStreams: GroupedStreams;
|
||||||
preloadedEpisodeStreams: { [episodeId: string]: GroupedStreams };
|
preloadedEpisodeStreams: { [episodeId: string]: GroupedStreams };
|
||||||
selectedEpisode: string | null;
|
selectedEpisode: string | null;
|
||||||
|
|
@ -134,6 +135,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
|
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
|
||||||
const [scraperStatuses, setScraperStatuses] = useState<ScraperStatus[]>([]);
|
const [scraperStatuses, setScraperStatuses] = useState<ScraperStatus[]>([]);
|
||||||
const [activeFetchingScrapers, setActiveFetchingScrapers] = useState<string[]>([]);
|
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
|
// Prevent re-initializing season selection repeatedly for the same series
|
||||||
const initializedSeasonRef = useRef(false);
|
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) {
|
if (isEpisode) {
|
||||||
setEpisodeStreams(updateState);
|
setEpisodeStreams(updateState);
|
||||||
setLoadingEpisodeStreams(false);
|
setLoadingEpisodeStreams(false);
|
||||||
|
|
@ -824,6 +835,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Reset scraper tracking
|
// Reset scraper tracking
|
||||||
setScraperStatuses([]);
|
setScraperStatuses([]);
|
||||||
setActiveFetchingScrapers([]);
|
setActiveFetchingScrapers([]);
|
||||||
|
setAddonResponseOrder([]); // Reset response order
|
||||||
|
|
||||||
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
||||||
if (__DEV__) console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
|
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
|
// Reset scraper tracking for episodes
|
||||||
setScraperStatuses([]);
|
setScraperStatuses([]);
|
||||||
setActiveFetchingScrapers([]);
|
setActiveFetchingScrapers([]);
|
||||||
|
setAddonResponseOrder([]); // Reset response order
|
||||||
|
|
||||||
// Initialize scraper tracking for episodes
|
// Initialize scraper tracking for episodes
|
||||||
try {
|
try {
|
||||||
|
|
@ -1358,6 +1371,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
loadingStreams,
|
loadingStreams,
|
||||||
episodeStreams,
|
episodeStreams,
|
||||||
loadingEpisodeStreams,
|
loadingEpisodeStreams,
|
||||||
|
addonResponseOrder,
|
||||||
preloadedStreams,
|
preloadedStreams,
|
||||||
preloadedEpisodeStreams,
|
preloadedEpisodeStreams,
|
||||||
selectedEpisode,
|
selectedEpisode,
|
||||||
|
|
|
||||||
|
|
@ -705,26 +705,36 @@ const StatusBadge: React.FC<{
|
||||||
const getStatusConfig = () => {
|
const getStatusConfig = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'enabled':
|
case 'enabled':
|
||||||
return { color: '#34C759', text: 'Active', icon: 'checkmark-circle' };
|
return { color: '#34C759', text: 'Active' };
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return { color: colors.mediumGray, text: 'Disabled', icon: 'close-circle' };
|
return { color: colors.mediumGray, text: 'Disabled' };
|
||||||
case 'available':
|
case 'available':
|
||||||
return { color: colors.primary, text: 'Available', icon: 'download' };
|
return { color: colors.primary, text: 'Available' };
|
||||||
case 'platform-disabled':
|
case 'platform-disabled':
|
||||||
return { color: '#FF9500', text: 'Platform Disabled', icon: 'phone-portrait' };
|
return { color: '#FF9500', text: 'Platform Disabled' };
|
||||||
case 'error':
|
case 'error':
|
||||||
return { color: '#FF3B30', text: 'Error', icon: 'warning' };
|
return { color: '#FF3B30', text: 'Error' };
|
||||||
default:
|
default:
|
||||||
return { color: colors.mediumGray, text: 'Unknown', icon: 'help-circle' };
|
return { color: colors.mediumGray, text: 'Unknown' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = getStatusConfig();
|
const config = getStatusConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[{ backgroundColor: config.color, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, gap: 4 }]}>
|
<View style={{
|
||||||
<Ionicons name={config.icon as any} size={12} color="white" />
|
flexDirection: 'row',
|
||||||
<Text style={{ color: 'white', fontSize: 11, fontWeight: '600' }}>{config.text}</Text>
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -874,7 +884,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
setShowAddRepositoryModal(false);
|
setShowAddRepositoryModal(false);
|
||||||
Alert.alert('Success', 'Repository added and refreshed successfully');
|
Alert.alert('Success', 'Repository added and refreshed successfully');
|
||||||
} catch (error) {
|
} 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');
|
Alert.alert('Error', 'Failed to add repository');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -1447,7 +1457,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.scraperLogo} />
|
<View style={styles.scraperLogo} />
|
||||||
)}
|
)}
|
||||||
<View style={styles.scraperCardInfo}>
|
<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>
|
<Text style={styles.scraperName}>{scraper.name}</Text>
|
||||||
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
|
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,7 @@ export const StreamsScreen = () => {
|
||||||
imdbId,
|
imdbId,
|
||||||
scraperStatuses,
|
scraperStatuses,
|
||||||
activeFetchingScrapers,
|
activeFetchingScrapers,
|
||||||
|
addonResponseOrder,
|
||||||
} = useMetadata({ id, type });
|
} = useMetadata({ id, type });
|
||||||
|
|
||||||
// Get backdrop from metadata assets
|
// Get backdrop from metadata assets
|
||||||
|
|
@ -1378,13 +1379,20 @@ export const StreamsScreen = () => {
|
||||||
return addonId === selectedProvider;
|
return addonId === selectedProvider;
|
||||||
})
|
})
|
||||||
.sort(([addonIdA], [addonIdB]) => {
|
.sort(([addonIdA], [addonIdB]) => {
|
||||||
// Sort by Stremio addon installation order
|
// Sort by response order (actual order addons responded)
|
||||||
const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
|
const indexA = addonResponseOrder.indexOf(addonIdA);
|
||||||
const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
|
const indexB = addonResponseOrder.indexOf(addonIdB);
|
||||||
|
|
||||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
// 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 (indexA !== -1) return -1;
|
||||||
if (indexB !== -1) return 1;
|
if (indexB !== -1) return 1;
|
||||||
|
|
||||||
|
// If neither is in response order, maintain original order
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1408,17 +1416,16 @@ export const StreamsScreen = () => {
|
||||||
pluginOriginalCount += providerStreams.length;
|
pluginOriginalCount += providerStreams.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply quality filtering and sorting to streams
|
// Apply quality filtering only; keep original addon stream order
|
||||||
const filteredStreams = filterStreamsByQuality(providerStreams);
|
const filteredStreams = filterStreamsByQuality(providerStreams);
|
||||||
const sortedStreams = sortStreams(filteredStreams);
|
|
||||||
|
|
||||||
if (isInstalledAddon) {
|
if (isInstalledAddon) {
|
||||||
addonStreams.push(...sortedStreams);
|
addonStreams.push(...filteredStreams);
|
||||||
if (!addonNames.includes(addonName)) {
|
if (!addonNames.includes(addonName)) {
|
||||||
addonNames.push(addonName);
|
addonNames.push(addonName);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pluginStreams.push(...sortedStreams);
|
pluginStreams.push(...filteredStreams);
|
||||||
if (!pluginNames.includes(addonName)) {
|
if (!pluginNames.includes(addonName)) {
|
||||||
pluginNames.push(addonName);
|
pluginNames.push(addonName);
|
||||||
}
|
}
|
||||||
|
|
@ -1427,14 +1434,10 @@ export const StreamsScreen = () => {
|
||||||
|
|
||||||
const sections = [];
|
const sections = [];
|
||||||
if (addonStreams.length > 0) {
|
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({
|
sections.push({
|
||||||
title: addonNames.join(', '),
|
title: addonNames.join(', '),
|
||||||
addonId: 'grouped-addons',
|
addonId: 'grouped-addons',
|
||||||
data: finalSortedAddonStreams
|
data: addonStreams
|
||||||
});
|
});
|
||||||
} else if (addonOriginalCount > 0 && addonStreams.length === 0) {
|
} else if (addonOriginalCount > 0 && addonStreams.length === 0) {
|
||||||
// Show empty section with message for addons that had streams but all were filtered
|
// 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) {
|
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({
|
sections.push({
|
||||||
title: localScraperService.getRepositoryName(),
|
title: localScraperService.getRepositoryName(),
|
||||||
addonId: 'grouped-plugins',
|
addonId: 'grouped-plugins',
|
||||||
data: finalSortedPluginStreams
|
data: pluginStreams
|
||||||
});
|
});
|
||||||
} else if (pluginOriginalCount > 0 && pluginStreams.length === 0) {
|
} else if (pluginOriginalCount > 0 && pluginStreams.length === 0) {
|
||||||
// Show empty section with message for plugins that had streams but all were filtered
|
// 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
|
// Count original streams before filtering
|
||||||
const originalCount = providerStreams.length;
|
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 filteredStreams = filterStreamsByQuality(providerStreams);
|
||||||
const sortedStreams = sortStreams(filteredStreams);
|
|
||||||
|
|
||||||
const isEmptyDueToQualityFilter = originalCount > 0 && sortedStreams.length === 0;
|
const isEmptyDueToQualityFilter = originalCount > 0 && filteredStreams.length === 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: addonName,
|
title: addonName,
|
||||||
addonId,
|
addonId,
|
||||||
data: isEmptyDueToQualityFilter ? [{ isEmptyPlaceholder: true } as any] : sortedStreams,
|
data: isEmptyDueToQualityFilter ? [{ isEmptyPlaceholder: true } as any] : filteredStreams,
|
||||||
isEmptyDueToQualityFilter
|
isEmptyDueToQualityFilter
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, sortStreams]);
|
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, sortStreams, addonResponseOrder]);
|
||||||
|
|
||||||
const episodeImage = useMemo(() => {
|
const episodeImage = useMemo(() => {
|
||||||
if (episodeThumbnail) {
|
if (episodeThumbnail) {
|
||||||
|
|
|
||||||
|
|
@ -113,19 +113,28 @@ class SyncService {
|
||||||
await storageService.addWatchProgressTombstone(id, type, episodeId || undefined, remoteUpdated);
|
await storageService.addWatchProgressTombstone(id, type, episodeId || undefined, remoteUpdated);
|
||||||
} catch {}
|
} catch {}
|
||||||
} else {
|
} 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(
|
await storageService.setWatchProgress(
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
{
|
{
|
||||||
currentTime: row.current_time_seconds || 0,
|
currentTime: row.current_time_seconds || 0,
|
||||||
duration: row.duration_seconds || 0,
|
duration: row.duration_seconds || 0,
|
||||||
lastUpdated: row.last_updated_ms || Date.now(),
|
lastUpdated: finalTimestamp,
|
||||||
traktSynced: row.trakt_synced ?? undefined,
|
traktSynced: row.trakt_synced ?? undefined,
|
||||||
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
|
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
|
||||||
traktProgress: row.trakt_progress_percent ?? undefined,
|
traktProgress: row.trakt_progress_percent ?? undefined,
|
||||||
},
|
},
|
||||||
// Ensure we pass through the full remote episode_id as-is; empty string becomes 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 {}
|
} catch {}
|
||||||
|
|
@ -372,19 +381,32 @@ class SyncService {
|
||||||
if (wp && Array.isArray(wp)) {
|
if (wp && Array.isArray(wp)) {
|
||||||
const remoteActiveKeys = new Set<string>();
|
const remoteActiveKeys = new Set<string>();
|
||||||
for (const row of wp as any[]) {
|
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(
|
await storageService.setWatchProgress(
|
||||||
row.media_id,
|
row.media_id,
|
||||||
row.media_type,
|
row.media_type,
|
||||||
{
|
{
|
||||||
currentTime: row.current_time_seconds,
|
currentTime: row.current_time_seconds,
|
||||||
duration: row.duration_seconds,
|
duration: row.duration_seconds,
|
||||||
lastUpdated: row.last_updated_ms,
|
lastUpdated: finalTimestamp,
|
||||||
traktSynced: row.trakt_synced ?? undefined,
|
traktSynced: row.trakt_synced ?? undefined,
|
||||||
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
|
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
|
||||||
traktProgress: row.trakt_progress_percent ?? undefined,
|
traktProgress: row.trakt_progress_percent ?? undefined,
|
||||||
},
|
},
|
||||||
// Ensure full episode_id is preserved; treat empty as 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 || ''}`);
|
remoteActiveKeys.add(`${row.media_type}|${row.media_id}|${row.episode_id || ''}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,10 +223,11 @@ class StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setWatchProgress(
|
public async setWatchProgress(
|
||||||
id: string,
|
id: string,
|
||||||
type: string,
|
type: string,
|
||||||
progress: WatchProgress,
|
progress: WatchProgress,
|
||||||
episodeId?: string
|
episodeId?: string,
|
||||||
|
options?: { preserveTimestamp?: boolean; forceNotify?: boolean; forceWrite?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
|
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
|
||||||
|
|
@ -243,23 +244,32 @@ class StorageService {
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Check if progress has actually changed significantly
|
// Check if progress has actually changed significantly, unless forceWrite is requested
|
||||||
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
if (!options?.forceWrite) {
|
||||||
if (existingProgress) {
|
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
||||||
const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime);
|
if (existingProgress) {
|
||||||
const durationDiff = Math.abs(progress.duration - existingProgress.duration);
|
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) {
|
// Only update if there's a significant change (>5 seconds or duration change)
|
||||||
return; // Skip update for minor changes
|
if (timeDiff < 5 && durationDiff < 1) {
|
||||||
|
return; // Skip update for minor changes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...progress, lastUpdated: Date.now() };
|
const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number')
|
||||||
await AsyncStorage.setItem(key, JSON.stringify(updated));
|
? progress.lastUpdated
|
||||||
|
: Date.now();
|
||||||
// Use debounced notification to reduce spam
|
const updated = { ...progress, lastUpdated: timestamp };
|
||||||
this.debouncedNotifySubscribers();
|
await AsyncStorage.setItem(key, JSON.stringify(updated));
|
||||||
|
|
||||||
|
// Notify subscribers; allow forcing immediate notification
|
||||||
|
if (options?.forceNotify) {
|
||||||
|
this.notifyWatchProgressSubscribers();
|
||||||
|
} else {
|
||||||
|
this.debouncedNotifySubscribers();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error setting watch progress:', error);
|
logger.error('Error setting watch progress:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue